chore: 初始化仓库
中华文明全图鉴——文物全图系统(PC Web 地图 + NestJS API + 管理后台)。 含三大 IP(文物南迁北归 / 国宝海外回归 / 博物馆手艺人)、AI 文物对话、 文物地图与详情、以及 demo-video-kit 演示视频生成工具。
@@ -0,0 +1,3 @@
|
||||
NEXT_PUBLIC_API_URL=http://localhost:3002
|
||||
# 地图样式 URL(MapLibre GL 格式,需申请后填入)
|
||||
NEXT_PUBLIC_MAP_STYLE=
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 107 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 381 KiB |
|
After Width: | Height: | Size: 238 KiB |
|
After Width: | Height: | Size: 430 KiB |
|
After Width: | Height: | Size: 356 KiB |
|
After Width: | Height: | Size: 3.4 MiB |
|
After Width: | Height: | Size: 586 KiB |
|
After Width: | Height: | Size: 280 KiB |
|
After Width: | Height: | Size: 990 KiB |
|
After Width: | Height: | Size: 614 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 302 KiB |
|
After Width: | Height: | Size: 242 KiB |
|
After Width: | Height: | Size: 334 KiB |
|
After Width: | Height: | Size: 710 KiB |
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function Home() {
|
||||
redirect("/map");
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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"]
|
||||
}
|
||||