chore: 初始化仓库

中华文明全图鉴——文物全图系统(PC Web 地图 + NestJS API + 管理后台)。
含三大 IP(文物南迁北归 / 国宝海外回归 / 博物馆手艺人)、AI 文物对话、
文物地图与详情、以及 demo-video-kit 演示视频生成工具。
This commit is contained in:
selfrelease
2026-06-13 20:55:44 +08:00
commit 2d847e154f
161 changed files with 22629 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
NEXT_PUBLIC_API_URL=http://localhost:3002
# 地图样式 URLMapLibre GL 格式,需申请后填入)
NEXT_PUBLIC_MAP_STYLE=
+5
View File
@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
+11
View File
@@ -0,0 +1,11 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
transpilePackages: ["@wenwumap/shared"],
env: {
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || "http://localhost:3002",
NEXT_PUBLIC_MAP_STYLE: process.env.NEXT_PUBLIC_MAP_STYLE || "",
},
};
module.exports = nextConfig;
+42
View File
@@ -0,0 +1,42 @@
{
"name": "@wenwumap/web",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev -p 3000",
"build": "next build",
"start": "next start -p 3000",
"lint": "next lint",
"type-check": "tsc --noEmit"
},
"dependencies": {
"@googlemaps/markerclusterer": "^2.6.2",
"@radix-ui/react-dialog": "^1.1.1",
"@radix-ui/react-select": "^2.1.1",
"@radix-ui/react-slider": "^1.2.0",
"@radix-ui/react-tooltip": "^1.1.2",
"@vis.gl/react-google-maps": "^1.4.0",
"@wenwumap/shared": "workspace:*",
"clsx": "^2.1.1",
"lucide-react": "^0.414.0",
"maplibre-gl": "^4.5.0",
"next": "^14.2.5",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-map-gl": "^7.1.7",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"swr": "^2.2.5",
"tailwind-merge": "^2.4.0"
},
"devDependencies": {
"@types/google.maps": "^3.65.1",
"@types/node": "^22.0.0",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"autoprefixer": "^10.4.19",
"postcss": "^8.4.40",
"tailwindcss": "^3.4.7",
"typescript": "^5.5.3"
}
}
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 381 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 430 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 356 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 280 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 990 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 614 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 302 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 334 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 710 KiB

