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
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>中华文明全图鉴 · 管理后台</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+29
View File
@@ -0,0 +1,29 @@
{
"name": "@wenwumap/admin",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "vite --port 3001",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint src --ext .ts,.tsx",
"type-check": "tsc --noEmit"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.25.1",
"antd": "^5.19.3",
"@ant-design/icons": "^5.3.7",
"swr": "^2.2.5",
"@wenwumap/shared": "workspace:*"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"typescript": "^5.5.3",
"vite": "^5.3.5"
}
}
+48
View File
@@ -0,0 +1,48 @@
import { Routes, Route, Navigate } from "react-router-dom";
import { ConfigProvider, theme } from "antd";
import zhCN from "antd/locale/zh_CN";
import Login from "./pages/Login";
import AdminLayout from "./components/AdminLayout";
import Dashboard from "./pages/Dashboard";
import ArtifactList from "./pages/artifacts/ArtifactList";
import { getStoredUser } from "./store/auth";
function RequireAuth({ children }: { children: React.ReactNode }) {
const user = getStoredUser();
return user ? <>{children}</> : <Navigate to="/login" replace />;
}
export default function App() {
return (
<ConfigProvider
locale={zhCN}
theme={{
algorithm: theme.darkAlgorithm,
token: {
colorPrimary: "#c9a84c",
colorBgBase: "#14121e",
colorBgContainer: "#1a1825",
colorBgLayout: "#0f0d18",
fontFamily: "\"Noto Sans SC\", \"PingFang SC\", sans-serif",
},
}}
>
<Routes>
<Route path="/login" element={<Login />} />
<Route
path="/"
element={
<RequireAuth>
<AdminLayout />
</RequireAuth>
}
>
<Route index element={<Navigate to="/dashboard" replace />} />
<Route path="dashboard" element={<Dashboard />} />
<Route path="artifacts" element={<ArtifactList />} />
</Route>
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes>
</ConfigProvider>
);
}
+31
View File
@@ -0,0 +1,31 @@
const BASE_URL = import.meta.env.VITE_API_URL ?? "http://localhost:3002";
function getToken() {
return localStorage.getItem("access_token");
}
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const token = getToken();
const res = await fetch(`${BASE_URL}${path}`, {
...options,
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(options.headers ?? {}),
},
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error((err as { message?: string }).message ?? `HTTP ${res.status}`);
}
return res.json() as Promise<T>;
}
export const api = {
get: <T>(path: string) => request<T>(path),
post: <T>(path: string, body: unknown) =>
request<T>(path, { method: "POST", body: JSON.stringify(body) }),
patch: <T>(path: string, body: unknown) =>
request<T>(path, { method: "PATCH", body: JSON.stringify(body) }),
delete: <T>(path: string) => request<T>(path, { method: "DELETE" }),
};
+124
View File
@@ -0,0 +1,124 @@
import { useState } from "react";
import { Layout, Menu, Avatar, Dropdown, theme, Typography } from "antd";
import {
DashboardOutlined,
PicLeftOutlined,
BankOutlined,
TagsOutlined,
LogoutOutlined,
UserOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
} from "@ant-design/icons";
import { Outlet, useNavigate, useLocation } from "react-router-dom";
import { useAuth } from "../store/auth";
const { Header, Sider, Content } = Layout;
const NAV_ITEMS = [
{ key: "/dashboard", icon: <DashboardOutlined />, label: "概览" },
{ key: "/artifacts", icon: <PicLeftOutlined />, label: "文物管理" },
{ key: "/institutions", icon: <BankOutlined />, label: "机构管理" },
{ key: "/tags", icon: <TagsOutlined />, label: "标签管理" },
];
export default function AdminLayout() {
const [collapsed, setCollapsed] = useState(false);
const navigate = useNavigate();
const location = useLocation();
const { user, logout } = useAuth();
const { token } = theme.useToken();
const userMenuItems = [
{
key: "logout",
icon: <LogoutOutlined />,
label: "退出登录",
onClick: () => {
logout();
navigate("/login");
},
},
];
return (
<Layout style={{ minHeight: "100vh" }}>
<Sider
collapsible
collapsed={collapsed}
onCollapse={setCollapsed}
trigger={null}
style={{
background: token.colorBgContainer,
borderRight: `1px solid ${token.colorBorderSecondary}`,
}}
>
<div
style={{
height: 56,
display: "flex",
alignItems: "center",
justifyContent: collapsed ? "center" : "flex-start",
padding: collapsed ? 0 : "0 20px",
borderBottom: `1px solid ${token.colorBorderSecondary}`,
}}
>
{collapsed ? (
<span style={{ fontSize: 18 }}>🏛</span>
) : (
<Typography.Text strong style={{ color: token.colorPrimary, fontSize: 13, letterSpacing: 1 }}>
</Typography.Text>
)}
</div>
<Menu
mode="inline"
selectedKeys={[location.pathname]}
items={NAV_ITEMS}
onClick={({ key }) => navigate(key)}
style={{ border: "none", marginTop: 8 }}
/>
</Sider>
<Layout>
<Header
style={{
background: token.colorBgContainer,
borderBottom: `1px solid ${token.colorBorderSecondary}`,
height: 56,
padding: "0 20px",
display: "flex",
alignItems: "center",
gap: 16,
}}
>
<button
onClick={() => setCollapsed(!collapsed)}
style={{
background: "none",
border: "none",
cursor: "pointer",
color: token.colorText,
fontSize: 16,
}}
>
{collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
</button>
<div style={{ flex: 1 }} />
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight">
<div style={{ display: "flex", alignItems: "center", gap: 8, cursor: "pointer" }}>
<Avatar size={28} icon={<UserOutlined />} style={{ background: token.colorPrimary }} />
<Typography.Text style={{ fontSize: 13 }}>
{user?.nickname ?? user?.username}
</Typography.Text>
</div>
</Dropdown>
</Header>
<Content style={{ padding: 24, background: token.colorBgLayout }}>
<Outlet />
</Content>
</Layout>
</Layout>
);
}
+10
View File
@@ -0,0 +1,10 @@
/* 全站隐藏滚动条但仍可滚动 */
* {
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* 旧版 Edge / IE */
}
*::-webkit-scrollbar {
width: 0;
height: 0;
display: none; /* Chrome / Safari / 新版 Edge */
}
+16
View File
@@ -0,0 +1,16 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
import "./index.css";
const root = document.getElementById("root");
if (!root) throw new Error("找不到 #root 挂载点");
ReactDOM.createRoot(root).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
);
+67
View File
@@ -0,0 +1,67 @@
import { useEffect, useState } from "react";
import { Card, Statistic, Row, Col, Typography, Spin } from "antd";
import { PicLeftOutlined, BankOutlined, EnvironmentOutlined } from "@ant-design/icons";
import { api } from "../api/client";
interface Stats {
total_artifacts: number;
total_institutions: number;
total_locations: number;
}
export default function Dashboard() {
const [stats, setStats] = useState<Stats | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
api
.get<Stats>("/api/v1/map/stats")
.then(setStats)
.catch(() => null)
.finally(() => setLoading(false));
}, []);
return (
<div>
<Typography.Title level={4} style={{ marginBottom: 24 }}>
</Typography.Title>
{loading ? (
<Spin />
) : (
<Row gutter={16}>
<Col span={8}>
<Card>
<Statistic
title="已发布文物"
value={stats?.total_artifacts ?? 0}
prefix={<PicLeftOutlined />}
valueStyle={{ color: "#c9a84c" }}
/>
</Card>
</Col>
<Col span={8}>
<Card>
<Statistic
title="合作机构"
value={stats?.total_institutions ?? 0}
prefix={<BankOutlined />}
valueStyle={{ color: "#c9a84c" }}
/>
</Card>
</Col>
<Col span={8}>
<Card>
<Statistic
title="地图点位"
value={stats?.total_locations ?? 0}
prefix={<EnvironmentOutlined />}
valueStyle={{ color: "#c9a84c" }}
/>
</Card>
</Col>
</Row>
)}
</div>
);
}
+103
View File
@@ -0,0 +1,103 @@
import { useState } from "react";
import { Form, Input, Button, message, Typography } from "antd";
import { UserOutlined, LockOutlined } from "@ant-design/icons";
import { useNavigate } from "react-router-dom";
import { api } from "../api/client";
import { saveAuth, AuthUser } from "../store/auth";
interface LoginResponse {
access_token: string;
user: AuthUser;
}
export default function Login() {
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
const [form] = Form.useForm();
const onFinish = async (values: { username: string; password: string }) => {
setLoading(true);
try {
const res = await api.post<LoginResponse>("/api/v1/auth/login", values);
saveAuth(res.access_token, res.user);
message.success(`欢迎回来,${res.user.nickname ?? res.user.username}`);
navigate("/dashboard");
} catch (err) {
message.error((err as Error).message || "登录失败,请检查账号密码");
} finally {
setLoading(false);
}
};
return (
<div
style={{
minHeight: "100vh",
background: "linear-gradient(135deg, #0f0c1a 0%, #1a1225 50%, #0d1117 100%)",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<div
style={{
width: 380,
background: "rgba(255,255,255,0.04)",
border: "1px solid rgba(201,168,76,0.2)",
borderRadius: 12,
padding: "40px 36px",
boxShadow: "0 8px 40px rgba(0,0,0,0.5)",
}}
>
<div style={{ textAlign: "center", marginBottom: 32 }}>
<div style={{ fontSize: 28, marginBottom: 8 }}>🏛</div>
<Typography.Title
level={3}
style={{ color: "#c9a84c", margin: 0, fontWeight: 700, letterSpacing: 2 }}
>
</Typography.Title>
<Typography.Text style={{ color: "rgba(255,255,255,0.4)", fontSize: 13 }}>
</Typography.Text>
</div>
<Form form={form} onFinish={onFinish} size="large" requiredMark={false}>
<Form.Item
name="username"
rules={[{ required: true, message: "请输入邮箱或用户名" }]}
>
<Input
prefix={<UserOutlined style={{ color: "rgba(255,255,255,0.3)" }} />}
placeholder="邮箱 / 用户名"
autoComplete="username"
/>
</Form.Item>
<Form.Item
name="password"
rules={[{ required: true, message: "请输入密码" }]}
>
<Input.Password
prefix={<LockOutlined style={{ color: "rgba(255,255,255,0.3)" }} />}
placeholder="密码"
autoComplete="current-password"
/>
</Form.Item>
<Form.Item style={{ marginBottom: 0 }}>
<Button
type="primary"
htmlType="submit"
block
loading={loading}
style={{ height: 44, fontWeight: 600, letterSpacing: 1 }}
>
</Button>
</Form.Item>
</Form>
</div>
</div>
);
}
@@ -0,0 +1,220 @@
import { useState, useEffect, useCallback } from "react";
import {
Table,
Input,
Select,
Space,
Tag,
Typography,
Button,
message,
Tooltip,
} from "antd";
import { SearchOutlined, ReloadOutlined } from "@ant-design/icons";
import type { ColumnsType } from "antd/es/table";
import { api } from "../../api/client";
interface Artifact {
id: string;
name: string;
category: string;
level: string;
dynasty: string;
story_hook: string;
current_institution: string;
}
interface PageMeta {
total: number;
page: number;
limit: number;
total_pages: number;
}
interface ArtifactListResponse {
data: Artifact[];
meta: PageMeta;
}
const CATEGORY_LABELS: Record<string, string> = {
bronze: "青铜器",
painting_calligraphy: "书画",
porcelain: "陶瓷",
jade: "玉器",
gold_silver: "金银器",
lacquer: "漆木器",
textile: "织绣",
stone_carving: "石刻造像",
wood_carving: "木雕",
dunhuang: "敦煌遗珍",
ancient_book: "古籍文献",
other: "其他",
};
const LEVEL_COLORS: Record<string, string> = {
level_1: "gold",
level_2: "blue",
level_3: "default",
unknown: "default",
};
const LEVEL_LABELS: Record<string, string> = {
level_1: "国家一级",
level_2: "国家二级",
level_3: "国家三级",
unknown: "未定级",
};
export default function ArtifactList() {
const [data, setData] = useState<Artifact[]>([]);
const [meta, setMeta] = useState<PageMeta>({ total: 0, page: 1, limit: 20, total_pages: 1 });
const [loading, setLoading] = useState(false);
const [q, setQ] = useState("");
const [category, setCategory] = useState<string | undefined>();
const [dynasty, setDynasty] = useState<string | undefined>();
const fetchData = useCallback(
async (page = 1) => {
setLoading(true);
try {
const params = new URLSearchParams({ page: String(page), limit: "20" });
if (q) params.set("q", q);
if (category) params.set("category", category);
if (dynasty) params.set("dynasty", dynasty);
const res = await api.get<ArtifactListResponse>(
`/api/v1/artifacts?${params}`
);
setData(res.data);
setMeta(res.meta);
} catch (err) {
message.error((err as Error).message);
} finally {
setLoading(false);
}
},
[q, category, dynasty]
);
useEffect(() => {
fetchData(1);
}, [fetchData]);
const columns: ColumnsType<Artifact> = [
{
title: "文物名称",
dataIndex: "name",
key: "name",
render: (text: string, record) => (
<div>
<Typography.Text strong>{text}</Typography.Text>
{record.story_hook && (
<Tooltip title={record.story_hook}>
<Typography.Text
type="secondary"
style={{ display: "block", fontSize: 12, maxWidth: 200 }}
ellipsis
>
{record.story_hook}
</Typography.Text>
</Tooltip>
)}
</div>
),
},
{
title: "门类",
dataIndex: "category",
key: "category",
width: 80,
render: (cat: string) => CATEGORY_LABELS[cat] ?? cat,
},
{
title: "级别",
dataIndex: "level",
key: "level",
width: 100,
render: (lv: string) => (
<Tag color={LEVEL_COLORS[lv] ?? "default"}>{LEVEL_LABELS[lv] ?? lv}</Tag>
),
},
{
title: "朝代",
dataIndex: "dynasty",
key: "dynasty",
width: 90,
},
{
title: "所在机构",
dataIndex: "current_institution",
key: "current_institution",
render: (v: string) => v ?? <Typography.Text type="secondary"></Typography.Text>,
},
];
return (
<div>
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 16 }}>
<Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title>
</div>
<Space style={{ marginBottom: 16 }} wrap>
<Input
placeholder="搜索文物名称"
prefix={<SearchOutlined />}
value={q}
onChange={(e) => setQ(e.target.value)}
onPressEnter={() => fetchData(1)}
style={{ width: 200 }}
allowClear
onClear={() => setQ("")}
/>
<Select
placeholder="门类"
style={{ width: 100 }}
allowClear
value={category}
onChange={setCategory}
options={Object.entries(CATEGORY_LABELS).map(([value, label]) => ({
value,
label,
}))}
/>
<Select
placeholder="朝代"
style={{ width: 140 }}
allowClear
value={dynasty}
onChange={setDynasty}
options={["先秦", "秦汉", "魏晋南北朝", "隋唐", "宋元", "明清"].map((d) => ({
value: d,
label: d,
}))}
/>
<Button
icon={<ReloadOutlined />}
onClick={() => fetchData(1)}
loading={loading}
>
</Button>
</Space>
<Table
rowKey="id"
columns={columns}
dataSource={data}
loading={loading}
pagination={{
total: meta.total,
current: meta.page,
pageSize: meta.limit,
showTotal: (t) => `${t}`,
onChange: (page) => fetchData(page),
showSizeChanger: false,
}}
size="middle"
/>
</div>
);
}
+47
View File
@@ -0,0 +1,47 @@
import { useState, useEffect } from "react";
export interface AuthUser {
id: string;
username: string;
nickname: string;
role: string;
}
const USER_KEY = "admin_user";
const TOKEN_KEY = "access_token";
export function saveAuth(token: string, user: AuthUser) {
localStorage.setItem(TOKEN_KEY, token);
localStorage.setItem(USER_KEY, JSON.stringify(user));
}
export function clearAuth() {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_KEY);
}
export function getStoredUser(): AuthUser | null {
try {
const s = localStorage.getItem(USER_KEY);
return s ? (JSON.parse(s) as AuthUser) : null;
} catch {
return null;
}
}
export function useAuth() {
const [user, setUser] = useState<AuthUser | null>(getStoredUser);
useEffect(() => {
const handler = () => setUser(getStoredUser());
window.addEventListener("storage", handler);
return () => window.removeEventListener("storage", handler);
}, []);
const logout = () => {
clearAuth();
setUser(null);
};
return { user, setUser, logout };
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"paths": {
"@wenwumap/shared": ["../../packages/shared/src/index.ts"]
}
},
"include": ["src"],
"exclude": ["node_modules"]
}
+21
View File
@@ -0,0 +1,21 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import path from "path";
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@wenwumap/shared": path.resolve(__dirname, "../../packages/shared/src/index.ts"),
},
},
server: {
port: 3001,
proxy: {
"/api": {
target: "http://localhost:3002",
changeOrigin: true,
},
},
},
});
+14
View File
@@ -0,0 +1,14 @@
PORT=3002
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/wenwumap
REDIS_URL=redis://localhost:6379
JWT_SECRET=change-me-in-production
JWT_EXPIRES_IN=7d
OSS_ENDPOINT=http://localhost:9000
OSS_ACCESS_KEY=minioadmin
OSS_SECRET_KEY=minioadmin
OSS_BUCKET=wenwumap
# AI 对话(通义千问 DashScopeOpenAI 兼容模式)
AI_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
AI_API_KEY=your-dashscope-api-key
AI_MODEL=qwen-plus
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}
+44
View File
@@ -0,0 +1,44 @@
{
"name": "@wenwumap/api",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "nest start --watch",
"build": "nest build",
"start": "node dist/main",
"lint": "eslint src --ext .ts",
"type-check": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@nestjs/common": "^10.3.10",
"@nestjs/config": "^3.2.3",
"@nestjs/core": "^10.3.10",
"@nestjs/jwt": "^10.2.0",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.3.10",
"@nestjs/swagger": "^7.4.0",
"@nestjs/terminus": "^10.2.3",
"bcryptjs": "^2.4.3",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0",
"pg": "^8.12.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@nestjs/cli": "^10.4.5",
"@nestjs/testing": "^10.3.10",
"@types/bcryptjs": "^2.4.6",
"@types/express": "^4.17.21",
"@types/node": "^22.0.0",
"@types/passport-jwt": "^4.0.1",
"@types/passport-local": "^1.0.38",
"@types/pg": "^8.11.6",
"typescript": "^5.5.3",
"vitest": "^2.1.9"
}
}
+42
View File
@@ -0,0 +1,42 @@
import { Body, Controller, Post, Res, UseGuards } from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { Response } from "express";
import { AiService } from "./ai.service";
import { ChatDto } from "./dto/chat.dto";
import { RateLimitGuard } from "../common/rate-limit.guard";
@ApiTags("ai")
@Controller("ai")
@UseGuards(RateLimitGuard)
export class AiController {
constructor(private readonly ai: AiService) {}
@Post("chat")
@ApiOperation({ summary: "与文物进行 AI 角色对话(SSE 流式输出)" })
async chat(@Body() dto: ChatDto, @Res() res: Response): Promise<void> {
res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
res.setHeader("Cache-Control", "no-cache, no-transform");
res.setHeader("Connection", "keep-alive");
res.setHeader("X-Accel-Buffering", "no");
res.flushHeaders?.();
try {
await this.ai.streamChat(dto, (token) => {
res.write(`data: ${JSON.stringify({ t: token })}\n\n`);
});
res.write("data: [DONE]\n\n");
} catch (err) {
const message = err instanceof Error ? err.message : "AI 服务异常";
res.write(`data: ${JSON.stringify({ error: message })}\n\n`);
} finally {
res.end();
}
}
@Post("suggestions")
@ApiOperation({ summary: "生成 4 个下一步追问建议" })
async suggestions(@Body() dto: ChatDto): Promise<{ suggestions: string[] }> {
const suggestions = await this.ai.getSuggestions(dto);
return { suggestions };
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Module } from "@nestjs/common";
import { AiService } from "./ai.service";
import { AiController } from "./ai.controller";
@Module({
providers: [AiService],
controllers: [AiController],
})
export class AiModule {}
+284
View File
@@ -0,0 +1,284 @@
import { Injectable, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { DatabaseService } from "../database/database.service";
import { ChatDto } from "./dto/chat.dto";
import { parseSuggestions } from "./suggestions.util";
interface ArtifactContext {
name: string;
category: string;
dynasty: string | null;
level: string | null;
material: string | null;
summary: string | null;
story_hook: string | null;
persona_quote: string | null;
current_status: string | null;
institution_name: string | null;
city: string | null;
province: string | null;
country: string | null;
location_type: string | null;
}
const CATEGORY_LABELS: Record<string, string> = {
bronze: "青铜器",
painting_calligraphy: "书画",
porcelain: "陶瓷",
jade: "玉器",
gold_silver: "金银器",
lacquer: "漆木器",
textile: "织绣",
stone_carving: "石刻造像",
wood_carving: "木雕",
dunhuang: "敦煌遗珍",
ancient_book: "古籍文献",
other: "其他",
};
const LEVEL_LABELS: Record<string, string> = {
level_1: "国家一级文物",
level_2: "国家二级文物",
level_3: "国家三级文物",
general: "一般文物",
unknown: "未定级",
};
export type ChatPersona = "artifact" | "guide" | "scholar" | "migration" | "repatriation" | "youth";
@Injectable()
export class AiService {
private readonly logger = new Logger(AiService.name);
constructor(
private readonly config: ConfigService,
private readonly db: DatabaseService
) {}
private async getArtifactContext(artifactId: string): Promise<ArtifactContext | null> {
const { rows } = await this.db.query<ArtifactContext>(
`SELECT a.name, a.category, a.dynasty, a.level, a.material, a.summary,
a.story_hook, a.persona_quote, a.current_status,
i.name AS institution_name, i.city, i.province, i.country,
al.location_type
FROM artifacts a
LEFT JOIN artifact_locations al ON a.id = al.artifact_id AND al.is_current = true
LEFT JOIN institutions i ON al.institution_id = i.id
WHERE a.id = $1
LIMIT 1`,
[artifactId]
);
return rows[0] ?? null;
}
private buildSystemPrompt(ctx: ArtifactContext, persona: ChatPersona): string {
const category = CATEGORY_LABELS[ctx.category] ?? ctx.category;
const level = ctx.level ? LEVEL_LABELS[ctx.level] ?? ctx.level : "未定级";
const place = [ctx.country, ctx.province, ctx.city].filter(Boolean).join(" · ") || "未知";
const overseas = ctx.location_type === "overseas";
const facts = [
`名称:${ctx.name}`,
`门类:${category}`,
ctx.dynasty ? `年代:${ctx.dynasty}` : null,
`级别:${level}`,
ctx.material ? `材质:${ctx.material}` : null,
ctx.institution_name ? `收藏机构:${ctx.institution_name}` : null,
`所在地:${place}${overseas ? "(流失海外)" : ""}`,
ctx.summary ? `简介:${ctx.summary}` : null,
ctx.story_hook ? `故事钩子:${ctx.story_hook}` : null,
ctx.persona_quote ? `拟人化自白:「${ctx.persona_quote}` : null,
]
.filter(Boolean)
.join("\n");
const common = `你掌握以下这件文物的真实资料,回答必须严格基于这些事实,不可编造未提供的史实、出土地、尺寸或价格等具体数据;不确定时要坦诚说明。
【文物档案】
${facts}
输出要求:
- 使用 Markdown 排版(适当用标题、**加粗**、列表、引用 > 来组织内容),让回答精美易读。
- 回答用中文,语言生动、有人文温度,篇幅适中(一般 3 段以内,除非用户要求展开)。
- 涉及不确定或学界有争议的内容时明确标注。`;
switch (persona) {
case "artifact":
return `你现在就是这件文物本身,请以第一人称「我」与观众对话,用拟人化、有性格、略带诗意与幽默的口吻讲述自己的故事,仿佛跨越千年与今人攀谈。可参考下方的「拟人化自白」基调。
${common}
- 始终以文物第一人称「我」叙述,不要跳出角色。${overseas ? "\n- 你目前流落海外,可以在合适时流露一丝乡愁,但保持克制与尊严。" : ""}`;
case "scholar":
return `你是一位严谨的文物与历史学者,以专业、客观、考据的方式为提问者讲解这件文物,必要时点明研究背景与学术意义。
${common}`;
case "migration":
return `你现在就是这件文物本身,请以第一人称「我」讲述"文物南迁"中的亲历——1933 至 1947 年间,为躲避战火,无数文物被装箱辗转上海、南京、宝鸡、汉中、成都、峨眉、重庆等地,行程逾万里。请把自己代入这段颠沛流离又被普通文保人以生命守护的历史,语气深沉而有温度,突出"平凡人守护文明火种"的主题。
${common}
- 始终以文物第一人称「我」叙述;可描写木箱、车马、江轮、防空洞、护送者的细节。
- 不可虚构具体史料数字;不确定处用"据说/相传"等措辞。`;
case "repatriation":
return `你现在就是这件文物本身,请以第一人称「我」讲述"流失与回归"的历程——从离散海外、辗转飘零,到被国家与同胞接回故土的心路。语气饱含乡愁与归家的激动,呼应"国宝回归、文化自信"的主题。
${common}
- 始终以文物第一人称「我」叙述。${overseas ? "\n- 你目前仍流落海外,讲述时可表达对回家的期盼。" : "\n- 若你已回归,可讲述重返故土的喜悦与不易。"}`;
case "youth":
return `你是一位面向中小学生的"小小讲解员",用活泼、亲切、好懂的语言介绍这件文物,多用类比和提问,适当穿插一个小知识点或趣味问答,激发青少年对文物保护的兴趣。
${common}
- 语言浅显生动,避免生僻术语;可以在结尾抛出一个引导思考的小问题。`;
case "guide":
default:
return `你是博物馆里一位亲切的资深讲解员,面向普通观众,用通俗易懂又引人入胜的方式介绍这件文物,善于用类比和故事吸引兴趣。
${common}`;
}
}
/**
* 以流式方式与 DashScopeOpenAI 兼容)对话,逐 token 回调。
*/
async streamChat(dto: ChatDto, onToken: (token: string) => void): Promise<void> {
const apiKey = this.config.get<string>("AI_API_KEY");
const baseUrl =
this.config.get<string>("AI_BASE_URL") ??
"https://dashscope.aliyuncs.com/compatible-mode/v1";
const model = this.config.get<string>("AI_MODEL") ?? "qwen-plus";
if (!apiKey) {
throw new Error("AI 服务未配置:请在 .env 中设置 AI_API_KEY");
}
const ctx = await this.getArtifactContext(dto.artifactId);
if (!ctx) {
throw new Error("未找到该文物");
}
const systemPrompt = this.buildSystemPrompt(ctx, dto.persona ?? "artifact");
const payload = {
model,
stream: true,
temperature: 0.8,
messages: [
{ role: "system", content: systemPrompt },
...dto.messages.map((m) => ({ role: m.role, content: m.content })),
],
};
const resp = await fetch(`${baseUrl.replace(/\/$/, "")}/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(payload),
});
if (!resp.ok || !resp.body) {
const text = await resp.text().catch(() => "");
this.logger.error(`上游 AI 接口错误 ${resp.status}: ${text}`);
throw new Error(`AI 接口返回 ${resp.status}`);
}
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
// 解析 OpenAI 兼容的 SSE 流:每个事件以 \n\n 分隔,数据行以 "data: " 开头
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let sepIndex: number;
while ((sepIndex = buffer.indexOf("\n\n")) >= 0) {
const rawEvent = buffer.slice(0, sepIndex);
buffer = buffer.slice(sepIndex + 2);
for (const line of rawEvent.split("\n")) {
const trimmed = line.trim();
if (!trimmed.startsWith("data:")) continue;
const data = trimmed.slice(5).trim();
if (data === "[DONE]") return;
try {
const json = JSON.parse(data);
const token: string | undefined = json?.choices?.[0]?.delta?.content;
if (token) onToken(token);
} catch {
// 忽略无法解析的心跳/分片
}
}
}
}
}
/**
* 基于文物资料与已有对话,生成 4 个“下一步追问”建议(非流式)。
*/
async getSuggestions(dto: ChatDto): Promise<string[]> {
const apiKey = this.config.get<string>("AI_API_KEY");
if (!apiKey) throw new Error("AI 服务未配置:请在 .env 中设置 AI_API_KEY");
const ctx = await this.getArtifactContext(dto.artifactId);
if (!ctx) throw new Error("未找到该文物");
const category = CATEGORY_LABELS[ctx.category] ?? ctx.category;
const facts = [
`名称:${ctx.name}`,
`门类:${category}`,
ctx.dynasty ? `年代:${ctx.dynasty}` : null,
ctx.institution_name ? `收藏机构:${ctx.institution_name}` : null,
]
.filter(Boolean)
.join("");
const convo = dto.messages
.slice(-6)
.map((m) => `${m.role === "user" ? "观众" : "文物"}${m.content}`)
.join("\n");
const sys = `你在为一个文物科普对话生成"下一步追问"建议。请站在观众视角,结合文物资料与已有对话,提出 4 个简短(每个不超过 16 个字)、具体、能引发兴趣且彼此不重复的中文追问。
只输出一个 JSON 字符串数组,形如 ["问题1","问题2","问题3","问题4"],不要任何额外说明或编号。
【文物】${facts}`;
const userMsg = convo
? `已有对话:\n${convo}\n\n请生成 4 个自然的后续追问。`
: `请生成 4 个适合作为开场的追问。`;
const content = await this.chatComplete(
[
{ role: "system", content: sys },
{ role: "user", content: userMsg },
],
0.9
);
return parseSuggestions(content);
}
private async chatComplete(
messages: { role: string; content: string }[],
temperature = 0.7
): Promise<string> {
const apiKey = this.config.get<string>("AI_API_KEY");
const baseUrl =
this.config.get<string>("AI_BASE_URL") ??
"https://dashscope.aliyuncs.com/compatible-mode/v1";
const model = this.config.get<string>("AI_MODEL") ?? "qwen-plus";
const resp = await fetch(`${baseUrl.replace(/\/$/, "")}/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({ model, temperature, stream: false, messages }),
});
if (!resp.ok) {
const t = await resp.text().catch(() => "");
this.logger.error(`上游 AI 接口错误 ${resp.status}: ${t}`);
throw new Error(`AI 接口返回 ${resp.status}`);
}
const json = (await resp.json()) as {
choices?: { message?: { content?: string } }[];
};
return json?.choices?.[0]?.message?.content ?? "";
}
}
+35
View File
@@ -0,0 +1,35 @@
import { Type } from "class-transformer";
import {
ArrayMaxSize,
IsArray,
IsIn,
IsOptional,
IsString,
MaxLength,
ValidateNested,
} from "class-validator";
export class ChatMessageDto {
@IsIn(["user", "assistant"])
role!: "user" | "assistant";
@IsString()
@MaxLength(4000)
content!: string;
}
export class ChatDto {
@IsString()
artifactId!: string;
/** 角色设置:文物自述 / 博物馆讲解员 / 历史学者 / 南迁亲历 / 回归叙事 / 青少年讲解员 */
@IsOptional()
@IsIn(["artifact", "guide", "scholar", "migration", "repatriation", "youth"])
persona?: "artifact" | "guide" | "scholar" | "migration" | "repatriation" | "youth";
@IsArray()
@ArrayMaxSize(40)
@ValidateNested({ each: true })
@Type(() => ChatMessageDto)
messages!: ChatMessageDto[];
}
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { parseSuggestions } from "./suggestions.util";
describe("parseSuggestions", () => {
it("解析标准 JSON 数组", () => {
const out = parseSuggestions('["问题一","问题二","问题三","问题四"]');
expect(out).toEqual(["问题一", "问题二", "问题三", "问题四"]);
});
it("从夹带文字中提取 JSON 数组", () => {
const out = parseSuggestions('好的,建议如下:\n["A","B"]\n谢谢');
expect(out).toEqual(["A", "B"]);
});
it("最多返回 4 条", () => {
const out = parseSuggestions('["1","2","3","4","5","6"]');
expect(out).toHaveLength(4);
});
it("JSON 失败时按行解析并清理编号", () => {
const out = parseSuggestions("1. 它是谁\n2、它从哪来\n- 它去哪");
expect(out).toEqual(["它是谁", "它从哪来", "它去哪"]);
});
it("过滤非字符串与空白", () => {
const out = parseSuggestions('["有效", "", " "]');
expect(out).toEqual(["有效"]);
});
});
+33
View File
@@ -0,0 +1,33 @@
/**
* 从模型输出中解析“下一步追问”建议。
* 优先解析 JSON 数组,失败则按行解析并清理编号/符号,最多返回 4 条。
*/
export function parseSuggestions(text: string): string[] {
let items: string[] = [];
const match = text.match(/\[[\s\S]*\]/);
if (match) {
try {
const arr: unknown = JSON.parse(match[0]);
if (Array.isArray(arr)) {
items = arr.filter((x): x is string => typeof x === "string");
}
} catch {
/* fall through to line parsing */
}
}
if (items.length === 0) {
items = text
.split("\n")
.map((l) =>
l
.replace(/^[\s\-*0-9.、)"“”]+/, "")
.replace(/["“”]+$/, "")
.trim()
)
.filter(Boolean);
}
return items.map((s) => s.trim()).filter(Boolean).slice(0, 4);
}
+29
View File
@@ -0,0 +1,29 @@
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { TerminusModule } from "@nestjs/terminus";
import { DatabaseModule } from "./database/database.module";
import { AuthModule } from "./auth/auth.module";
import { MapModule } from "./map/map.module";
import { ArtifactsModule } from "./artifacts/artifacts.module";
import { InstitutionsModule } from "./institutions/institutions.module";
import { AiModule } from "./ai/ai.module";
import { AssetsModule } from "./assets/assets.module";
import { RoutesModule } from "./routes/routes.module";
import { HealthController } from "./health/health.controller";
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true, envFilePath: ["../../.env", ".env"] }),
TerminusModule,
DatabaseModule,
AuthModule,
MapModule,
ArtifactsModule,
InstitutionsModule,
AiModule,
AssetsModule,
RoutesModule,
],
controllers: [HealthController],
})
export class AppModule {}
@@ -0,0 +1,22 @@
import { Controller, Get, Param, Query } from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { ArtifactsService } from "./artifacts.service";
import { ArtifactQueryDto } from "./dto/artifact-query.dto";
@ApiTags("Artifacts")
@Controller("artifacts")
export class ArtifactsController {
constructor(private artifacts: ArtifactsService) {}
@Get()
@ApiOperation({ summary: "文物列表(支持搜索/筛选/分页)" })
findAll(@Query() query: ArtifactQueryDto) {
return this.artifacts.findAll(query);
}
@Get(":id")
@ApiOperation({ summary: "文物详情" })
findOne(@Param("id") id: string) {
return this.artifacts.findOne(id);
}
}
@@ -0,0 +1,9 @@
import { Module } from "@nestjs/common";
import { ArtifactsService } from "./artifacts.service";
import { ArtifactsController } from "./artifacts.controller";
@Module({
providers: [ArtifactsService],
controllers: [ArtifactsController],
})
export class ArtifactsModule {}
+117
View File
@@ -0,0 +1,117 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { DatabaseService } from "../database/database.service";
import { ArtifactQueryDto } from "./dto/artifact-query.dto";
@Injectable()
export class ArtifactsService {
constructor(private db: DatabaseService) {}
async findAll(query: ArtifactQueryDto) {
const page = query.page ?? 1;
const limit = query.limit ?? 20;
const offset = (page - 1) * limit;
const conditions: string[] = ["a.publish_status = 'published'"];
const params: unknown[] = [];
let idx = 1;
if (query.q) {
conditions.push(`(a.name ILIKE $${idx} OR a.summary ILIKE $${idx})`);
params.push(`%${query.q}%`);
idx++;
}
if (query.category) {
conditions.push(`a.category = $${idx++}`);
params.push(query.category);
}
if (query.level) {
conditions.push(`a.level = $${idx++}`);
params.push(query.level);
}
if (query.dynasty) {
conditions.push(`a.dynasty ILIKE $${idx++}`);
params.push(`%${query.dynasty}%`);
}
if (query.institution_id) {
conditions.push(
`EXISTS (SELECT 1 FROM artifact_locations al WHERE al.artifact_id = a.id AND al.institution_id = $${idx++} AND al.is_current = true)`
);
params.push(query.institution_id);
}
if (query.tag_ids) {
const ids = query.tag_ids.split(",").filter(Boolean);
if (ids.length) {
conditions.push(
`EXISTS (SELECT 1 FROM artifact_tags at2 WHERE at2.artifact_id = a.id AND at2.tag_id = ANY($${idx++}::uuid[]))`
);
params.push(ids);
}
}
const where = conditions.join(" AND ");
const countParams = [...params];
const countResult = await this.db.query<{ count: string }>(
`SELECT COUNT(*) as count FROM artifacts a WHERE ${where}`,
countParams
);
const total = parseInt(countResult.rows[0].count);
params.push(limit, offset);
const { rows } = await this.db.query<{
id: string;
name: string;
category: string;
level: string;
dynasty: string;
current_institution: string;
story_hook: string;
}>(
`SELECT a.id, a.name, a.category, a.level, a.dynasty,
a.story_hook,
i.name AS current_institution
FROM artifacts a
LEFT JOIN artifact_locations al ON a.id = al.artifact_id AND al.is_current = true
LEFT JOIN institutions i ON al.institution_id = i.id
WHERE ${where}
ORDER BY a.level DESC, a.created_at DESC
LIMIT $${idx++} OFFSET $${idx++}`,
params
);
return {
data: rows,
meta: { total, page, limit, total_pages: Math.ceil(total / limit) },
};
}
async findOne(id: string) {
const { rows } = await this.db.query<Record<string, unknown>>(
`SELECT a.*,
i.name AS current_institution_name,
i.id AS current_institution_id,
ST_X(al.public_location::geometry) AS lng,
ST_Y(al.public_location::geometry) AS lat,
al.location_type,
al.precision AS location_precision
FROM artifacts a
LEFT JOIN artifact_locations al ON a.id = al.artifact_id AND al.is_current = true
LEFT JOIN institutions i ON al.institution_id = i.id
WHERE a.id = $1 AND a.publish_status = 'published'`,
[id]
);
if (!rows.length) throw new NotFoundException("文物不存在");
const [artifact] = rows;
const tagResult = await this.db.query<{ id: string; name: string; category_name: string }>(
`SELECT t.id, t.name, tc.name AS category_name
FROM tags t
JOIN artifact_tags at2 ON t.id = at2.tag_id
JOIN tag_categories tc ON t.category_id = tc.id
WHERE at2.artifact_id = $1`,
[id]
);
return { ...artifact, tags: tagResult.rows };
}
}
@@ -0,0 +1,50 @@
import { IsInt, IsOptional, IsString, Max, Min } from "class-validator";
import { Type } from "class-transformer";
import { ApiPropertyOptional } from "@nestjs/swagger";
export class ArtifactQueryDto {
@ApiPropertyOptional({ description: "关键词搜索(名称/描述)" })
@IsOptional()
@IsString()
q?: string;
@ApiPropertyOptional({ description: "文物门类", example: "bronze" })
@IsOptional()
@IsString()
category?: string;
@ApiPropertyOptional({ description: "文物级别", example: "national_first" })
@IsOptional()
@IsString()
level?: string;
@ApiPropertyOptional({ description: "朝代" })
@IsOptional()
@IsString()
dynasty?: string;
@ApiPropertyOptional({ description: "机构ID" })
@IsOptional()
@IsString()
institution_id?: string;
@ApiPropertyOptional({ description: "标签ID(逗号分隔)" })
@IsOptional()
@IsString()
tag_ids?: string;
@ApiPropertyOptional({ description: "页码", default: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;
@ApiPropertyOptional({ description: "每页数量", default: 20 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
limit?: number = 20;
}
+27
View File
@@ -0,0 +1,27 @@
import { Controller, Get, Param, Query, Res } from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { Response } from "express";
import { AssetsService } from "./assets.service";
@ApiTags("assets")
@Controller("assets")
export class AssetsController {
constructor(private readonly assets: AssetsService) {}
@Get("image/:artifactId")
@ApiOperation({ summary: "文物图片代理(同源 + 本地缓存),失败返回 404" })
async image(
@Param("artifactId") artifactId: string,
@Query("hd") hd: string | undefined,
@Res() res: Response
): Promise<void> {
const img = await this.assets.getArtifactImage(artifactId, hd === "1");
if (!img) {
res.status(404).end();
return;
}
res.setHeader("Content-Type", img.contentType);
res.setHeader("Cache-Control", "public, max-age=86400");
res.end(img.buffer);
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Module } from "@nestjs/common";
import { AssetsService } from "./assets.service";
import { AssetsController } from "./assets.controller";
@Module({
providers: [AssetsService],
controllers: [AssetsController],
})
export class AssetsModule {}
+72
View File
@@ -0,0 +1,72 @@
import { Injectable, Logger } from "@nestjs/common";
import * as fs from "fs";
import * as path from "path";
import { DatabaseService } from "../database/database.service";
interface CachedImage {
buffer: Buffer;
contentType: string;
}
/**
* 文物图片代理 + 本地磁盘缓存。
* 浏览器统一从本服务的同源地址取图,首次访问时从上游(如 Wikimedia)拉取并落盘缓存,
* 之后直接由本地提供,实现“自建托管”。上游不可达时返回 null,前端回退到示意图。
*/
@Injectable()
export class AssetsService {
private readonly logger = new Logger(AssetsService.name);
private readonly cacheDir = path.resolve(process.cwd(), ".cache/images");
constructor(private readonly db: DatabaseService) {
fs.mkdirSync(this.cacheDir, { recursive: true });
}
private async getImageUrl(artifactId: string): Promise<string | null> {
const { rows } = await this.db.query<{ image_url: string | null }>(
"SELECT image_url FROM artifacts WHERE id = $1 LIMIT 1",
[artifactId]
);
return rows[0]?.image_url ?? null;
}
async getArtifactImage(artifactId: string, hd: boolean): Promise<CachedImage | null> {
const key = `${artifactId}${hd ? "_hd" : ""}`;
const binPath = path.join(this.cacheDir, key);
const typePath = path.join(this.cacheDir, `${key}.type`);
// 命中本地缓存
if (fs.existsSync(binPath) && fs.existsSync(typePath)) {
return {
buffer: fs.readFileSync(binPath),
contentType: fs.readFileSync(typePath, "utf-8") || "image/jpeg",
};
}
const rawUrl = await this.getImageUrl(artifactId);
if (!rawUrl) return null;
const url = hd
? /\?width=\d+/.test(rawUrl)
? rawUrl.replace(/\?width=\d+/, "?width=2000")
: `${rawUrl}${rawUrl.includes("?") ? "&" : "?"}width=2000`
: rawUrl;
try {
const resp = await fetch(url, { signal: AbortSignal.timeout(15_000) });
if (!resp.ok) {
this.logger.warn(`拉取图片失败 ${resp.status}: ${url}`);
return null;
}
const contentType = resp.headers.get("content-type") ?? "image/jpeg";
const buffer = Buffer.from(await resp.arrayBuffer());
// 落盘缓存
fs.writeFileSync(binPath, buffer);
fs.writeFileSync(typePath, contentType);
return { buffer, contentType };
} catch (err) {
this.logger.warn(`图片代理异常:${(err as Error).message}`);
return null;
}
}
}
+25
View File
@@ -0,0 +1,25 @@
import { Body, Controller, Get, Post, Request, UseGuards } from "@nestjs/common";
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import { AuthService } from "./auth.service";
import { LoginDto } from "./dto/login.dto";
import { JwtAuthGuard } from "./jwt-auth.guard";
@ApiTags("Auth")
@Controller("auth")
export class AuthController {
constructor(private auth: AuthService) {}
@Post("login")
@ApiOperation({ summary: "管理员登录" })
login(@Body() dto: LoginDto) {
return this.auth.login(dto);
}
@Get("me")
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: "获取当前用户信息" })
me(@Request() req: { user: { id: string } }) {
return this.auth.me(req.user.id);
}
}
+25
View File
@@ -0,0 +1,25 @@
import { Module } from "@nestjs/common";
import { JwtModule } from "@nestjs/jwt";
import { PassportModule } from "@nestjs/passport";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { AuthService } from "./auth.service";
import { AuthController } from "./auth.controller";
import { JwtStrategy } from "./jwt.strategy";
@Module({
imports: [
PassportModule,
JwtModule.registerAsync({
imports: [ConfigModule],
useFactory: (config: ConfigService) => ({
secret: config.get<string>("JWT_SECRET"),
signOptions: { expiresIn: config.get<string>("JWT_EXPIRES_IN") ?? "7d" },
}),
inject: [ConfigService],
}),
],
providers: [AuthService, JwtStrategy],
controllers: [AuthController],
exports: [JwtModule],
})
export class AuthModule {}
+62
View File
@@ -0,0 +1,62 @@
import { Injectable, UnauthorizedException } from "@nestjs/common";
import { JwtService } from "@nestjs/jwt";
import { DatabaseService } from "../database/database.service";
import { LoginDto } from "./dto/login.dto";
import * as bcrypt from "bcryptjs";
@Injectable()
export class AuthService {
constructor(
private db: DatabaseService,
private jwt: JwtService
) {}
async login(dto: LoginDto) {
const { rows } = await this.db.query<{
id: string;
username: string;
password_hash: string;
nickname: string;
role: string;
}>(
`SELECT u.id, u.username, u.password_hash, u.nickname, r.name as role
FROM users u
JOIN user_roles ur ON u.id = ur.user_id
JOIN roles r ON ur.role_id = r.id
WHERE (u.username = $1 OR u.email = $1) AND u.is_active = true
LIMIT 1`,
[dto.username]
);
if (!rows.length) throw new UnauthorizedException("用户名或密码错误");
const user = rows[0];
const valid = await bcrypt.compare(dto.password, user.password_hash);
if (!valid) throw new UnauthorizedException("用户名或密码错误");
const payload = { sub: user.id, username: user.username, role: user.role };
return {
access_token: this.jwt.sign(payload),
user: { id: user.id, username: user.username, nickname: user.nickname, role: user.role },
};
}
async me(userId: string) {
const { rows } = await this.db.query<{
id: string;
username: string;
nickname: string;
email: string;
role: string;
}>(
`SELECT u.id, u.username, u.nickname, u.email, r.name as role
FROM users u
JOIN user_roles ur ON u.id = ur.user_id
JOIN roles r ON ur.role_id = r.id
WHERE u.id = $1 LIMIT 1`,
[userId]
);
if (!rows.length) throw new UnauthorizedException();
return rows[0];
}
}
+13
View File
@@ -0,0 +1,13 @@
import { IsString, MinLength } from "class-validator";
import { ApiProperty } from "@nestjs/swagger";
export class LoginDto {
@ApiProperty({ example: "admin" })
@IsString()
username!: string;
@ApiProperty({ example: "password123" })
@IsString()
@MinLength(6)
password!: string;
}
+5
View File
@@ -0,0 +1,5 @@
import { Injectable } from "@nestjs/common";
import { AuthGuard } from "@nestjs/passport";
@Injectable()
export class JwtAuthGuard extends AuthGuard("jwt") {}
+33
View File
@@ -0,0 +1,33 @@
import { Injectable, UnauthorizedException } from "@nestjs/common";
import { PassportStrategy } from "@nestjs/passport";
import { ExtractJwt, Strategy } from "passport-jwt";
import { ConfigService } from "@nestjs/config";
import { DatabaseService } from "../database/database.service";
export interface JwtPayload {
sub: string;
username: string;
role: string;
}
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(config: ConfigService, private db: DatabaseService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: config.get<string>("JWT_SECRET") ?? "fallback-secret",
});
}
async validate(payload: JwtPayload) {
const { rows } = await this.db.query(
`SELECT u.id, u.username, u.nickname, r.name as role
FROM users u JOIN user_roles ur ON u.id = ur.user_id
JOIN roles r ON ur.role_id = r.id
WHERE u.id = $1 AND u.is_active = true LIMIT 1`,
[payload.sub]
);
if (!rows.length) throw new UnauthorizedException();
return rows[0];
}
}
+44
View File
@@ -0,0 +1,44 @@
import {
CanActivate,
ExecutionContext,
HttpException,
HttpStatus,
Injectable,
} from "@nestjs/common";
import { Request } from "express";
/**
* 简单的内存级 IP 限流守卫:滑动窗口。
* 用于保护成本敏感的 AI 接口,防止被刷爆额度。
*/
@Injectable()
export class RateLimitGuard implements CanActivate {
private static readonly WINDOW_MS = 60_000;
private static readonly MAX_REQUESTS = 20;
private static readonly buckets = new Map<string, number[]>();
canActivate(context: ExecutionContext): boolean {
const req = context.switchToHttp().getRequest<Request>();
const forwarded = req.headers["x-forwarded-for"];
const ip =
(Array.isArray(forwarded) ? forwarded[0] : forwarded?.split(",")[0])?.trim() ||
req.ip ||
"unknown";
const now = Date.now();
const recent = (RateLimitGuard.buckets.get(ip) ?? []).filter(
(t) => now - t < RateLimitGuard.WINDOW_MS
);
if (recent.length >= RateLimitGuard.MAX_REQUESTS) {
throw new HttpException(
"请求过于频繁,请稍后再试",
HttpStatus.TOO_MANY_REQUESTS
);
}
recent.push(now);
RateLimitGuard.buckets.set(ip, recent);
return true;
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Global, Module } from "@nestjs/common";
import { DatabaseService } from "./database.service";
@Global()
@Module({
providers: [DatabaseService],
exports: [DatabaseService],
})
export class DatabaseModule {}
+41
View File
@@ -0,0 +1,41 @@
import { Injectable, OnModuleDestroy, OnModuleInit, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Pool, PoolClient, QueryResult } from "pg";
@Injectable()
export class DatabaseService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(DatabaseService.name);
private pool!: Pool;
constructor(private config: ConfigService) {}
async onModuleInit() {
this.pool = new Pool({ connectionString: this.config.get<string>("DATABASE_URL") });
const client = await this.pool.connect();
client.release();
this.logger.log("PostgreSQL connection pool ready");
}
async onModuleDestroy() {
await this.pool.end();
}
async query<T extends object = Record<string, unknown>>(sql: string, params?: unknown[]): Promise<QueryResult<T>> {
return this.pool.query<T>(sql, params);
}
async transaction<T>(fn: (client: PoolClient) => Promise<T>): Promise<T> {
const client = await this.pool.connect();
try {
await client.query("BEGIN");
const result = await fn(client);
await client.query("COMMIT");
return result;
} catch (err) {
await client.query("ROLLBACK");
throw err;
} finally {
client.release();
}
}
}
+16
View File
@@ -0,0 +1,16 @@
import { Controller, Get } from "@nestjs/common";
import { ApiTags, ApiOperation } from "@nestjs/swagger";
@ApiTags("health")
@Controller("health")
export class HealthController {
@Get()
@ApiOperation({ summary: "服务健康检查" })
check() {
return {
status: "ok",
time: new Date().toISOString(),
service: "wenwumap-api",
};
}
}
@@ -0,0 +1,26 @@
import { Controller, Get, Param, Query } from "@nestjs/common";
import { ApiOperation, ApiQuery, ApiTags } from "@nestjs/swagger";
import { InstitutionsService } from "./institutions.service";
@ApiTags("Institutions")
@Controller("institutions")
export class InstitutionsController {
constructor(private institutions: InstitutionsService) {}
@Get()
@ApiOperation({ summary: "机构列表" })
@ApiQuery({ name: "page", required: false, type: Number })
@ApiQuery({ name: "limit", required: false, type: Number })
findAll(
@Query("page") page?: string,
@Query("limit") limit?: string
) {
return this.institutions.findAll(page ? parseInt(page) : 1, limit ? parseInt(limit) : 20);
}
@Get(":id")
@ApiOperation({ summary: "机构详情" })
findOne(@Param("id") id: string) {
return this.institutions.findOne(id);
}
}
@@ -0,0 +1,9 @@
import { Module } from "@nestjs/common";
import { InstitutionsService } from "./institutions.service";
import { InstitutionsController } from "./institutions.controller";
@Module({
providers: [InstitutionsService],
controllers: [InstitutionsController],
})
export class InstitutionsModule {}
@@ -0,0 +1,58 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { DatabaseService } from "../database/database.service";
@Injectable()
export class InstitutionsService {
constructor(private db: DatabaseService) {}
async findAll(page = 1, limit = 20) {
const offset = (page - 1) * limit;
const countResult = await this.db.query<{ count: string }>(
`SELECT COUNT(*) as count FROM institutions WHERE publish_status = 'published'`
);
const total = parseInt(countResult.rows[0].count);
const { rows } = await this.db.query<{
id: string;
name: string;
country: string;
city: string;
artifact_count: string;
lng: number;
lat: number;
}>(
`SELECT i.id, i.name, i.country, i.city,
ST_X(i.location::geometry) AS lng,
ST_Y(i.location::geometry) AS lat,
COUNT(al.artifact_id) AS artifact_count
FROM institutions i
LEFT JOIN artifact_locations al ON i.id = al.institution_id AND al.is_current = true
WHERE i.publish_status = 'published'
GROUP BY i.id
ORDER BY artifact_count DESC, i.name
LIMIT $1 OFFSET $2`,
[limit, offset]
);
return {
data: rows.map((r) => ({ ...r, artifact_count: parseInt(r.artifact_count) })),
meta: { total, page, limit, total_pages: Math.ceil(total / limit) },
};
}
async findOne(id: string) {
const { rows } = await this.db.query<Record<string, unknown>>(
`SELECT i.*,
ST_X(i.location::geometry) AS lng,
ST_Y(i.location::geometry) AS lat,
COUNT(al.artifact_id) AS artifact_count
FROM institutions i
LEFT JOIN artifact_locations al ON i.id = al.institution_id AND al.is_current = true
WHERE i.id = $1 AND i.publish_status = 'published'
GROUP BY i.id`,
[id]
);
if (!rows.length) throw new NotFoundException("机构不存在");
return { ...rows[0], artifact_count: parseInt(rows[0].artifact_count as string) };
}
}
+42
View File
@@ -0,0 +1,42 @@
import { NestFactory } from "@nestjs/core";
import { ValidationPipe } from "@nestjs/common";
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
import { AppModule } from "./app.module";
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.setGlobalPrefix("api/v1");
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
forbidNonWhitelisted: true,
})
);
app.enableCors({
origin: [
process.env["WEB_URL"] ?? "http://localhost:3000",
process.env["ADMIN_URL"] ?? "http://localhost:3001",
],
credentials: true,
});
const config = new DocumentBuilder()
.setTitle("中华文明全图鉴 API")
.setDescription("文物全图系统后端 API")
.setVersion("1.0")
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup("api/docs", app, document);
const port = process.env["PORT"] ?? 3002;
await app.listen(port);
console.log(`API 服务启动:http://localhost:${port}`);
console.log(`Swagger 文档:http://localhost:${port}/api/docs`);
}
bootstrap();
+57
View File
@@ -0,0 +1,57 @@
import { IsNumber, IsOptional, IsString, Max, Min } from "class-validator";
import { Type } from "class-transformer";
import { ApiPropertyOptional } from "@nestjs/swagger";
export class MapPointsQueryDto {
@ApiPropertyOptional({ description: "西经(左边界)", example: 73.5 })
@IsOptional()
@Type(() => Number)
@IsNumber()
west?: number;
@ApiPropertyOptional({ description: "东经(右边界)", example: 135.0 })
@IsOptional()
@Type(() => Number)
@IsNumber()
east?: number;
@ApiPropertyOptional({ description: "南纬(下边界)", example: 18.0 })
@IsOptional()
@Type(() => Number)
@IsNumber()
south?: number;
@ApiPropertyOptional({ description: "北纬(上边界)", example: 53.5 })
@IsOptional()
@Type(() => Number)
@IsNumber()
north?: number;
@ApiPropertyOptional({ description: "地图缩放级别", example: 5 })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
@Max(20)
zoom?: number;
@ApiPropertyOptional({ description: "文物门类筛选", example: "bronze" })
@IsOptional()
@IsString()
category?: string;
@ApiPropertyOptional({ description: "朝代/年代筛选", example: "唐" })
@IsOptional()
@IsString()
dynasty?: string;
@ApiPropertyOptional({ description: "机构ID筛选" })
@IsOptional()
@IsString()
institution_id?: string;
@ApiPropertyOptional({ description: "标签ID(逗号分隔)" })
@IsOptional()
@IsString()
tag_ids?: string;
}
+35
View File
@@ -0,0 +1,35 @@
import { Controller, Get, Query } from "@nestjs/common";
import { ApiOperation, ApiQuery, ApiTags } from "@nestjs/swagger";
import { MapService } from "./map.service";
import { MapPointsQueryDto } from "./dto/map-query.dto";
@ApiTags("Map")
@Controller("map")
export class MapController {
constructor(private map: MapService) {}
@Get("stats")
@ApiOperation({ summary: "地图统计数据(文物总数、机构总数、位置总数)" })
getStats() {
return this.map.getStats();
}
@Get("points")
@ApiOperation({ summary: "获取地图点位(支持视口范围 + 多维筛选)" })
getPoints(@Query() query: MapPointsQueryDto) {
return this.map.getPoints(query);
}
@Get("nearby")
@ApiOperation({ summary: "附近文物查询" })
@ApiQuery({ name: "lng", type: Number, example: 116.4 })
@ApiQuery({ name: "lat", type: Number, example: 39.9 })
@ApiQuery({ name: "radius_km", type: Number, required: false, example: 50 })
getNearby(
@Query("lng") lng: string,
@Query("lat") lat: string,
@Query("radius_km") radius?: string
) {
return this.map.getNearby(parseFloat(lng), parseFloat(lat), radius ? parseFloat(radius) : 50);
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Module } from "@nestjs/common";
import { MapService } from "./map.service";
import { MapController } from "./map.controller";
@Module({
providers: [MapService],
controllers: [MapController],
})
export class MapModule {}
+125
View File
@@ -0,0 +1,125 @@
import { Injectable } from "@nestjs/common";
import { DatabaseService } from "../database/database.service";
import { MapPointsQueryDto } from "./dto/map-query.dto";
@Injectable()
export class MapService {
constructor(private db: DatabaseService) {}
async getStats() {
const { rows } = await this.db.query<{ total_artifacts: string; total_institutions: string; total_locations: string }>(
`SELECT
(SELECT COUNT(*) FROM artifacts WHERE publish_status = 'published') AS total_artifacts,
(SELECT COUNT(*) FROM institutions WHERE publish_status = 'published') AS total_institutions,
(SELECT COUNT(*) FROM artifact_locations WHERE is_current = true) AS total_locations`
);
const row = rows[0];
return {
total_artifacts: parseInt(row.total_artifacts),
total_institutions: parseInt(row.total_institutions),
total_locations: parseInt(row.total_locations),
};
}
async getPoints(query: MapPointsQueryDto) {
const conditions: string[] = [
"a.publish_status = 'published'",
"al.is_current = true",
"al.public_location IS NOT NULL",
];
const params: unknown[] = [];
let idx = 1;
if (query.west != null && query.east != null && query.south != null && query.north != null) {
conditions.push(
`al.public_location && ST_MakeEnvelope($${idx++}, $${idx++}, $${idx++}, $${idx++}, 4326)`
);
params.push(query.west, query.south, query.east, query.north);
}
if (query.category) {
conditions.push(`a.category = $${idx++}`);
params.push(query.category);
}
if (query.dynasty) {
conditions.push(`a.dynasty ILIKE $${idx++}`);
params.push(`%${query.dynasty}%`);
}
if (query.institution_id) {
conditions.push(`al.institution_id = $${idx++}`);
params.push(query.institution_id);
}
const where = conditions.join(" AND ");
const { rows } = await this.db.query<{
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;
}>(
`SELECT DISTINCT ON (a.id)
a.id, a.name, a.category, a.level, a.dynasty, a.story_hook,
ST_X(al.public_location::geometry) AS lng,
ST_Y(al.public_location::geometry) AS lat,
i.name AS institution_name,
i.province,
i.city,
al.location_type,
a.image_url,
a.repatriation_status
FROM artifacts a
JOIN artifact_locations al ON a.id = al.artifact_id
LEFT JOIN institutions i ON al.institution_id = i.id
WHERE ${where}
ORDER BY a.id, a.level DESC, a.name
LIMIT 2000`,
params
);
return rows;
}
async getNearby(lng: number, lat: number, radiusKm = 50) {
const { rows } = await this.db.query<{
id: string;
name: string;
category: string;
level: string;
distance_km: number;
institution_name: string;
}>(
`SELECT
a.id, a.name, a.category, a.level,
ROUND(ST_Distance(al.public_location, ST_SetSRID(ST_MakePoint($1,$2),4326)::geography) / 1000)::float AS distance_km,
i.name AS institution_name
FROM artifacts a
JOIN artifact_locations al ON a.id = al.artifact_id
LEFT JOIN institutions i ON al.institution_id = i.id
WHERE a.publish_status = 'published'
AND al.is_current = true
AND al.public_location IS NOT NULL
AND ST_DWithin(
al.public_location,
ST_SetSRID(ST_MakePoint($1,$2),4326)::geography,
$3 * 1000
)
ORDER BY distance_km
LIMIT 20`,
[lng, lat, radiusKm]
);
return rows;
}
}
+21
View File
@@ -0,0 +1,21 @@
import { Controller, Get, Param } from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { RoutesService } from "./routes.service";
@ApiTags("routes")
@Controller("routes")
export class RoutesController {
constructor(private readonly routes: RoutesService) {}
@Get()
@ApiOperation({ summary: "叙事路线列表(南迁 / 回归)" })
list() {
return this.routes.list();
}
@Get(":code")
@ApiOperation({ summary: "按 code 获取路线及途经点" })
getByCode(@Param("code") code: string) {
return this.routes.getByCode(code);
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Module } from "@nestjs/common";
import { RoutesService } from "./routes.service";
import { RoutesController } from "./routes.controller";
@Module({
providers: [RoutesService],
controllers: [RoutesController],
})
export class RoutesModule {}
+66
View File
@@ -0,0 +1,66 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { DatabaseService } from "../database/database.service";
export interface RouteRow {
code: string;
title: string;
type: string;
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 StopRow {
seq: number;
name: string;
lng: number;
lat: number;
year_label: string | null;
event: string | null;
}
export type RouteDetail = RouteRow & { stops: StopRow[] };
const ROUTE_SELECT = `
SELECT nr.code, nr.title, nr.type, nr.color, nr.summary, nr.artifact_id,
a.name AS artifact_name, a.dynasty AS artifact_dynasty, a.category AS artifact_category,
i.name AS institution_name
FROM narrative_routes nr
LEFT JOIN artifacts a ON a.id = nr.artifact_id
LEFT JOIN artifact_locations al ON al.artifact_id = a.id AND al.is_current = true
LEFT JOIN institutions i ON i.id = al.institution_id`;
@Injectable()
export class RoutesService {
constructor(private db: DatabaseService) {}
async list(): Promise<RouteRow[]> {
const { rows } = await this.db.query<RouteRow>(
`${ROUTE_SELECT} ORDER BY nr.type, nr.code`
);
return rows;
}
async getByCode(code: string): Promise<RouteDetail> {
const { rows } = await this.db.query<RouteRow>(
`${ROUTE_SELECT} WHERE nr.code = $1 LIMIT 1`,
[code]
);
const route = rows[0];
if (!route) throw new NotFoundException("路线不存在");
const { rows: stops } = await this.db.query<StopRow>(
`SELECT seq, name, lng, lat, year_label, event
FROM route_stops rs
JOIN narrative_routes nr ON nr.id = rs.route_id
WHERE nr.code = $1
ORDER BY rs.seq`,
[code]
);
return { ...route, stops };
}
}
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"module": "CommonJS",
"moduleResolution": "node",
"target": "ES2022",
"lib": ["ES2022"],
"types": ["node"],
"strict": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src",
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"paths": {
"@wenwumap/shared": ["../../packages/shared/src/index.ts"]
}
},
"include": ["src"],
"exclude": ["node_modules", "dist", "**/*.spec.ts"]
}
+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