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
+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>
);
}