Files
WenwuMap/apps/web/src/app/map/page.tsx
T
selfrelease 2d847e154f chore: 初始化仓库
中华文明全图鉴——文物全图系统(PC Web 地图 + NestJS API + 管理后台)。
含三大 IP(文物南迁北归 / 国宝海外回归 / 博物馆手艺人)、AI 文物对话、
文物地图与详情、以及 demo-video-kit 演示视频生成工具。
2026-06-13 20:55:44 +08:00

1261 lines
58 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { APIProvider, AdvancedMarker, Map as GoogleMap, useMap } from "@vis.gl/react-google-maps";
import {
Footprints,
Undo2,
HeartHandshake,
Waves,
Home,
Play,
Pause,
SkipBack,
SkipForward,
X,
ChevronLeft,
ChevronRight,
MapPinned,
} from "lucide-react";
import ArtifactChat from "../../components/ArtifactChat";
import ArtifactImage from "../../components/ArtifactImage";
import RouteLayer from "../../components/RouteLayer";
import {
API_URL,
CATEGORY_LABELS,
CATEGORY_MARKS,
DYNASTY_OPTIONS,
LEVEL_LABELS,
REPATRIATION_LABELS,
themeOf,
markerSizeFor,
type MapPoint,
type RouteDetail,
type RouteSummary,
type Theme,
} from "../../lib/artifact";
const API_KEY = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY ?? "";
interface MapStats {
total_artifacts: number;
total_institutions: number;
total_locations: number;
}
interface ArtifactGroup {
key: string;
institution_name: string;
city: string;
lat: number;
lng: number;
theme: Theme;
points: MapPoint[];
}
interface MarkersLayerProps {
groups: ArtifactGroup[];
selected: MapPoint | null;
selectedGroup: ArtifactGroup | null;
zoom: number;
onGroupClick: (g: ArtifactGroup) => void;
onSingleClick: (p: MapPoint) => void;
}
function MarkersLayer({ groups, selected, selectedGroup, zoom, onGroupClick, onSingleClick }: MarkersLayerProps) {
return (
<>
{groups.map((group) => {
const count = group.points.length;
const isMulti = count > 1;
const isGroupActive = selectedGroup?.key === group.key;
const hasAnyActive = group.points.some((p) => p.id === selected?.id);
const isActive = isGroupActive || hasAnyActive;
const first = group.points[0];
const size = markerSizeFor(zoom, count);
const fontSize = Math.max(9, Math.round(size * 0.44));
const showLabel = zoom >= 5 || isActive;
const label = isMulti ? group.institution_name : first.name;
// 国内馆藏=琥珀金,流失海外=青瓷绿,已回归=回归金绿
const palette =
group.theme === "overseas"
? isActive
? "border-[#cdeee2] bg-[#3f8d7e] text-[#06140f] ring-[#cdeee2]/45 ring-offset-[#0c0a06]"
: "border-[#bfe3d6]/70 bg-[#2f6b60] text-[#e2f7ef] ring-[#8fc7b8]/30 ring-offset-[#0c0a06] hover:border-[#cdeee2] hover:bg-[#3f8d7e]"
: group.theme === "repatriated"
? isActive
? "border-[#d6f5c9] bg-[#3f9e6a] text-[#06140f] ring-[#d6f5c9]/45 ring-offset-[#0c0a06]"
: "border-[#bfe7b8]/70 bg-[#2f7d52] text-[#e6ffe0] ring-[#8fc79a]/30 ring-offset-[#0c0a06] hover:border-[#d6f5c9] hover:bg-[#3f9e6a]"
: isActive
? "border-[#ffe2a0] bg-[#d99b3d] text-[#1a1005] ring-[#ffe2a0]/45 ring-offset-[#0c0a06]"
: "border-[#ffe2a0]/70 bg-[#b9832f] text-[#1f140a] ring-[#c9a55a]/30 ring-offset-[#0c0a06] hover:border-[#ffe2a0] hover:bg-[#d99b3d]";
return (
<AdvancedMarker
key={group.key}
position={{ lat: group.lat, lng: group.lng }}
onClick={() => {
if (isMulti) onGroupClick(group);
else onSingleClick(first);
}}
title={isMulti ? `${group.institution_name}\uff08${count} \u4ef6\uff09` : first.name}
>
<div className="flex cursor-pointer flex-col items-center gap-1">
<div
className={`flex items-center justify-center rounded-full border-[1.5px] font-serif font-bold leading-none shadow-[0_8px_28px_rgba(0,0,0,0.6)] ring-1 ring-offset-[1.5px] transition-colors ${palette}`}
style={{ width: size, height: size, fontSize }}
>
{count}
</div>
{showLabel && (
<span
className={`max-w-28 truncate rounded-[2px] px-1.5 py-0.5 text-[10px] font-medium shadow ${
isActive ? "bg-[#d99b3d] text-[#1a1005]" : "bg-[#100e09]/92 text-[#d6aa5b]"
}`}
>
{label}
</span>
)}
</div>
</AdvancedMarker>
);
})}
</>
);
}
function MapIdleListener({ onIdle, onZoomChange }: { onIdle: () => void; onZoomChange?: (z: number) => void }) {
const map = useMap();
useEffect(() => {
if (!map) return;
const listener = map.addListener("idle", () => {
onIdle();
if (onZoomChange) onZoomChange(map.getZoom() ?? 4);
});
return () => listener.remove();
}, [map, onIdle, onZoomChange]);
return null;
}
export default function MapPage() {
const [points, setPoints] = useState<MapPoint[]>([]);
const [stats, setStats] = useState<MapStats | null>(null);
const [selected, setSelected] = useState<MapPoint | null>(null);
const [pointsLoading, setPointsLoading] = useState(false);
const [pointsError, setPointsError] = useState(false);
const pointsCacheRef = useRef<Map<string, MapPoint[]>>(new Map());
const fetchAbortRef = useRef<AbortController | null>(null);
const fetchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [filterCategory, setFilterCategory] = useState("");
const [filterDynasty, setFilterDynasty] = useState("");
const [filterCity, setFilterCity] = useState("");
const [cityPage, setCityPage] = useState(0);
const [sameInstOpen, setSameInstOpen] = useState(false);
const [routes, setRoutes] = useState<RouteSummary[]>([]);
const [activeRoute, setActiveRoute] = useState<RouteDetail | null>(null);
const [routeStep, setRouteStep] = useState(1);
const [routePlaying, setRoutePlaying] = useState(false);
const [showWelfare, setShowWelfare] = useState(false);
const [showRepatriationPicker, setShowRepatriationPicker] = useState(false);
const [repatPickerCat, setRepatPickerCat] = useState("");
const [searchQ, setSearchQ] = useState("");
const [selectedGroup, setSelectedGroup] = useState<ArtifactGroup | null>(null);
const [zoom, setZoom] = useState(4);
const onZoomChange = useCallback((z: number) => setZoom(z), []);
const [rightWidth, setRightWidth] = useState(320);
const [leftCollapsed, setLeftCollapsed] = useState(false);
const [rightCollapsed, setRightCollapsed] = useState(false);
const leftW = leftCollapsed ? 0 : 288;
const effRightW = rightCollapsed ? 0 : rightWidth;
// 初始化:右栏宽度取屏幕宽度的 1/3
useEffect(() => {
setRightWidth(Math.round(window.innerWidth / 3));
}, []);
const [chatStarted, setChatStarted] = useState(false);
const [infoCollapsed, setInfoCollapsed] = useState(false);
const handleConversationChange = useCallback((active: boolean) => {
setChatStarted(active);
setInfoCollapsed(active);
}, []);
// 切换文物时恢复信息展开
useEffect(() => {
setChatStarted(false);
setInfoCollapsed(false);
setSameInstOpen(false);
}, [selected?.id]);
const startResize = useCallback((e: React.MouseEvent) => {
e.preventDefault();
const onMove = (ev: MouseEvent) => {
const w = window.innerWidth - ev.clientX;
setRightWidth(Math.max(300, Math.min(Math.round(window.innerWidth * 0.6), w)));
};
const onUp = () => {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
document.body.style.userSelect = "";
document.body.style.cursor = "";
};
document.body.style.userSelect = "none";
document.body.style.cursor = "col-resize";
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
}, []);
useEffect(() => {
fetch(`${API_URL}/api/v1/map/stats`)
.then((r) => r.json())
.then(setStats)
.catch(() => null);
}, []);
// 加载叙事路线列表(南迁 / 回归)
useEffect(() => {
fetch(`${API_URL}/api/v1/routes`)
.then((r) => r.json())
.then((d) => setRoutes(Array.isArray(d) ? d : []))
.catch(() => null);
}, []);
const loadRoute = useCallback(async (code: string): Promise<RouteDetail | null> => {
try {
const res = await fetch(`${API_URL}/api/v1/routes/${code}`);
const data: RouteDetail = await res.json();
setActiveRoute(data);
setRouteStep(1);
setRoutePlaying(false);
return data;
} catch {
setActiveRoute(null);
return null;
}
}, []);
const toggleRoute = useCallback(
async (code: string) => {
setRoutePlaying(false);
if (activeRoute?.code === code) {
setActiveRoute(null);
return;
}
await loadRoute(code);
},
[activeRoute, loadRoute]
);
// 选择某件回归国宝:激活其路线 + 右栏定位到该文物
const selectRepatriation = useCallback(
async (route: RouteSummary) => {
setShowRepatriationPicker(false);
const detail = await loadRoute(route.code);
const aid = detail?.artifact_id ?? route.artifact_id;
if (aid) {
const p = points.find((x) => x.id === aid);
if (p) {
setSelected(p);
setSelectedGroup(null);
}
}
},
[loadRoute, points]
);
// 路线自动播放:逐站推进
useEffect(() => {
if (!routePlaying || !activeRoute) return;
const total = activeRoute.stops?.length ?? 0;
if (routeStep >= total) {
setRoutePlaying(false);
return;
}
const t = setTimeout(() => setRouteStep((s) => Math.min(total, s + 1)), 1500);
return () => clearTimeout(t);
}, [routePlaying, routeStep, activeRoute]);
const fetchPoints = useCallback(async () => {
const params = new URLSearchParams();
if (filterCategory) params.set("category", filterCategory);
if (filterDynasty) params.set("dynasty", filterDynasty);
const key = params.toString();
// 命中缓存直接用,避免地图移动时重复请求
const cached = pointsCacheRef.current.get(key);
if (cached) {
setPoints(cached);
setPointsError(false);
return;
}
fetchAbortRef.current?.abort();
const ctrl = new AbortController();
fetchAbortRef.current = ctrl;
setPointsLoading(true);
setPointsError(false);
try {
const res = await fetch(`${API_URL}/api/v1/map/points?${key}`, { signal: ctrl.signal });
const data: unknown = await res.json();
const arr = Array.isArray(data) ? (data as MapPoint[]) : [];
pointsCacheRef.current.set(key, arr);
setPoints(arr);
} catch (e) {
if ((e as Error).name !== "AbortError") {
setPoints([]);
setPointsError(true);
}
} finally {
setPointsLoading(false);
}
}, [filterCategory, filterDynasty]);
// 地图移动后的取数做防抖,避免频繁触发
const scheduleFetch = useCallback(() => {
if (fetchTimerRef.current) clearTimeout(fetchTimerRef.current);
fetchTimerRef.current = setTimeout(() => {
void fetchPoints();
}, 250);
}, [fetchPoints]);
useEffect(() => {
fetchPoints();
}, [fetchPoints]);
const cityStats = useMemo(() => {
const count = points.reduce<Record<string, number>>((acc, point) => {
const city = point.city || point.province || "未知";
acc[city] = (acc[city] ?? 0) + 1;
return acc;
}, {});
return Object.entries(count)
.sort((a, b) => b[1] - a[1])
.map(([city, total]) => ({ city, total }));
}, [points]);
const CITY_PAGE_SIZE = 6;
const cityTotalPages = Math.max(1, Math.ceil(cityStats.length / CITY_PAGE_SIZE));
const citySafePage = Math.min(cityPage, cityTotalPages - 1);
const pagedCities = cityStats.slice(
citySafePage * CITY_PAGE_SIZE,
citySafePage * CITY_PAGE_SIZE + CITY_PAGE_SIZE
);
const visiblePoints = useMemo(() => {
return points.filter((point) => {
const hitKeyword = searchQ
? point.name.includes(searchQ) ||
point.institution_name?.includes(searchQ) ||
point.dynasty?.includes(searchQ) ||
point.city?.includes(searchQ)
: true;
const hitCity = filterCity ? point.city === filterCity : true;
return hitKeyword && hitCity;
});
}, [filterCity, points, searchQ]);
const groupedPoints = useMemo(() => {
const useCity = zoom <= 7;
const map = new Map<string, ArtifactGroup>();
visiblePoints.forEach((p) => {
const key = useCity
? (p.city || p.province || `${p.lat.toFixed(1)},${p.lng.toFixed(1)}`)
: (p.institution_name || `${p.lat.toFixed(3)},${p.lng.toFixed(3)}`);
const label = useCity
? (p.city || p.province || "未知城市")
: (p.institution_name || "未知机构");
if (!map.has(key)) {
map.set(key, {
key,
institution_name: label,
city: p.city || p.province || "未知",
lat: p.lat,
lng: p.lng,
theme: themeOf(p),
points: [],
});
}
const grp = map.get(key)!;
grp.points.push(p);
// 组内只要有海外/回归则整体按该主题着色(海外优先)
const t = themeOf(p);
if (t === "overseas") grp.theme = "overseas";
else if (t === "repatriated" && grp.theme !== "overseas") grp.theme = "repatriated";
});
if (useCity) {
for (const g of map.values()) {
if (g.points.length > 1) {
g.lat = g.points.reduce((s, p) => s + p.lat, 0) / g.points.length;
g.lng = g.points.reduce((s, p) => s + p.lng, 0) / g.points.length;
}
}
}
return Array.from(map.values());
}, [visiblePoints, zoom]);
const institutionCount = useMemo(() => {
return new Set(visiblePoints.map((p) => p.institution_name).filter(Boolean)).size;
}, [visiblePoints]);
const selectedCityText = selected?.city || selected?.province || "未知城市";
const noKey = !API_KEY;
return (
<div className="relative h-screen w-full overflow-hidden bg-[#090806] text-[#f6eddc]">
<div className="pointer-events-none absolute inset-0 z-0 bg-[radial-gradient(circle_at_top_left,rgba(190,137,55,0.18),transparent_32%),radial-gradient(circle_at_bottom_right,rgba(50,101,91,0.18),transparent_36%)]" />
<header className="absolute left-0 right-0 top-0 z-50 flex h-16 items-center border-b border-[#d6aa5b]/15 bg-[#080705]/92 px-6 shadow-[0_18px_40px_rgba(0,0,0,0.45)] backdrop-blur-xl">
<div className="flex items-center gap-4">
<div className="flex h-9 w-9 items-center justify-center rounded-full border border-[#d6aa5b]/35 bg-[#d6aa5b]/10 text-sm text-[#f2cf83]">
</div>
<div>
<div className="font-serif text-base font-semibold tracking-[0.28em] text-[#f2cf83]">
</div>
<div className="mt-0.5 text-[10px] uppercase tracking-[0.35em] text-[#9b8b6a]">
Cultural Heritage Atlas
</div>
</div>
</div>
<div className="ml-10 hidden items-center gap-6 text-xs text-[#a89d88] lg:flex">
<span className="text-[#f2cf83]"></span>
<span></span>
<span>线</span>
<span></span>
</div>
<div className="flex-1" />
<div className="hidden items-center gap-5 text-xs text-[#8f8066] md:flex">
<span>{stats?.total_artifacts ?? points.length} </span>
<span className="h-3 w-px bg-[#d6aa5b]/20" />
<span>{cityStats.length} </span>
<span className="h-3 w-px bg-[#d6aa5b]/20" />
<span>{stats?.total_institutions ?? 0} </span>
</div>
</header>
<main className="absolute bottom-11 top-16 overflow-hidden border-x border-[#d6aa5b]/10 bg-[#0e1110] transition-all duration-300" style={{ left: leftW, right: effRightW }}>
{noKey ? (
<div className="flex h-full flex-col items-center justify-center gap-4 bg-[linear-gradient(135deg,#17110b,#0b1514)]">
<div className="rounded-full border border-[#d6aa5b]/20 bg-[#d6aa5b]/10 px-5 py-4 text-[#f2cf83]">
<MapPinned size={40} />
</div>
<div className="text-center">
<p className="text-sm font-medium text-[#f2cf83]"> Google Maps API Key</p>
<code className="mt-3 block rounded-lg border border-[#d6aa5b]/20 bg-black/35 px-4 py-2 text-xs text-[#dcb96f]">
NEXT_PUBLIC_GOOGLE_MAPS_API_KEY=Key
</code>
<p className="mt-3 text-xs text-[#8f8066]">
API {visiblePoints.length}
</p>
</div>
</div>
) : (
<APIProvider apiKey={API_KEY}>
<GoogleMap
defaultCenter={{ lat: 34.4, lng: 108.9 }}
defaultZoom={4}
mapId="wenwumap-main"
gestureHandling="greedy"
disableDefaultUI
zoomControl
clickableIcons={false}
colorScheme="DARK"
style={{ width: "100%", height: "100%" }}
>
<MapIdleListener onIdle={scheduleFetch} onZoomChange={onZoomChange} />
{!activeRoute && (
<MarkersLayer
groups={groupedPoints}
selected={selected}
selectedGroup={selectedGroup}
zoom={zoom}
onGroupClick={(g) => { setSelectedGroup(g); setSelected(null); }}
onSingleClick={(p) => { setSelected(p); setSelectedGroup(null); }}
/>
)}
{activeRoute && activeRoute.stops?.length > 0 && (
<RouteLayer
stops={activeRoute.stops}
color={activeRoute.color ?? "#e0b15a"}
visibleCount={routeStep}
activeIndex={routeStep - 1}
onStopClick={(i) => {
setRoutePlaying(false);
setRouteStep(i + 1);
}}
/>
)}
</GoogleMap>
</APIProvider>
)}
<div className="pointer-events-none absolute inset-0 bg-[linear-gradient(90deg,rgba(8,7,5,0.24),transparent_12%,transparent_82%,rgba(8,7,5,0.18)),linear-gradient(180deg,rgba(8,7,5,0.22),transparent_18%,transparent_80%,rgba(8,7,5,0.2))]" />
{(pointsLoading || pointsError) && (
<div className="pointer-events-none absolute left-1/2 top-3 z-10 -translate-x-1/2">
{pointsError ? (
<button
onClick={() => fetchPoints()}
className="pointer-events-auto rounded-full border border-[#c94b4b]/40 bg-[#1a0c0c]/90 px-3 py-1 text-[11px] text-[#f0b9b9] backdrop-blur transition hover:border-[#c94b4b]/70"
>
·
</button>
) : (
<span className="flex items-center gap-1.5 rounded-full border border-[#d6aa5b]/25 bg-[#0c0a06]/85 px-3 py-1 text-[11px] text-[#d6aa5b] backdrop-blur">
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-[#d6aa5b] [animation-delay:-0.2s]" />
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-[#d6aa5b] [animation-delay:-0.1s]" />
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-[#d6aa5b]" />
<span className="ml-1"></span>
</span>
)}
</div>
)}
{activeRoute && activeRoute.stops?.length > 0 && (
<div className="absolute bottom-4 left-1/2 z-20 w-[min(720px,90%)] -translate-x-1/2 rounded-2xl border border-[#d6aa5b]/25 bg-[#0c0a06]/94 px-5 py-3.5 shadow-[0_12px_44px_rgba(0,0,0,0.55)] backdrop-blur-xl">
<div className="mb-2.5 flex items-center justify-between">
<span
className="flex items-center gap-2 font-serif text-sm font-semibold"
style={{ color: activeRoute.color ?? "#e0b15a" }}
>
{activeRoute.type === "migration" ? <Footprints size={16} /> : <Undo2 size={16} />}
{activeRoute.title}
</span>
<button
onClick={() => setActiveRoute(null)}
className="flex items-center gap-1 rounded-full border border-[#d6aa5b]/20 px-2.5 py-1 text-[11px] text-[#8f8066] transition hover:border-[#d6aa5b]/40 hover:text-[#f2cf83]"
>
<X size={12} /> 退线
</button>
</div>
{/* 可点击时间轴 */}
<div className="no-scrollbar mb-2.5 flex items-center gap-0 overflow-x-auto pb-1">
{activeRoute.stops.map((s, i) => {
const active = i === routeStep - 1;
const past = i < routeStep - 1;
const c = activeRoute.color ?? "#e0b15a";
return (
<div key={s.seq} className="flex flex-shrink-0 items-center">
{i > 0 && (
<span
className="h-px w-7"
style={{ background: past || active ? c : "rgba(214,170,91,0.2)" }}
/>
)}
<button
onClick={() => {
setRoutePlaying(false);
setRouteStep(i + 1);
}}
className="group flex flex-col items-center gap-1"
title={s.event ?? s.name}
>
<span
className="flex h-6 w-6 items-center justify-center rounded-full border text-[11px] font-bold transition-all"
style={{
background: active || past ? c : "transparent",
borderColor: c,
color: active || past ? "#1a1005" : c,
transform: active ? "scale(1.18)" : "scale(1)",
}}
>
{s.seq}
</span>
<span
className={`max-w-[64px] truncate text-[10px] ${active ? "text-[#f2cf83]" : "text-[#8f8066]"}`}
>
{s.year_label || s.name}
</span>
</button>
</div>
);
})}
</div>
{/* 播放控制 + 当前事件 */}
<div className="flex items-center gap-3">
<div className="flex flex-shrink-0 items-center gap-1">
<button
onClick={() => {
setRoutePlaying(false);
setRouteStep((s) => Math.max(1, s - 1));
}}
disabled={routeStep <= 1}
aria-label="上一站"
className="flex h-7 w-7 items-center justify-center rounded-full border border-[#d6aa5b]/25 text-[#d6aa5b] transition hover:bg-[#d6aa5b]/12 disabled:opacity-30"
>
<SkipBack size={13} />
</button>
<button
onClick={() => {
if (routeStep >= activeRoute.stops.length) setRouteStep(1);
setRoutePlaying((v) => !v);
}}
aria-label={routePlaying ? "暂停" : "播放"}
className="flex h-8 w-8 items-center justify-center rounded-full text-[#1a1005] transition hover:brightness-110"
style={{ background: activeRoute.color ?? "#e0b15a" }}
>
{routePlaying ? <Pause size={15} /> : <Play size={15} />}
</button>
<button
onClick={() => {
setRoutePlaying(false);
setRouteStep((s) => Math.min(activeRoute.stops.length, s + 1));
}}
disabled={routeStep >= activeRoute.stops.length}
aria-label="下一站"
className="flex h-7 w-7 items-center justify-center rounded-full border border-[#d6aa5b]/25 text-[#d6aa5b] transition hover:bg-[#d6aa5b]/12 disabled:opacity-30"
>
<SkipForward size={13} />
</button>
</div>
<p className="min-w-0 flex-1 text-[12px] leading-5 text-[#d8cbb0]">
<span className="text-[#f2cf83]">
{activeRoute.stops[routeStep - 1]?.year_label} {activeRoute.stops[routeStep - 1]?.name}
</span>
{activeRoute.stops[routeStep - 1]?.event ? ` —— ${activeRoute.stops[routeStep - 1]?.event}` : ""}
</p>
</div>
</div>
)}
</main>
{/* 左栏收起/展开 */}
<button
onClick={() => setLeftCollapsed((v) => !v)}
title={leftCollapsed ? "展开左栏" : "收起左栏"}
className="absolute top-1/2 z-50 flex h-14 w-5 -translate-y-1/2 items-center justify-center rounded-r-md border border-l-0 border-[#d6aa5b]/20 bg-[#0c0a06]/90 text-sm text-[#d6aa5b] backdrop-blur transition-all duration-300 hover:bg-[#d6aa5b]/15 hover:text-[#f2cf83]"
style={{ left: leftW }}
>
{leftCollapsed ? <ChevronRight size={14} /> : <ChevronLeft size={14} />}
</button>
{/* 右栏收起/展开 */}
<button
onClick={() => setRightCollapsed((v) => !v)}
title={rightCollapsed ? "展开右栏" : "收起右栏"}
className="absolute top-1/2 z-50 flex h-14 w-5 -translate-y-1/2 items-center justify-center rounded-l-md border border-r-0 border-[#d6aa5b]/20 bg-[#0c0a06]/90 text-sm text-[#d6aa5b] backdrop-blur transition-all duration-300 hover:bg-[#d6aa5b]/15 hover:text-[#f2cf83]"
style={{ right: effRightW }}
>
{rightCollapsed ? <ChevronLeft size={14} /> : <ChevronRight size={14} />}
</button>
<aside className="absolute bottom-11 left-0 top-16 z-40 flex w-72 flex-col border-r border-[#d6aa5b]/15 bg-[#080705]/94 shadow-[18px_0_48px_rgba(0,0,0,0.38)] backdrop-blur-xl transition-transform duration-300" style={{ transform: leftCollapsed ? "translateX(-100%)" : "none" }}>
<div className="shrink-0 border-b border-[#d6aa5b]/12 p-5">
<div className="text-xs uppercase tracking-[0.3em] text-[#8f8066]">Explore</div>
<h1 className="mt-2 font-serif text-xl font-semibold text-[#f6eddc]"></h1>
<p className="mt-2 text-xs leading-5 text-[#9d927c]">
</p>
<div className="mt-4 grid grid-cols-3 gap-2">
<div className="rounded-xl border border-[#d6aa5b]/12 bg-[#d6aa5b]/8 p-3">
<div className="text-lg font-semibold text-[#f2cf83]">
{stats?.total_artifacts ?? points.length}
</div>
<div className="mt-1 text-[10px] text-[#8f8066]"></div>
</div>
<div className="rounded-xl border border-[#d6aa5b]/12 bg-white/[0.03] p-3">
<div className="text-lg font-semibold text-[#f2cf83]">{cityStats.length}</div>
<div className="mt-1 text-[10px] text-[#8f8066]"></div>
</div>
<div className="rounded-xl border border-[#d6aa5b]/12 bg-white/[0.03] p-3">
<div className="text-lg font-semibold text-[#f2cf83]">
{stats?.total_institutions ?? 0}
</div>
<div className="mt-1 text-[10px] text-[#8f8066]"></div>
</div>
</div>
</div>
<div className="no-scrollbar flex-1 min-h-0 space-y-5 overflow-y-auto p-5 pb-8">
<input
value={searchQ}
onChange={(e) => setSearchQ(e.target.value)}
type="text"
placeholder="搜索文物、机构、城市…"
className="w-full rounded-xl border border-[#d6aa5b]/16 bg-[#11100d] px-3 py-2 text-xs text-[#f6eddc] outline-none transition placeholder:text-[#6f6656] focus:border-[#d6aa5b]/55 focus:bg-[#161310]"
/>
<section>
<p className="mb-2 text-[11px] font-medium uppercase tracking-[0.24em] text-[#a99566]">
</p>
<div className="space-y-1.5">
{routes
.filter((r) => r.type === "migration")
.map((r) => (
<button
key={r.code}
onClick={() => toggleRoute(r.code)}
title={r.summary ?? ""}
className={`flex w-full items-center justify-between rounded-lg border px-3 py-2 text-xs transition ${
activeRoute?.code === r.code
? "border-[#d6aa5b]/55 bg-[#d6aa5b]/16 text-[#f2cf83]"
: "border-transparent bg-white/[0.03] text-[#c7baa0] hover:border-[#d6aa5b]/24 hover:text-[#f2cf83]"
}`}
>
<span className="flex items-center gap-2">
<Footprints size={14} />
{r.title}
</span>
<span className="text-[10px] text-[#8f8066]">{activeRoute?.code === r.code ? "进行中" : "路线"}</span>
</button>
))}
<button
onClick={() => setShowRepatriationPicker(true)}
className={`flex w-full items-center justify-between rounded-lg border px-3 py-2 text-xs transition ${
activeRoute?.type === "repatriation"
? "border-[#3f9e6a]/55 bg-[#3f9e6a]/16 text-[#aef0c6]"
: "border-transparent bg-white/[0.03] text-[#c7baa0] hover:border-[#3f9e6a]/30 hover:text-[#aef0c6]"
}`}
>
<span className="flex items-center gap-2">
<Undo2 size={14} />
</span>
<span className="text-[10px] text-[#8f8066]">
{routes.filter((r) => r.type === "repatriation").length}
</span>
</button>
<button
onClick={() => setShowWelfare(true)}
className="flex w-full items-center justify-between rounded-lg border border-[#c94b4b]/25 bg-[#c94b4b]/8 px-3 py-2 text-xs text-[#f0c0b3] transition hover:border-[#c94b4b]/45 hover:text-[#f7d8cf]"
>
<span className="flex items-center gap-2"><HeartHandshake size={14} /> </span>
<span className="text-[10px] text-[#8f8066]"></span>
</button>
</div>
</section>
<section>
<div className="mb-2 flex items-center justify-between">
<p className="text-[11px] font-medium uppercase tracking-[0.24em] text-[#a99566]">
</p>
<div className="flex items-center gap-2">
{filterCity && (
<button
onClick={() => setFilterCity("")}
className="text-[11px] text-[#8f8066] hover:text-[#f2cf83]"
>
</button>
)}
{cityTotalPages > 1 && (
<div className="flex items-center gap-1 text-[10px] text-[#8f8066]">
<button
onClick={() => setCityPage((p) => Math.max(0, p - 1))}
disabled={citySafePage === 0}
aria-label="上一页"
className="flex h-5 w-5 items-center justify-center rounded border border-[#d6aa5b]/15 transition hover:border-[#d6aa5b]/40 hover:text-[#f2cf83] disabled:opacity-30 disabled:hover:border-[#d6aa5b]/15"
>
<ChevronLeft size={13} />
</button>
<span className="tabular-nums">{citySafePage + 1}/{cityTotalPages}</span>
<button
onClick={() => setCityPage((p) => Math.min(cityTotalPages - 1, p + 1))}
disabled={citySafePage === cityTotalPages - 1}
aria-label="下一页"
className="flex h-5 w-5 items-center justify-center rounded border border-[#d6aa5b]/15 transition hover:border-[#d6aa5b]/40 hover:text-[#f2cf83] disabled:opacity-30 disabled:hover:border-[#d6aa5b]/15"
>
<ChevronRight size={13} />
</button>
</div>
)}
</div>
</div>
<div className="space-y-1.5">
{pagedCities.map((item) => (
<button
key={item.city}
onClick={() => setFilterCity(filterCity === item.city ? "" : item.city)}
className={`flex w-full items-center justify-between rounded-lg border px-3 py-2 text-xs transition ${
filterCity === item.city
? "border-[#d6aa5b]/45 bg-[#d6aa5b]/16 text-[#f2cf83]"
: "border-transparent bg-white/[0.03] text-[#c7baa0] hover:border-[#d6aa5b]/20 hover:bg-white/[0.06]"
}`}
>
<span>{item.city}</span>
<span className="text-[#8f8066]">{item.total}</span>
</button>
))}
</div>
</section>
<section>
<p className="mb-2 text-[11px] font-medium uppercase tracking-[0.24em] text-[#a99566]">
</p>
<div className="grid grid-cols-2 gap-2">
{Object.entries(CATEGORY_LABELS).map(([value, label]) => (
<button
key={value}
onClick={() => setFilterCategory(filterCategory === value ? "" : value)}
className={`rounded-lg border px-3 py-2 text-left text-xs transition ${
filterCategory === value
? "border-[#d6aa5b]/45 bg-[#d6aa5b]/18 text-[#f2cf83]"
: "border-[#d6aa5b]/10 bg-white/[0.03] text-[#b5aa94] hover:border-[#d6aa5b]/24 hover:text-[#f2cf83]"
}`}
>
{label}
</button>
))}
</div>
</section>
<section>
<p className="mb-2 text-[11px] font-medium uppercase tracking-[0.24em] text-[#a99566]">
</p>
<div className="flex flex-wrap gap-2">
{DYNASTY_OPTIONS.map((dynasty) => (
<button
key={dynasty}
onClick={() => setFilterDynasty(filterDynasty === dynasty ? "" : dynasty)}
className={`rounded-full border px-3 py-1 text-xs transition ${
filterDynasty === dynasty
? "border-[#d6aa5b]/50 bg-[#d6aa5b]/18 text-[#f2cf83]"
: "border-[#d6aa5b]/12 bg-white/[0.03] text-[#b5aa94] hover:border-[#d6aa5b]/28 hover:text-[#f2cf83]"
}`}
>
{dynasty}
</button>
))}
</div>
</section>
</div>
</aside>
<aside className="absolute bottom-11 right-0 top-16 z-40 border-l border-[#d6aa5b]/15 bg-[#080705]/94 shadow-[-18px_0_48px_rgba(0,0,0,0.38)] backdrop-blur-xl transition-transform duration-300" style={{ width: rightWidth, transform: rightCollapsed ? "translateX(100%)" : "none" }}>
{/* 拖拽手柄:调整右栏宽度 */}
<div
onMouseDown={startResize}
title="拖动调整宽度"
className="group absolute left-0 top-0 z-50 flex h-full w-1.5 -translate-x-1/2 cursor-col-resize items-center justify-center"
>
<span className="h-full w-px bg-transparent transition group-hover:bg-[#d6aa5b]/40" />
<span className="absolute h-10 w-1 rounded-full bg-[#d6aa5b]/25 transition group-hover:bg-[#d6aa5b]/70" />
</div>
{selectedGroup ? (
<div className="flex h-full flex-col">
<div className="border-b border-[#d6aa5b]/12 p-5">
<div className="mb-3 flex items-center justify-between">
<span className="text-[11px] uppercase tracking-[0.28em] text-[#a99566]">
</span>
<button
onClick={() => setSelectedGroup(null)}
className="rounded-full border border-[#d6aa5b]/12 px-2 py-0.5 text-xs text-[#8f8066] hover:border-[#d6aa5b]/35 hover:text-[#f2cf83]"
>
</button>
</div>
<h2 className="font-serif text-xl font-semibold leading-snug text-[#f6eddc]">
{selectedGroup.institution_name}
</h2>
<p className="mt-1.5 text-xs text-[#9d927c]">
{selectedGroup.city} · {selectedGroup.points.length}
</p>
</div>
<div className="no-scrollbar flex-1 overflow-y-auto p-4">
{(() => {
const byInst = new Map<string, MapPoint[]>();
selectedGroup.points.forEach((p) => {
const k = p.institution_name || "未知机构";
if (!byInst.has(k)) byInst.set(k, []);
byInst.get(k)!.push(p);
});
const entries = Array.from(byInst.entries()).sort((a, b) => b[1].length - a[1].length);
if (entries.length <= 1) {
return (
<div className="space-y-2">
{selectedGroup.points.map((p) => (
<button
key={p.id}
onClick={() => { setSelected(p); setSelectedGroup(null); }}
className="w-full rounded-xl border border-white/8 bg-white/[0.03] px-3 py-3 text-left transition hover:border-[#d6aa5b]/28 hover:bg-white/[0.06]"
>
<div className="flex items-start gap-3">
<span className="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full border border-[#d6aa5b]/25 bg-[#d6aa5b]/12 text-xs font-bold text-[#f6d48e]">
{CATEGORY_MARKS[p.category] ?? "物"}
</span>
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-semibold text-[#f1ead9]">{p.name}</div>
<div className="mt-0.5 text-[11px] text-[#8f8066]">
{p.dynasty || "年代待考"} · {CATEGORY_LABELS[p.category] ?? p.category}
</div>
</div>
<span className="flex-shrink-0 text-[10px] text-[#a99566]">
{p.level === "level_1" ? "一级" : p.level === "level_2" ? "二级" : ""}
</span>
</div>
</button>
))}
</div>
);
}
return (
<div className="space-y-4">
{entries.map(([inst, pts]) => (
<div key={inst}>
<div className="mb-2 flex items-center justify-between">
<span className="text-xs font-semibold text-[#f2cf83]">{inst}</span>
<span className="text-[10px] text-[#8f8066]">{pts.length} </span>
</div>
<div className="space-y-1.5">
{pts.map((p) => (
<button
key={p.id}
onClick={() => { setSelected(p); setSelectedGroup(null); }}
className="w-full rounded-xl border border-white/8 bg-white/[0.03] px-3 py-2.5 text-left transition hover:border-[#d6aa5b]/28 hover:bg-white/[0.06]"
>
<div className="flex items-start gap-3">
<span className="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full border border-[#d6aa5b]/25 bg-[#d6aa5b]/12 text-xs font-bold text-[#f6d48e]">
{CATEGORY_MARKS[p.category] ?? "物"}
</span>
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-semibold text-[#f1ead9]">{p.name}</div>
<div className="mt-0.5 text-[11px] text-[#8f8066]">
{p.dynasty || "年代待考"} · {CATEGORY_LABELS[p.category] ?? p.category}
</div>
</div>
<span className="flex-shrink-0 text-[10px] text-[#a99566]">
{p.level === "level_1" ? "一级" : p.level === "level_2" ? "二级" : ""}
</span>
</div>
</button>
))}
</div>
</div>
))}
</div>
);
})()}
</div>
</div>
) : selected ? (
<div className="flex h-full flex-col">
<div className="border-b border-[#d6aa5b]/12 p-5">
<div className="mb-3 flex items-center justify-between">
<span className="text-[11px] uppercase tracking-[0.28em] text-[#a99566]">
</span>
<button
onClick={() => setSelected(null)}
className="rounded-full border border-[#d6aa5b]/12 px-2 py-0.5 text-xs text-[#8f8066] hover:border-[#d6aa5b]/35 hover:text-[#f2cf83]"
>
</button>
</div>
<h2 className="font-serif text-2xl font-semibold leading-snug text-[#f6eddc]">
{selected.name}
</h2>
<p className="mt-2 text-xs text-[#9d927c]">
{selectedCityText} · {selected.institution_name || "未知机构"}
</p>
</div>
<button
onClick={() => setInfoCollapsed((v) => !v)}
className="mx-5 mt-3 flex shrink-0 items-center justify-between rounded-lg bg-white/[0.02] px-3 py-2 text-[11px] uppercase tracking-[0.24em] text-[#a99566] transition hover:bg-white/[0.04] hover:text-[#f2cf83]"
>
<span></span>
<span className="tracking-normal">{infoCollapsed ? "展开 ▾" : "收起 ▴"}</span>
</button>
{!infoCollapsed && (
<div className={`no-scrollbar overflow-y-auto px-5 ${chatStarted ? "max-h-[38%] pt-3" : "flex-1 pt-5"}`}>
<ArtifactImage key={selected.id} point={selected} />
<div className="mt-4 flex flex-wrap gap-2">
<span className="rounded-full border border-[#d6aa5b]/40 bg-[#d6aa5b]/18 px-3 py-1 text-xs font-medium text-[#f6dd9f]">
{CATEGORY_LABELS[selected.category] ?? selected.category}
</span>
<span className="rounded-full border border-[#8faea7]/35 bg-[#8faea7]/13 px-3 py-1 text-xs font-medium text-[#cce7de]">
{selected.dynasty || "年代待考"}
</span>
<span className="rounded-full border border-white/12 bg-white/[0.06] px-3 py-1 text-xs font-medium text-[#f1ead9]">
{LEVEL_LABELS[selected.level] ?? selected.level}
</span>
{selected.repatriation_status === "lost_overseas" && (
<span className="flex items-center gap-1 rounded-full border border-[#8faea7]/45 bg-[#2f6b60]/25 px-3 py-1 text-xs font-medium text-[#bfe3d6]">
<Waves size={12} />
</span>
)}
{selected.repatriation_status === "repatriated" && (
<span className="flex items-center gap-1 rounded-full border border-[#3f9e6a]/55 bg-[#3f9e6a]/22 px-3 py-1 text-xs font-medium text-[#aef0c6]">
<Home size={12} />
</span>
)}
</div>
{selected.story_hook && (
<div className="mt-5 rounded-2xl border border-[#d6aa5b]/[0.07] bg-[#d6aa5b]/[0.05] p-4">
<div className="mb-2 text-[11px] uppercase tracking-[0.24em] text-[#a99566]">
</div>
<p className="font-serif text-base leading-7 text-[#f6eddc]">
{selected.story_hook}
</p>
</div>
)}
<div className="mt-5 space-y-3 rounded-2xl border border-white/[0.04] bg-white/[0.02] p-4 text-xs">
<div className="flex items-center justify-between gap-4">
<span className="text-[#8f8066]"></span>
<span className="text-right text-[#f1ead9]">{selected.institution_name || "—"}</span>
</div>
<div className="flex items-center justify-between gap-4">
<span className="text-[#8f8066]"></span>
<span className="text-[#f1ead9]">{selectedCityText}</span>
</div>
<div className="flex items-center justify-between gap-4">
<span className="text-[#8f8066]"></span>
<span className="text-[#f1ead9]">
{selected.location_type === "domestic" ? "国内馆藏" : selected.location_type || "—"}
</span>
</div>
<div className="flex items-center justify-between gap-4">
<span className="text-[#8f8066]"></span>
<span className="font-mono text-[11px] text-[#f1ead9]">
{selected.lng.toFixed(4)}, {selected.lat.toFixed(4)}
</span>
</div>
</div>
{(() => {
const sameInst = points.filter(
(p) =>
p.institution_name &&
p.institution_name === selected.institution_name &&
p.id !== selected.id
);
if (sameInst.length === 0) return null;
return (
<div className="mt-5">
<button
onClick={() => setSameInstOpen((v) => !v)}
className="flex w-full items-center justify-between rounded-xl border border-[#d6aa5b]/[0.08] bg-white/[0.02] px-3 py-3 text-left transition hover:border-[#d6aa5b]/25 hover:bg-[#d6aa5b]/8"
>
<div className="min-w-0">
<div className="text-[11px] uppercase tracking-[0.24em] text-[#a99566]">
</div>
<div className="mt-1 truncate text-xs text-[#c7baa0]">
{selected.institution_name}
</div>
</div>
<span className="ml-2 flex-shrink-0 text-xs text-[#d6aa5b]">
{sameInstOpen ? "收起 ▴" : `还有 ${sameInst.length} 件 ▾`}
</span>
</button>
{sameInstOpen && (
<div className="mt-2 space-y-1.5">
{sameInst.map((p) => (
<button
key={p.id}
onClick={() => {
setSelected(p);
setSelectedGroup(null);
}}
className="flex w-full items-center gap-3 rounded-lg border border-white/[0.05] bg-white/[0.02] px-3 py-2 text-left transition hover:border-[#d6aa5b]/25 hover:bg-[#d6aa5b]/8"
>
<span className="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full border border-[#d6aa5b]/25 bg-[#d6aa5b]/12 text-xs font-bold text-[#f6d48e]">
{CATEGORY_MARKS[p.category] ?? "\u7269"}
</span>
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-semibold text-[#f1ead9]">{p.name}</div>
<div className="mt-0.5 text-[11px] text-[#8f8066]">
{p.dynasty || "年代待考"} · {CATEGORY_LABELS[p.category] ?? p.category}
</div>
</div>
<span className="flex-shrink-0 text-[10px] text-[#a99566]">
{p.level === "level_1" ? "一级" : p.level === "level_2" ? "二级" : ""}
</span>
</button>
))}
</div>
)}
</div>
);
})()}
</div>
)}
<div className={chatStarted || infoCollapsed ? "flex min-h-0 flex-1 flex-col px-5 pb-5 pt-3" : "shrink-0 px-5 pb-5"}>
<ArtifactChat
fill={chatStarted || infoCollapsed}
artifactId={selected.id}
artifactName={selected.name}
onConversationChange={handleConversationChange}
/>
</div>
</div>
) : (
<div className="flex h-full flex-col p-5">
<div className="text-[11px] uppercase tracking-[0.28em] text-[#a99566]"></div>
<h2 className="mt-3 font-serif text-2xl font-semibold text-[#f6eddc]">
</h2>
<p className="mt-3 text-sm leading-6 text-[#9d927c]">
</p>
</div>
)}
</aside>
<footer className="absolute bottom-0 left-0 right-0 z-50 flex h-11 items-center border-t border-[#d6aa5b]/12 bg-[#080705]/95 px-6 text-xs text-[#8f8066] backdrop-blur-xl">
<span className="text-[#c8b88e]"> {groupedPoints.length} {zoom <= 7 ? "个城市" : "个机构"}{institutionCount} · {visiblePoints.length} </span>
{filterCity && <span className="ml-4">{filterCity}</span>}
{filterCategory && <span className="ml-4">{CATEGORY_LABELS[filterCategory]}</span>}
{filterDynasty && <span className="ml-4">{filterDynasty}</span>}
<div className="flex-1" />
<span className="mr-4 flex items-center gap-3">
<span className="flex items-center gap-1.5">
<span className="inline-block h-2.5 w-2.5 rounded-full bg-[#b9832f]" />
</span>
<span className="flex items-center gap-1.5">
<span className="inline-block h-2.5 w-2.5 rounded-full bg-[#2f6b60]" />
</span>
<span className="flex items-center gap-1.5">
<span className="inline-block h-2.5 w-2.5 rounded-full bg-[#3f9e6a]" />
</span>
</span>
<span> seed · MVP </span>
</footer>
{showWelfare && (
<div
onClick={() => setShowWelfare(false)}
className="fixed inset-0 z-[300] flex items-center justify-center bg-black/80 backdrop-blur-sm"
>
<div
onClick={(e) => e.stopPropagation()}
className="w-[min(460px,90vw)] rounded-2xl border border-[#d6aa5b]/25 bg-[#0c0a06] p-6 shadow-[0_20px_60px_rgba(0,0,0,0.6)]"
>
<div className="mb-3 flex items-center justify-between">
<span className="font-serif text-lg font-semibold text-[#f2cf83]"></span>
<button
onClick={() => setShowWelfare(false)}
className="flex items-center gap-1 rounded-full border border-[#d6aa5b]/20 px-2.5 py-0.5 text-xs text-[#8f8066] hover:text-[#f2cf83]"
>
<X size={13} />
</button>
</div>
<p className="text-sm leading-7 text-[#d8cbb0]">
</p>
<p className="mt-4 text-xs leading-6 text-[#9d927c]">
</p>
<div className="mt-5 flex gap-2">
<button
onClick={() => setShowWelfare(false)}
className="flex-1 rounded-xl bg-[#d99b3d] px-4 py-2 text-sm font-semibold text-[#1a1005] transition hover:bg-[#e7ad52]"
>
</button>
</div>
<p className="mt-3 text-center text-[10px] text-[#6f6656]">MVP · </p>
</div>
</div>
)}
{showRepatriationPicker && (
<div
onClick={() => setShowRepatriationPicker(false)}
className="fixed inset-0 z-[300] flex items-center justify-center bg-black/80 backdrop-blur-sm"
>
<div
onClick={(e) => e.stopPropagation()}
className="flex max-h-[80vh] w-[min(560px,92vw)] flex-col rounded-2xl border border-[#3f9e6a]/35 bg-[#0c0a06] shadow-[0_20px_60px_rgba(0,0,0,0.6)]"
>
<div className="flex items-center justify-between border-b border-[#3f9e6a]/15 px-5 py-3.5">
<span className="flex items-center gap-2 font-serif text-base font-semibold text-[#aef0c6]">
<Undo2 size={16} /> ·
</span>
<button
onClick={() => setShowRepatriationPicker(false)}
className="flex items-center gap-1 rounded-full border border-[#d6aa5b]/20 px-2.5 py-0.5 text-xs text-[#8f8066] hover:text-[#f2cf83]"
>
<X size={13} />
</button>
</div>
{/* 门类筛选 */}
<div className="flex flex-wrap gap-1.5 border-b border-[#3f9e6a]/10 px-5 py-3">
<button
onClick={() => setRepatPickerCat("")}
className={`rounded-full border px-2.5 py-1 text-[11px] transition ${
repatPickerCat === ""
? "border-[#3f9e6a]/55 bg-[#3f9e6a]/18 text-[#aef0c6]"
: "border-[#d6aa5b]/12 bg-white/[0.03] text-[#b5aa94] hover:text-[#f2cf83]"
}`}
>
</button>
{Array.from(
new Set(
routes
.filter((r) => r.type === "repatriation" && r.artifact_category)
.map((r) => r.artifact_category as string)
)
).map((cat) => (
<button
key={cat}
onClick={() => setRepatPickerCat(cat)}
className={`rounded-full border px-2.5 py-1 text-[11px] transition ${
repatPickerCat === cat
? "border-[#3f9e6a]/55 bg-[#3f9e6a]/18 text-[#aef0c6]"
: "border-[#d6aa5b]/12 bg-white/[0.03] text-[#b5aa94] hover:text-[#f2cf83]"
}`}
>
{CATEGORY_LABELS[cat] ?? cat}
</button>
))}
</div>
{/* 文物列表 */}
<div className="no-scrollbar flex-1 space-y-2 overflow-y-auto p-4">
{routes
.filter(
(r) =>
r.type === "repatriation" &&
(repatPickerCat === "" || r.artifact_category === repatPickerCat)
)
.map((r) => (
<button
key={r.code}
onClick={() => selectRepatriation(r)}
className="flex w-full items-start gap-3 rounded-xl border border-white/[0.05] bg-white/[0.03] px-4 py-3 text-left transition hover:border-[#3f9e6a]/35 hover:bg-[#3f9e6a]/8"
>
<span className="mt-0.5 flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-full border border-[#3f9e6a]/30 bg-[#3f9e6a]/12 font-serif text-base font-bold text-[#aef0c6]">
{CATEGORY_MARKS[r.artifact_category ?? "other"] ?? "物"}
</span>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold text-[#f1ead9]">
{r.artifact_name ?? r.title}
</div>
<div className="mt-0.5 text-[11px] text-[#8f8066]">
{r.artifact_dynasty || "年代待考"} · {CATEGORY_LABELS[r.artifact_category ?? "other"] ?? ""}
{r.institution_name ? ` · 现藏 ${r.institution_name}` : ""}
</div>
{r.summary && (
<div className="mt-1 line-clamp-2 text-[11px] leading-4 text-[#9d927c]">{r.summary}</div>
)}
</div>
<ChevronRight size={16} className="mt-2 flex-shrink-0 text-[#3f9e6a]" />
</button>
))}
</div>
<div className="border-t border-[#3f9e6a]/10 px-5 py-2 text-center text-[10px] text-[#6f6656]">
</div>
</div>
</div>
)}
</div>
);
}