This commit is contained in:
root
2026-06-08 10:02:16 +08:00
parent 934b9a4a23
commit b65908e328
24 changed files with 1216 additions and 93 deletions

16
app/data/avatars.ts Normal file
View 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
View 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;
});
}

View File

@@ -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",