chore: 初始化仓库

中华文明全图鉴——文物全图系统(PC Web 地图 + NestJS API + 管理后台)。
含三大 IP(文物南迁北归 / 国宝海外回归 / 博物馆手艺人)、AI 文物对话、
文物地图与详情、以及 demo-video-kit 演示视频生成工具。
This commit is contained in:
selfrelease
2026-06-13 20:55:44 +08:00
commit 2d847e154f
161 changed files with 22629 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>中华文明全图鉴 · 管理后台</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+29
View File
@@ -0,0 +1,29 @@
{
"name": "@wenwumap/admin",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "vite --port 3001",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint src --ext .ts,.tsx",
"type-check": "tsc --noEmit"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.25.1",
"antd": "^5.19.3",
"@ant-design/icons": "^5.3.7",
"swr": "^2.2.5",
"@wenwumap/shared": "workspace:*"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"typescript": "^5.5.3",
"vite": "^5.3.5"
}
}
+48
View File
@@ -0,0 +1,48 @@
import { Routes, Route, Navigate } from "react-router-dom";
import { ConfigProvider, theme } from "antd";
import zhCN from "antd/locale/zh_CN";
import Login from "./pages/Login";
import AdminLayout from "./components/AdminLayout";
import Dashboard from "./pages/Dashboard";
import ArtifactList from "./pages/artifacts/ArtifactList";
import { getStoredUser } from "./store/auth";
function RequireAuth({ children }: { children: React.ReactNode }) {
const user = getStoredUser();
return user ? <>{children}</> : <Navigate to="/login" replace />;
}
export default function App() {
return (
<ConfigProvider
locale={zhCN}
theme={{
algorithm: theme.darkAlgorithm,
token: {
colorPrimary: "#c9a84c",
colorBgBase: "#14121e",
colorBgContainer: "#1a1825",
colorBgLayout: "#0f0d18",
fontFamily: "\"Noto Sans SC\", \"PingFang SC\", sans-serif",
},
}}
>
<Routes>
<Route path="/login" element={<Login />} />
<Route
path="/"
element={
<RequireAuth>
<AdminLayout />
</RequireAuth>
}
>
<Route index element={<Navigate to="/dashboard" replace />} />
<Route path="dashboard" element={<Dashboard />} />
<Route path="artifacts" element={<ArtifactList />} />
</Route>
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes>
</ConfigProvider>
);
}
+31
View File
@@ -0,0 +1,31 @@
const BASE_URL = import.meta.env.VITE_API_URL ?? "http://localhost:3002";
function getToken() {
return localStorage.getItem("access_token");
}
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const token = getToken();
const res = await fetch(`${BASE_URL}${path}`, {
...options,
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(options.headers ?? {}),
},
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error((err as { message?: string }).message ?? `HTTP ${res.status}`);
}
return res.json() as Promise<T>;
}
export const api = {
get: <T>(path: string) => request<T>(path),
post: <T>(path: string, body: unknown) =>
request<T>(path, { method: "POST", body: JSON.stringify(body) }),
patch: <T>(path: string, body: unknown) =>
request<T>(path, { method: "PATCH", body: JSON.stringify(body) }),
delete: <T>(path: string) => request<T>(path, { method: "DELETE" }),
};
+124
View File
@@ -0,0 +1,124 @@
import { useState } from "react";
import { Layout, Menu, Avatar, Dropdown, theme, Typography } from "antd";
import {
DashboardOutlined,
PicLeftOutlined,
BankOutlined,
TagsOutlined,
LogoutOutlined,
UserOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
} from "@ant-design/icons";
import { Outlet, useNavigate, useLocation } from "react-router-dom";
import { useAuth } from "../store/auth";
const { Header, Sider, Content } = Layout;
const NAV_ITEMS = [
{ key: "/dashboard", icon: <DashboardOutlined />, label: "概览" },
{ key: "/artifacts", icon: <PicLeftOutlined />, label: "文物管理" },
{ key: "/institutions", icon: <BankOutlined />, label: "机构管理" },
{ key: "/tags", icon: <TagsOutlined />, label: "标签管理" },
];
export default function AdminLayout() {
const [collapsed, setCollapsed] = useState(false);
const navigate = useNavigate();
const location = useLocation();
const { user, logout } = useAuth();
const { token } = theme.useToken();
const userMenuItems = [
{
key: "logout",
icon: <LogoutOutlined />,
label: "退出登录",
onClick: () => {
logout();
navigate("/login");
},
},
];
return (
<Layout style={{ minHeight: "100vh" }}>
<Sider
collapsible
collapsed={collapsed}
onCollapse={setCollapsed}
trigger={null}
style={{
background: token.colorBgContainer,
borderRight: `1px solid ${token.colorBorderSecondary}`,
}}
>
<div
style={{
height: 56,
display: "flex",
alignItems: "center",
justifyContent: collapsed ? "center" : "flex-start",
padding: collapsed ? 0 : "0 20px",
borderBottom: `1px solid ${token.colorBorderSecondary}`,
}}
>
{collapsed ? (
<span style={{ fontSize: 18 }}>🏛</span>
) : (
<Typography.Text strong style={{ color: token.colorPrimary, fontSize: 13, letterSpacing: 1 }}>
</Typography.Text>
)}
</div>
<Menu
mode="inline"
selectedKeys={[location.pathname]}
items={NAV_ITEMS}
onClick={({ key }) => navigate(key)}
style={{ border: "none", marginTop: 8 }}
/>
</Sider>
<Layout>
<Header
style={{
background: token.colorBgContainer,
borderBottom: `1px solid ${token.colorBorderSecondary}`,
height: 56,
padding: "0 20px",
display: "flex",
alignItems: "center",
gap: 16,
}}
>
<button
onClick={() => setCollapsed(!collapsed)}
style={{
background: "none",
border: "none",
cursor: "pointer",
color: token.colorText,
fontSize: 16,
}}
>
{collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
</button>
<div style={{ flex: 1 }} />
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight">
<div style={{ display: "flex", alignItems: "center", gap: 8, cursor: "pointer" }}>
<Avatar size={28} icon={<UserOutlined />} style={{ background: token.colorPrimary }} />
<Typography.Text style={{ fontSize: 13 }}>
{user?.nickname ?? user?.username}
</Typography.Text>
</div>
</Dropdown>
</Header>
<Content style={{ padding: 24, background: token.colorBgLayout }}>
<Outlet />
</Content>
</Layout>
</Layout>
);
}
+10
View File
@@ -0,0 +1,10 @@
/* 全站隐藏滚动条但仍可滚动 */
* {
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* 旧版 Edge / IE */
}
*::-webkit-scrollbar {
width: 0;
height: 0;
display: none; /* Chrome / Safari / 新版 Edge */
}
+16
View File
@@ -0,0 +1,16 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
import "./index.css";
const root = document.getElementById("root");
if (!root) throw new Error("找不到 #root 挂载点");
ReactDOM.createRoot(root).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
);
+67
View File
@@ -0,0 +1,67 @@
import { useEffect, useState } from "react";
import { Card, Statistic, Row, Col, Typography, Spin } from "antd";
import { PicLeftOutlined, BankOutlined, EnvironmentOutlined } from "@ant-design/icons";
import { api } from "../api/client";
interface Stats {
total_artifacts: number;
total_institutions: number;
total_locations: number;
}
export default function Dashboard() {
const [stats, setStats] = useState<Stats | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
api
.get<Stats>("/api/v1/map/stats")
.then(setStats)
.catch(() => null)
.finally(() => setLoading(false));
}, []);
return (
<div>
<Typography.Title level={4} style={{ marginBottom: 24 }}>
</Typography.Title>
{loading ? (
<Spin />
) : (
<Row gutter={16}>
<Col span={8}>
<Card>
<Statistic
title="已发布文物"
value={stats?.total_artifacts ?? 0}
prefix={<PicLeftOutlined />}
valueStyle={{ color: "#c9a84c" }}
/>
</Card>
</Col>
<Col span={8}>
<Card>
<Statistic
title="合作机构"
value={stats?.total_institutions ?? 0}
prefix={<BankOutlined />}
valueStyle={{ color: "#c9a84c" }}
/>
</Card>
</Col>
<Col span={8}>
<Card>
<Statistic
title="地图点位"
value={stats?.total_locations ?? 0}
prefix={<EnvironmentOutlined />}
valueStyle={{ color: "#c9a84c" }}
/>
</Card>
</Col>
</Row>
)}
</div>
);
}
+103
View File
@@ -0,0 +1,103 @@
import { useState } from "react";
import { Form, Input, Button, message, Typography } from "antd";
import { UserOutlined, LockOutlined } from "@ant-design/icons";
import { useNavigate } from "react-router-dom";
import { api } from "../api/client";
import { saveAuth, AuthUser } from "../store/auth";
interface LoginResponse {
access_token: string;
user: AuthUser;
}
export default function Login() {
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
const [form] = Form.useForm();
const onFinish = async (values: { username: string; password: string }) => {
setLoading(true);
try {
const res = await api.post<LoginResponse>("/api/v1/auth/login", values);
saveAuth(res.access_token, res.user);
message.success(`欢迎回来,${res.user.nickname ?? res.user.username}`);
navigate("/dashboard");
} catch (err) {
message.error((err as Error).message || "登录失败,请检查账号密码");
} finally {
setLoading(false);
}
};
return (
<div
style={{
minHeight: "100vh",
background: "linear-gradient(135deg, #0f0c1a 0%, #1a1225 50%, #0d1117 100%)",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<div
style={{
width: 380,
background: "rgba(255,255,255,0.04)",
border: "1px solid rgba(201,168,76,0.2)",
borderRadius: 12,
padding: "40px 36px",
boxShadow: "0 8px 40px rgba(0,0,0,0.5)",
}}
>
<div style={{ textAlign: "center", marginBottom: 32 }}>
<div style={{ fontSize: 28, marginBottom: 8 }}>🏛</div>
<Typography.Title
level={3}
style={{ color: "#c9a84c", margin: 0, fontWeight: 700, letterSpacing: 2 }}
>
</Typography.Title>
<Typography.Text style={{ color: "rgba(255,255,255,0.4)", fontSize: 13 }}>
</Typography.Text>
</div>
<Form form={form} onFinish={onFinish} size="large" requiredMark={false}>
<Form.Item
name="username"
rules={[{ required: true, message: "请输入邮箱或用户名" }]}
>
<Input
prefix={<UserOutlined style={{ color: "rgba(255,255,255,0.3)" }} />}
placeholder="邮箱 / 用户名"
autoComplete="username"
/>
</Form.Item>
<Form.Item
name="password"
rules={[{ required: true, message: "请输入密码" }]}
>
<Input.Password
prefix={<LockOutlined style={{ color: "rgba(255,255,255,0.3)" }} />}
placeholder="密码"
autoComplete="current-password"
/>
</Form.Item>
<Form.Item style={{ marginBottom: 0 }}>
<Button
type="primary"
htmlType="submit"
block
loading={loading}
style={{ height: 44, fontWeight: 600, letterSpacing: 1 }}
>
</Button>
</Form.Item>
</Form>
</div>
</div>
);
}
@@ -0,0 +1,220 @@
import { useState, useEffect, useCallback } from "react";
import {
Table,
Input,
Select,
Space,
Tag,
Typography,
Button,
message,
Tooltip,
} from "antd";
import { SearchOutlined, ReloadOutlined } from "@ant-design/icons";
import type { ColumnsType } from "antd/es/table";
import { api } from "../../api/client";
interface Artifact {
id: string;
name: string;
category: string;
level: string;
dynasty: string;
story_hook: string;
current_institution: string;
}
interface PageMeta {
total: number;
page: number;
limit: number;
total_pages: number;
}
interface ArtifactListResponse {
data: Artifact[];
meta: PageMeta;
}
const CATEGORY_LABELS: Record<string, string> = {
bronze: "青铜器",
painting_calligraphy: "书画",
porcelain: "陶瓷",
jade: "玉器",
gold_silver: "金银器",
lacquer: "漆木器",
textile: "织绣",
stone_carving: "石刻造像",
wood_carving: "木雕",
dunhuang: "敦煌遗珍",
ancient_book: "古籍文献",
other: "其他",
};
const LEVEL_COLORS: Record<string, string> = {
level_1: "gold",
level_2: "blue",
level_3: "default",
unknown: "default",
};
const LEVEL_LABELS: Record<string, string> = {
level_1: "国家一级",
level_2: "国家二级",
level_3: "国家三级",
unknown: "未定级",
};
export default function ArtifactList() {
const [data, setData] = useState<Artifact[]>([]);
const [meta, setMeta] = useState<PageMeta>({ total: 0, page: 1, limit: 20, total_pages: 1 });
const [loading, setLoading] = useState(false);
const [q, setQ] = useState("");
const [category, setCategory] = useState<string | undefined>();
const [dynasty, setDynasty] = useState<string | undefined>();
const fetchData = useCallback(
async (page = 1) => {
setLoading(true);
try {
const params = new URLSearchParams({ page: String(page), limit: "20" });
if (q) params.set("q", q);
if (category) params.set("category", category);
if (dynasty) params.set("dynasty", dynasty);
const res = await api.get<ArtifactListResponse>(
`/api/v1/artifacts?${params}`
);
setData(res.data);
setMeta(res.meta);
} catch (err) {
message.error((err as Error).message);
} finally {
setLoading(false);
}
},
[q, category, dynasty]
);
useEffect(() => {
fetchData(1);
}, [fetchData]);
const columns: ColumnsType<Artifact> = [
{
title: "文物名称",
dataIndex: "name",
key: "name",
render: (text: string, record) => (
<div>
<Typography.Text strong>{text}</Typography.Text>
{record.story_hook && (
<Tooltip title={record.story_hook}>
<Typography.Text
type="secondary"
style={{ display: "block", fontSize: 12, maxWidth: 200 }}
ellipsis
>
{record.story_hook}
</Typography.Text>
</Tooltip>
)}
</div>
),
},
{
title: "门类",
dataIndex: "category",
key: "category",
width: 80,
render: (cat: string) => CATEGORY_LABELS[cat] ?? cat,
},
{
title: "级别",
dataIndex: "level",
key: "level",
width: 100,
render: (lv: string) => (
<Tag color={LEVEL_COLORS[lv] ?? "default"}>{LEVEL_LABELS[lv] ?? lv}</Tag>
),
},
{
title: "朝代",
dataIndex: "dynasty",
key: "dynasty",
width: 90,
},
{
title: "所在机构",
dataIndex: "current_institution",
key: "current_institution",
render: (v: string) => v ?? <Typography.Text type="secondary"></Typography.Text>,
},
];
return (
<div>
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 16 }}>
<Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title>
</div>
<Space style={{ marginBottom: 16 }} wrap>
<Input
placeholder="搜索文物名称"
prefix={<SearchOutlined />}
value={q}
onChange={(e) => setQ(e.target.value)}
onPressEnter={() => fetchData(1)}
style={{ width: 200 }}
allowClear
onClear={() => setQ("")}
/>
<Select
placeholder="门类"
style={{ width: 100 }}
allowClear
value={category}
onChange={setCategory}
options={Object.entries(CATEGORY_LABELS).map(([value, label]) => ({
value,
label,
}))}
/>
<Select
placeholder="朝代"
style={{ width: 140 }}
allowClear
value={dynasty}
onChange={setDynasty}
options={["先秦", "秦汉", "魏晋南北朝", "隋唐", "宋元", "明清"].map((d) => ({
value: d,
label: d,
}))}
/>
<Button
icon={<ReloadOutlined />}
onClick={() => fetchData(1)}
loading={loading}
>
</Button>
</Space>
<Table
rowKey="id"
columns={columns}
dataSource={data}
loading={loading}
pagination={{
total: meta.total,
current: meta.page,
pageSize: meta.limit,
showTotal: (t) => `${t}`,
onChange: (page) => fetchData(page),
showSizeChanger: false,
}}
size="middle"
/>
</div>
);
}
+47
View File
@@ -0,0 +1,47 @@
import { useState, useEffect } from "react";
export interface AuthUser {
id: string;
username: string;
nickname: string;
role: string;
}
const USER_KEY = "admin_user";
const TOKEN_KEY = "access_token";
export function saveAuth(token: string, user: AuthUser) {
localStorage.setItem(TOKEN_KEY, token);
localStorage.setItem(USER_KEY, JSON.stringify(user));
}
export function clearAuth() {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_KEY);
}
export function getStoredUser(): AuthUser | null {
try {
const s = localStorage.getItem(USER_KEY);
return s ? (JSON.parse(s) as AuthUser) : null;
} catch {
return null;
}
}
export function useAuth() {
const [user, setUser] = useState<AuthUser | null>(getStoredUser);
useEffect(() => {
const handler = () => setUser(getStoredUser());
window.addEventListener("storage", handler);
return () => window.removeEventListener("storage", handler);
}, []);
const logout = () => {
clearAuth();
setUser(null);
};
return { user, setUser, logout };
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"paths": {
"@wenwumap/shared": ["../../packages/shared/src/index.ts"]
}
},
"include": ["src"],
"exclude": ["node_modules"]
}
+21
View File
@@ -0,0 +1,21 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import path from "path";
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@wenwumap/shared": path.resolve(__dirname, "../../packages/shared/src/index.ts"),
},
},
server: {
port: 3001,
proxy: {
"/api": {
target: "http://localhost:3002",
changeOrigin: true,
},
},
},
});