s
This commit is contained in:
16
app/data/avatars.ts
Normal file
16
app/data/avatars.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export const AVATAR_BASE_URL = "https://minio.nomadro.cn/hackrobot/nomadcna/avatars";
|
||||
|
||||
export const AVATAR_URLS = Array.from(
|
||||
{ length: 32 },
|
||||
(_, index) => `${AVATAR_BASE_URL}/avatar-${String(index + 1).padStart(2, "0")}.jpg`
|
||||
);
|
||||
|
||||
export function avatarForIndex(index: number): string {
|
||||
const safeIndex = Number.isFinite(index) ? Math.abs(Math.trunc(index)) : 0;
|
||||
return AVATAR_URLS[safeIndex % AVATAR_URLS.length];
|
||||
}
|
||||
|
||||
export function avatarForName(name: string): string {
|
||||
const seed = Array.from(name || "NomadCNA").reduce((sum, char) => sum + char.charCodeAt(0), 0);
|
||||
return avatarForIndex(seed);
|
||||
}
|
||||
271
app/data/city-social.ts
Normal file
271
app/data/city-social.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
import type { City } from "./cities";
|
||||
import { avatarForIndex, avatarForName } from "./avatars";
|
||||
|
||||
export type SocialMode = "coffeechat" | "localGuide" | "cityWalk";
|
||||
export type ActivityMode = "offline" | "online" | "hybrid";
|
||||
|
||||
export interface CityProfileRecord {
|
||||
id?: string;
|
||||
name?: string;
|
||||
age?: number;
|
||||
location?: string;
|
||||
tags?: string[];
|
||||
bio?: string;
|
||||
photo?: string;
|
||||
}
|
||||
|
||||
export interface CitySocialMember {
|
||||
id: string;
|
||||
name: string;
|
||||
age?: number;
|
||||
role: string;
|
||||
location: string;
|
||||
photo: string;
|
||||
bio: string;
|
||||
availability: string;
|
||||
responseTime: string;
|
||||
focus: string[];
|
||||
openTo: SocialMode[];
|
||||
}
|
||||
|
||||
export interface CityActivity {
|
||||
id: string;
|
||||
title: string;
|
||||
mode: ActivityMode;
|
||||
city: string;
|
||||
date: string;
|
||||
time: string;
|
||||
venue: string;
|
||||
organizer: string;
|
||||
description: string;
|
||||
rsvpCount: number;
|
||||
maxAttendees?: number;
|
||||
}
|
||||
|
||||
const FALLBACK_NAMES = [
|
||||
"林晓雨",
|
||||
"陈浩然",
|
||||
"王思琪",
|
||||
"张明远",
|
||||
"李雅婷",
|
||||
"刘子轩",
|
||||
"周雨桐",
|
||||
"吴俊杰",
|
||||
"郑诗涵",
|
||||
"孙宇航",
|
||||
];
|
||||
|
||||
const ROLE_BY_INDEX = [
|
||||
"产品设计师",
|
||||
"独立开发者",
|
||||
"内容创作者",
|
||||
"远程运营",
|
||||
"自由摄影师",
|
||||
"数据顾问",
|
||||
];
|
||||
|
||||
const COFFEE_FOCUS = [
|
||||
["远程工作节奏", "城市落地", "产品设计"],
|
||||
["独立开发", "副业收入", "咖啡馆办公"],
|
||||
["内容创作", "本地生活", "社群连接"],
|
||||
["创业项目", "远程团队", "效率工具"],
|
||||
];
|
||||
|
||||
const GUIDE_FOCUS = [
|
||||
["老街路线", "本地咖啡", "短租避坑"],
|
||||
["共享办公", "夜生活", "美食路线"],
|
||||
["城市漫步", "摄影机位", "周末短途"],
|
||||
["交通动线", "生活区", "新朋友局"],
|
||||
];
|
||||
|
||||
const CITY_WALK_FOCUS = [
|
||||
["Citywalk", "老城徒步", "街区观察"],
|
||||
["周末徒步", "轻户外", "补给路线"],
|
||||
["城市漫步", "摄影路线", "本地小店"],
|
||||
["公园路线", "日落机位", "低强度同行"],
|
||||
];
|
||||
|
||||
function citySeed(city: City): number {
|
||||
return city.idNumber || city.id || city.name.charCodeAt(0);
|
||||
}
|
||||
|
||||
export function getCitySocialSummary(city: City) {
|
||||
const base = Math.max(1, city.nomadsNow);
|
||||
const seed = citySeed(city);
|
||||
return {
|
||||
coffeechat: Math.max(4, Math.round(base * 0.055) + (seed % 4)),
|
||||
localGuide: Math.max(2, Math.round(base * 0.022) + (seed % 3)),
|
||||
cityWalk: Math.max(3, Math.round(base * 0.032) + (seed % 5)),
|
||||
};
|
||||
}
|
||||
|
||||
function fallbackMember(city: City, index: number): CitySocialMember {
|
||||
const seed = citySeed(city) + index;
|
||||
const name = FALLBACK_NAMES[seed % FALLBACK_NAMES.length];
|
||||
const isGuideFirst = index % 3 === 1;
|
||||
const openTo: SocialMode[] =
|
||||
index % 2 === 0
|
||||
? ["coffeechat", "cityWalk"]
|
||||
: isGuideFirst
|
||||
? ["localGuide"]
|
||||
: ["coffeechat"];
|
||||
if (index % 4 === 0 && !openTo.includes("localGuide")) openTo.push("localGuide");
|
||||
const focus = openTo.includes("cityWalk")
|
||||
? CITY_WALK_FOCUS[index % CITY_WALK_FOCUS.length]
|
||||
: openTo.includes("localGuide")
|
||||
? GUIDE_FOCUS[index % GUIDE_FOCUS.length]
|
||||
: COFFEE_FOCUS[index % COFFEE_FOCUS.length];
|
||||
|
||||
return {
|
||||
id: `fallback-${city.slug || city.name}-${index}`,
|
||||
name,
|
||||
age: 25 + (seed % 11),
|
||||
role: ROLE_BY_INDEX[index % ROLE_BY_INDEX.length],
|
||||
location: city.name,
|
||||
photo: avatarForIndex(seed + index),
|
||||
bio: `目前在${city.name}旅居/居住,资料开放,欢迎同城线下见面。`,
|
||||
availability: index % 2 === 0 ? "本周可约 2 次" : "周末更方便",
|
||||
responseTime: index % 3 === 0 ? "通常当天回复" : "通常 24 小时内回复",
|
||||
focus,
|
||||
openTo,
|
||||
};
|
||||
}
|
||||
|
||||
export function getFallbackCitySocialMembers(city: City): CitySocialMember[] {
|
||||
return Array.from({ length: 8 }, (_, index) => fallbackMember(city, index));
|
||||
}
|
||||
|
||||
function profileOpenTo(profile: CityProfileRecord, index: number): SocialMode[] {
|
||||
const tags = (profile.tags || []).join(" ");
|
||||
const modes = new Set<SocialMode>();
|
||||
if (/coffee|咖啡|聊天|线下/.test(tags)) modes.add("coffeechat");
|
||||
if (/地陪|向导|探店|游玩|路线|本地/.test(tags)) modes.add("localGuide");
|
||||
if (/citywalk|徒步|漫步|散步|户外|hiking/i.test(tags)) modes.add("cityWalk");
|
||||
if (modes.size === 0) {
|
||||
modes.add(index % 3 === 0 ? "cityWalk" : index % 2 === 0 ? "coffeechat" : "localGuide");
|
||||
if (index % 3 === 0) modes.add("localGuide");
|
||||
}
|
||||
return Array.from(modes);
|
||||
}
|
||||
|
||||
function profileToMember(city: City, profile: CityProfileRecord, index: number): CitySocialMember {
|
||||
const name = profile.name || "社区成员";
|
||||
const openTo = profileOpenTo(profile, index);
|
||||
return {
|
||||
id: profile.id || `${city.slug || city.name}-profile-${index}`,
|
||||
name,
|
||||
age: profile.age,
|
||||
role: profile.tags?.find((tag) => !["数字游民", city.name, "可线下见面"].includes(tag)) || "数字游民",
|
||||
location: profile.location || city.name,
|
||||
photo: profile.photo || avatarForName(name),
|
||||
bio: profile.bio || `目前在${city.name},欢迎同城咖啡或城市散步。`,
|
||||
availability: index % 2 === 0 ? "本周可约" : "提前 1 天约",
|
||||
responseTime: index % 3 === 0 ? "通常当天回复" : "通常 24 小时内回复",
|
||||
focus: openTo.includes("cityWalk")
|
||||
? CITY_WALK_FOCUS[index % CITY_WALK_FOCUS.length]
|
||||
: openTo.includes("localGuide")
|
||||
? GUIDE_FOCUS[index % GUIDE_FOCUS.length]
|
||||
: COFFEE_FOCUS[index % COFFEE_FOCUS.length],
|
||||
openTo,
|
||||
};
|
||||
}
|
||||
|
||||
export function getCitySocialMembers(city: City, profiles: CityProfileRecord[]): CitySocialMember[] {
|
||||
const cityProfiles = profiles.filter((profile) => {
|
||||
const location = profile.location || "";
|
||||
const tags = profile.tags || [];
|
||||
return location.includes(city.name) || tags.some((tag) => tag.includes(city.name));
|
||||
});
|
||||
|
||||
const merged = [
|
||||
...cityProfiles.map((profile, index) => profileToMember(city, profile, index)),
|
||||
...getFallbackCitySocialMembers(city),
|
||||
];
|
||||
const seen = new Set<string>();
|
||||
return merged.filter((member) => {
|
||||
const key = `${member.name}-${member.location}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function getFallbackCityActivities(city: City): CityActivity[] {
|
||||
const seed = citySeed(city);
|
||||
return [
|
||||
{
|
||||
id: `fallback-${city.slug || city.name}-offline`,
|
||||
title: `${city.name}同城咖啡与落地经验局`,
|
||||
mode: "offline",
|
||||
city: city.name,
|
||||
date: "2026-07-15",
|
||||
time: "19:30",
|
||||
venue: `${city.name}核心生活区咖啡馆`,
|
||||
organizer: `${city.name}城市主理人`,
|
||||
description: `围绕${city.name}租房、办公、社群和长期停留做小规模线下交流。`,
|
||||
rsvpCount: 8 + (seed % 14),
|
||||
maxAttendees: 28,
|
||||
},
|
||||
{
|
||||
id: `fallback-${city.slug || city.name}-online`,
|
||||
title: `${city.name}线上问答房`,
|
||||
mode: "online",
|
||||
city: city.name,
|
||||
date: "2026-07-18",
|
||||
time: "20:00",
|
||||
venue: "MiroTalk 线上房间",
|
||||
organizer: "NomadCNA",
|
||||
description: `给准备去${city.name}的人做一次线上答疑,集中聊预算、住区、网络和社交入口。`,
|
||||
rsvpCount: 18 + (seed % 20),
|
||||
maxAttendees: 80,
|
||||
},
|
||||
{
|
||||
id: `fallback-${city.slug || city.name}-hybrid`,
|
||||
title: `${city.name}周末城市漫步`,
|
||||
mode: "hybrid",
|
||||
city: city.name,
|
||||
date: "2026-07-21",
|
||||
time: "15:00",
|
||||
venue: `${city.name}本地生活路线`,
|
||||
organizer: "同城成员自发",
|
||||
description: "开放给新到城市的成员,线下同行,线上同步路线和问答。",
|
||||
rsvpCount: 6 + (seed % 10),
|
||||
maxAttendees: 18,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function normalizeCityActivities(city: City, meetups: Array<Record<string, unknown>>): CityActivity[] {
|
||||
return meetups
|
||||
.filter((meetup) => String(meetup.city || city.name) === city.name)
|
||||
.map((meetup, index) => {
|
||||
const rawMode = String(meetup.mode || "offline");
|
||||
const mode: ActivityMode = rawMode === "online" ? "online" : rawMode === "hybrid" ? "hybrid" : "offline";
|
||||
return {
|
||||
id: String(meetup.id || `${city.slug || city.name}-meetup-${index}`),
|
||||
title: String(meetup.title || meetup.venue || `${city.name}同城活动`),
|
||||
mode,
|
||||
city: String(meetup.city || city.name),
|
||||
date: String(meetup.date || "时间待定"),
|
||||
time: String(meetup.time || ""),
|
||||
venue: String(meetup.venue || (mode === "online" ? "线上房间" : "活动地点待定")),
|
||||
organizer: String(meetup.organizer || `${city.name}城市主理人`),
|
||||
description: String(meetup.description || "同城成员发起的交流活动。"),
|
||||
rsvpCount: Number(meetup.rsvpCount || 0),
|
||||
maxAttendees: meetup.maxAttendees ? Number(meetup.maxAttendees) : undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function getCityActivities(city: City, meetups: Array<Record<string, unknown>> = []): CityActivity[] {
|
||||
const normalized = normalizeCityActivities(city, meetups);
|
||||
const fallback = getFallbackCityActivities(city);
|
||||
const merged = [...normalized, ...fallback];
|
||||
const seen = new Set<string>();
|
||||
return merged.filter((activity) => {
|
||||
const key = `${activity.title}-${activity.mode}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
/**
|
||||
* 博客文章 Mock 数据
|
||||
*/
|
||||
import { avatarForIndex } from "./avatars";
|
||||
|
||||
export interface BlogPost {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -26,7 +28,7 @@ export const blogPosts: BlogPost[] = [
|
||||
excerptEn: "Based on 3000+ surveys, analyzing the lifestyle, work patterns and future trends of China's digital nomad community.",
|
||||
coverImage: "https://images.unsplash.com/photo-1507525428034-b723cf961d3e?w=800",
|
||||
author: "游牧中国",
|
||||
authorAvatar: "https://api.dicebear.com/7.x/initials/svg?seed=NomadCNA",
|
||||
authorAvatar: avatarForIndex(20),
|
||||
date: "2024-12-20",
|
||||
category: "研究报告",
|
||||
categoryEn: "Research",
|
||||
@@ -41,7 +43,7 @@ export const blogPosts: BlogPost[] = [
|
||||
excerptEn: "A comprehensive comparison of digital nomad visa policies and application processes in Thailand, Indonesia, Vietnam, Malaysia and more.",
|
||||
coverImage: "https://images.unsplash.com/photo-1528181304800-259b08848526?w=800",
|
||||
author: "小明",
|
||||
authorAvatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=xiaoming",
|
||||
authorAvatar: avatarForIndex(9),
|
||||
date: "2024-12-15",
|
||||
category: "签证指南",
|
||||
categoryEn: "Visa Guide",
|
||||
@@ -56,7 +58,7 @@ export const blogPosts: BlogPost[] = [
|
||||
excerptEn: "Why has Dali become the top destination for digital nomads? A comprehensive analysis of climate, cost of living and community vibe.",
|
||||
coverImage: "https://images.unsplash.com/photo-1584551246679-0daf3d275d0f?w=800",
|
||||
author: "小红",
|
||||
authorAvatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=xiaohong",
|
||||
authorAvatar: avatarForIndex(2),
|
||||
date: "2024-12-10",
|
||||
category: "城市攻略",
|
||||
categoryEn: "City Guide",
|
||||
@@ -71,7 +73,7 @@ export const blogPosts: BlogPost[] = [
|
||||
excerptEn: "From time management to self-motivation, sharing common challenges and practical solutions in remote work.",
|
||||
coverImage: "https://images.unsplash.com/photo-1499750310107-5fef28a66643?w=800",
|
||||
author: "老王",
|
||||
authorAvatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=laowang",
|
||||
authorAvatar: avatarForIndex(21),
|
||||
date: "2024-12-05",
|
||||
category: "经验分享",
|
||||
categoryEn: "Experience",
|
||||
@@ -86,7 +88,7 @@ export const blogPosts: BlogPost[] = [
|
||||
excerptEn: "In the era of social media, how can digital nomads build their personal brand to attract more opportunities and resources?",
|
||||
coverImage: "https://images.unsplash.com/photo-1552664730-d307ca884978?w=800",
|
||||
author: "阿花",
|
||||
authorAvatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=ahua",
|
||||
authorAvatar: avatarForIndex(12),
|
||||
date: "2024-11-28",
|
||||
category: "职业发展",
|
||||
categoryEn: "Career",
|
||||
@@ -101,7 +103,7 @@ export const blogPosts: BlogPost[] = [
|
||||
excerptEn: "Understanding tax residency definitions in different countries and how to plan your tax status rationally.",
|
||||
coverImage: "https://images.unsplash.com/photo-1554224155-6726b3ff858f?w=800",
|
||||
author: "李老师",
|
||||
authorAvatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=lishilaoshi",
|
||||
authorAvatar: avatarForIndex(14),
|
||||
date: "2024-11-20",
|
||||
category: "财务管理",
|
||||
categoryEn: "Finance",
|
||||
|
||||
Reference in New Issue
Block a user