272 lines
9.0 KiB
TypeScript
272 lines
9.0 KiB
TypeScript
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;
|
|
});
|
|
}
|