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