+140
View File
@@ -0,0 +1,140 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@import url('https://fonts.googleapis.com/css2?family=Noto+Serif+SC:wght@400;500;600;700&family=Noto+Sans+SC:wght@300;400;500&display=swap');
:root {
--color-ink: #1a1a2e;
--color-gold: #c9a84c;
--color-celadon: #8fbcb0;
--color-vermilion: #c94b4b;
--color-parchment: #f5f0e8;
}
html, body {
margin: 0;
padding: 0;
background: var(--color-ink);
color: var(--color-parchment);
font-family: 'Noto Sans SC', sans-serif;
height: 100%;
overflow: hidden;
}
#map {
width: 100%;
height: 100%;
}
/* 全站隐藏滚动条但仍可滚动 */
* {
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* 旧版 Edge / IE */
}
*::-webkit-scrollbar {
width: 0;
height: 0;
display: none; /* Chrome / Safari / 新版 Edge */
}
/* 兼容旧用法:保留 .no-scrollbar 工具类 */
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
.no-scrollbar::-webkit-scrollbar {
width: 0;
height: 0;
display: none;
}
.maplibregl-popup-content {
background: rgba(26, 26, 46, 0.95);
border: 1px solid var(--color-gold);
border-radius: 4px;
color: var(--color-parchment);
padding: 0;
box-shadow: 0 4px 24px rgba(0,0,0,0.5);
}
.maplibregl-popup-tip {
border-top-color: var(--color-gold);
}
/* AI 对话气泡中的 Markdown 排版 */
.md-chat {
font-size: 12px;
line-height: 1.7;
color: #ecdfc6;
word-break: break-word;
}
.md-chat > :first-child { margin-top: 0; }
.md-chat > :last-child { margin-bottom: 0; }
.md-chat p { margin: 0.5em 0; }
.md-chat h1, .md-chat h2, .md-chat h3, .md-chat h4 {
margin: 0.8em 0 0.4em;
font-weight: 600;
color: #f2cf83;
line-height: 1.4;
}
.md-chat h1 { font-size: 1.25em; }
.md-chat h2 { font-size: 1.15em; }
.md-chat h3 { font-size: 1.05em; }
.md-chat strong { color: #f6d48e; font-weight: 600; }
.md-chat em { color: #cce7de; }
.md-chat a { color: #e7ad52; text-decoration: underline; text-underline-offset: 2px; }
.md-chat ul, .md-chat ol { margin: 0.5em 0; padding-left: 1.25em; }
.md-chat li { margin: 0.25em 0; }
.md-chat ul li { list-style: disc; }
.md-chat ol li { list-style: decimal; }
.md-chat blockquote {
margin: 0.6em 0;
padding: 0.2em 0.9em;
border-left: 3px solid var(--color-gold);
background: rgba(214, 170, 91, 0.08);
color: #d8c6a0;
border-radius: 0 6px 6px 0;
}
.md-chat code {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.9em;
background: rgba(0, 0, 0, 0.4);
border: 1px solid rgba(214, 170, 91, 0.18);
border-radius: 4px;
padding: 0.05em 0.35em;
color: #f0d39a;
}
.md-chat pre {
margin: 0.6em 0;
padding: 0.8em;
overflow-x: auto;
background: rgba(0, 0, 0, 0.5);
border: 1px solid rgba(214, 170, 91, 0.18);
border-radius: 8px;
}
.md-chat pre code { background: none; border: none; padding: 0; }
.md-chat table {
width: 100%;
margin: 0.6em 0;
border-collapse: collapse;
font-size: 0.95em;
}
.md-chat th, .md-chat td {
border: 1px solid rgba(214, 170, 91, 0.2);
padding: 0.35em 0.6em;
text-align: left;
}
.md-chat th { background: rgba(214, 170, 91, 0.12); color: #f2cf83; }
.md-chat hr { margin: 0.8em 0; border: none; border-top: 1px solid rgba(214, 170, 91, 0.2); }
/* 修正浏览器自动填充把输入框背景变白、文字看不清的问题 */
input:-webkit-autofill,
input:-webkit-autofill:hover,
input:-webkit-autofill:focus,
input:-webkit-autofill:active {
-webkit-text-fill-color: #f6eddc !important;
-webkit-box-shadow: 0 0 0 1000px #11100d inset !important;
caret-color: #f6eddc;
transition: background-color 99999s ease-in-out 0s;
}
+25
View File
@@ -0,0 +1,25 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "中华文明全图鉴 · 文物全图",
description: "探索中华文明珍贵文物的全球分布,追溯它们跨越千年的流转故事。",
keywords: ["文物", "中华文明", "博物馆", "地图", "文化遗产"],
openGraph: {
title: "中华文明全图鉴 · 文物全图",
description: "探索中华文明珍贵文物的全球分布",
type: "website",
},
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="zh-CN">
<body>{children}</body>
</html>
);
}
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function Home() {
redirect("/map");
}
+398
View File
@@ -0,0 +1,398 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { Sparkles, ChevronRight } from "lucide-react";
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3002";
type Persona = "artifact" | "guide" | "scholar" | "migration" | "repatriation" | "youth";
type Role = "user" | "assistant";
interface ChatMessage {
role: Role;
content: string;
}
const PERSONAS: { key: Persona; label: string; desc: string }[] = [
{ key: "artifact", label: "文物自述", desc: "以「我」第一人称,拟人化讲述自己的故事" },
{ key: "guide", label: "讲解员", desc: "亲切的资深讲解员,通俗生动" },
{ key: "scholar", label: "历史学者", desc: "严谨客观,考据式讲解" },
{ key: "migration", label: "南迁亲历", desc: "以文物视角讲述文物南迁的颠沛与守护" },
{ key: "repatriation", label: "回归叙事", desc: "讲述流失海外与回归故土的心路" },
{ key: "youth", label: "少年讲解", desc: "面向青少年的活泼科普讲解" },
];
const STARTERS: Record<Persona, string[]> = {
artifact: ["你是谁?", "讲讲你的身世", "你最特别的地方是什么?", "你经历过什么劫难?"],
guide: ["带我认识一下这件文物", "它为什么珍贵?", "有什么有趣的故事?", "它是怎么被发现的?"],
scholar: ["它的历史背景是什么?", "学术上有哪些争议?", "它的工艺有何特点?", "它有何研究价值?"],
migration: ["南迁时你经历了什么?", "谁在守护你?", "路上最危险的一刻?", "你想对护送你的人说什么?"],
repatriation: ["你是怎么流落海外的?", "回家那天什么心情?", "漂泊时最想念什么?", "你想对同胞说什么?"],
youth: ["你几岁啦?", "用一句话介绍自己", "你身上有什么小秘密?", "我能从你身上学到什么?"],
};
interface ArtifactChatProps {
artifactId: string;
artifactName: string;
onConversationChange?: (active: boolean) => void;
fill?: boolean;
}
export default function ArtifactChat({ artifactId, artifactName, onConversationChange, fill }: ArtifactChatProps) {
const [persona, setPersona] = useState<Persona>("artifact");
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState("");
const [streaming, setStreaming] = useState(false);
const [error, setError] = useState<string | null>(null);
const [suggestions, setSuggestions] = useState<string[]>([]);
const [loadingSuggestions, setLoadingSuggestions] = useState(false);
const abortRef = useRef<AbortController | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null);
const storageKey = `wenwu_chat_${artifactId}_${persona}`;
const persist = useCallback(
(msgs: ChatMessage[]) => {
try {
if (msgs.length > 0) localStorage.setItem(storageKey, JSON.stringify(msgs));
else localStorage.removeItem(storageKey);
} catch {
/* localStorage 不可用时忽略 */
}
},
[storageKey]
);
// 切换文物或角色时:从本地存储恢复该组合的历史对话
useEffect(() => {
abortRef.current?.abort();
setError(null);
setStreaming(false);
setSuggestions([]);
let restored: ChatMessage[] = [];
try {
const raw = localStorage.getItem(storageKey);
if (raw) {
const parsed: unknown = JSON.parse(raw);
if (Array.isArray(parsed)) {
restored = parsed.filter(
(m): m is ChatMessage =>
!!m &&
(m.role === "user" || m.role === "assistant") &&
typeof m.content === "string"
);
}
}
} catch {
/* ignore */
}
setMessages(restored);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [storageKey]);
useEffect(() => {
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" });
}, [messages, streaming, suggestions]);
// 通知父组件对话是否已开始(用于折叠上方文物信息)
useEffect(() => {
onConversationChange?.(messages.length > 0);
}, [messages.length, onConversationChange]);
const fetchSuggestions = useCallback(
async (history: ChatMessage[]) => {
setLoadingSuggestions(true);
try {
const res = await fetch(`${API_URL}/api/v1/ai/suggestions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ artifactId, persona, messages: history }),
});
if (!res.ok) return;
const data = (await res.json()) as { suggestions?: string[] };
if (Array.isArray(data.suggestions) && data.suggestions.length > 0) {
setSuggestions(data.suggestions.slice(0, 4));
}
} catch {
/* 建议生成失败不影响主流程 */
} finally {
setLoadingSuggestions(false);
}
},
[artifactId, persona]
);
const send = useCallback(
async (text: string) => {
const content = text.trim();
if (!content || streaming) return;
const history: ChatMessage[] = [...messages, { role: "user", content }];
setMessages([...history, { role: "assistant", content: "" }]);
setInput("");
setStreaming(true);
setError(null);
setSuggestions([]);
const controller = new AbortController();
abortRef.current = controller;
let assistantContent = "";
let ok = false;
try {
const res = await fetch(`${API_URL}/api/v1/ai/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ artifactId, persona, messages: history }),
signal: controller.signal,
});
if (!res.ok || !res.body) {
throw new Error(`请求失败(${res.status}`);
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let sep: number;
while ((sep = buffer.indexOf("\n\n")) >= 0) {
const evt = buffer.slice(0, sep);
buffer = buffer.slice(sep + 2);
for (const line of evt.split("\n")) {
const t = line.trim();
if (!t.startsWith("data:")) continue;
const data = t.slice(5).trim();
if (data === "[DONE]") continue;
try {
const json = JSON.parse(data) as { t?: string; error?: string };
if (json.error) {
setError(json.error);
} else if (json.t) {
assistantContent += json.t;
setMessages((prev) => {
const next = [...prev];
const last = next[next.length - 1];
if (last && last.role === "assistant") {
next[next.length - 1] = { ...last, content: last.content + json.t };
}
return next;
});
}
} catch {
/* 忽略 */
}
}
}
}
ok = assistantContent.length > 0;
} catch (e) {
if ((e as Error).name !== "AbortError") {
setError((e as Error).message || "对话出错了");
}
} finally {
setStreaming(false);
abortRef.current = null;
}
// 每轮回答后生成 4 个下一步追问
if (ok) {
const finalHistory: ChatMessage[] = [
...history,
{ role: "assistant", content: assistantContent },
];
persist(finalHistory);
void fetchSuggestions(finalHistory);
}
},
[artifactId, persona, messages, streaming, fetchSuggestions, persist]
);
return (
<div
className={`${
fill ? "flex h-full min-h-0 flex-col" : "mt-5"
} rounded-2xl border border-[#d6aa5b]/[0.07] bg-black/20`}
>
<div className="flex shrink-0 items-center justify-between border-b border-[#d6aa5b]/[0.06] px-4 py-3">
<div className="flex items-center gap-2">
<span className="flex h-6 w-6 items-center justify-center rounded-full border border-[#d6aa5b]/35 bg-[#d6aa5b]/12 text-[#f2cf83]">
<Sparkles size={13} />
</span>
<span className="text-[11px] uppercase tracking-[0.24em] text-[#a99566]"></span>
</div>
{messages.length > 0 && (
<button
onClick={() => {
abortRef.current?.abort();
setMessages([]);
setError(null);
setSuggestions([]);
persist([]);
}}
className="text-[11px] text-[#8f8066] transition hover:text-[#f2cf83]"
>
</button>
)}
</div>
{/* 角色设置 */}
<div className="shrink-0 border-b border-[#d6aa5b]/[0.05] px-4 py-3">
<div className="mb-2 text-[10px] uppercase tracking-[0.22em] text-[#8f8066]"></div>
<div className="flex flex-wrap gap-1.5">
{PERSONAS.map((p) => (
<button
key={p.key}
title={p.desc}
onClick={() => setPersona(p.key)}
className={`rounded-full border px-2.5 py-1 text-[11px] transition ${
persona === p.key
? "border-[#d6aa5b]/55 bg-[#d6aa5b]/18 text-[#f2cf83]"
: "border-[#d6aa5b]/12 bg-white/[0.03] text-[#b5aa94] hover:border-[#d6aa5b]/30 hover:text-[#f2cf83]"
}`}
>
{p.label}
</button>
))}
</div>
<p className="mt-2 text-[10px] leading-4 text-[#7c7058]">
{PERSONAS.find((p) => p.key === persona)?.desc}
</p>
</div>
{/* 消息区 */}
<div
ref={scrollRef}
className={`no-scrollbar space-y-3 overflow-y-auto px-4 py-3 ${
fill ? "flex-1 min-h-0" : "max-h-72"
}`}
>
{messages.length === 0 && (
<div className="space-y-2">
<p className="text-[11px] leading-5 text-[#9d927c]">
{persona === "artifact"
? `我是「${artifactName}」,问我点什么吧。`
: `关于「${artifactName}」,你想了解什么?`}
</p>
<div className="flex flex-wrap gap-1.5">
{STARTERS[persona].map((s) => (
<button
key={s}
onClick={() => send(s)}
className="rounded-full border border-[#d6aa5b]/15 bg-white/[0.03] px-2.5 py-1 text-[11px] text-[#c7baa0] transition hover:border-[#d6aa5b]/30 hover:text-[#f2cf83]"
>
{s}
</button>
))}
</div>
</div>
)}
{messages.map((m, i) =>
m.role === "user" ? (
<div key={i} className="flex justify-end">
<div className="max-w-[85%] rounded-2xl rounded-br-sm bg-[#d99b3d] px-3 py-2 text-xs leading-5 text-[#1a1005]">
{m.content}
</div>
</div>
) : (
<div key={i} className="flex justify-start">
<div className="max-w-[92%] rounded-2xl rounded-bl-sm border border-[#d6aa5b]/14 bg-[#11100d]/80 px-3 py-2 text-[#ecdfc6]">
{m.content ? (
<div className="md-chat">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{m.content}</ReactMarkdown>
</div>
) : (
<span className="inline-flex gap-1 py-1 align-middle">
<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>
)}
</div>
</div>
)
)}
{error && (
<div className="rounded-lg border border-[#c94b4b]/35 bg-[#c94b4b]/10 px-3 py-2 text-[11px] text-[#f0b9b9]">
{error}
</div>
)}
{/* 下一步追问建议 */}
{!streaming && (loadingSuggestions || suggestions.length > 0) && messages.length > 0 && (
<div className="pt-1">
<div className="mb-1.5 text-[10px] uppercase tracking-[0.22em] text-[#8f8066]">
·
</div>
{loadingSuggestions && suggestions.length === 0 ? (
<div className="flex items-center gap-1.5 text-[11px] text-[#7c7058]">
<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>
</div>
) : (
<div className="flex flex-col gap-1.5">
{suggestions.map((s, i) => (
<button
key={`${s}-${i}`}
onClick={() => send(s)}
className="group flex items-center gap-2 rounded-xl border border-[#d6aa5b]/15 bg-white/[0.03] px-3 py-2 text-left text-[11px] leading-4 text-[#c7baa0] transition hover:border-[#d6aa5b]/35 hover:bg-[#d6aa5b]/8 hover:text-[#f2cf83]"
>
<span className="text-[#a99566] group-hover:text-[#f2cf83]">
<ChevronRight size={13} />
</span>
<span className="flex-1">{s}</span>
</button>
))}
</div>
)}
</div>
)}
</div>
{/* 输入区 */}
<form
onSubmit={(e) => {
e.preventDefault();
send(input);
}}
className="flex shrink-0 items-center gap-2 border-t border-[#d6aa5b]/[0.06] px-3 py-2.5"
>
<input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder={streaming ? "回答生成中…" : "输入你的问题…"}
disabled={streaming}
className="flex-1 rounded-full border border-[#d6aa5b]/16 bg-black/30 px-3 py-2 text-xs text-[#f6eddc] outline-none transition placeholder:text-[#6f6656] focus:border-[#d6aa5b]/50 disabled:opacity-60"
/>
{streaming ? (
<button
type="button"
onClick={() => abortRef.current?.abort()}
className="flex-shrink-0 rounded-full border border-[#d6aa5b]/30 px-3 py-2 text-xs text-[#f2cf83] transition hover:bg-[#d6aa5b]/12"
>
</button>
) : (
<button
type="submit"
disabled={!input.trim()}
className="flex-shrink-0 rounded-full bg-[#d99b3d] px-4 py-2 text-xs font-semibold text-[#1a1005] transition hover:bg-[#e7ad52] disabled:opacity-40"
>
</button>
)}
</form>
</div>
);
}
+211
View File
@@ -0,0 +1,211 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { ZoomIn, Minus, Plus, RotateCcw, X } from "lucide-react";
import {
artifactImageSrc,
CATEGORY_LABELS,
CATEGORY_MARKS,
type MapPoint,
} from "../lib/artifact";
const clamp = (v: number, a: number, b: number) => Math.max(a, Math.min(b, v));
export default function ArtifactImage({ point }: { point: MapPoint }) {
const [failed, setFailed] = useState(false);
const [useProxy, setUseProxy] = useState(true);
const [open, setOpen] = useState(false);
const [mounted, setMounted] = useState(false);
const [scale, setScale] = useState(1);
const [tx, setTx] = useState(0);
const [ty, setTy] = useState(0);
const [dragging, setDragging] = useState(false);
const dragRef = useRef<{ x: number; y: number; tx: number; ty: number } | null>(null);
const showReal = Boolean(point.image_url) && !failed;
const mark = CATEGORY_MARKS[point.category] ?? "物";
// 本地静态图(/artifacts/<id>.jpg);加载失败回退到原始直链,再失败显示示意图
const directHd = point.image_url
? /\?width=\d+/.test(point.image_url)
? point.image_url.replace(/\?width=\d+/, "?width=2000")
: `${point.image_url}${point.image_url.includes("?") ? "&" : "?"}width=2000`
: "";
const coverSrc = useProxy ? artifactImageSrc(point.id) : (point.image_url as string);
const modalSrc = useProxy ? artifactImageSrc(point.id, true) : directHd;
const onImgError = () => {
if (useProxy) setUseProxy(false);
else setFailed(true);
};
useEffect(() => setMounted(true), []);
// 切换文物时重置图片加载态
useEffect(() => {
setFailed(false);
setUseProxy(true);
}, [point.id]);
const resetZoom = () => {
setScale(1);
setTx(0);
setTy(0);
};
const openModal = () => {
resetZoom();
setOpen(true);
};
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open]);
// 以鼠标位置为锚点缩放
const onWheel = (e: React.WheelEvent) => {
const rect = e.currentTarget.getBoundingClientRect();
const mx = e.clientX - rect.left - rect.width / 2;
const my = e.clientY - rect.top - rect.height / 2;
const factor = e.deltaY < 0 ? 1.12 : 0.892;
setScale((prev) => {
const next = clamp(prev * factor, 0.3, 12);
const ratio = next / prev;
setTx((x) => mx - ratio * (mx - x));
setTy((y) => my - ratio * (my - y));
return next;
});
};
const onDown = (e: React.MouseEvent) => {
dragRef.current = { x: e.clientX, y: e.clientY, tx, ty };
setDragging(true);
};
const onMove = (e: React.MouseEvent) => {
const d = dragRef.current;
if (!d) return;
setTx(d.tx + (e.clientX - d.x));
setTy(d.ty + (e.clientY - d.y));
};
const onUp = () => {
dragRef.current = null;
setDragging(false);
};
return (
<>
<div className="relative h-44 w-full overflow-hidden rounded-2xl border border-[#d6aa5b]/[0.08] bg-[#0c0a06]">
{showReal ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={coverSrc}
alt={point.name}
loading="lazy"
onError={onImgError}
onClick={openModal}
className="h-full w-full cursor-zoom-in object-cover transition hover:opacity-90"
/>
) : (
<div className="flex h-full w-full flex-col items-center justify-center bg-[radial-gradient(circle_at_30%_25%,rgba(214,170,91,0.18),transparent_60%),linear-gradient(135deg,#17120a,#0b1413)]">
<span className="flex h-16 w-16 items-center justify-center rounded-full border border-[#d6aa5b]/30 bg-[#d6aa5b]/10 font-serif text-3xl text-[#f2cf83]">
{mark}
</span>
<span className="mt-2 text-[10px] tracking-[0.3em] text-[#8f8066]"></span>
</div>
)}
<span className="absolute left-2 top-2 rounded-full bg-black/55 px-2 py-0.5 text-[10px] text-[#f6d48e] backdrop-blur">
{CATEGORY_LABELS[point.category] ?? point.category}
</span>
{showReal && (
<span className="pointer-events-none absolute bottom-2 right-2 flex items-center gap-1 rounded-full bg-black/55 px-2 py-0.5 text-[10px] text-[#f6d48e] backdrop-blur">
<ZoomIn size={11} />
</span>
)}
</div>
{mounted &&
open &&
showReal &&
createPortal(
<div
onClick={() => setOpen(false)}
className="fixed inset-0 z-[2000] flex flex-col bg-black/92 backdrop-blur-sm"
>
<div
className="flex shrink-0 items-center justify-between px-5 py-3 text-[#f6eddc]"
onClick={(e) => e.stopPropagation()}
>
<div className="min-w-0">
<div className="truncate font-serif text-base font-semibold text-[#f2cf83]">{point.name}</div>
<div className="truncate text-[11px] text-[#9d927c]">
{point.institution_name || ""} · · ·
</div>
</div>
<button
onClick={() => setOpen(false)}
className="ml-3 flex flex-shrink-0 items-center gap-1 rounded-full border border-[#d6aa5b]/30 px-3 py-1 text-xs text-[#f2cf83] transition hover:bg-[#d6aa5b]/15"
>
<X size={13} />
</button>
</div>
<div
className="relative flex-1 overflow-hidden"
onClick={(e) => e.stopPropagation()}
onWheel={onWheel}
onMouseDown={onDown}
onMouseMove={onMove}
onMouseUp={onUp}
onMouseLeave={onUp}
onDoubleClick={resetZoom}
style={{ cursor: dragging ? "grabbing" : "grab" }}
>
<div className="flex h-full w-full items-center justify-center">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={modalSrc}
alt={point.name}
draggable={false}
onError={onImgError}
className="max-h-[86vh] max-w-[92vw] select-none object-contain"
style={{
transform: `translate(${tx}px, ${ty}px) scale(${scale})`,
transition: dragging ? "none" : "transform 0.08s ease-out",
}}
/>
</div>
<div
className="absolute bottom-4 left-1/2 flex -translate-x-1/2 items-center gap-3 rounded-full border border-[#d6aa5b]/20 bg-black/65 px-4 py-1.5 text-[#f2cf83] backdrop-blur"
onClick={(e) => e.stopPropagation()}
>
<button
onClick={() => setScale((s) => clamp(s * 0.8, 0.3, 12))}
className="flex items-center transition hover:text-[#ffe2a0]"
>
<Minus size={16} />
</button>
<span className="w-12 text-center text-[11px] tabular-nums">{Math.round(scale * 100)}%</span>
<button
onClick={() => setScale((s) => clamp(s * 1.25, 0.3, 12))}
className="flex items-center transition hover:text-[#ffe2a0]"
>
<Plus size={16} />
</button>
<button
onClick={resetZoom}
className="ml-1 flex items-center gap-1 text-[11px] text-[#c7baa0] transition hover:text-[#f2cf83]"
>
<RotateCcw size={12} />
</button>
</div>
</div>
</div>,
document.body
)}
</>
);
}
+182
View File
@@ -0,0 +1,182 @@
"use client";
import { useEffect, useRef } from "react";
import { AdvancedMarker, useMap } from "@vis.gl/react-google-maps";
import type { RouteStop } from "../lib/artifact";
interface RouteLayerProps {
stops: RouteStop[];
color: string;
visibleCount: number; // 时间轴:显示前 N 个途经点
activeIndex: number;
onStopClick?: (index: number) => void;
}
export default function RouteLayer({ stops, color, visibleCount, activeIndex, onStopClick }: RouteLayerProps) {
const map = useMap();
const fullRef = useRef<google.maps.Polyline | null>(null); // 全程虚线预览
const progRef = useRef<google.maps.Polyline | null>(null); // 已走过实线
const segRef = useRef<google.maps.Polyline | null>(null); // 当前段(承载流动箭头)
const shown = stops.slice(0, Math.max(1, visibleCount));
// 激活路线时自动缩放到全程
useEffect(() => {
if (!map || typeof google === "undefined" || stops.length === 0) return;
const bounds = new google.maps.LatLngBounds();
stops.forEach((s) => bounds.extend({ lat: s.lat, lng: s.lng }));
map.fitBounds(bounds, 150);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [map, stops]);
// 全程:虚线预览(未走过的部分)
useEffect(() => {
if (!map || typeof google === "undefined") return;
if (!fullRef.current) {
fullRef.current = new google.maps.Polyline({ clickable: false, strokeOpacity: 0 });
}
fullRef.current.setOptions({
strokeOpacity: 0,
icons: [
{
icon: { path: "M 0,-1 0,1", strokeOpacity: 0.5, strokeColor: color, scale: 2.4 },
offset: "0",
repeat: "13px",
},
],
});
fullRef.current.setPath(stops.map((s) => ({ lat: s.lat, lng: s.lng })));
fullRef.current.setMap(map);
return () => fullRef.current?.setMap(null);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [map, stops, color]);
// 已走过:实线
useEffect(() => {
if (!map || typeof google === "undefined") return;
if (!progRef.current) {
progRef.current = new google.maps.Polyline({ clickable: false, geodesic: false });
}
progRef.current.setOptions({ strokeColor: color, strokeOpacity: 0.95, strokeWeight: 4, zIndex: 5 });
progRef.current.setPath(shown.map((s) => ({ lat: s.lat, lng: s.lng })));
progRef.current.setMap(map);
return () => progRef.current?.setMap(null);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [map, visibleCount, stops, color]);
// 流动动画:箭头只沿「当前段」走一趟(约 1 秒),到站后自动移除——
// 既满足"完成后去掉箭头",也避免持续刷新地图导致 GPU 长期占用
useEffect(() => {
if (!map || typeof google === "undefined") return;
if (segRef.current) {
segRef.current.setMap(null);
segRef.current = null;
}
if (activeIndex <= 0) return; // 第一站没有来向段
const a = stops[activeIndex - 1];
const b = stops[activeIndex];
if (!a || !b) return;
const line = new google.maps.Polyline({
clickable: false,
strokeOpacity: 0,
zIndex: 7,
path: [
{ lat: a.lat, lng: a.lng },
{ lat: b.lat, lng: b.lng },
],
});
line.setMap(map);
segRef.current = line;
const arrow = {
path: google.maps.SymbolPath.FORWARD_CLOSED_ARROW,
strokeColor: "#fff7e6",
strokeWeight: 1,
fillColor: color,
fillOpacity: 1,
scale: 3.6,
};
let off = 0;
const timer = window.setInterval(() => {
off += 5;
if (off >= 100) {
window.clearInterval(timer);
line.setMap(null); // 到站后移除箭头,仅留实线 + 落点脉冲
if (segRef.current === line) segRef.current = null;
return;
}
line.setOptions({ icons: [{ icon: arrow, offset: `${off}%`, repeat: "0" }] });
}, 40);
return () => {
window.clearInterval(timer);
line.setMap(null);
if (segRef.current === line) segRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [map, activeIndex, stops, color]);
// 卸载清理
useEffect(() => {
return () => {
fullRef.current?.setMap(null);
progRef.current?.setMap(null);
segRef.current?.setMap(null);
fullRef.current = null;
progRef.current = null;
segRef.current = null;
};
}, []);
return (
<>
{shown.map((s, i) => {
const isActive = i === activeIndex;
const isPast = i < activeIndex;
return (
<AdvancedMarker
key={`${s.seq}-${i}`}
position={{ lat: s.lat, lng: s.lng }}
zIndex={1000 + i}
onClick={() => onStopClick?.(i)}
>
<div className="flex -translate-y-1 cursor-pointer flex-col items-center gap-0.5">
{isActive && (
<span
className="mb-0.5 whitespace-nowrap rounded-md px-2 py-0.5 text-[11px] font-semibold text-[#1a1005] shadow-[0_4px_14px_rgba(0,0,0,0.5)]"
style={{ background: color }}
>
{s.year_label ? `${s.year_label} · ` : ""}
{s.name}
</span>
)}
<div className="relative flex items-center justify-center">
{isActive && (
<span
className="absolute inline-flex h-8 w-8 animate-ping rounded-full opacity-60"
style={{ background: color }}
/>
)}
<div
className="relative flex items-center justify-center rounded-full border-2 font-serif font-bold leading-none shadow-[0_6px_18px_rgba(0,0,0,0.55)] transition-all"
style={{
width: isActive ? 32 : 20,
height: isActive ? 32 : 20,
fontSize: isActive ? 14 : 10,
background: isActive || isPast ? color : "#1a160d",
borderColor: isActive ? "#fff7e6" : color,
color: isActive || isPast ? "#1a1005" : color,
opacity: isPast ? 0.9 : 1,
}}
>
{s.seq}
</div>
</div>
</div>
</AdvancedMarker>
);
})}
</>
);
}
+129
View File
@@ -0,0 +1,129 @@
export const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3002";
export interface MapPoint {
id: string;
name: string;
category: string;
level: string;
dynasty: string;
story_hook: string;
lng: number;
lat: number;
institution_name: string;
province?: string;
city?: string;
location_type?: string;
image_url?: string | null;
repatriation_status?: string;
}
export interface RouteStop {
seq: number;
name: string;
lng: number;
lat: number;
year_label: string | null;
event: string | null;
}
export interface RouteSummary {
code: string;
title: string;
type: string; // migration | repatriation
color: string | null;
summary: string | null;
artifact_id?: string | null;
artifact_name?: string | null;
artifact_dynasty?: string | null;
artifact_category?: string | null;
institution_name?: string | null;
}
export interface RouteDetail extends RouteSummary {
stops: RouteStop[];
}
export type Theme = "domestic" | "overseas" | "repatriated";
export function themeOf(p: { location_type?: string; repatriation_status?: string }): Theme {
if (p.repatriation_status === "repatriated") return "repatriated";
if (p.repatriation_status === "lost_overseas" || p.location_type === "overseas") return "overseas";
return "domestic";
}
export const REPATRIATION_LABELS: Record<string, string> = {
domestic: "国内传承",
lost_overseas: "流失海外",
repatriated: "已回归",
in_transit: "在途",
};
export const CATEGORY_LABELS: Record<string, string> = {
bronze: "青铜器",
painting_calligraphy: "书画",
porcelain: "陶瓷",
jade: "玉器",
gold_silver: "金银器",
lacquer: "漆木器",
textile: "织绣",
stone_carving: "石刻造像",
wood_carving: "木雕",
dunhuang: "敦煌遗珍",
ancient_book: "古籍文献",
other: "其他",
};
export const CATEGORY_MARKS: Record<string, string> = {
bronze: "铜",
painting_calligraphy: "画",
porcelain: "瓷",
jade: "玉",
gold_silver: "金",
lacquer: "漆",
textile: "织",
stone_carving: "石",
wood_carving: "木",
dunhuang: "敦",
ancient_book: "书",
other: "物",
};
export const LEVEL_LABELS: Record<string, string> = {
level_1: "国家一级",
level_2: "国家二级",
level_3: "国家三级",
general: "一般文物",
unknown: "未定级",
};
export const DYNASTY_OPTIONS = [
"商代",
"西周",
"春秋",
"战国",
"秦代",
"汉代",
"唐代",
"五代",
"北宋",
"南宋",
"元代",
"明代",
"清代",
];
// 根据缩放层级与聚合数量计算 marker 直径(像素)。
export function markerSizeFor(zoom: number, count: number): number {
const z = Math.max(2, Math.min(18, zoom));
const base = 13 + (z - 2) * 2.3;
const countBonus = Math.min(15, Math.log2(count + 1) * 4);
return Math.round(base + countBonus);
}
// 文物图片:优先用已下载到本地的静态图(同源、无需外网),
// 加载失败时组件会回退到原始直链,再回退到统一示意图。
export function artifactImageSrc(id: string, _hd = false): string {
return `/artifacts/${id}.jpg`;
}
export function isOverseas(locationType?: string): boolean {
return locationType === "overseas";
}
+58
View File
@@ -0,0 +1,58 @@
import type { Config } from "tailwindcss";
const config: Config = {
content: ["./src/**/*.{ts,tsx}"],
theme: {
extend: {
colors: {
// 东方审美主色
ink: {
DEFAULT: "#1a1a2e",
light: "#2d2d4a",
},
gold: {
DEFAULT: "#c9a84c",
light: "#e8cc7a",
dark: "#9e7a28",
},
celadon: {
DEFAULT: "#8fbcb0",
light: "#b5d4cd",
dark: "#5f9088",
},
vermilion: {
DEFAULT: "#c94b4b",
light: "#e07070",
dark: "#9e2a2a",
},
parchment: {
DEFAULT: "#f5f0e8",
dark: "#e8dfc8",
},
},
fontFamily: {
serif: ["Noto Serif SC", "serif"],
sans: ["Noto Sans SC", "sans-serif"],
mono: ["JetBrains Mono", "monospace"],
},
animation: {
"pulse-slow": "pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite",
"fade-in": "fadeIn 0.4s ease-in-out",
"slide-up": "slideUp 0.3s ease-out",
},
keyframes: {
fadeIn: {
"0%": { opacity: "0" },
"100%": { opacity: "1" },
},
slideUp: {
"0%": { transform: "translateY(12px)", opacity: "0" },
"100%": { transform: "translateY(0)", opacity: "1" },
},
},
},
},
plugins: [],
};
export default config;
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "ES2022"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "ESNext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": {
"@/*": ["./src/*"],
"@wenwumap/shared": ["../../packages/shared/src/index.ts"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
File diff suppressed because one or more lines are too long