2d847e154f
中华文明全图鉴——文物全图系统(PC Web 地图 + NestJS API + 管理后台)。 含三大 IP(文物南迁北归 / 国宝海外回归 / 博物馆手艺人)、AI 文物对话、 文物地图与详情、以及 demo-video-kit 演示视频生成工具。
1261 lines
58 KiB
TypeScript
1261 lines
58 KiB
TypeScript
"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>
|
||
);
|
||
}
|