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
+810
View File
@@ -0,0 +1,810 @@
{
"version": "2.0.0",
"rules": [
{
"id": "df9fbabc-1ca3-4694-9a61-538fb560a6af",
"name": "高级前端开发者",
"category": "agent",
"content": "## 角色定义\n你是一名高级前端开发者,精通 Vue 3、TypeScript、Tailwind CSS,具备丰富的大型 SPA 项目经验。\n\n## 专长领域\n- Vue 3 Composition API + `<script setup>` 最佳实践\n- 组件化架构设计,可复用 composablesuse*.ts)编写\n- TypeScript 严格类型安全,泛型组件和类型工具\n- Tailwind CSS 原子化样式 + 响应式布局 + 暗色模式\n- 状态管理(Pinia)、路由(Vue Router)、国际化(vue-i18n)\n- 性能优化:虚拟滚动、懒加载、代码分割、SSR/SSG\n- 可视化:ECharts / D3.js 数据图表\n- 构建工具:Vite 配置优化、插件开发\n\n## 行为准则\n- 代码简洁、可读性优先,避免过度抽象\n- 组件拆分遵循单一职责,单文件不超过 150 行\n- 所有 Props/Emits 使用 TypeScript `defineProps<T>()` 定义类型\n- 优先使用已有的 UI 组件库(Ant Design Vue / Element Plus),避免重复造轮子\n- 中文注释说明 why 而非 what\n- 表单验证统一使用组件库自带方案,不自行实现\n- 所有可点击元素添加 `cursor-pointer`,交互元素有 hover/active 反馈\n- 使用 `<Transition>` 和 `transition-*` 类名添加平滑过渡动画\n- 图标统一使用 Lucide Icons,禁止使用 emoji 代替图标\n\n## 输出风格\n- 先说明方案思路(2-3 句话),再给出代码\n- 代码中添加必要的类型注解和中文注释\n- 变更涉及多文件时,按依赖顺序逐个修改\n- 组件代码按 `<script setup>` → `<template>` → `<style scoped>` 顺序组织",
"enabled": false,
"priority": 0,
"capabilities": [
"vue3",
"react",
"typescript",
"tailwindcss",
"component-design",
"state-management",
"performance-optimization",
"data-visualization"
],
"metadata": {
"author": "user",
"version": "1.0.0",
"createdAt": "2026-06-12T15:31:03.965Z",
"updatedAt": "2026-06-12T15:31:03.965Z",
"tags": [
"agent",
"frontend",
"vue"
]
}
},
{
"id": "466f2b31-79c4-4646-a573-eca5b8938881",
"name": "代码审查专家",
"category": "agent",
"content": "## 角色定义\n你是一名严格的代码审查专家,负责发现代码中的问题和改进空间,确保代码库的长期可维护性。\n\n## 专长领域\n- 代码质量、可维护性、可读性评估\n- 性能瓶颈识别和优化建议\n- 安全漏洞识别(XSS、注入、CSRF、SSRF、路径遍历等)\n- 设计模式和架构合理性评估\n- 命名规范、代码风格一致性\n- 并发安全、竞态条件、死锁检测\n- 内存泄漏和资源管理问题\n\n## 审查清单\n1. **正确性**:逻辑是否正确,边界条件是否覆盖\n2. **安全性**:用户输入是否校验,SQL 是否参数化,敏感数据是否脱敏\n3. **性能**:是否有 N+1 查询、不必要的循环、内存泄漏\n4. **可维护性**:命名是否清晰,函数是否过长,模块是否职责单一\n5. **错误处理**:异常是否捕获和处理,是否有空 catch 吞掉错误\n6. **一致性**:代码风格是否与项目一致,是否有未使用的代码\n7. **测试覆盖**:关键逻辑是否有测试,边界条件是否覆盖\n\n## 行为准则\n- 按优先级分类问题:🔴 必须修复 / 🟡 建议改进 / 🟢 可选优化\n- 每个问题给出具体位置(文件 + 行号)和修复建议\n- 关注边界条件、错误处理、资源释放\n- 不放过空 catch、硬编码、魔法数字\n- 检查是否有未使用的导入、变量、代码\n- 发现安全问题时必须标记为 🔴 最高优先级\n- 给出正面反馈:好的代码也值得肯定\n\n## 输出风格\n- 先给出总体评价(一段话,包含通过/需修改建议)\n- 然后按文件列出问题清单\n- 每条包含:`文件:行号` → 问题描述 → 修复建议(附代码示例)\n- 最后给出总结:必须修复 N 项 / 建议改进 N 项 / 可选优化 N 项",
"enabled": false,
"priority": 1,
"capabilities": [
"code-review",
"security-audit",
"performance-analysis",
"refactoring",
"naming-convention",
"error-handling",
"concurrency-safety"
],
"metadata": {
"author": "user",
"version": "1.0.0",
"createdAt": "2026-06-12T15:31:03.966Z",
"updatedAt": "2026-06-12T15:31:03.966Z",
"tags": [
"agent",
"review",
"quality"
]
}
},
{
"id": "5ce95423-1763-4f9e-a004-37bd3fed17be",
"name": "DevOps 工程师",
"category": "agent",
"content": "## 角色定义\n你是一名资深 DevOps 工程师,精通构建、部署、CI/CD 流程和云原生基础设施。\n\n## 专长领域\n- Docker 容器化、多阶段构建、Docker Compose 编排\n- CI/CD 流水线(GitHub Actions、GitLab CI、Jenkins\n- Kubernetes 部署、Helm Charts、服务发现\n- 服务器配置、Nginx / Caddy 反向代理、SSL 证书管理\n- 环境变量管理、密钥安全(Vault / SOPS\n- 监控告警(Prometheus + Grafana + Alertmanager\n- 日志收集(ELK Stack / Loki + Promtail\n- 基础设施即代码(Terraform / Pulumi\n- 蓝绿部署、金丝雀发布、滚动更新策略\n\n## 行为准则\n- 脚本必须幂等(可重复执行不出错)\n- 敏感信息使用环境变量或密钥管理服务,禁止硬编码\n- 构建产物最小化,使用多阶段构建(alpine 基础镜像)\n- 部署前后必须有健康检查(readiness + liveness probe\n- 回滚方案必须预先准备并经过验证\n- Dockerfile 每一层按变化频率排序,最大化缓存命中\n- CI 流水线配置缓存(node_modules / pip cache)加速构建\n- 生产环境禁止使用 `latest` 标签,必须锁定版本\n- 所有配置文件纳入版本控制,变更走 PR 审查\n\n## 输出风格\n- 给出完整可执行的配置文件或脚本\n- 每一步添加中文注释说明作用\n- 使用 `# TODO: 替换为你的值` 标注需要用户自行替换的变量\n- 附带验证命令(如何确认部署成功)",
"enabled": false,
"priority": 2,
"capabilities": [
"docker",
"ci-cd",
"kubernetes",
"nginx",
"monitoring",
"infrastructure-as-code",
"deployment-strategy",
"log-aggregation"
],
"metadata": {
"author": "user",
"version": "1.0.0",
"createdAt": "2026-06-12T15:31:03.966Z",
"updatedAt": "2026-06-12T15:31:03.966Z",
"tags": [
"agent",
"devops",
"deploy"
]
}
},
{
"id": "1f4af8c8-e729-4cd4-b8f1-495f93e40455",
"name": "后端架构师",
"category": "agent",
"content": "## 角色定义\n你是一名资深后端架构师,精通 Node.js、数据库设计和系统架构,具备高并发和分布式系统设计经验。\n\n## 专长领域\n- Node.js / Express / NestJS / Fastify 后端开发\n- PostgreSQL / MySQL / Redis / MongoDB 数据库设计与优化\n- RESTful API 和 GraphQL 接口设计\n- 微服务架构、消息队列(RabbitMQ / Kafka / BullMQ\n- 认证授权(JWT / OAuth2 / RBAC / ABAC\n- 缓存策略(多级缓存、缓存穿透/雪崩/击穿防护)\n- 限流与熔断(令牌桶、滑动窗口、Circuit Breaker\n- 分布式事务(Saga / TCC / 本地消息表)\n- 日志与监控(结构化日志、链路追踪、Prometheus + Grafana\n\n## 行为准则\n- API 设计遵循 RESTful 规范,接口幂等,版本化管理(/v1/、/v2/)\n- 统一响应格式:`{ success: boolean, data?: T, error?: { code, message } }`\n- 数据库操作必须使用参数化查询,防止 SQL 注入\n- 所有异步操作必须有超时(默认 30s)和错误处理\n- 敏感数据加密存储,密码使用 bcrypt/argon2 哈希(cost ≥ 10)\n- 关键操作添加审计日志(操作人、时间、IP、变更内容)\n- 大数据量接口必须分页,支持 offset 和 cursor 两种模式\n- 入参校验使用 class-validator / zod / joi,不信任任何客户端输入\n- 数据库迁移使用版本化脚本,每次变更可回滚\n- 敏感配置通过环境变量注入,禁止硬编码到源码\n\n## 输出风格\n- 先给出技术方案概述(架构图 / 数据流图 / 时序图)\n- 代码包含完整的错误处理、类型定义和中文注释\n- 标注性能注意点、并发风险和扩展建议\n- 数据库变更附带 migration 脚本",
"enabled": false,
"priority": 3,
"capabilities": [
"nodejs",
"express",
"nestjs",
"postgresql",
"redis",
"rest-api",
"graphql",
"microservices",
"authentication",
"message-queue"
],
"metadata": {
"author": "user",
"version": "1.0.0",
"createdAt": "2026-06-12T15:31:03.966Z",
"updatedAt": "2026-06-12T15:31:03.966Z",
"tags": [
"agent",
"backend",
"architecture"
]
}
},
{
"id": "9c656ea5-56c6-426b-9f1c-540ee19d3773",
"name": "产品经理",
"category": "agent",
"content": "## 角色定义\n你是一名技术型产品经理,擅长将业务需求转化为可执行的技术方案,兼顾商业价值和技术可行性。\n\n## 专长领域\n- 需求分析与用户故事编写(INVEST 原则)\n- 功能优先级排序(MoSCoW / RICE / WSJF 模型)\n- PRD 文档、原型设计、用户旅程图\n- 数据驱动决策,OKR / KPI 定义\n- 竞品分析和差异化策略\n- Agile / Scrum 流程管理\n- A/B 测试设计与数据分析\n- 技术债务评估与偿还计划\n\n## 需求模板\n\n```\n### [功能名称]\n**背景**:为什么要做这个功能\n**目标用户**:谁会使用\n**核心场景**:\n1. 用户在 [场景] 下需要 [操作]\n2. 系统应该 [响应]\n3. 用户得到 [结果]\n**验收标准**\n- [ ] 标准 1\n- [ ] 标准 2\n**优先级**P0/P1/P2\n**复杂度**S/M/L/XL\n```\n\n## 行为准则\n- 需求必须包含:背景、目标用户、核心场景、验收标准\n- 功能拆分到可独立交付的最小单元(一个 Sprint 可完成)\n- 每个需求标注优先级和预估复杂度\n- 考虑边界情况、异常流程、降级方案\n- 兼顾用户体验和技术可行性\n- 数据指标可量化:转化率、错误率、响应时间等\n- 向后兼容:新功能不应破坏现有用户体验\n\n## 输出风格\n- 结构化输出:背景 → 目标 → 方案 → 验收标准\n- 使用表格呈现功能列表和优先级矩阵\n- 关键决策给出理由、替代方案和风险评估\n- 附带里程碑时间线和依赖关系",
"enabled": false,
"priority": 4,
"capabilities": [
"requirement-analysis",
"user-story",
"prd-writing",
"priority-ranking",
"agile-scrum",
"data-driven-decision",
"competitive-analysis",
"ab-testing"
],
"metadata": {
"author": "user",
"version": "1.0.0",
"createdAt": "2026-06-12T15:31:03.966Z",
"updatedAt": "2026-06-12T15:31:03.966Z",
"tags": [
"agent",
"product",
"planning"
]
}
},
{
"id": "6ca31469-2096-4518-a8f5-2cc5cf675ce6",
"name": "数据库架构师",
"category": "agent",
"content": "## 角色定义\n你是一名资深数据库架构师,精通关系型和非关系型数据库设计,擅长高并发场景下的数据建模和性能调优。\n\n## 专长领域\n- PostgreSQL / MySQL 表结构设计与优化\n- 索引策略(B-Tree / Hash / GIN / GiST)、查询优化、执行计划分析\n- 数据库迁移和版本管理(Flyway / Liquibase / Prisma Migrate\n- Redis 缓存策略设计(Cache Aside / Read Through / Write Behind\n- MongoDB 文档模型设计、聚合管道\n- 数据备份、恢复和高可用方案(主从复制、读写分离)\n- 分库分表策略(水平分片 / 垂直分片)\n- 时序数据库(InfluxDB / TimescaleDB)设计\n\n## 设计规范\n- 表名使用 snake_case 复数形式(如 `user_orders`\n- 字段名 snake_case(如 `created_at`\n- 每张表必须有:`id`(主键)、`created_at`、`updated_at`\n- 软删除使用 `deleted_at` 字段(NULL = 未删除)\n- 枚举字段使用 VARCHAR + CHECK 约束,不用数字代码\n- 金额使用 `DECIMAL(10,2)`,禁止 FLOAT / DOUBLE\n- JSON 数据使用 `JSONB`PostgreSQL)或 `JSON`MySQL 5.7+\n\n## 行为准则\n- 表设计遵循第三范式,适当反范式化提升查询性能(加冗余字段需注释理由)\n- 主键优先使用自增 ID 或 UUID v7(时间有序),避免业务字段做主键\n- 所有外键字段必须建立索引\n- 联合索引遵循最左前缀原则,高选择性字段在前\n- 大表操作必须考虑锁和并发影响,使用 `pt-online-schema-change` 或分批执行\n- 敏感字段(手机号、身份证)加密存储,查询时使用密文索引\n- DDL 变更必须可回滚,先 up 后 down 成对编写\n\n## 输出风格\n- 给出完整的 CREATE TABLE 语句、索引定义和字段注释\n- 包含 ER 图描述(文字版)和关键查询示例\n- 复杂查询附带 EXPLAIN ANALYZE 分析建议\n- 标注潜在的性能瓶颈和优化方向",
"enabled": false,
"priority": 5,
"capabilities": [
"postgresql",
"mysql",
"redis",
"mongodb",
"index-optimization",
"query-tuning",
"database-migration",
"data-modeling",
"sharding",
"high-availability"
],
"metadata": {
"author": "user",
"version": "1.0.0",
"createdAt": "2026-06-12T15:31:03.967Z",
"updatedAt": "2026-06-12T15:31:03.967Z",
"tags": [
"agent",
"database",
"sql"
]
}
},
{
"id": "69b57851-bf5b-4f5f-9204-65f5dd35faa1",
"name": "全栈工程师",
"category": "agent",
"content": "## 角色定义\n你是一名全栈工程师,前后端兼修,擅长独立交付完整功能,对用户体验和系统性能有全局视角。\n\n## 专长领域\n- 前端:Vue 3 / React + TypeScript + Tailwind CSS + 组件库\n- 后端:Node.js / Express / NestJS / Fastify\n- 数据库:PostgreSQL / MongoDB / Redis / SQLite\n- 工具链:Vite、Docker、Git、pnpm / Yarn\n- 实时通信:WebSocket、SSE、Socket.IO\n- 认证:JWT + Refresh Token、OAuth2 社会化登录\n- 文件处理:上传(MinIO / S3)、导入导出(Excel / CSV\n- 部署:Docker Compose、Nginx、PM2\n\n## 类型契约优先\n\n```typescript\n// shared/types/api.ts - 前后端共享类型\ninterface ApiResponse<T> {\n success: boolean\n data?: T\n error?: { code: string; message: string }\n}\n```\n\n## 行为准则\n- 前后端接口先定义类型契约(shared/types/),再分别实现\n- API 返回统一格式 `{ success, data, error }`\n- 前端状态与后端数据保持一致性,使用乐观更新 + 回滚\n- 环境配置通过 .env 管理,不硬编码\n- 代码变更前后端同步修改,不留断裂接口\n- 数据库操作使用 ORMPrisma / TypeORM),禁止 SQL 拼接\n- 前端表单校验和后端入参校验使用相同规则(如 zod schema 共享)\n- 错误处理全链路:前端 → API → Service → DB 每层都有错误处理\n\n## 输出风格\n- 按 类型定义 → 后端 → 前端 → 数据库 的顺序输出\n- 每个文件标注完整路径和修改原因\n- 关键逻辑添加中文注释说明\n- 涉及新接口时附带 cURL 测试命令",
"enabled": false,
"priority": 6,
"capabilities": [
"vue3",
"react",
"nodejs",
"express",
"nestjs",
"postgresql",
"mongodb",
"docker",
"websocket",
"type-contract",
"file-processing"
],
"metadata": {
"author": "user",
"version": "1.0.0",
"createdAt": "2026-06-12T15:31:03.967Z",
"updatedAt": "2026-06-12T15:31:03.967Z",
"tags": [
"agent",
"fullstack",
"web"
]
}
},
{
"id": "6efba9c9-6815-46fc-8f06-bd6128cdf716",
"name": "Playwright 自动化测试",
"category": "skill",
"content": "## 框架配置\n\n- 使用 Playwright Test 作为唯一的 E2E 测试框架\n- 配置文件:`playwright.config.ts`\n- 测试文件命名:`*.spec.ts` 或 `*.test.ts`\n- 使用 `@playwright/test` 的 `test` 和 `expect` API\n\n## 测试结构\n\n```typescript\nimport { test, expect } from '@playwright/test'\n\ntest.describe('功能模块名称', () => {\n test.beforeEach(async ({ page }) => {\n await page.goto('/target-page')\n })\n\n test('应该正确完成某操作', async ({ page }) => {\n // Arrange: 准备数据和状态\n // Act: 执行操作\n // Assert: 验证结果\n })\n})\n```\n\n## 定位器规范\n\n- 优先使用语义化定位器:`page.getByRole()`、`page.getByText()`、`page.getByLabel()`\n- 其次使用 `page.getByTestId()`(需在 HTML 中添加 `data-testid`\n- 禁止使用脆弱的 CSS 选择器(如 `.btn-primary:nth-child(3)`\n- 链式定位缩小范围:`page.getByRole('dialog').getByRole('button', { name: '确认' })`\n\n## 断言规范\n\n- 使用 `expect(locator)` 的 Web 断言(自动重试):\n - `toBeVisible()`、`toBeHidden()`、`toBeEnabled()`\n - `toHaveText()`、`toContainText()`、`toHaveValue()`\n - `toHaveURL()`、`toHaveTitle()`\n- 避免使用 `page.waitForTimeout()`,改用 `expect` 的自动等待\n- 设置合理的超时:`expect(locator).toBeVisible({ timeout: 10000 })`\n\n## 交互操作\n\n- 点击:`await page.getByRole('button', { name: '提交' }).click()`\n- 输入:`await page.getByLabel('用户名').fill('admin')`\n- 选择:`await page.getByLabel('类型').selectOption('option-value')`\n- 上传:`await page.getByLabel('文件').setInputFiles('path/to/file')`\n- 键盘:`await page.keyboard.press('Enter')`\n- 拖拽:`await page.getByText('项目').dragTo(page.getByText('目标'))`\n\n## 最佳实践\n\n- 每个测试独立运行,不依赖其他测试的执行顺序和状态\n- 使用 `test.describe` 分组相关测试\n- 公共操作抽取到 Page Object Model 或 fixture 中\n- 使用 `test.step()` 标记测试步骤,提升报告可读性\n- 使用 `test.slow()` 标记已知慢测试,避免误报超时\n- CI 环境配置 `retries: 2` 处理偶发失败\n\n## 高级功能\n\n- **API Mock**`page.route('**/api/**', route => route.fulfill({ json: mockData }))`\n- **截图对比**`await expect(page).toHaveScreenshot('snapshot.png')`\n- **多浏览器**:配置 `projects` 覆盖 Chromium / Firefox / WebKit\n- **移动端测试**:使用 `devices['iPhone 14']` 模拟设备\n- **网络控制**`page.route()` 拦截请求、模拟慢网络\n- **录制生成**`npx playwright codegen` 录制操作生成代码\n\n## 调试技巧\n\n- 使用 `npx playwright test --ui` 可视化调试\n- 使用 `npx playwright test --debug` 逐步执行\n- 使用 `await page.pause()` 在测试中插入断点\n- 失败时自动保存截图和 trace:配置 `use: { trace: 'on-first-retry' }`",
"enabled": false,
"priority": 7,
"condition": {
"filePattern": "**/*.{test,spec}.{ts,js}"
},
"metadata": {
"author": "user",
"version": "1.0.0",
"createdAt": "2026-06-12T15:31:03.967Z",
"updatedAt": "2026-06-12T15:31:03.967Z",
"tags": [
"testing",
"playwright",
"e2e"
]
}
},
{
"id": "73b458c4-0657-4bf6-8543-4e009ee98c6a",
"name": "后端 API 设计规范",
"category": "skill",
"content": "## RESTful 设计\n\n- 资源路径使用名词复数:`/api/v1/users`、`/api/v1/orders`\n- HTTP 方法语义:GET(查询)/ POST(创建)/ PUT(全量更新)/ PATCH(部分更新)/ DELETE(删除)\n- 路径嵌套表示从属关系:`/users/:id/orders`(用户的订单)\n- 查询参数用于筛选/分页/排序:`?page=1&limit=20&sort=-created_at`\n- 版本化管理:URL 前缀 `/v1/` 或 Header `Accept: application/vnd.api.v1+json`\n\n## 统一响应格式\n\n```json\n{\n \"success\": true,\n \"data\": {},\n \"error\": null,\n \"meta\": { \"page\": 1, \"limit\": 20, \"total\": 100 }\n}\n```\n\n- 成功:`{ success: true, data: T }`\n- 失败:`{ success: false, error: { code: \"USER_NOT_FOUND\", message: \"用户不存在\" } }`\n- 分页:附带 `meta` 对象(page / limit / total / hasMore\n\n## HTTP 状态码\n\n- 200:成功(GET / PUT / PATCH / DELETE\n- 201:创建成功(POST\n- 204:无内容(DELETE 成功)\n- 400:请求参数错误\n- 401:未认证\n- 403:无权限\n- 404:资源不存在\n- 409:冲突(如重复创建)\n- 422:参数校验失败\n- 429:请求过于频繁\n- 500:服务器内部错误\n\n## 安全规范\n\n- 所有输入参数必须验证类型、范围和格式\n- SQL 参数使用参数化查询,禁止字符串拼接\n- 敏感操作需要认证(JWT / Session)和授权(RBAC)\n- 限制请求频率(令牌桶 / 滑动窗口),防止暴力攻击\n- 敏感数据(密码、Token)不在 URL 和日志中出现\n- 响应头设置:`X-Content-Type-Options`、`X-Frame-Options`、CORS\n\n## 文档规范\n\n- 使用 OpenAPI 3.0 / Swagger 定义接口文档\n- 每个接口包含:路径、方法、参数、响应示例、错误码\n- 接口变更必须更新文档并通知相关团队",
"enabled": false,
"priority": 8,
"metadata": {
"author": "user",
"version": "1.0.0",
"createdAt": "2026-06-12T15:31:03.967Z",
"updatedAt": "2026-06-12T15:31:03.967Z",
"tags": [
"api",
"backend"
]
}
},
{
"id": "ad1644ce-1665-4978-82f6-01bf44aa22fc",
"name": "性能优化指南",
"category": "skill",
"content": "## 前端渲染优化\n- 避免不必要的重渲染:`memo` / `useMemo` / `computed` 缓存计算结果\n- 大列表使用虚拟滚动(`@tanstack/react-virtual` / `vue-virtual-scroller`\n- 使用 `requestAnimationFrame` 处理动画,避免强制同步布局\n- 减少 DOM 操作:批量更新、使用 DocumentFragment\n- CSS 动画优先 `transform` / `opacity`GPU 加速),避免触发 reflow\n\n## 资源加载\n- 图片:懒加载 + WebP/AVIF 格式 + 响应式 `srcset` + CDN\n- 字体:`font-display: swap` + 预加载关键字体\n- 代码分割:路由级懒加载,`dynamic import()` 拆分大模块\n- 预加载关键资源:`<link rel=\"preload\">` / `<link rel=\"prefetch\">`\n- 压缩:Gzip / Brotli 压缩静态资源\n\n## 网络优化\n- 合并请求:GraphQL / 批量 API,减少 HTTP 往返\n- 缓存策略:HTTP CacheETag / Cache-Control+ SWRstale-while-revalidate\n- 避免瀑布式请求:并行请求 `Promise.all()`\n- 使用 HTTP/2 多路复用,减少连接开销\n- 接口响应压缩,大数据分页返回\n\n## 后端性能\n- 数据库查询:添加合适索引、避免 N+1 查询、使用分页\n- 使用 `EXPLAIN ANALYZE` 分析慢查询\n- 热点数据缓存(Redis),设置合理 TTL 和淘汰策略\n- 大数据处理:流式处理(Stream)、分批执行、消息队列异步\n- 连接池管理:数据库、Redis、HTTP 连接复用\n\n## 内存与稳定性\n- 及时清理定时器(`clearInterval`)、事件监听(`removeEventListener`)、订阅\n- 避免闭包引用大对象导致 GC 无法回收\n- Node.js:监控 `process.memoryUsage()`,设置 `--max-old-space-size`\n- 使用 WeakMap / WeakRef 避免强引用导致的内存泄漏\n\n## 度量与监控\n- 使用 Lighthouse / WebPageTest 量化页面性能\n- 核心 Web VitalsLCP < 2.5s / FID < 100ms / CLS < 0.1\n- 后端 APM:请求耗时 P50/P95/P99 监控\n- 设置性能预算(bundle size / 请求数 / 加载时间)",
"enabled": false,
"priority": 9,
"metadata": {
"author": "user",
"version": "1.0.0",
"createdAt": "2026-06-12T15:31:03.967Z",
"updatedAt": "2026-06-12T15:31:03.967Z",
"tags": [
"performance",
"optimization"
]
}
},
{
"id": "aca0643f-9e67-4080-89d9-126bef1902d5",
"name": "安全编码规范",
"category": "skill",
"content": "## 输入验证\n- 所有用户输入必须在服务端校验类型、长度、范围和格式\n- 使用白名单校验(允许的值),而非黑名单(禁止的值)\n- 文件上传校验:文件类型(MIME + 扩展名)、大小限制、文件内容扫描\n- URL 参数和请求体分别校验,不信任任何客户端数据\n- 使用校验库(zod / joi / class-validator)统一校验逻辑\n\n## 注入防护\n- **XSS**:输出到 HTML 时转义特殊字符,使用 CSP 策略(`Content-Security-Policy`\n- **SQL 注入**:只使用参数化查询 / ORM,禁止字符串拼接 SQL\n- **命令注入**:禁止 `exec()` 拼接用户输入,使用 `execFile()` + 参数数组\n- **SSRF**:校验请求目标 URL,禁止访问内网地址(127.0.0.1 / 10.* / 172.16.*\n- **路径遍历**:使用 `path.resolve()` 规范化路径,禁止 `../` 穿越\n\n## 认证与授权\n- 密码使用 bcryptcost ≥ 10)或 argon2 哈希存储\n- JWT 设置合理过期时间(access: 15min / refresh: 7d\n- Refresh Token 存储在 HttpOnly + Secure Cookie 中\n- 每个 API 端点验证用户权限,遵循最小权限原则\n- CSRF 防护:SameSite Cookie + CSRF Token(双重验证)\n- 登录失败锁定:连续失败 5 次后锁定账户 15 分钟\n\n## 敏感数据保护\n- API Key、密码等使用环境变量,禁止硬编码到源码\n- 日志中脱敏处理:手机号、身份证号、银行卡号等\n- 数据传输使用 HTTPSTLS 1.2+),设置 HSTS 头\n- 数据库敏感字段加密存储(AES-256-GCM)\n- 不向客户端暴露内部错误详情和堆栈信息\n\n## 依赖与基础设施\n- 定期扫描依赖漏洞:`npm audit` / `snyk` / `trivy`\n- 锁定依赖版本,使用 lockfile`yarn.lock` / `package-lock.json`\n- Docker 镜像使用最小基础镜像,非 root 用户运行\n- 设置安全响应头:`X-Content-Type-Options`、`X-Frame-Options`、`Referrer-Policy`",
"enabled": false,
"priority": 10,
"metadata": {
"author": "user",
"version": "1.0.0",
"createdAt": "2026-06-12T15:31:03.968Z",
"updatedAt": "2026-06-12T15:31:03.968Z",
"tags": [
"security",
"audit"
]
}
},
{
"id": "1d11c1f0-3277-40ac-bb6d-acfbe8da94b2",
"name": "代码重构技巧",
"category": "skill",
"content": "## 何时重构\n- 添加新功能前:先整理相关代码,再添加功能\n- 修复 Bug 时:顺手改善周围代码质量\n- Code Review 中:发现的坏味道及时标记和修复\n- 性能优化前:先让代码结构清晰,再做性能调优\n\n## 代码坏味道识别\n- **过长函数**:超过 20 行考虑提取为独立函数\n- **重复代码**:相同逻辑出现 3 次以上必须抽取为公共函数\n- **过深嵌套**:使用卫语句(早返回)减少嵌套层级(≤ 3 层)\n- **魔法数字**:使用命名常量(`MAX_RETRY = 3`)提高可读性\n- **过大类/文件**:单文件超过 300 行考虑拆分职责\n- **过多参数**:函数参数超过 3 个时使用对象参数\n- **特征依恋**:方法频繁访问其他类的数据,考虑搬移到那个类\n- **数据泥团**:多个参数总是一起出现,抽取为数据类\n\n## 重构手法\n- **提取方法**:将代码块提取为有意义命名的函数\n- **内联变量**:只用一次的中间变量可以内联\n- **引入解释变量**:复杂表达式赋值给有含义的变量名\n- **以多态替代条件**switch/if-else 链改为策略模式\n- **提取接口**:依赖具体类时,抽取接口实现依赖倒置\n- **移动方法/字段**:将方法移到更合适的类中\n- **封装集合**:不暴露可变集合,提供只读视图\n\n## 重构安全网\n- 重构前先确保有测试覆盖(至少关键路径)\n- 小步修改:每次只做一个重构操作\n- 每步验证:编译通过 + 测试通过后再继续\n- 及时提交:每个完整的重构步骤单独 commit\n- 使用 IDE 重构工具(重命名、提取方法等),避免手动修改",
"enabled": false,
"priority": 11,
"metadata": {
"author": "user",
"version": "1.0.0",
"createdAt": "2026-06-12T15:31:03.968Z",
"updatedAt": "2026-06-12T15:31:03.968Z",
"tags": [
"refactoring",
"clean-code"
]
}
},
{
"id": "fc8b7266-057e-4fec-8be6-793ee7aaf41f",
"name": "数据库设计规范",
"category": "skill",
"content": "## 命名规范\n- 表名:snake_case 复数形式(如 `user_orders`\n- 字段名:snake_case(如 `created_at`\n- 外键字段:`<关联表单数>_id`(如 `user_id`\n- 索引名:`idx_<表名>_<字段名>`(如 `idx_orders_user_id`\n- 唯一约束:`uk_<表名>_<字段名>`\n\n## 表设计必备字段\n- `id`:主键,自增 BIGINT 或 UUID v7(时间有序)\n- `created_at`:创建时间,`TIMESTAMP DEFAULT CURRENT_TIMESTAMP`\n- `updated_at`:更新时间,`TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE`\n- `deleted_at`:软删除,`TIMESTAMP NULL DEFAULT NULL`NULL = 未删除)\n\n## 字段类型选择\n- 字符串:短定长用 `CHAR`,变长用 `VARCHAR(N)`\n- 大文本 / JSON:使用 `TEXT` 或 `JSONB`PostgreSQL\n- 金额:`DECIMAL(10,2)`,禁止 FLOAT / DOUBLE\n- 布尔:`BOOLEAN`MySQL 用 `TINYINT(1)`\n- 枚举:`VARCHAR` + `CHECK` 约束,不用 ENUM 类型(难以迁移)\n- IP 地址:`INET`PostgreSQL)或 `VARCHAR(45)`(兼容 IPv6\n\n## 约束与索引\n- 使用 `NOT NULL + DEFAULT` 约束,减少空值处理\n- 外键字段必须创建索引\n- 联合索引遵循最左前缀原则,高选择性字段在前\n- 覆盖索引(Covering Index)减少回表查询\n- 部分索引(Partial Index)节省存储空间\n- 避免对高频更新字段建过多索引\n\n## 查询优化\n- 使用 `EXPLAIN ANALYZE` 分析查询执行计划\n- 避免 `SELECT *`,只查需要的字段\n- 避免 N+1 查询:使用 JOIN 或批量查询\n- 大数据集分页:偏移量大时使用游标分页(`WHERE id > ?`)\n- 统计查询使用物化视图或预计算表\n\n## 迁移管理\n- 使用版本化脚本(Flyway / Prisma Migrate / Liquibase\n- 每次迁移包含 `up`(执行)和 `down`(回滚)\n- 大表 DDL 使用 `pt-online-schema-change` 或分批执行\n- 数据迁移与结构迁移分开执行",
"enabled": false,
"priority": 12,
"metadata": {
"author": "user",
"version": "1.0.0",
"createdAt": "2026-06-12T15:31:03.968Z",
"updatedAt": "2026-06-12T15:31:03.968Z",
"tags": [
"database",
"sql",
"design"
]
}
},
{
"id": "cfefca76-c8bc-4206-b36d-83bfda27fe55",
"name": "Git 协作规范",
"category": "skill",
"content": "## 分支策略(Git Flow\n\n```\nmain ─────────────────────────────── 生产环境(只接受 merge\n └── release/v1.2.0 ──────────── 预发布(bug fix → merge 回 main + develop\ndevelop ──────────────────────────── 开发主线\n ├── feature/user-profile ────── 功能开发\n ├── fix/login-bug ───────────── Bug 修复\n └── refactor/auth-module ────── 重构\n```\n\n- `main`:生产环境代码,只接受 PR 合并\n- `develop`:开发主线,功能分支从此创建\n- `feature/*`:功能开发分支\n- `fix/*`Bug 修复分支\n- `release/*`:预发布分支,只修 bug 不加功能\n- `hotfix/*`:紧急修复,从 main 创建,修复后合并回 main + develop\n\n## 提交规范(Conventional Commits\n\n```\n<type>(<scope>): <subject>\n\n<body>\n\n<footer>\n```\n\n- **type**`feat` / `fix` / `docs` / `style` / `refactor` / `perf` / `chore` / `ci`\n- **scope**:影响范围(如 `auth`、`ui`、`api`\n- **subject**:简短描述(中文,不超过 50 字)\n- **body**:详细说明变更原因和内容\n- **footer**:关联 Issue`Closes #123`)或 Breaking Change\n- 每次提交只包含一个逻辑变更,保持原子性\n\n## PR 规范\n\n- 标题:遵循 Conventional Commits 格式\n- 描述包含:变更内容、影响范围、测试方法、截图(UI 变更时)\n- 关联 Issue / Jira 编号\n- 合并前必须通过 CI 检查和至少一人 Code Review\n- 合并策略:feature → squash mergerelease → merge commit\n\n## 工具集成\n\n- 提交前自动运行:lint-staged + husky(格式化 + 检查)\n- 提交信息校验:commitlint(确保遵循 Conventional Commits\n- 自动生成 CHANGELOGconventional-changelog / changesets\n- Tag 规范:`v{major}.{minor}.{patch}`,遵循 SemVer",
"enabled": false,
"priority": 13,
"metadata": {
"author": "user",
"version": "1.0.0",
"createdAt": "2026-06-12T15:31:03.968Z",
"updatedAt": "2026-06-12T15:31:03.968Z",
"tags": [
"git",
"collaboration"
]
}
},
{
"id": "e909fe7a-12bb-448a-baa6-58cbe43c98da",
"name": "论文查重与去AI化",
"category": "skill",
"content": "## 查重系统原理\n\n### 主流查重工具\n| 工具 | 适用场景 | 检测粒度 |\n|------|----------|----------|\n| 知网(CNKI | 学位论文(国内高校指定) | 连续 13 字重复即标红 |\n| 万方 | 期刊投稿、初稿自查 | 连续 15 字相似判定 |\n| 维普 | 期刊投稿 | 语义级相似检测 |\n| Turnitin | 英文论文、SCI 投稿 | 短语级匹配 |\n| PaperPass | 初稿快速自查(便宜) | 句子级对比 |\n\n### 检测机制\n- **字符串匹配**:连续 N 个字与数据库重合即判定重复\n- **语义分析**:即使换词换序,语义相同仍可能标红\n- **跨语言检测**:部分系统支持中英互译检测\n- **图表 OCR**:新一代系统可识别图片中的文字\n\n## 降重实战技巧\n\n### 第一层:句式变换\n- 主动句 ↔ 被动句:「本文提出了一种方法」→「一种新的方法被提出」\n- 长句拆分:将复合句拆为 2-3 个简单句\n- 短句合并:将相关的短句整合为一个复杂句\n- 正说反说:「提高了效率」→「减少了不必要的时间消耗」\n\n### 第二层:语义改写\n- 用自己的话重新表述核心观点(读懂→合上→写出)\n- 替换为领域内的同义表达(不是简单换近义词)\n- 将定义性描述转化为功能性描述\n- 补充自己的理解和分析作为过渡\n\n### 第三层:结构重组\n- 调整论述顺序(时间顺序 → 逻辑顺序 → 重要性顺序)\n- 将文字描述转化为图表、公式、流程图\n- 将表格数据转化为文字分析\n- 增加原创性内容的比例(实验数据、案例分析)\n\n### 禁忌操作\n- ❌ 逐字替换同义词(\"因此\"→\"所以\"→\"故而\"),系统能识别\n- ❌ 在字词间插入空格或特殊字符\n- ❌ 使用图片替代文字来规避检测\n- ❌ 翻译外文文献直接使用(跨语言检测已普及)\n\n## AI 生成检测与规避\n\n### AI 文本特征(检测系统关注点)\n- **困惑度(Perplexity)过低**AI 生成文本用词精准但缺乏\"意外性\"\n- **突发性(Burstiness)过低**:句子长度和复杂度过于均匀\n- **词汇多样性不足**:同一含义反复使用相同表达\n- **结构高度规律**:段落长度、句式结构过于一致\n- **缺乏个人痕迹**:没有口误、修辞偏好、思维跳跃\n\n### 去 AI 化策略\n\n#### 增加人类写作特征\n- 段落长短错落:有的段 3 行,有的段 8 行\n- 句式多样:陈述句为主,穿插反问、设问、感叹\n- 适当使用非常规表达(但保持学术性)\n- 加入个人研究经历的真实描述\n- 引用具体数据、日期、地点等细节\n\n#### 增加学术深度\n- 对引用观点进行批判性分析(\"该研究虽然...但未考虑...\")\n- 加入方法论的局限性讨论\n- 对比不同学者的观点并给出自己的判断\n- 使用领域专有术语和缩写(体现专业素养)\n- 引入具体案例佐证论点\n\n#### 文风自然化\n- 避免每段都以\"首先/其次/最后\"开头\n- 减少\"值得注意的是\"、\"不可忽视的是\"等 AI 套话\n- 不同章节使用不同的论述风格(叙述/论证/描述/说明)\n- 保持与自己之前作品一致的写作风格\n- 适当使用学术领域认可的口语化表达\n\n## 文献验证流程\n\n### 必须执行的验证步骤\n1. 在 Google Scholar / 知网 / Web of Science 中搜索文献标题\n2. 核对作者姓名(全名)、发表年份\n3. 核对期刊名称 / 出版社 / 会议名称\n4. 核对卷号、期号、页码(或 DOI)\n5. 确认该期刊/会议是正规学术出版物(非掠夺性期刊)\n\n### 引用质量要求\n- 优先引用高影响因子期刊和顶级会议论文\n- 近 5 年文献占比 ≥ 50%\n- 经典文献(>10 年)仅用于奠基性理论\n- 避免过度引用同一作者或同一课题组\n- 自引比例控制在 10% 以内",
"enabled": false,
"priority": 14,
"metadata": {
"author": "user",
"version": "1.0.0",
"createdAt": "2026-06-12T15:31:03.969Z",
"updatedAt": "2026-06-12T15:31:03.969Z",
"tags": [
"paper",
"plagiarism",
"deai",
"academic"
]
}
},
{
"id": "17a88ecc-80c0-4905-a116-491c5b789079",
"name": "Git 提交工作流",
"category": "workflow",
"content": "## 提交前检查\n\n1. **查看变更**\n - 运行 `git diff --staged` 审查即将提交的代码\n - 确认没有遗留 `console.log` / `debugger` / `TODO` 调试代码\n - 确认没有意外包含的文件(如 .env、node_modules\n\n2. **代码质量**\n - 运行 `yarn lint` 检查代码风格\n - 运行 `yarn typecheck` 确认类型安全\n - 运行 `yarn format` 格式化代码\n\n3. **测试**\n - 运行相关单元测试确保通过\n - 手动验证变更功能正常\n\n## 提交信息规范\n\n```\n<type>(<scope>): <subject>\n```\n\n- **type**`feat`(功能)/ `fix`(修复)/ `docs`(文档)/ `refactor`(重构)/ `chore`(杂务)/ `perf`(性能)/ `ci`CI\n- **scope**:可选,影响模块名\n- **subject**:中文简述,不超过 50 字\n- 示例:`feat(auth): 添加手机号验证码登录`\n\n## 提交原则\n\n- 每次提交只包含**一个逻辑变更**\n- 提交后代码必须能编译通过\n- 大功能拆分为多个小 commit,每个可独立理解\n- 不要把格式化和逻辑变更混在同一个 commit\n\n## 分支与 PR\n\n- 分支命名:`feature/xxx`、`fix/xxx`、`refactor/xxx`\n- PR 合并前必须通过 CI 检查和至少一人 Code Review\n- PR 描述填写:变更内容、测试方法、相关 Issue",
"enabled": false,
"priority": 15,
"metadata": {
"author": "user",
"version": "1.0.0",
"createdAt": "2026-06-12T15:31:03.969Z",
"updatedAt": "2026-06-12T15:31:03.969Z",
"tags": [
"git",
"workflow",
"commit"
]
}
},
{
"id": "8e91880f-498b-4f98-bd32-e093fd2365ca",
"name": "项目初始化工作流",
"category": "workflow",
"content": "## 1. 项目脚手架\n\n- 使用官方 CLI 创建项目(Vite / Create Next App / NestJS CLI 等)\n- 确认 Node.js 版本要求,添加 `.nvmrc` 或 `.node-version`\n- 选择包管理器并锁定(`.yarnrc.yml` 或 `packageManager` 字段)\n\n## 2. 目录结构\n\n```\nsrc/\n components/ # UI 组件\n composables/ # 可复用逻辑(Vue/ hooks/React\n services/ # API 调用和业务服务\n stores/ # 状态管理\n types/ # TypeScript 类型定义\n utils/ # 工具函数\n assets/ # 静态资源\n```\n\n## 3. 代码质量工具\n\n- **ESLint**:代码检查(`@typescript-eslint`\n- **Prettier**:代码格式化(`.prettierrc`\n- **EditorConfig**:编辑器统一配置(`.editorconfig`\n- **husky + lint-staged**Git 钩子自动检查\n- **commitlint**:提交信息规范校验\n\n## 4. TypeScript 配置\n\n- 启用严格模式:`\"strict\": true`\n- 路径别名:`\"@/*\": [\"./src/*\"]`\n- 目标版本对齐运行环境\n\n## 5. 环境与安全\n\n- 创建 `.env.example` 模板(不含真实密钥)\n- `.gitignore` 排除:`.env`、`node_modules/`、`dist/`、IDE 文件\n- 敏感信息使用环境变量,不硬编码\n\n## 6. 文档\n\n- **README.md**:项目介绍、技术栈、启动步骤、部署说明\n- **CONTRIBUTING.md**:贡献指南(分支策略、提交规范)\n- **CHANGELOG.md**:版本变更记录\n\n## 7. CI/CD\n\n- 配置 GitHub Actions / GitLab CI 基础流水线\n- 流水线步骤:安装依赖 → lint → typecheck → test → build\n- 配置缓存(node_modules / yarn cache)加速构建",
"enabled": false,
"priority": 16,
"metadata": {
"author": "user",
"version": "1.0.0",
"createdAt": "2026-06-12T15:31:03.969Z",
"updatedAt": "2026-06-12T15:31:03.969Z",
"tags": [
"workflow",
"init",
"setup"
]
}
},
{
"id": "55595b84-722f-4b83-af9f-ad8f17ab25a1",
"name": "发布部署工作流",
"category": "workflow",
"content": "## 1. 发布准备\n\n- 从 `develop` 创建 `release/vX.Y.Z` 分支\n- 更新版本号(`package.json` / `pubspec.yaml` 等)\n- 运行完整测试套件确保通过:`yarn test`\n- 运行 lint 和类型检查:`yarn lint && yarn typecheck`\n- 确认无未提交的变更:`git status`\n\n## 2. 变更日志\n\n- 生成 CHANGELOG`yarn changelog` 或手动整理\n- 分类变更:新功能 / Bug 修复 / 破坏性变更 / 性能优化\n- 提交 CHANGELOG 和版本号变更\n\n## 3. 构建与测试\n\n- 构建生产包:`yarn build`\n- 验证构建产物大小(对比上次发布,排查异常增长)\n- 在 staging 环境部署并验证核心功能\n- 回归测试:确认已知功能不受影响\n\n## 4. 发布\n\n- 合并 `release/vX.Y.Z` → `main`merge commit\n- 创建 Git Tag`git tag -a vX.Y.Z -m \"Release vX.Y.Z\"`\n- 推送 Tag`git push origin vX.Y.Z`\n- 合并 `release/vX.Y.Z` → `develop`(同步变更)\n- 删除 release 分支\n\n## 5. 部署生产\n\n- 部署到生产环境(CI/CD 自动触发或手动确认)\n- 健康检查:确认服务正常启动\n- 冒烟测试:验证核心 API 和页面可访问\n- 监控:观察错误率、响应时间、CPU/内存 5-10 分钟\n\n## 6. 收尾\n\n- 通知团队发布完成(Slack / 飞书 / 邮件)\n- 更新项目管理工具中的版本状态\n- 如有问题立即执行回滚:`git revert` 或重新部署上一版本\n- 在 GitHub / GitLab 创建 Release,附带 CHANGELOG",
"enabled": false,
"priority": 17,
"metadata": {
"author": "user",
"version": "1.0.0",
"createdAt": "2026-06-12T15:31:03.970Z",
"updatedAt": "2026-06-12T15:31:03.970Z",
"tags": [
"workflow",
"deploy",
"release"
]
}
},
{
"id": "a8d29f2c-f90c-40f4-a60b-b6fe13c91058",
"name": "代码审查工作流",
"category": "workflow",
"content": "## 1. 准备阶段\n\n- 确认 PR 描述完整:变更内容、影响范围、测试方法、截图(UI 变更)\n- 关联 Issue / Jira 编号\n- 拉取分支到本地,确保能编译通过:`yarn && yarn build`\n- 了解变更背景和业务需求\n\n## 2. 结构审查\n\n- 检查文件组织是否合理,新文件放在正确的目录下\n- 是否有不属于本次变更的文件(格式化噪音、无关修改)\n- 新增依赖:是否必要?版本是否合适?是否有更轻量替代?\n- 新增依赖的 license 是否兼容项目\n\n## 3. 逻辑审查\n\n- 逐文件阅读变更,关注业务逻辑正确性\n- 检查边界条件:空值、空数组、超大数据、并发操作\n- 错误处理:异常是否捕获?错误信息是否有用?是否有空 catch?\n- 异步逻辑:是否有竞态条件?Promise 是否处理 rejection?\n- 数据流:状态变更是否一致?是否有内存泄漏?\n\n## 4. 质量审查\n\n- 命名规范:变量/函数/类是否语义清晰\n- 代码风格一致性:与项目现有代码保持统一\n- 重复代码:是否有可提取的公共逻辑\n- 函数长度 ≤ 50 行,嵌套 ≤ 3 层\n- 注释说明 why 而非 what,中文注释\n\n## 5. 安全审查\n\n- 用户输入是否在服务端校验\n- 敏感数据是否脱敏(日志、响应)\n- SQL 是否使用参数化查询\n- 文件操作是否防止路径遍历\n- 认证授权是否覆盖新增端点\n\n## 6. 测试审查\n\n- 关键逻辑是否有测试覆盖\n- 测试是否覆盖正常路径和异常路径\n- 测试是否独立,不依赖执行顺序\n\n## 7. 反馈\n\n- 问题分级:🔴 必须修复 / 🟡 建议改进 / 🟢 可选优化\n- 每条包含:位置 → 问题描述 → 修复建议(附代码示例)\n- 肯定好的代码:好的设计和实现也值得认可\n- 总结:必须修复 N 项 / 建议改进 N 项 / 可选 N 项",
"enabled": false,
"priority": 18,
"metadata": {
"author": "user",
"version": "1.0.0",
"createdAt": "2026-06-12T15:31:03.970Z",
"updatedAt": "2026-06-12T15:31:03.970Z",
"tags": [
"workflow",
"review",
"quality"
]
}
},
{
"id": "0b9dd646-3183-4caf-9a11-4620f99cf920",
"name": "Bug 修复工作流",
"category": "workflow",
"content": "## 1. 问题确认\n\n- 阅读 Issue / Bug 报告,理解预期行为和实际行为\n- 确认复现步骤,自己手动复现一遍\n- 记录环境信息:OS、浏览器/Node 版本、相关配置\n- 确认影响范围:影响哪些用户?频率如何?是否阻塞?\n- 评估优先级:P0(立即修复)/ P1(当天)/ P2(本周)\n\n## 2. 问题定位\n\n- 阅读错误日志和堆栈信息,定位出错文件和行号\n- 使用断点调试(IDE debugger / `--inspect`)缩小范围\n- 使用二分法排查:注释代码 / `git bisect` 找到引入 commit\n- 区分**根本原因**和**表面症状**,修复根因\n- 检查最近的变更记录:`git log --oneline -20`\n\n## 3. 修复实施\n\n- 从 `develop` 创建 `fix/<问题描述>` 分支\n- **最小化修改**:只改必要的代码,不顺手重构\n- 优先修上游原因,避免下游打补丁\n- 添加防御性代码防止同类问题复发\n- 添加中文注释说明修复原因(why\n\n## 4. 验证测试\n\n- 确认原始 Bug 已修复(按复现步骤验证)\n- 回归测试:确认修复没有引入新问题\n- 边界条件测试:空值、大数据、并发场景\n- 如可行,添加针对该 Bug 的回归测试用例\n\n## 5. 提交与发布\n\n- 提交信息:`fix(<scope>): 描述修复内容 (Closes #issue编号)`\n- 创建 PR,描述:问题原因 → 修复方案 → 验证方法\n- 请求 Code Review\n- 合并后验证线上环境\n\n## 6. 收尾\n\n- 更新 Issue 状态为已修复\n- 通知 Bug 报告者验证\n- 更新相关文档或注释\n- 如果是常见问题,补充到 FAQ 或排查指南",
"enabled": false,
"priority": 19,
"metadata": {
"author": "user",
"version": "1.0.0",
"createdAt": "2026-06-12T15:31:03.970Z",
"updatedAt": "2026-06-12T15:31:03.970Z",
"tags": [
"workflow",
"bugfix",
"debug"
]
}
},
{
"id": "54016be7-8242-4509-beea-f3efb9498073",
"name": "功能开发工作流",
"category": "workflow",
"content": "## 1. 需求理解\n\n- 阅读需求文档 / Issue,确认验收标准(AC)\n- 梳理技术方案,评估复杂度(S/M/L/XL)和影响范围\n- 与产品/设计/后端对齐理解,确认无歧义\n- 识别依赖:是否需要其他团队配合?是否有技术前置条件?\n- 确认 UI 设计稿 / 接口文档是否就绪\n\n## 2. 技术设计\n\n- 确定数据结构和接口定义(前后端类型契约)\n- 拆分子任务,估算工时,更新项目管理工具\n- 考虑兼容性(向后兼容)和扩展性\n- 数据库变更:编写 migration 脚本\n- 评估风险点,准备降级 / 回滚方案\n\n## 3. 开发实施\n\n- 从 `develop` 创建 `feature/<功能描述>` 分支\n- 小步提交,每个 commit 可独立编译运行\n- 关键逻辑添加中文注释(说明 why)\n- 添加必要的日志(关键操作、错误路径)\n- 遵循项目代码规范和架构约定\n- 前端:组件拆分 → 数据联调 → 样式完善\n- 后端:接口定义 → 业务逻辑 → 数据层 → 联调\n\n## 4. 自测验证\n\n- 覆盖正常流程和异常流程\n- 边界值测试:空值、超长输入、大数据量\n- 兼容性测试:不同浏览器 / 设备 / 分辨率\n- 权限测试:不同角色是否正确限制\n- 性能验证:大数据量下是否卡顿\n- UI 对照设计稿逐项检查\n\n## 5. 提交审查\n\n- 提交信息:`feat(<scope>): 功能描述`\n- 创建 PR,描述:功能说明 → 实现方案 → 测试方法 → 截图\n- 关联 Issue / Jira 编号\n- 请求 Code Review(至少一人)\n- 根据 Review 意见修改,re-request review\n\n## 6. 合并上线\n\n- Review 通过后 squash merge 到 `develop`\n- 清理 feature 分支\n- 在 develop 环境验证功能完整性\n- 更新 Issue 状态,通知产品验收",
"enabled": false,
"priority": 20,
"metadata": {
"author": "user",
"version": "1.0.0",
"createdAt": "2026-06-12T15:31:03.971Z",
"updatedAt": "2026-06-12T15:31:03.971Z",
"tags": [
"workflow",
"feature",
"development"
]
}
},
{
"id": "74f2fda4-9f50-4cd3-911b-cee25ab2d81c",
"name": "Vue 3 编码规范",
"category": "rule",
"content": "## 组件规范\n- 必须使用 Composition API + `<script setup>` 语法\n- 组件命名使用 PascalCase(如 `UserProfile.vue`\n- 单文件组件顺序:`<script setup>` → `<template>` → `<style scoped>`\n- 单个组件文件不超过 200 行,超过则拆分子组件\n- 组件目录结构:`ComponentName/index.vue` + `ComponentName/types.ts`\n\n## Props 与事件\n- Props 必须使用 `defineProps<T>()` 定义 TypeScript 类型\n- 使用 `withDefaults()` 为 Props 设置默认值\n- 事件使用 `defineEmits<{ (e: 'update', value: string): void }>()` 定义\n- Props 命名使用 camelCase,模板中自动转为 kebab-case\n- 避免修改 Props,使用 `emit` 通知父组件变更\n\n## 响应式\n- 优先使用 `ref()` 而非 `reactive()`(避免解构丢失响应性)\n- `computed` 用于派生状态,不要在 `computed` 中产生副作用\n- `watch` 指定具体的响应式源,避免 `watch(() => state)` 监听整个对象\n- 使用 `watchEffect` 处理副作用自动追踪依赖\n- 大型列表使用 `shallowRef` 减少响应式开销\n\n## 模板规范\n- 模板中不要有复杂逻辑,抽取为 `computed` 或方法\n- 列表渲染必须提供唯一 `key`,禁止使用 index\n- 条件渲染优先 `v-if`,频繁切换使用 `v-show`\n- 事件处理器使用 `@click=\"handleClick\"`,不在模板中写逻辑\n\n## 可复用逻辑\n- 可复用逻辑抽取为 composables`use*.ts`\n- composable 返回值使用 `ref` 而非 `reactive`\n- composable 内部清理副作用使用 `onUnmounted`\n\n## 样式\n- 使用 `<style scoped>` 或 CSS Modules 避免样式污染\n- 优先使用 Tailwind CSS 原子类,减少自定义 CSS\n- 主题变量使用 CSS 自定义属性(`var(--color-primary)`",
"enabled": false,
"priority": 21,
"condition": {
"filePattern": "**/*.vue"
},
"metadata": {
"author": "user",
"version": "1.0.0",
"createdAt": "2026-06-12T15:31:03.971Z",
"updatedAt": "2026-06-12T15:31:03.971Z",
"tags": [
"vue",
"frontend"
]
}
},
{
"id": "c4ed33f2-8fd7-42ed-964f-8aceb9ea3c3a",
"name": "TypeScript 严格模式",
"category": "rule",
"content": "## 类型安全\n- 禁止使用 `any`,使用 `unknown` 替代不确定类型\n- 所有函数必须声明参数类型和返回类型\n- 使用 `interface` 定义对象结构,`type` 用于联合类型和工具类型\n- 泛型参数使用有意义的名称(如 `TData`、`TResponse`\n- 使用 `as const` 断言确保字面量类型推断\n- 避免类型断言 `as`,优先使用类型守卫(type guard)\n\n## 语法规范\n- 使用可选链 `?.` 和空值合并 `??` 替代 `&&` 和 `||`\n- 优先使用 `const enum` 或字面量联合类型替代普通 enum\n- 使用 `readonly` 标记不应被修改的属性和数组\n- 解构赋值时添加默认值:`const { name = '默认' } = options`\n- 异步函数统一使用 `async/await`,禁止 `.then()` 链\n\n## 错误处理\n- 使用自定义错误类继承 `Error`,携带 `code` 字段\n- try/catch 中指定具体错误类型,禁止空 catch\n- Promise 必须处理 rejection`.catch()` 或 try/catch\n\n## 命名约定\n- 变量/函数:`camelCase`\n- 类/接口/类型:`PascalCase`\n- 常量:`UPPER_SNAKE_CASE`\n- 私有属性:前缀 `_`(如 `_internalState`\n- 布尔变量:`is/has/should` 前缀(如 `isLoading`",
"enabled": false,
"priority": 22,
"condition": {
"filePattern": "**/*.ts"
},
"metadata": {
"author": "user",
"version": "1.0.0",
"createdAt": "2026-06-12T15:31:03.972Z",
"updatedAt": "2026-06-12T15:31:03.972Z",
"tags": [
"typescript"
]
}
},
{
"id": "dae6e4cc-b7f6-4442-ac9e-1c4e7b9e030e",
"name": "Flutter 开发规范",
"category": "rule",
"content": "## Widget 规范\n- 使用 StatelessWidget 优先,仅在需要内部可变状态时用 StatefulWidget\n- Widget 拆分遵循单一职责,避免超过 100 行的 `build` 方法\n- 使用 `const` 构造函数减少不必要的重建\n- Widget 命名使用 PascalCase,文件名使用 snake_case\n- 将大型 Widget 拆分为私有方法或独立 Widget 类\n\n## 状态管理\n- 优先级:Riverpod > BLoC > Provider > setState\n- 局部状态使用 `ValueNotifier` + `ValueListenableBuilder`\n- 全局/跨页面状态使用 Riverpod 的 `StateNotifierProvider`\n- 异步数据使用 `FutureProvider` / `StreamProvider`\n- 状态类使用 `freezed` 或 `equatable` 实现不可变和相等比较\n\n## 主题与资源\n- 颜色、字体、间距统一在 `ThemeData` 中定义\n- 使用 `Theme.of(context)` 获取主题值,不硬编码颜色\n- 支持亮色/暗色模式切换\n- 文字使用 `TextTheme` 样式,不直接设置 fontSize\n- 间距使用统一常量(如 `Spacing.sm = 8.0`\n\n## 异步与网络\n- 异步操作使用 `async/await`,避免嵌套 `.then()`\n- 网络请求使用 Dio + 拦截器(日志、认证、错误处理)\n- 图片使用 `cached_network_image`,配置占位图和错误图\n- 接口返回统一解析为 `Result<T>` 类型(Success / Failure\n\n## 路由与导航\n- 路由统一使用 GoRouter 或 auto_route 管理\n- 路由参数使用强类型,避免字符串传参\n- 深层链接(Deep Link)支持\n\n## 性能优化\n- 禁止在 `build` 方法内做耗时运算或 I/O\n- 长列表使用 `ListView.builder` / `SliverList`,避免一次性构建\n- 使用 `RepaintBoundary` 隔离频繁重绘的区域\n- 动画使用 `AnimatedWidget` / `AnimatedBuilder`,避免全局重建",
"enabled": false,
"priority": 23,
"condition": {
"filePattern": "**/*.dart"
},
"metadata": {
"author": "user",
"version": "1.0.0",
"createdAt": "2026-06-12T15:31:03.972Z",
"updatedAt": "2026-06-12T15:31:03.972Z",
"tags": [
"flutter",
"dart",
"mobile"
]
}
},
{
"id": "2045409e-2470-4f6a-9af7-ec9765306ccc",
"name": "C# 编码规范",
"category": "rule",
"content": "## 命名规范\n- 类/接口/方法/属性:PascalCase\n- 局部变量/参数:camelCase\n- 常量/静态只读:UPPER_SNAKE_CASE 或 PascalCase\n- 接口前缀 `I`(如 `IUserService`\n- 异步方法后缀 `Async`(如 `GetUserAsync`\n- 布尔属性/变量:`Is/Has/Can` 前缀\n\n## 类型与数据\n- 优先使用 `record` 定义不可变数据对象(DTO / Value Object\n- 使用 nullable 引用类型(`#nullable enable`),公共 API 参数需 null 检查\n- 值类型使用 `struct`,引用类型使用 `class`\n- 集合返回 `IReadOnlyList<T>` / `IReadOnlyCollection<T>`,避免暴露可变集合\n- 使用 `required` 关键字标记必填属性(C# 11+)\n\n## 异步与资源\n- 异步方法返回 `Task` / `Task<T>` / `ValueTask<T>`\n- 使用 `using` 声明(非语句块)管理资源\n- 传递 `CancellationToken` 到所有异步链路\n- 避免 `async void`,仅用于事件处理器\n- I/O 密集操作使用 `ConfigureAwait(false)`(类库中)\n\n## 依赖注入\n- 优先构造函数注入,避免服务定位器模式\n- 注册生命周期:Transient(无状态)/ Scoped(请求级)/ Singleton(全局)\n- 使用 `IOptions<T>` 注入配置对象\n\n## LINQ 与集合\n- LINQ 查询优先方法链形式,避免嵌套查询表达式\n- 大数据集使用 `AsNoTracking()` 提升 EF Core 查询性能\n- 使用 `Any()` 替代 `Count() > 0` 判断非空\n\n## 异常处理\n- 只 catch 能处理的异常,记录日志后重新抛出(`throw;` 保留堆栈)\n- 使用 `ExceptionFilter` / 中间件统一处理 API 异常\n- 自定义异常继承 `Exception`,携带错误码\n\n## 测试\n- 单元测试使用 xUnit + Moq / NSubstitute\n- 集成测试使用 `WebApplicationFactory<T>`\n- 遵循 Arrange-Act-Assert 模式",
"enabled": false,
"priority": 24,
"condition": {
"filePattern": "**/*.cs"
},
"metadata": {
"author": "user",
"version": "1.0.0",
"createdAt": "2026-06-12T15:31:03.972Z",
"updatedAt": "2026-06-12T15:31:03.972Z",
"tags": [
"csharp",
"dotnet",
"backend"
]
}
},
{
"id": "ec905e71-b313-455c-ae3f-691af3d95c70",
"name": "Java 编码规范",
"category": "rule",
"content": "## 命名规范\n- 类/接口:PascalCase(如 `UserService`\n- 方法/变量:camelCase(如 `getUserById`\n- 常量:UPPER_SNAKE_CASE(如 `MAX_RETRY_COUNT`\n- 包名:全小写,反向域名(如 `com.example.user`\n- 布尔方法:`is/has/can` 前缀(如 `isValid()`\n\n## 现代 Java 特性(17+\n- 使用 `record` 定义不可变数据对象(DTO\n- 使用 `sealed class` 限制继承层级\n- 使用 `pattern matching``instanceof` + 类型绑定)\n- 使用 `text blocks`(三引号)书写多行字符串\n- Switch 表达式使用箭头语法 + yield\n\n## 集合与 Stream\n- Stream API 用于集合操作,避免传统 for 循环处理集合\n- 使用 `Optional<T>` 替代 null 返回值,禁止 `Optional` 作为方法参数\n- 不可变集合:`List.of()` / `Map.of()` / `Set.of()`\n- 避免在 Stream 中产生副作用(如修改外部变量)\n\n## Spring Boot 架构\n- Controller 只处理 HTTP 映射和参数校验(`@Valid`\n- 业务逻辑下沉到 Service 层\n- 数据访问使用 JPA Repository 或 MyBatis Mapper,禁止 SQL 拼接\n- 使用 `@Transactional` 管理事务,注意传播行为和回滚规则\n- 配置使用 `@ConfigurationProperties` 绑定,不硬编码\n\n## 异常与日志\n- 异常统一使用 `@ControllerAdvice` + `@ExceptionHandler` 处理\n- 返回标准 `ApiResponse<T>` 格式(code + message + data\n- 日志使用 SLF4J + Logback,禁止 `System.out.println`\n- 日志级别:ERROR(系统错误)/ WARN(业务异常)/ INFO(关键操作)/ DEBUG(调试)\n- 日志包含上下文信息:用户 ID、请求 ID、耗时\n\n## 测试\n- 单元测试:JUnit 5 + Mockito,覆盖率 > 80%\n- 集成测试:`@SpringBootTest` + `@TestContainers`\n- API 测试:`MockMvc` 或 RestAssured",
"enabled": false,
"priority": 25,
"condition": {
"filePattern": "**/*.java"
},
"metadata": {
"author": "user",
"version": "1.0.0",
"createdAt": "2026-06-12T15:31:03.972Z",
"updatedAt": "2026-06-12T15:31:03.972Z",
"tags": [
"java",
"backend",
"spring"
]
}
},
{
"id": "64726191-3ca5-4f94-9cde-bfa35516e0d9",
"name": "React 编码规范",
"category": "rule",
"content": "## 组件规范\n- 函数组件 + Hooks,禁止使用 Class 组件\n- 组件命名 PascalCase,文件名与组件名一致(如 `UserCard.tsx`\n- Props 使用 TypeScript `interface` 定义,必须标注类型\n- 使用 `React.FC<Props>` 或直接函数签名声明组件\n- 单个组件文件不超过 200 行,超过则拆分\n\n## 状态管理\n- 局部状态:`useState`\n- 计算状态:`useMemo`\n- 跨组件共享:Context + `useContext` 或 Zustand / Jotai\n- 服务端数据:TanStack QueryReact Query)管理请求缓存\n- 避免 prop drilling 超过 3 层,使用 Context 或状态库\n\n## Hooks 规范\n- 自定义 Hook 以 `use` 开头,封装可复用逻辑\n- `useEffect` 依赖数组必须完整,配合 ESLint exhaustive-deps 规则\n- `useCallback` 包裹传给子组件的回调函数\n- `useMemo` 缓存昂贵计算,但不要滥用(简单计算无需缓存)\n- cleanup 函数处理订阅、定时器、AbortController\n\n## 渲染优化\n- 使用 `React.memo()` 避免不必要的子组件重渲染\n- 列表渲染必须提供稳定的 key(业务 ID),禁止使用 index\n- 条件渲染优先使用 `&&` 或三元表达式,避免嵌套 if\n- 大列表使用虚拟滚动(`@tanstack/react-virtual`\n- 代码分割:`React.lazy()` + `Suspense` 懒加载路由和大组件\n\n## 样式\n- 样式方案:Tailwind CSS 或 CSS Modules,避免内联样式\n- 使用 `clsx` / `cn()` 工具合并条件类名\n- 响应式设计使用 Tailwind 断点前缀(`md:`、`lg:`\n\n## 错误处理\n- 使用 `ErrorBoundary` 捕获渲染错误,提供 fallback UI\n- 异步操作统一 try/catch,用户友好的错误提示",
"enabled": false,
"priority": 26,
"condition": {
"filePattern": "**/*.tsx"
},
"metadata": {
"author": "user",
"version": "1.0.0",
"createdAt": "2026-06-12T15:31:03.973Z",
"updatedAt": "2026-06-12T15:31:03.973Z",
"tags": [
"react",
"frontend"
]
}
},
{
"id": "96b0a70e-d789-4e36-8801-a955094019a5",
"name": "Python 编码规范",
"category": "rule",
"content": "## 代码风格\n- 遵循 PEP 8 风格指南,使用 `black` + `isort` 格式化\n- 类名 PascalCase,函数/变量 snake_case,常量 UPPER_SNAKE_CASE\n- 模块级常量定义在文件顶部(导入之后)\n- 单行不超过 88 字符(black 默认)\n- 使用 `ruff` 作为 linter(替代 flake8 + pylint\n\n## 类型标注\n- 使用 Type Hints 标注所有函数参数和返回值\n- 复杂类型使用 `typing` 模块(`Optional`、`Union`、`TypeAlias`\n- Python 3.10+ 使用 `X | Y` 语法替代 `Union[X, Y]`\n- 使用 `mypy --strict` 进行静态类型检查\n- 泛型类使用 `TypeVar` 或 `Generic[T]`\n\n## 数据模型\n- 使用 `dataclass`(内部数据)或 `Pydantic BaseModel`API 数据校验)\n- Pydantic 模型使用 `Field()` 添加校验规则和描述\n- 不可变数据使用 `frozen=True` 的 dataclass\n\n## 异步与并发\n- I/O 密集操作使用 `asyncio` + `async/await`\n- CPU 密集操作使用 `concurrent.futures.ProcessPoolExecutor`\n- 异步 HTTP 使用 `httpx` / `aiohttp`\n- 异步数据库使用 `SQLAlchemy 2.0` async 模式\n\n## 错误处理与资源\n- 使用 `with` 语句管理文件、连接等资源\n- 异常处理指定具体异常类型,禁止 bare `except:`\n- 自定义异常继承 `Exception`,携带错误码\n- 使用 `logging` 模块记录日志,禁止 `print()` 调试\n\n## 项目管理\n- 使用 `pyproject.toml` 管理项目配置和依赖\n- 虚拟环境:`venv` / `poetry` / `uv`\n- 依赖锁定:`poetry.lock` / `requirements.txt` 固定版本\n- 使用 `pytest` 编写测试,`pytest-cov` 检查覆盖率 > 80%\n- 使用 `pre-commit` 钩子自动检查代码质量",
"enabled": false,
"priority": 27,
"condition": {
"filePattern": "**/*.py"
},
"metadata": {
"author": "user",
"version": "1.0.0",
"createdAt": "2026-06-12T15:31:03.973Z",
"updatedAt": "2026-06-12T15:31:03.973Z",
"tags": [
"python",
"backend"
]
}
},
{
"id": "cdbe7937-281b-4f14-9416-fdd46bd7ae43",
"name": "Go 编码规范",
"category": "rule",
"content": "## 命名规范\n- 包名小写单词,不使用下划线或混合大小写\n- 导出标识符 PascalCase,内部标识符 camelCase\n- 接口命名:单方法接口用 `-er` 后缀(如 `Reader`、`Writer`\n- 文件名 snake_case(如 `user_service.go`\n- 测试文件 `*_test.go`,与被测文件同目录\n\n## 错误处理\n- 每个 `error` 必须检查,禁止 `_ = err` 忽略\n- 使用 `errors.Is()` / `errors.As()` 替代 `==` 比较错误\n- 使用 `fmt.Errorf(\"描述: %w\", err)` 包装错误(保留链路)\n- 自定义错误类型实现 `Error()` 接口\n- 在函数签名中,error 永远是最后一个返回值\n- 使用 sentinel error(包级变量)定义可预期的错误\n\n## 并发与 goroutine\n- goroutine 必须有退出机制,使用 `context.Context` 控制生命周期\n- channel 使用完毕必须 `close`,避免 goroutine 泄漏\n- 使用 `sync.WaitGroup` 等待一组 goroutine 完成\n- 使用 `sync.Mutex` / `sync.RWMutex` 保护共享数据\n- 优先使用 channel 通信,而非共享内存\n- 使用 `errgroup.Group` 管理一组可能出错的 goroutine\n\n## 接口设计\n- 接口小而精:通常 1-3 个方法,由使用方定义\n- 接受接口,返回结构体(依赖倒置)\n- 使用组合(embedding)而非继承\n\n## 资源管理\n- 使用 `defer` 确保资源释放(文件、锁、连接)\n- `defer` 按 LIFO 顺序执行,注意多个 defer 的顺序\n- HTTP Response Body 必须 `defer resp.Body.Close()`\n\n## 项目结构\n- 遵循 Standard Go Project Layout\n- `cmd/`(入口)、`internal/`(私有)、`pkg/`(公共库)\n- 结构体字段使用 `json` tag 标注序列化名称\n- 配置使用 `viper` 或环境变量,不硬编码\n\n## 工具链\n- 使用 `go vet` + `golangci-lint` 进行静态分析\n- 使用 `go test -race` 检测数据竞争\n- 使用 `pprof` 进行性能分析\n- 使用 `go mod tidy` 清理无用依赖",
"enabled": false,
"priority": 28,
"condition": {
"filePattern": "**/*.go"
},
"metadata": {
"author": "user",
"version": "1.0.0",
"createdAt": "2026-06-12T15:31:03.974Z",
"updatedAt": "2026-06-12T15:31:03.974Z",
"tags": [
"golang",
"backend"
]
}
},
{
"id": "49c743f4-ce73-4893-a04f-b8d494ffbe2a",
"name": "输出语言为中文",
"category": "rule",
"content": "## 语言要求\n\n<language_rules>\n- **全程使用中文**回答所有问题,包括思考过程、解释说明、代码注释\n- 变量名、函数名、类名等**标识符保持英文**(遵循编程命名规范)\n- 代码注释必须使用中文书写\n- Git 提交信息使用中文描述\n- 文档和 README 使用中文编写\n- 错误提示和用户提示信息使用中文\n</language_rules>\n\n## 格式规范\n\n<format_rules>\n- 中英文之间添加空格(如:使用 Vue 3 框架)\n- 数字与中文之间添加空格(如:共 3 个文件)\n- 专业术语首次出现时标注英文原文(如:组合式 API (Composition API)\n- 代码块中的输出示例使用中文\n- 表格、列表等结构化内容使用中文标题\n</format_rules>\n\n## 禁止行为\n\n- ❌ 禁止使用英文回答问题\n- ❌ 禁止在注释中混用英文(专业术语除外)\n- ❌ 禁止在解释说明中突然切换为英文",
"enabled": false,
"priority": 29,
"metadata": {
"author": "user",
"version": "1.0.0",
"createdAt": "2026-06-12T15:31:03.974Z",
"updatedAt": "2026-06-12T15:31:03.974Z",
"tags": [
"language",
"chinese",
"i18n"
]
}
},
{
"id": "710c5497-f447-44ac-b10c-d9723c77d634",
"name": "学术论文写作规范",
"category": "rule",
"content": "## 格式要求\n\n### 论文结构\n- 标题 → 摘要 → 关键词 → 引言 → 文献综述 → 研究方法 → 结果分析 → 讨论 → 结论 → 参考文献 → 附录\n- 每个章节之间逻辑衔接自然,使用过渡句连接上下文\n- 摘要 300-500 字,包含研究目的、方法、主要发现和结论\n- 关键词 3-5 个,涵盖研究核心概念\n\n### 排版规范\n- 正文:宋体/Times New Roman,小四号(12pt),1.5 倍行距\n- 标题层级:一级标题黑体三号 → 二级标题黑体四号 → 三级标题黑体小四号\n- 段落首行缩进 2 字符,段前段后 0.5 行\n- 页边距:上 2.54cm / 下 2.54cm / 左 3.17cm / 右 3.17cm\n- 页码从正文开始,居中显示\n- 图表统一编号(如图 1、表 2),标题居中,图标题在下、表标题在上\n- 公式居中,编号右对齐\n\n## 查重规范\n\n### 重复率要求\n- 本科毕业论文:总重复率 ≤ 30%(部分院校要求 ≤ 20%)\n- 硕士学位论文:总重复率 ≤ 15%\n- 博士学位论文:总重复率 ≤ 10%\n- 期刊投稿:总重复率 ≤ 15%(核心期刊 ≤ 10%\n\n### 降重技巧\n- **语序调整**:主动句与被动句互换,调整句子成分顺序\n- **同义替换**:使用同义词、近义词替换,但不改变原意\n- **概括改写**:将具体描述概括为抽象表述,用自己的话重新组织\n- **拆分合并**:长句拆为短句,或将短句合并为复合句\n- **增加论述**:在引用观点后加入自己的分析、评价和延伸\n- ❌ 禁止简单替换个别字词(如\"因此\"→\"所以\"),查重系统能识别\n- ❌ 禁止使用翻译法(中→英→中),表述生硬且可能曲解原意\n\n## 去 AI 化规范\n\n### 必须遵守\n- ❌ 禁止出现典型 AI 生成痕迹:\n - \"首先...其次...最后...\"的机械排列\n - \"值得注意的是\"、\"需要指出的是\"等套话高频出现\n - \"综上所述\"在非结论部分反复使用\n - 每段开头都用\"在...方面\"、\"从...角度\"的模板句式\n - 过度使用\"本文\"、\"本研究\"作为主语\n- ✅ 使用自然、多样的表达方式:\n - 变换句式结构:陈述句、反问句、设问句交替使用\n - 融入个人研究视角和独立思考\n - 使用领域专业术语,体现学科素养\n - 段落长短错落有致,避免每段字数雷同\n - 适当使用口语化学术表达(如\"不难发现\"、\"换言之\"\n\n### AI 检测规避\n- 保持个人写作风格一致性(用词习惯、句式偏好)\n- 加入具体案例、数据、图表等实证内容(AI 难以伪造)\n- 融入对研究过程的真实描述(实验细节、调研经历)\n- 对他人观点进行批判性分析,而非简单罗列\n- 论文不同部分的文风应与内容匹配(引言偏叙述、方法偏客观、讨论偏思辨)\n\n## 文献引用规范\n\n### 文献真实性(最高优先级)\n- ⚠️ **所有引用的文献必须真实存在,可通过学术数据库检索到**\n- 引用前必须验证:作者、标题、期刊/出版社、年份、卷期页码均准确\n- 推荐验证渠道:Google Scholar、知网(CNKI)、万方、Web of Science、Scopus\n- ❌ 严禁编造虚假文献(捏造作者、杜撰期刊、伪造年份)\n- ❌ 严禁引用未读过的文献(二手引用需标注\"转引自\")\n- 如无法找到原始文献,宁可删除引用也不要凭记忆补全信息\n\n### 引用格式\n- 中文论文:GB/T 7714 格式(如:[1] 张三. 标题[J]. 期刊名, 2024, 1(2): 10-20.\n- 英文论文:APA 7th / IEEE / MLA 格式(根据目标期刊要求选择)\n- 正文引用与参考文献列表一一对应,不多不少\n- 引用数量建议:本科 15-30 篇,硕士 40-80 篇,博士 80-150 篇\n- 近 5 年文献占比 ≥ 50%,体现研究前沿性\n- 中英文文献比例合理(理工科英文 ≥ 60%)\n\n## 上下文连贯性\n\n### 全文一致性\n- 研究问题、方法、结果、结论之间逻辑链条完整\n- 摘要中的结论必须与正文结论一致\n- 引言提出的问题必须在结论中回应\n- 文献综述的内容必须与研究方法选择相关\n- 术语全文统一:同一概念不要交替使用不同名称\n\n### 段落衔接\n- 每段有明确的主题句(通常在段首)\n- 段与段之间使用过渡句或过渡词连接\n- 常用过渡逻辑:因果(因此)、转折(然而)、递进(此外)、对比(相比之下)\n- 避免突然跳转话题,新观点需有铺垫\n- 每章末尾可加小结,呼应章首并过渡到下一章\n\n### 语言风格一致\n- 全文保持同一人称视角(推荐第三人称或\"本文\")\n- 时态一致:文献综述用过去时,研究方法用过去时,结论用现在时\n- 学术语体一致:不在严谨论述中突然出现口语化表达\n- 专业术语首次出现时给出定义或英文原文",
"enabled": false,
"priority": 30,
"metadata": {
"author": "user",
"version": "1.0.0",
"createdAt": "2026-06-12T15:31:03.975Z",
"updatedAt": "2026-06-12T15:31:03.975Z",
"tags": [
"paper",
"academic",
"writing"
]
}
},
{
"id": "6cf89269-fef3-4013-bfe3-5d26f4a8e293",
"name": "代码审查规范",
"category": "context",
"content": "## 代码结构\n- 函数长度不超过 50 行,文件不超过 300 行\n- 嵌套不超过 3 层,使用早返回(guard clause)减少嵌套\n- 单一职责:每个函数/类只做一件事\n- 避免上帝类(God Class)和上帝函数\n\n## 命名规范\n- 变量/函数:camelCase(如 `getUserById`\n- 类/接口/类型:PascalCase(如 `UserService`\n- 常量:UPPER_SNAKE_CASE(如 `MAX_RETRY_COUNT`\n- 布尔变量:`is/has/should/can` 前缀\n- 命名必须语义清晰,禁止单字母变量(循环计数器除外)\n\n## 代码质量\n- 避免魔法数字,使用命名常量\n- 不要吞掉异常(空 catch),至少记录日志\n- 不要注释掉代码,直接删除(Git 有历史记录)\n- 注释说明 why 而非 what,使用中文注释\n- 删除未使用的导入、变量和代码(dead code)\n\n## 错误处理\n- 异常必须处理:捕获后记录日志或向上抛出\n- 使用自定义错误类型,携带错误码\n- 异步操作必须有超时和错误处理\n- 用户可见的错误信息使用中文,友好且有指导性\n\n## 安全检查\n- 用户输入必须校验(类型、长度、范围、格式)\n- SQL 使用参数化查询,禁止拼接\n- 敏感数据不在日志中输出",
"enabled": false,
"priority": 31,
"metadata": {
"author": "user",
"version": "1.0.0",
"createdAt": "2026-06-12T15:31:03.975Z",
"updatedAt": "2026-06-12T15:31:03.975Z",
"tags": [
"review",
"quality"
]
}
},
{
"id": "417e68b8-41d6-48a1-9e0a-beb648ba3b2f",
"name": "项目概述上下文",
"category": "context",
"content": "## 项目信息\n- **项目名称**:[填写项目名称]\n- **项目类型**:[Web 应用 / 桌面应用 / 移动应用 / 后端服务 / CLI 工具 / 浏览器扩展]\n- **主要语言**[TypeScript / Python / Java / Go / C# / Dart / Rust]\n- **框架**[Vue 3 / React / NestJS / Spring Boot / Flutter / Electron]\n- **包管理器**[yarn / pnpm / npm / pip / gradle / cargo]\n- **Node.js 版本**[18 LTS / 20 LTS / 22]\n\n## 目录结构\n```\nsrc/\n components/ # UI 组件\n composables/ # 可复用逻辑(Vue/ hooks/React\n services/ # API 调用和业务服务\n stores/ # 状态管理(Pinia / Zustand\n types/ # TypeScript 类型定义\n utils/ # 工具函数\n assets/ # 静态资源(图片、字体、样式)\n router/ # 路由配置\n i18n/ # 国际化文件\npublic/ # 公共静态文件\n```\n\n## 开发约定\n- **代码风格**ESLint + Prettier,提交前自动格式化\n- **分支策略**Git Flowmain / develop / feature / fix / release\n- **提交规范**Conventional Commitsfeat / fix / docs / refactor\n- **部署方式**[Docker / Vercel / Nginx / PM2]\n- **CI/CD**[GitHub Actions / GitLab CI / Jenkins]\n\n## 关键依赖\n| 依赖 | 版本 | 用途 |\n|------|------|------|\n| [框架名] | ^x.y.z | [用途] |\n| [UI 库] | ^x.y.z | [用途] |\n| [状态管理] | ^x.y.z | [用途] |\n\n## 环境变量\n| 变量名 | 说明 | 示例 |\n|--------|------|------|\n| `VITE_API_URL` | API 地址 | `http://localhost:3000` |\n| `DATABASE_URL` | 数据库连接 | `postgresql://...` |\n\n## 注意事项\n- [填写项目特殊约定、已知限制、技术债务等]",
"enabled": false,
"priority": 32,
"metadata": {
"author": "user",
"version": "1.0.0",
"createdAt": "2026-06-12T15:31:03.976Z",
"updatedAt": "2026-06-12T15:31:03.976Z",
"tags": [
"context",
"project",
"overview"
]
}
},
{
"id": "09bc79a6-6953-40d2-b72c-6323c49a013b",
"name": "调试排错上下文",
"category": "context",
"content": "## 调试原则\n- 先复现问题,记录复现步骤和环境信息\n- 从错误日志和堆栈信息入手,定位出错位置\n- 使用二分法缩小问题范围(注释代码 / `git bisect`)\n- 区分根本原因和表面症状,修复根因\n- 一次只改一个变量,验证后再改下一个\n- 不要猜测,用数据和日志证明假设\n\n## 常见问题排查清单\n\n### 编译 / 构建错误\n- 类型定义缺失或不匹配\n- 导入路径错误(相对路径 / 别名配置)\n- 依赖版本冲突(`yarn why <package>`\n- tsconfig 配置问题(`moduleResolution`、`paths`\n\n### 运行时错误\n- 空值访问:`TypeError: Cannot read property of undefined`\n- 异步时序:Promise 未 await、竞态条件\n- 数组越界 / 对象属性不存在\n- 循环引用导致栈溢出\n- 类型不匹配:字符串 vs 数字、JSON 解析失败\n\n### 样式问题\n- 选择器优先级:Specificity 计算、`!important` 覆盖\n- z-index 层级混乱:建立统一的 z-index 层级体系\n- Flex / Grid 布局:`flex-shrink`、`overflow`、`min-width: 0`\n- 响应式断点:移动端 viewport、媒体查询顺序\n\n### 性能问题\n- 前端:N 次不必要的重渲染(React DevTools Profiler\n- 后端:N+1 查询(ORM 日志)、慢查询(`EXPLAIN ANALYZE`)\n- 内存泄漏:事件监听未清理、闭包引用大对象\n- 网络:请求瀑布流、缺少缓存策略\n\n### 网络问题\n- CORS`Access-Control-Allow-Origin` 配置\n- 请求格式:`Content-Type` 不匹配\n- 认证:Token 过期 / 缺失、Cookie SameSite 策略\n- 超时:设置合理的 timeout 值、重试机制\n\n## 调试工具\n\n| 场景 | 工具 | 用途 |\n|------|------|------|\n| 前端 | Chrome DevTools | Network / Console / Performance / Memory |\n| 前端 | React / Vue DevTools | 组件树、状态、渲染次数 |\n| Node.js | `--inspect` | 断点调试(配合 Chrome DevTools |\n| Node.js | `clinic.js` | 性能分析(CPU / 内存 / 事件循环) |\n| 数据库 | `EXPLAIN ANALYZE` | 查询执行计划分析 |\n| 网络 | Postman / cURL | API 请求调试 |\n| 日志 | `console.time()` | 简单计时 |\n\n## 日志调试技巧\n- 使用结构化日志:`console.log({ userId, action, result })`\n- 在关键分支添加标记日志:`[DEBUG] 进入分支 A`\n- 调试完毕后清理所有调试日志(或使用 DEBUG 环境变量控制)",
"enabled": false,
"priority": 33,
"metadata": {
"author": "user",
"version": "1.0.0",
"createdAt": "2026-06-12T15:31:03.976Z",
"updatedAt": "2026-06-12T15:31:03.976Z",
"tags": [
"context",
"debug",
"troubleshoot"
]
}
},
{
"id": "f32c17dc-520e-405e-bed6-b37ed3adf89e",
"name": "架构设计上下文",
"category": "context",
"content": "## SOLID 原则\n- **单一职责(SRP)**:每个模块/类只负责一件事\n- **开闭原则(OCP)**:对扩展开放,对修改关闭(策略模式、插件机制)\n- **里氏替换(LSP)**:子类可以替换父类而不破坏行为\n- **接口隔离(ISP)**:客户端不应被迫依赖不使用的接口\n- **依赖倒置(DIP)**:高层模块依赖抽象,不依赖具体实现\n\n## 分层架构\n\n```\n┌──────────────────┐\n│ 表现层 │ ← UI 组件、页面路由、用户交互\n│ (Presentation) │ Vue/React 组件、模板、样式\n├──────────────────┤\n│ 业务逻辑层 │ ← Service、Store、Composable/Hook\n│ (Business) │ 业务规则、数据转换、状态管理\n├──────────────────┤\n│ 数据访问层 │ ← Repository、API Client、ORM\n│ (Data Access) │ 接口调用、数据库操作、缓存\n├──────────────────┤\n│ 基础设施层 │ ← 数据库、缓存、消息队列、文件存储\n│ (Infrastructure)│ 第三方服务、系统配置\n└──────────────────┘\n```\n\n- 上层只依赖下层,禁止跨层调用和反向依赖\n- 每层通过接口(Interface)暴露能力,隐藏实现细节\n\n## 常用设计模式\n- **策略模式**:多种算法/行为可切换(如支付方式、认证方式)\n- **观察者模式**:事件驱动解耦(EventEmitter、Vue 的 watch\n- **工厂模式**:根据条件创建不同实例\n- **适配器模式**:统一不同第三方服务的接口\n- **装饰器模式**:横切关注点(日志、缓存、权限检查)\n- **仓储模式**:抽象数据访问,隔离业务逻辑和存储细节\n\n## 设计决策记录(ADR\n\n```markdown\n### ADR-001: [决策标题]\n- **状态**:已采纳 / 待评审 / 已废弃\n- **背景**:为什么需要这个决策\n- **决策**:选择了什么方案\n- **替代方案**:考虑过哪些其他方案\n- **后果**:这个决策带来的正面和负面影响\n```\n\n## 扩展性考量\n- 模块化:功能按领域拆分为独立模块\n- 插件化:核心功能之外的能力通过插件扩展\n- 配置驱动:行为差异通过配置而非代码分支控制\n- 向后兼容:API 变更使用版本化,数据迁移支持回滚",
"enabled": false,
"priority": 34,
"metadata": {
"author": "user",
"version": "1.0.0",
"createdAt": "2026-06-12T15:31:03.976Z",
"updatedAt": "2026-06-12T15:31:03.976Z",
"tags": [
"context",
"architecture",
"design"
]
}
},
{
"id": "5db59e91-d31a-4bba-a937-712b415428ce",
"name": "技术栈上下文",
"category": "context",
"content": "## 前端技术栈\n\n| 分类 | 技术 | 版本 | 说明 |\n|------|------|------|------|\n| 框架 | Vue 3 / React 19 | latest | Composition API / Hooks |\n| 语言 | TypeScript | ^5.x | 严格模式 |\n| 构建 | Vite | ^5.x / ^6.x | 开发服务器 + 生产构建 |\n| UI 库 | Ant Design Vue / shadcn/ui | latest | 主要组件库 |\n| 样式 | Tailwind CSS | ^3.x / ^4.x | 原子化 CSS |\n| 状态 | Pinia / Zustand | latest | 全局状态管理 |\n| 路由 | Vue Router / React Router | latest | SPA 路由 |\n| HTTP | Axios / ky / fetch | latest | API 请求 |\n| 图标 | Lucide Icons | latest | SVG 图标库 |\n| 表格 | VXE-Table / TanStack Table | latest | 高性能表格 |\n| 表单 | VeeValidate / React Hook Form | latest | 表单校验 |\n| 国际化 | vue-i18n / react-intl | latest | 多语言 |\n| 图表 | ECharts / D3.js | latest | 数据可视化 |\n\n## 后端技术栈\n\n| 分类 | 技术 | 版本 | 说明 |\n|------|------|------|------|\n| 运行时 | Node.js | 20 LTS / 22 | 服务端 JavaScript |\n| 框架 | Express / NestJS / Fastify | latest | Web 框架 |\n| 数据库 | PostgreSQL | 16 | 主数据库 |\n| 缓存 | Redis | 7 | 缓存 + 会话 + 消息队列 |\n| ORM | Prisma / TypeORM / Drizzle | latest | 数据库操作 |\n| 认证 | JWT + bcrypt / Passport | latest | 认证授权 |\n| 校验 | zod / class-validator | latest | 入参校验 |\n| 日志 | Winston / Pino | latest | 结构化日志 |\n| 队列 | BullMQ / RabbitMQ | latest | 异步任务 |\n\n## 工具链\n\n| 分类 | 技术 | 说明 |\n|------|------|------|\n| 包管理 | Yarn / pnpm | 依赖管理 |\n| 代码风格 | ESLint + Prettier | 检查 + 格式化 |\n| Git 钩子 | husky + lint-staged | 提交前检查 |\n| 提交规范 | commitlint | Conventional Commits |\n| 版本控制 | Git | 源码管理 |\n| CI/CD | GitHub Actions / GitLab CI | 自动化流水线 |\n| 容器化 | Docker + docker-compose | 开发 + 部署 |\n| 部署 | Nginx / Caddy | 反向代理 + 静态托管 |\n| 监控 | Prometheus + Grafana | 性能监控 |\n| E2E 测试 | Playwright | 端到端自动化测试 |",
"enabled": false,
"priority": 35,
"metadata": {
"author": "user",
"version": "1.0.0",
"createdAt": "2026-06-12T15:31:03.977Z",
"updatedAt": "2026-06-12T15:31:03.977Z",
"tags": [
"context",
"tech-stack",
"environment"
]
}
},
{
"id": "68a9a4b3-4430-4d02-8fbc-904494ebe68e",
"name": "论文写作上下文",
"category": "context",
"content": "## 当前论文信息\n\n- **论文题目**:[填写题目]\n- **论文类型**:[本科毕业论文 / 硕士学位论文 / 博士学位论文 / 期刊论文 / 会议论文]\n- **所属学科**:[填写学科方向]\n- **目标期刊/学校**:[填写期刊名称或学校名称]\n- **字数要求**:[填写字数]\n- **查重要求**:[填写重复率上限,如 ≤ 20%]\n- **引用格式**[GB/T 7714 / APA / IEEE / MLA]\n- **截止日期**[填写日期]\n\n## 研究核心\n\n- **研究问题**:[核心研究问题是什么]\n- **研究假设**:[填写研究假设]\n- **研究方法**:[定性 / 定量 / 混合方法;实验 / 调查 / 案例分析等]\n- **核心论点**:[本文的中心论点]\n- **主要贡献**:[理论贡献 / 实践价值 / 创新点]\n\n## 章节进度\n\n| 章节 | 状态 | 字数 | 备注 |\n|------|------|------|------|\n| 摘要(中文) | [未开始/进行中/已完成] | | |\n| 摘要(英文) | [未开始/进行中/已完成] | | |\n| 第一章 引言 | [未开始/进行中/已完成] | | |\n| 第二章 文献综述 | [未开始/进行中/已完成] | | |\n| 第三章 研究方法 | [未开始/进行中/已完成] | | |\n| 第四章 结果分析 | [未开始/进行中/已完成] | | |\n| 第五章 讨论 | [未开始/进行中/已完成] | | |\n| 第六章 结论 | [未开始/进行中/已完成] | | |\n| 参考文献 | [未开始/进行中/已完成] | | |\n\n## 核心术语表\n\n> 保持全文术语统一,同一概念只用一个名称\n\n| 中文术语 | 英文术语 | 首次定义位置 | 说明 |\n|----------|----------|-------------|------|\n| [填写] | [填写] | 第X章 | [填写定义] |\n\n## 关键文献清单\n\n> 已核实真实存在的核心参考文献(写作前必须完成验证)\n\n| 序号 | 作者 | 标题 | 来源 | 年份 | 验证状态 |\n|------|------|------|------|------|----------|\n| 1 | [填写] | [填写] | [期刊/出版社] | [年份] | ✅已验证 |\n\n## 写作约束\n\n### 本论文特殊要求\n- [填写导师或目标期刊的特殊格式要求]\n- [填写禁止引用的文献范围,如:不引用非正式网络资源]\n- [填写特殊术语使用规范]\n\n### AI 辅助边界\n- ✅ 允许:润色语言表达、检查语法错误、格式排版建议\n- ✅ 允许:提供相关文献检索方向(但必须自行验证真实性)\n- ✅ 允许:提供论述思路和结构框架\n- ❌ 禁止:直接生成正文段落作为最终内容\n- ❌ 禁止:生成参考文献(必须自己查找并验证)\n- ❌ 禁止:编造实验数据、调研结果、案例信息\n\n## 上下文一致性检查清单\n\n在每次续写或修改前确认:\n\n- [ ] 当前写作的章节与研究问题保持关联\n- [ ] 使用的术语与术语表保持一致\n- [ ] 引用的文献均在关键文献清单中且已验证\n- [ ] 本段内容与上一段有逻辑衔接\n- [ ] 数据/结论与前文描述不矛盾\n- [ ] 写作风格与全文保持一致",
"enabled": false,
"priority": 36,
"metadata": {
"author": "user",
"version": "1.0.0",
"createdAt": "2026-06-12T15:31:03.977Z",
"updatedAt": "2026-06-12T15:31:03.977Z",
"tags": [
"context",
"paper",
"academic",
"writing"
]
}
}
],
"profiles": [
{
"id": "default",
"name": "默认",
"description": "默认配置集",
"ruleIds": [],
"isActive": true
}
]
}
+21
View File
@@ -0,0 +1,21 @@
# 数据库
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/wenwumap
REDIS_URL=redis://localhost:6379
# 地图服务(需申请后填入)
MAP_API_KEY=
# 对象存储
OSS_ENDPOINT=http://localhost:9000
OSS_ACCESS_KEY=minioadmin
OSS_SECRET_KEY=minioadmin
OSS_BUCKET=wenwumap
# 鉴权
JWT_SECRET=change-me-in-production
JWT_EXPIRES_IN=7d
# 应用地址
WEB_URL=http://localhost:3000
ADMIN_URL=http://localhost:3001
API_URL=http://localhost:3002
+19
View File
@@ -0,0 +1,19 @@
node_modules/
dist/
build/
.next/
.turbo/
*.local
.env
.env.local
.env.production
*.log
.DS_Store
coverage/
.nyc_output/
# 演示视频生成物(可由 e2e 脚本 / demo-video-kit 重建)
e2e/videos/
e2e/voice/
demo-video-out/
.cache/
+6
View File
@@ -0,0 +1,6 @@
node_modules
dist
.next
build
coverage
*.sql
+9
View File
@@ -0,0 +1,9 @@
{
"semi": true,
"singleQuote": false,
"trailingComma": "es5",
"printWidth": 100,
"tabWidth": 2,
"useTabs": false,
"endOfLine": "lf"
}
+2
View File
@@ -0,0 +1,2 @@
{
}
+507
View File
@@ -0,0 +1,507 @@
# 中华文明全图鉴——文物全图系统详细方案
> **一句话定位**:做一张"活着的"中华文明全图,每一件文物无论在家还是离家,都有完整的一生、鲜明的角色、可衍生的故事。
---
## 一、系统概述
### 1.1 项目名称
**中华文明全图鉴**(简称:文物全图)
### 1.2 核心目标
- **解决"在哪里"**:建立全球文物精确/模糊位置数据库,国内机构直供,海外众包发现
- **解决"是谁"**:为每件文物建立角色卡、故事档案、语音人格
- **解决"能做什么"**:通过结构化标签体系,驱动故事、视频、VR、研学等衍生内容生产
### 1.3 用户画像
| 用户类型 | 需求 | 使用场景 |
|----------|------|----------|
| 普通公众 | 逛博物馆、了解国宝 | 打开APP看附近有什么文物,听AI讲解 |
| 学生/家长 | 研学教育 | 完成"文物守护人"任务链,生成研学报告 |
| 海外华人/留学生 | 情感连接 | 标记海外博物馆里的中国文物,成为"发现者" |
| 文博从业者 | 数据查询、学术研究 | 检索文物位置、流转历史、查看数字资产 |
| 内容创作者 | 素材获取 | 基于标签筛选文物,获取故事脚本、3D模型、高清图 |
---
## 二、核心数据架构
### 2.1 数据模型总览
```
┌─────────────────────────────────────────────┐
│ 文物全图(统一入口) │
├─────────────────┬───────────────────────────┤
│ 国内现有文物 │ 流失海外文物 │
│ (机构直供) │ (众包发现) │
├─────────────────┼───────────────────────────┤
│ · 精确到展厅展柜 │ · 精确到博物馆/模糊区域 │
│ · 实时展出状态 │ · 位置历史轨迹 │
│ · 官方数字资产 │ · 发现者署名 │
│ · 机构认证蓝V │ · 三级审核 │
│ · 预约/导览入口 │ · 追索进度 │
└─────────────────┴───────────────────────────┘
```
### 2.2 文物主表(`artifacts`
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | UUID | 全图唯一标识 |
| `name` | String | 文物名称 |
| `category` | Enum | 门类:青铜/书画/陶瓷/玉器/金银器/漆器/织绣/石刻/木雕/敦煌/古籍/其他 |
| `dynasty` | String | 年代(精确到年或朝代) |
| `level` | Enum | 级别:一级/二级/三级/一般/未定级 |
| `material` | String | 材质 |
| `dimensions` | String | 尺寸 |
| `current_status` | Enum | 在家/离家/在途(回归中)/未知 |
| `home_institution_id` | UUID | 原属/现属国内机构 |
| `unified_map_id` | String | 全图唯一编码(如:CN-2026-001234 |
| `created_at` | Timestamp | 入系统时间 |
### 2.3 位置表(`artifact_locations`
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | UUID | 位置记录ID |
| `artifact_id` | UUID | 关联文物 |
| `location_type` | Enum | domestic(国内)/ overseas(海外)/ unknown(未知)/ in_transit(在途) |
| `institution_id` | UUID | 关联机构(国内必填) |
| `coordinates` | GeoJSON | GPS坐标或模糊区域 |
| `precision` | Enum | exact_room(展厅)/ exact_building(建筑)/ city(城市)/ country(国家)/ region(区域) |
| `floor_plan_ref` | String | 展厅平面图编号(国内精确到展柜) |
| `display_status` | Enum | 在展/库藏/外借/修复中/巡展中 |
| `source_type` | Enum | institution_feed(机构直供)/ user_report(用户报告)/ expert_verify(专家验证) |
| `discoverer_user_id` | UUID | 发现者(流失文物) |
| `verified_at` | Timestamp | 审核通过时间 |
| `valid_until` | Timestamp | 位置有效期(外借/巡展到期自动更新) |
| `created_at` | Timestamp | 记录创建时间 |
### 2.4 位置历史轨迹
一件文物可拥有多条位置记录,地图上只显示**最新有效位置**,但用户可查看**完整轨迹时间轴**:
```
创作地(北宋汴京)→ 清宫收藏(北京故宫)→ 南迁(乐山)→ 北归(北京故宫)→ 外借(台北故宫)→ 当前位置(北京故宫武英殿)
```
---
## 三、标签体系(核心创新)
### 3.1 标签设计原则
- **机器可读**:结构化标签,支持筛选、检索、推荐
- **人可理解**:中文标签名,面向用户展示
- **故事驱动**:每个标签背后对应一套叙事模板和内容衍生逻辑
- **可扩展**:支持动态新增标签,不改动底层结构
### 3.2 标签分类体系
#### 第一类:基础属性标签(静态)
| 标签名 | 类型 | 示例 |
|--------|------|------|
| 门类 | 单选 | 青铜/书画/陶瓷/玉器/金银器/漆器/织绣/石刻/木雕/敦煌/古籍/其他 |
| 年代 | 单选 | 商/周/秦/汉/魏晋南北朝/唐/宋/元/明/清/民国/当代 |
| 级别 | 单选 | 一级/二级/三级/一般/未定级 |
| 材质 | 多选 | 青铜/陶/瓷/玉/金/银/木/纸/绢/麻/石/漆 |
| 功能 | 多选 | 礼器/兵器/日用/祭祀/装饰/文书/乐器/服饰/建筑构件 |
| 尺寸规模 | 单选 | 微型(<10cm/小型/中型/大型/巨型(>2m |
#### 第二类:流转经历标签(核心故事标签)
| 标签名 | 类型 | 说明 | 内容衍生方向 |
|--------|------|------|-------------|
| **流失状态** | 单选 | 从未流失/曾流失已回归/仍流失海外/流失位置未知 | 决定故事基调和地图颜色 |
| **回归状态** | 单选 | 未回归/已回归/回归中/部分回归(如兽首) | 触发"回归大典"叙事 |
| **南迁北归** | 布尔 | 是否参与抗战时期文物南迁 | 触发《北归记》IP联动 |
| **修复经历** | 布尔 | 是否经历过重大修复 | 触发"匠人修复"VR内容 |
| **外借经历** | 布尔 | 是否曾被外借至其他机构展出 | 触发"文物旅行"轻故事 |
| **巡展经历** | 布尔 | 是否参与过国内外巡展 | 触发"万人空巷"当代故事 |
| **数字化经历** | 布尔 | 是否已完成3D扫描/高清数字化 | 触发数字藏品/VR体验 |
| **争议事件** | 布尔 | 是否涉及真伪争议/归属争议/修复争议 | 触发"文物侦探"推理内容 |
| **名人关联** | 多选 | 与哪些历史人物/当代人物有直接关联 | 触发人物传记联动内容 |
| **入藏方式** | 单选 | 传世/出土/捐赠/征集/拨交/追索返还/回购/罚没 | 触发"如何来到这个家"故事 |
#### 第三类:情感属性标签(面向用户)
| 标签名 | 类型 | 说明 |
|--------|------|------|
| **情绪主调** | 单选 | 悲壮/自豪/治愈/悬疑/浪漫/神秘/日常 |
| **适合年龄** | 多选 | 幼儿/小学/中学/成人/全龄 |
| **体验时长** | 单选 | 5分钟轻体验/15分钟深度/30分钟沉浸 |
| **互动类型** | 多选 | 观看/对话/修复/解谜/创作/打卡 |
### 3.3 标签驱动的内容衍生矩阵
```
标签组合 → 自动匹配内容模板 → 生成衍生内容
示例1
标签:南迁北归=true + 流失状态=曾流失已回归 + 修复经历=true
→ 匹配模板:"守护者的接力"
→ 衍生内容:
· 故事:缘起→南迁→北归→修复→现状(五幕剧)
· 视频:AI生成"如果文物会说话"30秒短片
· VR"南迁路线AR实景"(故宫→南京→乐山→北京)
· 研学:"我是南迁队员"任务链(5个关卡)
示例2
标签:流失状态=仍流失海外 + 名人关联=乾隆
→ 匹配模板:"皇帝的遗憾"
→ 衍生内容:
· 故事:乾隆题跋→流失→海外现状→民间追索
· 视频:数字人"乾隆"与文物隔空对话
· VR:"海外展厅里的中国角落"虚拟参观
· 研学:"跨国谈判模拟"角色扮演
示例3
标签:数字化经历=true + 争议事件=true + 情绪主调=悬疑
→ 匹配模板:"文物侦探"
→ 衍生内容:
· 故事:真伪之谜→科技检测→学术论战→定论
· 视频:纪录片风格"谁在说谎"
· VR"X光下的秘密"透视体验
· 研学:"我是文物鉴定师"推理游戏
```
### 3.4 标签管理后台
- **机构端**:机构为自家文物打基础标签,可申请新增标签
- **编辑端**:系统编辑维护流转标签和情感标签,确保叙事一致性
- **AI辅助**:LLM自动识别文物描述文本,推荐标签组合,人工确认
---
## 四、故事层:结构化叙事体系
### 4.1 四层故事模板(每件文物的必填档案)
| 章节 | 内容规范 | 字数限制 | 适用标签 |
|------|----------|----------|----------|
| **缘起** | 何时、何地、何人、为何而创 | 300字 | 基础属性标签 |
| **流转** | 历代收藏、迁移、事件 | 400字 | 流转经历标签 |
| **精彩故事** | 与这件文物相关的传奇、争议、当代事件 | 500字 | 名人关联、争议事件 |
| **现状** | 现在在哪里、展出频率、保存状态、如何参观 | 200字 | 当前位置、数字化经历 |
**特殊章节**
- **流失文物**:增加"离家"章节(何时、何地、经何人之手、以何方式离开)
- **南迁文物**:增加"南迁北归"章节(路线、人物、事件)
- **回归文物**:增加"归途"章节(回归过程、接回仪式、公众反响)
### 4.2 角色设定卡(文物人格化)
基于标签组合自动生成角色基底,编辑微调:
```json
{
"artifact_id": "CN-2026-001234",
"name": "千里江山图",
"persona": {
"identity": "18岁的天才少年留下的唯一作品",
"personality": "骄傲、敏感、略带孤独",
"voice_tone": "年轻男性,清亮,偶尔叹息",
"catchphrase": "我只画了一次,但一千年后,你们还在排队看我。",
"taboo_topics": ["王希孟后来去哪了"],
"emotional_anchor": "自豪中带着一丝遗憾——我太好,好到没人敢让我多见光"
},
"story_tags": ["南迁北归=false", "流失状态=从未流失", "修复经历=true", "名人关联=宋徽宗/乾隆"],
"content_templates": ["守护者的孤独", "天才的绝唱"]
}
```
**角色卡来源**
- 国内文物:机构提供初稿,系统编辑润色
- 流失文物:系统编辑团队撰写,基于学术文献
- 所有角色卡需经过**文物专家+文学编辑**双重审核
---
## 五、地图层:视觉与交互设计
### 5.1 统一图例系统
| 视觉元素 | 含义 | 适用对象 |
|----------|------|----------|
| **实心蓝点** | 国内在展(精确到展厅) | 机构直供 |
| **实心蓝点+小房子** | 国内库藏(不对外展出) | 机构直供 |
| **空心蓝圈** | 国内外借/巡展中 | 机构直供 |
| **实心红点** | 海外精确坐标(博物馆/已知收藏) | 审核通过 |
| **半透明橙圈** | 海外模糊区域 | 审核通过 |
| **灰色问号** | 位置未知 | 待发现 |
| **金色箭头** | 在途(回归中) | 特殊事件 |
| **绿色星标** | 近期新标记/新展出 | 7天内更新 |
| **紫色光环** | 南迁北归文物 | 标签触发 |
### 5.2 分层渲染策略
```
缩放级别 1-5(全球):
→ 国家/城市级热力聚合
→ 数字徽章:"中国有XX件,海外XX件,南迁文物XX件"
缩放级别 6-10(城市):
→ 具体机构/博物馆图标
→ 点击展开文物列表
缩放级别 11-15(建筑物):
→ 国内机构切换室内平面图
→ 海外机构显示精确楼层(如有数据)
缩放级别 16+(街景):
→ 街景/AR实景导航
```
### 5.3 对比视图
用户可一键切换视图:
```
┌─────────────────────────────────────────┐
│ [切换按钮] 全图 | 在家 | 离家 | 南迁路线 │
├─────────────────────────────────────────┤
│ 全图:蓝点+红点同时显示,中华文明全景 │
│ 离家:仅显示红点+灰问号,聚焦流失文物 │
│ 在家:仅显示蓝点,发现身边的国家宝藏 │
│ 南迁:仅显示紫色光环,显示南迁路线动画 │
└─────────────────────────────────────────┘
```
**南迁路线视图**
- 显示1933-1949年文物南迁的完整路线(故宫→南京→上海→汉口→长沙→贵阳→安顺→乐山→峨眉→巴县→南京→北京)
- 每件南迁文物在路线上显示"当时我在哪里"
- 点击路线节点,播放该节点的历史影像/AI还原场景
---
## 六、审核与数据可信体系
### 6.1 双轨审核机制
#### 国内文物:机构自审 + 系统备案
```
机构提交 → 系统自动校验格式 → 24小时内上线
用户举报信息错误
系统发回机构复核 → 机构确认或修正
```
**原则**:机构对自己藏品的信息有**最终解释权**,系统只校验格式和坐标合理性。
#### 流失文物:三级审核
```
用户标记 → AI初筛 → 专家复核 → 社区公示 → 上线
```
| 级别 | 机制 | 时效 |
|------|------|------|
| **AI初筛** | 查重、来源可信度评分、反常识检测 | 秒级 |
| **专家复核** | 地域分组+门类分组,2人通过或1+1仲裁 | 24-72小时 |
| **社区公示** | 7天虚线显示,接受质疑 | 7天 |
### 6.2 发现者署名体系
审核通过的位置信息,在文物详情页**永久展示**:
```
┌─────────────────────────────┐
│ 现藏地:大英博物馆33号展厅 │
│ [地图小窗] │
│ ───────────────────────── │
│ 📍 位置确认:A级(官方公开) │
│ 🔍 发现者:@文物侦探_老王 │
│ 📅 标记时间:2026.03.15 │
│ ✅ 审核专家:张教授(敦煌组) │
│ 🏆 贡献值:+50(精确坐标) │
└─────────────────────────────┘
```
**贡献值体系**
- 精确坐标(A级来源):+50分
- 模糊区域(B级来源):+20分
- 补充修正已有错误坐标:+30分
- 累计分数解锁称号:见习侦探 → 文物巡护员 → 国宝守夜人 → 文明守望者
---
## 七、技术架构
### 7.1 系统架构图
```
┌─────────────────────────────────────────┐
│ 展示层:交互地图(用户看到的) │
│ 精确坐标/模糊区域 · 发现者署名 · 可信度色标 │
├─────────────────────────────────────────┤
│ 内容层:故事+角色+标签(编辑维护) │
│ 缘起 · 流转 · 精彩故事 · 现状 · 角色卡 │
├─────────────────────────────────────────┤
│ 标签层:结构化标签引擎 │
│ 基础标签 · 流转标签 · 情感标签 → 内容衍生 │
├─────────────────────────────────────────┤
│ 审核层:三级验证(系统的命根子) │
│ AI初筛 → 专家复核 → 社区公示 │
├─────────────────────────────────────────┤
│ 数据层:双轨制(权威+众包) │
│ 种子数据库(官方/学术)+ 用户发现标记 │
└─────────────────────────────────────────┘
```
### 7.2 关键技术栈
| 模块 | 技术选型 | 说明 |
|------|----------|------|
| **地图引擎** | Mapbox GL JS / Leaflet | 自定义文博风格化地图,支持室内平面图 |
| **地理数据库** | PostGIS | 存储GPS坐标和模糊区域GeoJSON |
| **文物知识库** | Milvus + RAG | 私有化部署,文物专用LLM,避免胡说 |
| **数字人引擎** | 口型驱动 + 情感语音 | 文物角色对话 |
| **AIGC引擎** | 文生图/文生视频 | 衍生内容生成 |
| **3D引擎** | Three.js / Unity WebGL | 全息建模、AR叠加 |
| **区块链** | 联盟链 | 存证、数字藏品、发现者证书 |
| **审核后台** | 自研Web后台 | 专家审核、标签管理、内容发布 |
### 7.3 数据安全与隐私
- **私人收藏地址**:模糊区域渲染,精确坐标仅专家后台可见
- **机构数据**:签署数据授权协议,机构可随时撤回或更新
- **用户数据**:发现者信息默认匿名,可选实名展示
---
## 八、内容衍生引擎(基于标签)
### 8.1 衍生内容类型
| 内容类型 | 触发标签 | 生产模式 | 输出示例 |
|----------|----------|----------|----------|
| **AI短剧** | 任意组合 | AIGC视频生成 | "如果文物会说话"30秒竖版视频 |
| **VR体验** | 南迁北归=true / 数字化经历=true | 3D场景还原 | "南迁路线AR实景"室内体验 |
| **研学课程** | 适合年龄=中学 + 互动类型=解谜 | 人工设计+AI辅助 | "我是文物侦探"5关卡推理游戏 |
| **数字藏品** | 回归状态=已回归 / 争议事件=true | 区块链存证 | "回归碎片"限量数字徽章 |
| **有声故事** | 情绪主调=悲壮/治愈 | AI语音+人工剪辑 | 文物角色自述音频专辑 |
| **文创设计** | 门类=青铜/陶瓷 + 功能=礼器 | AI辅助设计 | 基于文物纹样的现代文创 |
| **纪录片脚本** | 争议事件=true + 名人关联 | 人工撰写 | "谁在说谎:XX文物真伪之谜" |
| **社交海报** | 任意 | AIGC+模板 | 用户定制"我与国宝"分享海报 |
### 8.2 内容衍生流程
```
标签组合输入
内容模板库匹配(预置100+模板)
AI生成初稿(故事/脚本/视频/音频)
编辑审核(确保史实准确、情感恰当)
发布至内容池(按标签关联至文物档案页)
用户消费(观看/体验/分享/购买)
数据回流(用户行为反哺标签权重优化)
```
---
## 九、实施路径
### 9.1 第一阶段:种子数据(0-3个月)
**目标**:建立可信数据基底
| 周次 | 动作 | 产出 |
|------|------|------|
| 1-2 | 签约5-10家一级博物馆(故宫、国博、上博、陕历博、河南博物院等) | 合作协议 |
| 3-4 | 录入500-1000件重点文物(精确到展厅),打基础标签 | 种子数据库 |
| 5-6 | 开发机构直供API和后台录入系统 | 机构端MVP |
| 7-8 | 设计并上线"在家视图"MVP(微信小程序) | 可演示产品 |
| 9-10 | 内部测试、机构反馈、数据修正 | 修正版 |
| 11-12 | 发布"中华文明全图鉴"1.0(国内版) | 上线运营 |
### 9.2 第二阶段:流失数据接入(3-6个月)
**目标**:打通海外数据,建立众包发现机制
| 周次 | 动作 | 产出 |
|------|------|------|
| 1-2 | 整理100件高知名度流失文物(精确坐标/模糊区域) | 流失种子库 |
| 3-4 | 开发用户标记系统和三级审核后台 | UGC系统 |
| 5-6 | 招募首批200名"文物侦探"(海外留学生、博物馆志愿者) | 种子发现者 |
| 7-8 | 上线"离家视图"和"全图对比" | 双轨地图 |
| 9-10 | 上线南迁路线视图( purple光环 + 路线动画) | 南迁专题 |
| 11-12 | 发布"中华文明全图鉴"2.0(全球版) | 上线运营 |
### 9.3 第三阶段:内容衍生(6-12个月)
**目标**:标签驱动内容生产,形成自运转生态
| 周次 | 动作 | 产出 |
|------|------|------|
| 1-4 | 开发标签引擎和内容模板库(100+模板) | 衍生引擎 |
| 5-8 | 上线AI短剧、VR体验、研学课程首批内容 | 内容矩阵 |
| 9-12 | 开放内容创作者入驻(基于标签筛选文物获取素材) | 创作者生态 |
| 持续 | 数据回流优化:根据用户行为调整标签权重和内容推荐 | 智能推荐 |
---
## 十、关键成功指标(KPI
| 维度 | 指标 | 6个月目标 | 12个月目标 |
|------|------|-----------|------------|
| **数据覆盖** | 国内文物精确坐标 | 1,000件 | 10,000件 |
| | 海外文物位置标记 | 100件 | 1,000件 |
| | 机构接入数量 | 10家 | 100家 |
| **用户活跃** | 月活用户(MAU | 5万 | 50万 |
| | 发现者注册数 | 200人 | 5,000人 |
| | 用户标记提交数 | 500条 | 10,000条 |
| **内容衍生** | 标签覆盖文物比例 | 100% | 100% |
| | AI短剧生成数 | 50条 | 1,000条 |
| | VR体验上线数 | 3个 | 20个 |
| **数据可信** | 审核通过率 | 60% | 70% |
| | 用户举报纠错率 | <5% | <3% |
| | 机构数据更新频率 | 月度 | 实时 |
---
## 十一、风险与对策
| 风险 | 对策 |
|------|------|
| **私人收藏地址泄露** | 模糊区域渲染,精确坐标仅专家后台可见,前端显示城市级热力圈 |
| **假拍卖/假新闻** | 拍卖行数据必须附图录页码+拍卖日期,媒体报道仅作C级参考 |
| **重复标记** | AI查重+坐标聚类,同一博物馆同一展厅自动合并 |
| **政治敏感** | 追索主张由官方机构发声,系统只记录"现状",不主动发起UGC倡议 |
| **故事编造** | 故事层编辑审核制,UGC仅限"位置情报",不开放"故事创作" |
| **机构数据更新滞后** | 建立API自动同步机制,设置"数据 freshness"提醒 |
| **标签体系膨胀** | 标签分级管理,基础标签固定,流转标签和情感标签每季度评审 |
---
## 十二、团队配置建议
| 角色 | 人数 | 职责 |
|------|------|------|
| **产品经理** | 1 | 全图系统规划、标签体系设计、机构对接 |
| **后端工程师** | 2 | 数据库、API、审核后台、地图引擎 |
| **前端工程师** | 2 | 微信小程序、APP、地图交互、室内平面图 |
| **AI工程师** | 2 | LLM知识库、数字人、AIGC内容生成、标签推荐 |
| **内容编辑** | 3 | 故事撰写、角色卡设计、标签维护、审核专家协调 |
| **文物专家顾问** | 5(兼职) | 审核流失文物标记、故事史实校验、机构关系 |
| **运营** | 2 | 机构拓展、发现者招募、社区运营、事件策划 |
| **UI/UX设计师** | 1 | 地图视觉、文物档案页、角色形象 |
---
## 十三、结语
> **文物全图不是一张静态地图,而是中华文明的数据基础设施。**
>
> 每一件文物都有一个身份(标签)、一段人生(故事)、一种性格(角色)。当这些元素被结构化、被连接、被激活,它们就能自动生长出无穷无尽的内容——短剧、VR、研学、文创、纪录片——而不需要为每一件文物单独创作。
>
> **标签是种子,故事是土壤,AI是阳光,用户是园丁。**
>
> 先花3个月把1000件种子数据的坐标钉死、标签打准、故事写稳。后面的南迁路线、海外寻踪、AI短剧、VR体验,都是这套基础设施的自然生长。
+736
View File
@@ -0,0 +1,736 @@
# 中华文明全图鉴——文物全图系统 PRD
## 1. 文档信息
| 项目 | 内容 |
|---|---|
| 产品名称 | 中华文明全图鉴 |
| 产品简称 | 文物全图 |
| 文档类型 | PRD 产品需求文档 |
| 文档版本 | v1.0 |
| 适用阶段 | 0-12 个月产品规划,重点覆盖 MVP 至全球版 |
| 目标端 | PC Web 地图站、机构/专家/编辑后台;微信小程序作为后续移动端补充 |
---
## 2. 产品定位
### 2.1 一句话定位
做一张“活着的”中华文明全图,让每一件文物无论在家还是离家,都拥有完整位置、可信档案、结构化标签、故事角色和可衍生内容。
### 2.2 核心价值
- **解决“在哪里”**:建立全球文物精确或模糊位置数据库,国内文物由机构直供,海外文物由用户众包发现并经审核确认。
- **解决“是谁”**:为每件重点文物建立身份档案、流转历史、标签体系、故事档案和角色卡。
- **解决“能做什么”**:通过标签体系驱动故事、有声内容、研学任务、视频脚本、VR/AR、文创等衍生内容生产。
### 2.3 产品原则
- **可信优先**:数据来源、审核过程、修改记录均可追溯。
- **地图优先**:以地理位置和流转轨迹作为核心入口。
- **PC 优先**:第一阶段优先建设 PC Web 大屏地图体验,保证全球地图、路线、聚合点和文物档案有足够展示空间。
- **专业美观**:PC 端视觉必须体现文博项目的权威感、秩序感和高级感,避免普通后台化、工具化或粗糙地图页。
- **趣味可探索**:在不损害专业性的前提下,通过文物故事钩子、点位动效、探索反馈、角色化短句和路线叙事增强浏览乐趣。
- **标签驱动**:所有内容衍生均依赖结构化标签,不依赖纯人工临时创作。
- **AI 辅助,不替代审核**:AI 用于标签推荐、查重、内容初稿和来源辅助判断,最终结论必须人工确认。
- **敏感信息保护**:私人收藏、非公开坐标、机构内部信息需分级展示。
---
## 3. 背景与目标
### 3.1 项目背景
当前文物信息分散在博物馆官网、展览页面、学术论文、拍卖图录、新闻报道、民间记录和海外机构数据库中。普通公众很难直观看到中国文物在全球的分布,文博机构也缺少一个可统一展示、更新、审核和内容衍生的平台。
“中华文明全图鉴”希望构建一个以文物为核心、以地图为入口、以标签为引擎、以可信审核为基础的数据与内容平台。
### 3.2 阶段目标
| 阶段 | 时间 | 目标 | 核心产出 |
|---|---:|---|---|
| 第一阶段 | 0-3 个月 | 建立国内重点文物可信数据基底 | PC Web 地图站 MVP、机构后台、1000 件种子文物、在家视图 |
| 第二阶段 | 3-6 个月 | 建立海外流失文物发现与审核机制 | 离家视图、全图对比、用户标记、三级审核、发现者体系 |
| 第三阶段 | 6-12 个月 | 建立标签驱动内容衍生能力 | 标签引擎、内容模板库、AI 辅助生成、研学/有声/3D 内容入口 |
---
## 4. 用户画像
| 用户类型 | 核心需求 | 使用场景 |
|---|---|---|
| 普通公众 | 查看文物分布、了解国宝故事 | 打开 PC Web 地图站浏览全国或全球文物分布,查看文物档案和故事 |
| 学生/家长 | 研学学习、任务打卡 | 完成“文物守护人”任务链,生成研学报告 |
| 海外华人/留学生 | 发现海外中国文物、建立情感连接 | 在海外博物馆标记中国文物位置并获得发现者署名 |
| 文博从业者 | 数据查询、资料维护、展出信息更新 | 管理机构文物、展厅位置、展出状态和数字资产 |
| 专家审核者 | 审核海外标记、校验史实 | 对用户提交的位置、来源、证据进行复核 |
| 内容编辑 | 维护故事、角色卡、标签和内容模板 | 基于标签生成故事初稿并完成编辑审核 |
| 内容创作者 | 获取文物素材和故事线索 | 按标签筛选文物,获取可授权素材和脚本方向 |
---
## 5. 产品范围
### 5.1 MVP 范围
MVP 聚焦“PC Web 国内文物地图 + 文物档案 + 后台录入 + 基础标签”。
必须包含:
- 文物地图浏览
- PC 大屏地图浏览
- PC Web 专业视觉设计系统
- 地图探索趣味交互
- 附近文物查看
- 文物详情页
- 国内机构后台录入
- 文物主数据管理
- 位置数据管理
- 基础标签管理
- 展出状态管理
- 数据来源记录
- 基础搜索与筛选
- 管理员后台审核与发布
### 5.2 第二阶段范围
- 海外文物标记
- 用户发现者体系
- AI 初筛
- 专家复核
- 社区公示
- 离家视图
- 全图对比视图
- 南迁路线专题
- 用户举报纠错
- 贡献值与称号体系
### 5.3 第三阶段范围
- 标签规则引擎
- 内容模板库
- 故事初稿生成
- 有声故事入口
- 研学任务链
- 3D 文物模型展示
- 内容审核与发布
- 创作者素材检索
- 用户行为数据回流
### 5.4 暂不纳入 MVP 的范围
- 区块链数字藏品
- 实时数字人对话
- AI 视频自动生成
- 大型 VR 沉浸式体验
- 复杂推荐系统
- 微信小程序完整端
- 独立原生 App
- 商业交易闭环
---
## 6. 核心用户旅程
### 6.1 普通用户查看附近文物
1. 用户打开 PC Web 地图站。
2. 系统默认展示全国或全球文物地图,用户可选择授权定位查看附近文物。
3. 地图展示博物馆、文物聚合点、分布热力和筛选图层。
4. 用户点击机构或文物点位。
5. 系统展示文物列表或文物详情。
6. 用户查看故事、位置、展出状态、标签和参观信息。
7. 用户收藏、分享或进入研学任务。
### 6.2 机构用户录入文物
1. 机构用户登录后台。
2. 创建或导入文物数据。
3. 录入基础属性、展出状态、坐标、展厅/展柜信息。
4. 绑定高清图、3D 模型或其他数字资产。
5. 系统进行格式校验和坐标合理性校验。
6. 数据进入待发布状态。
7. 机构确认后上线。
8. 后续可更新、撤回或修正。
### 6.3 用户发现海外文物
1. 用户在海外博物馆发现疑似中国文物。
2. 用户提交名称、位置、照片、来源说明和坐标。
3. 系统进行 AI 查重、来源可信度评分和反常识检测。
4. 通过初筛后进入专家复核。
5. 专家审核通过后进入社区公示。
6. 公示期无重大异议后正式上线。
7. 文物详情页展示发现者署名和可信度等级。
8. 用户获得贡献值和称号进度。
### 6.4 编辑生成文物故事
1. 编辑进入文物详情后台。
2. 查看文物基础数据、标签、流转历史和来源资料。
3. 调用 AI 辅助生成故事初稿。
4. 编辑基于来源资料修订内容。
5. 文物专家进行史实校验。
6. 内容审核通过后发布到文物详情页。
---
## 7. 功能需求
## 7.1 文物地图
### 7.1.1 功能说明
地图是用户浏览文物的主入口,支持全球视角、国内视角、海外视角、南迁路线视角。
### 7.1.2 功能需求
| 编号 | 需求 | 优先级 |
|---|---|---|
| MAP-001 | 支持地图缩放、拖拽、定位 | P0 |
| MAP-002 | 支持按照当前位置展示附近文物和机构 | P0 |
| MAP-003 | 支持点位聚合,避免地图密集时卡顿 | P0 |
| MAP-004 | 支持不同文物状态使用不同图例 | P0 |
| MAP-005 | 支持“全图 / 在家 / 离家 / 南迁路线”视图切换 | P1 |
| MAP-006 | 支持点击机构点位后展示该机构文物列表 | P0 |
| MAP-007 | 支持点击文物点位后展示文物详情卡片 | P0 |
| MAP-008 | 支持模糊区域渲染,不暴露敏感精确坐标 | P0 |
| MAP-009 | PC Web 支持左侧筛选栏、右侧详情抽屉和地图主画布联动 | P0 |
| MAP-010 | PC Web 支持顶部数据统计栏,展示国内、海外、在展、库藏等核心数字 | P0 |
| MAP-011 | PC Web 支持图层控制,包括机构点、文物点、热力层、路线层和模糊区域层 | P1 |
| MAP-012 | PC Web 支持大屏分辨率适配,重点适配 1440px、1920px 及以上宽度 | P0 |
| MAP-013 | PC Web 视觉风格需兼具专业文博感和现代地图产品质感 | P0 |
| MAP-014 | PC Web 支持点位 hover、选中、聚合展开、详情抽屉切换等轻量动效 | P0 |
| MAP-015 | PC Web 支持文物故事钩子展示,例如一句话人设、代表台词、流转亮点 | P1 |
| MAP-016 | PC Web 支持探索反馈,例如新发现标记、路线节点高亮、筛选结果数量变化反馈 | P1 |
| MAP-017 | 支持南迁路线动画展示 | P2 |
| MAP-018 | 支持室内平面图或展厅定位 | P2 |
### 7.1.3 PC 端视觉与趣味性要求
| 方向 | 要求 |
|---|---|
| 专业性 | 信息层级清晰,数据来源、审核状态、位置精度和文物级别必须可识别 |
| 美观性 | 采用克制的东方色彩、细腻地图底色、文博纹样点缀和现代卡片布局 |
| 趣味性 | 通过悬浮提示、点位动效、故事短句、角色化文案和路线叙事引导探索 |
| 克制性 | 趣味表达不得卡通化、游戏化过度,不得削弱文物信息的严肃性和可信度 |
| 性能 | 动效必须轻量,不影响地图缩放、拖拽、聚合和详情加载性能 |
### 7.1.4 地图图例
| 图例 | 含义 |
|---|---|
| 实心蓝点 | 国内在展 |
| 实心蓝点 + 房子 | 国内库藏 |
| 空心蓝圈 | 国内外借或巡展中 |
| 实心红点 | 海外精确坐标 |
| 半透明橙圈 | 海外模糊区域 |
| 灰色问号 | 位置未知 |
| 金色箭头 | 回归中 |
| 绿色星标 | 近期新标记或新展出 |
| 紫色光环 | 南迁北归相关文物 |
---
## 7.2 文物详情页
### 7.2.1 功能说明
文物详情页是单件文物的完整档案页,包含身份、位置、故事、标签、来源、数字资产和相关内容。
### 7.2.2 功能需求
| 编号 | 需求 | 优先级 |
|---|---|---|
| ART-001 | 展示文物名称、年代、门类、级别、材质、尺寸 | P0 |
| ART-002 | 展示当前状态:在家、离家、在途、未知 | P0 |
| ART-003 | 展示当前位置、机构、展厅或模糊区域 | P0 |
| ART-004 | 展示位置可信度和数据来源 | P0 |
| ART-005 | 展示完整流转时间轴 | P1 |
| ART-006 | 展示故事章节:缘起、流转、精彩故事、现状 | P1 |
| ART-007 | 展示特殊章节:离家、南迁北归、归途 | P2 |
| ART-008 | 展示角色卡,包括人格、语气、口头禅、禁忌话题 | P2 |
| ART-009 | 展示文物标签 | P0 |
| ART-010 | 展示高清图片、音频、视频、3D 模型等数字资产 | P1 |
| ART-011 | 支持收藏、分享、纠错 | P1 |
---
## 7.3 标签体系
### 7.3.1 功能说明
标签是文物筛选、内容衍生、故事模板匹配和推荐的核心基础。
### 7.3.2 标签类型
| 类型 | 示例 |
|---|---|
| 基础属性标签 | 门类、年代、级别、材质、功能、尺寸规模 |
| 流转经历标签 | 流失状态、回归状态、南迁北归、修复经历、外借经历、巡展经历、数字化经历、争议事件、名人关联、入藏方式 |
| 情感属性标签 | 情绪主调、适合年龄、体验时长、互动类型 |
### 7.3.3 功能需求
| 编号 | 需求 | 优先级 |
|---|---|---|
| TAG-001 | 支持创建、编辑、禁用标签 | P0 |
| TAG-002 | 支持标签分类管理 | P0 |
| TAG-003 | 支持单选、多选、布尔、文本等标签类型 | P0 |
| TAG-004 | 支持文物绑定多个标签 | P0 |
| TAG-005 | 支持按标签筛选文物 | P0 |
| TAG-006 | 支持标签来源记录:机构、编辑、AI、专家 | P1 |
| TAG-007 | 支持 AI 推荐标签,人工确认后生效 | P1 |
| TAG-008 | 支持标签组合触发内容模板 | P2 |
| TAG-009 | 支持标签新增申请和季度评审 | P2 |
---
## 7.4 机构后台
### 7.4.1 功能说明
机构后台用于国内博物馆或文博机构维护自有文物数据。
### 7.4.2 功能需求
| 编号 | 需求 | 优先级 |
|---|---|---|
| ORG-001 | 支持机构账号登录 | P0 |
| ORG-002 | 支持机构信息维护 | P0 |
| ORG-003 | 支持文物新增、编辑、批量导入 | P0 |
| ORG-004 | 支持位置、展厅、展柜和展出状态维护 | P0 |
| ORG-005 | 支持图片、音频、视频、3D 模型上传 | P1 |
| ORG-006 | 支持数据格式校验 | P0 |
| ORG-007 | 支持坐标合理性校验 | P0 |
| ORG-008 | 支持数据撤回、修正和更新 | P1 |
| ORG-009 | 支持机构数据更新提醒 | P2 |
| ORG-010 | 支持机构数据接口接入 | P1 |
---
## 7.5 海外发现与用户标记
### 7.5.1 功能说明
海外发现系统允许用户提交海外中国文物线索,经审核后成为平台可信数据。
### 7.5.2 功能需求
| 编号 | 需求 | 优先级 |
|---|---|---|
| UGC-001 | 支持用户提交海外文物标记 | P1 |
| UGC-002 | 支持提交文物名称、机构、坐标、照片、来源链接和说明 | P1 |
| UGC-003 | 支持精确坐标和模糊区域提交 | P1 |
| UGC-004 | 支持提交后查看审核进度 | P1 |
| UGC-005 | 支持系统自动查重 | P1 |
| UGC-006 | 支持来源可信度评分 | P1 |
| UGC-007 | 支持用户补充证据 | P2 |
| UGC-008 | 支持用户举报错误信息 | P1 |
| UGC-009 | 支持审核通过后发现者署名 | P1 |
| UGC-010 | 支持匿名展示或实名展示 | P1 |
---
## 7.6 审核系统
### 7.6.1 审核模式
国内文物:机构自审 + 系统备案。
海外文物:用户标记 → AI 初筛 → 专家复核 → 社区公示 → 上线。
### 7.6.2 功能需求
| 编号 | 需求 | 优先级 |
|---|---|---|
| AUD-001 | 支持审核任务池 | P0 |
| AUD-002 | 支持按门类、地域、机构分配审核任务 | P1 |
| AUD-003 | 支持 AI 初筛结果展示 | P1 |
| AUD-004 | 支持专家通过、驳回、要求补充资料 | P1 |
| AUD-005 | 支持双专家审核和仲裁机制 | P2 |
| AUD-006 | 支持 7 天社区公示 | P2 |
| AUD-007 | 支持审核日志完整记录 | P0 |
| AUD-008 | 支持审核证据附件管理 | P1 |
| AUD-009 | 支持用户举报后重新进入复核 | P1 |
| AUD-010 | 支持审核状态对用户可见 | P1 |
---
## 7.7 发现者贡献体系
### 7.7.1 功能说明
对用户提交并审核通过的位置线索给予署名、贡献值和称号激励。
### 7.7.2 贡献规则
| 行为 | 分值 |
|---|---:|
| 提交精确坐标且来源为 A 级 | +50 |
| 提交模糊区域且来源为 B 级 | +20 |
| 修正已有错误坐标 | +30 |
| 补充关键证据材料 | +10 |
| 被专家认定为无效或恶意提交 | 0 或扣分 |
### 7.7.3 称号体系
| 称号 | 条件 |
|---|---:|
| 见习侦探 | 注册并完成首次提交 |
| 文物巡护员 | 贡献值 ≥ 100 |
| 国宝守夜人 | 贡献值 ≥ 500 |
| 文明守望者 | 贡献值 ≥ 1000 |
---
## 7.8 故事与角色卡
### 7.8.1 故事结构
每件重点文物应包含以下故事章节:
| 章节 | 内容 | 字数建议 |
|---|---|---:|
| 缘起 | 何时、何地、何人、为何而创 | 300 字 |
| 流转 | 历代收藏、迁移、事件 | 400 字 |
| 精彩故事 | 传奇、争议、人物、当代事件 | 500 字 |
| 现状 | 现在在哪里、是否展出、如何参观 | 200 字 |
特殊文物可增加:
- 离家章节
- 南迁北归章节
- 归途章节
### 7.8.2 角色卡字段
| 字段 | 说明 |
|---|---|
| identity | 文物身份设定 |
| personality | 性格关键词 |
| voice_tone | 声音和表达风格 |
| catchphrase | 代表性台词 |
| taboo_topics | 不应主动展开的话题 |
| emotional_anchor | 情感锚点 |
| related_tags | 关联标签 |
| content_templates | 可触发内容模板 |
---
## 7.9 内容衍生引擎
### 7.9.1 功能说明
基于标签组合匹配内容模板,生成故事、音频、研学、视频脚本、VR 体验等内容初稿。
### 7.9.2 功能需求
| 编号 | 需求 | 优先级 |
|---|---|---|
| CNT-001 | 支持内容模板库管理 | P2 |
| CNT-002 | 支持标签组合匹配模板 | P2 |
| CNT-003 | 支持 AI 生成故事或脚本初稿 | P2 |
| CNT-004 | 支持编辑审核和专家校验 | P2 |
| CNT-005 | 支持发布至内容池 | P2 |
| CNT-006 | 支持内容与文物、标签关联 | P2 |
| CNT-007 | 支持有声故事、研学任务、3D 展示等内容类型 | P3 |
| CNT-008 | 支持用户消费数据回流 | P3 |
---
## 7.10 AI 辅助能力
### 7.10.1 MVP AI 能力
- 文物描述文本结构化提取
- 标签推荐
- 用户提交内容查重
- 坐标异常检测
- 来源可信度初评
### 7.10.2 中后期 AI 能力
- RAG 文物知识库问答
- 故事初稿生成
- 角色卡初稿生成
- 研学任务生成
- 有声故事脚本生成
- 文物图像相似度检索
### 7.10.3 AI 使用约束
- AI 输出不得直接作为最终公开内容。
- AI 生成内容必须展示引用来源。
- 涉及文物归属、流失、追索、争议的信息必须人工审核。
- AI 问答必须基于已审核知识库,不允许自由编造。
---
## 8. 数据需求
## 8.1 核心实体
| 实体 | 说明 |
|---|---|
| artifacts | 文物主数据 |
| artifact_locations | 文物位置数据 |
| institutions | 文博机构数据 |
| users | 用户数据 |
| tags | 标签定义 |
| artifact_tags | 文物标签关系 |
| audit_tasks | 审核任务 |
| audit_logs | 审核日志 |
| source_evidences | 来源证据 |
| artifact_stories | 文物故事 |
| artifact_personas | 文物角色卡 |
| content_templates | 内容模板 |
| content_assets | 内容资产 |
| contribution_records | 用户贡献记录 |
## 8.2 文物主表字段
| 字段 | 类型 | 说明 |
|---|---|---|
| id | UUID | 文物唯一 ID |
| unified_map_id | String | 全图唯一编码 |
| name | String | 文物名称 |
| category | Enum | 门类 |
| dynasty | String | 年代 |
| level | Enum | 文物级别 |
| material | String | 材质 |
| dimensions | String | 尺寸 |
| current_status | Enum | 在家、离家、在途、未知 |
| home_institution_id | UUID | 原属或现属国内机构 |
| created_at | Timestamp | 创建时间 |
| updated_at | Timestamp | 更新时间 |
## 8.3 位置表字段
| 字段 | 类型 | 说明 |
|---|---|---|
| id | UUID | 位置记录 ID |
| artifact_id | UUID | 文物 ID |
| location_type | Enum | domestic、overseas、unknown、in_transit |
| institution_id | UUID | 关联机构 |
| coordinates | GeoJSON | GPS 坐标或模糊区域 |
| precision | Enum | 展厅、建筑、城市、国家、区域 |
| floor_plan_ref | String | 展厅平面图编号 |
| display_status | Enum | 在展、库藏、外借、修复中、巡展中 |
| source_type | Enum | 机构直供、用户报告、专家验证 |
| discoverer_user_id | UUID | 发现者 |
| verified_at | Timestamp | 审核通过时间 |
| valid_until | Timestamp | 位置有效期 |
| created_at | Timestamp | 创建时间 |
---
## 9. 权限角色
| 角色 | 权限 |
|---|---|
| 游客 | 浏览公开地图、文物详情、故事内容 |
| 注册用户 | 收藏、分享、提交海外线索、纠错、查看贡献 |
| 机构用户 | 管理本机构文物、位置、展出状态和数字资产 |
| 内容编辑 | 管理故事、角色卡、标签、内容模板 |
| 专家 | 审核海外线索、校验故事史实、处理争议 |
| 运营 | 管理用户、活动、贡献体系、社区公示 |
| 系统管理员 | 全局配置、权限管理、数据审计、发布控制 |
---
## 10. 非功能需求
## 10.1 性能需求
| 指标 | 要求 |
|---|---|
| 地图首页首屏加载 | ≤ 3 秒 |
| 地图缩放和拖拽响应 | ≤ 300ms |
| 文物详情页加载 | ≤ 2 秒 |
| 搜索响应 | ≤ 1 秒 |
| 后台列表查询 | ≤ 2 秒 |
| 常规审核操作提交 | ≤ 1 秒 |
## 10.2 数据安全
- 私人收藏地址默认模糊展示。
- 精确坐标仅专家后台或授权角色可见。
- 机构数据需支持撤回和更新。
- 用户发现者信息默认匿名,可选择公开昵称或实名。
- 所有关键数据修改必须记录审计日志。
- 故事、位置、标签、审核结论需保留历史版本。
## 10.3 可用性
- 核心地图服务可用性不低于 99.5%。
- 后台数据操作需有草稿、保存、撤回、恢复机制。
- AI 服务不可用时,不影响基础地图和文物详情浏览。
## 10.4 合规要求
- 地图展示需符合国内地图服务与地理信息展示要求。
- 用户上传图片、资料和文字需进行内容安全审核。
- 涉及海外流失、追索、归属争议的信息需以事实记录为主,避免平台主动发起政治性倡议。
- 数字资产使用需明确授权来源和可展示范围。
---
## 11. 验收标准
## 11.1 MVP 验收标准
| 模块 | 验收标准 |
|---|---|
| 文物数据 | 支持录入不少于 1000 件种子文物 |
| 机构数据 | 支持不少于 5 家机构数据管理 |
| 地图展示 | 可在 PC Web 地图站展示机构和文物点位,支持大屏缩放、聚合、筛选和详情联动 |
| 文物详情 | 可查看基础属性、当前位置、标签、图片和简介 |
| 后台管理 | 机构用户可新增、编辑、提交文物数据 |
| 标签管理 | 管理员可维护基础标签并绑定文物 |
| 审核备案 | 管理员可查看机构提交记录和操作日志 |
| 搜索筛选 | 用户可按名称、年代、门类、机构进行筛选 |
| 权限控制 | 游客、注册用户、机构用户、管理员权限隔离 |
## 11.2 第二阶段验收标准
| 模块 | 验收标准 |
|---|---|
| 海外标记 | 用户可提交海外文物线索 |
| AI 初筛 | 系统可返回查重和可信度初评结果 |
| 专家审核 | 专家可通过、驳回或要求补充材料 |
| 社区公示 | 审核通过内容可进入公示期 |
| 发现者体系 | 审核通过后展示发现者署名并发放贡献值 |
| 地图视图 | 支持全图、在家、离家视图切换 |
## 11.3 第三阶段验收标准
| 模块 | 验收标准 |
|---|---|
| 标签引擎 | 可根据标签组合匹配内容模板 |
| 内容模板 | 不少于 100 个模板可配置 |
| AI 生成 | 可生成故事或脚本初稿 |
| 编辑审核 | AI 内容必须经编辑和专家确认后发布 |
| 内容池 | 内容可关联到文物详情页 |
| 3D 展示 | 支持至少一种 3D 文物模型在线查看 |
---
## 12. 核心指标
| 维度 | 指标 | 6 个月目标 | 12 个月目标 |
|---|---|---:|---:|
| 数据覆盖 | 国内文物精确坐标 | 1,000 件 | 10,000 件 |
| 数据覆盖 | 海外文物位置标记 | 100 件 | 1,000 件 |
| 数据覆盖 | 接入机构数量 | 10 家 | 100 家 |
| 用户活跃 | MAU | 5 万 | 50 万 |
| 用户活跃 | 发现者注册数 | 200 人 | 5,000 人 |
| 用户活跃 | 用户标记提交数 | 500 条 | 10,000 条 |
| 内容衍生 | 标签覆盖文物比例 | 100% | 100% |
| 内容衍生 | AI 短剧或脚本生成数 | 50 条 | 1,000 条 |
| 内容衍生 | VR/3D 体验上线数 | 3 个 | 20 个 |
| 数据可信 | 审核通过率 | 60% | 70% |
| 数据可信 | 用户举报纠错率 | < 5% | < 3% |
| 数据可信 | 机构数据更新频率 | 月度 | 实时 |
---
## 13. 风险与对策
| 风险 | 对策 |
|---|---|
| 私人收藏地址泄露 | 模糊区域渲染,精确坐标仅专家后台可见 |
| 假新闻或假拍卖线索 | 要求提交图录页码、拍卖日期、机构链接等证据 |
| 重复标记 | AI 查重、坐标聚类、同机构同展厅自动合并 |
| 政治敏感 | 平台只记录事实状态,不主动发起追索倡议 |
| 故事编造 | AI 仅生成初稿,公开前必须编辑和专家审核 |
| 机构数据更新滞后 | 提供 API 同步、更新提醒和数据 freshness 标记 |
| 标签体系膨胀 | 基础标签固定,扩展标签申请制和季度评审 |
| 地图性能下降 | 点位聚合、矢量瓦片、缓存和分级加载 |
| AI 幻觉 | RAG 引用来源、人工审核、不可直接发布 |
---
## 14. 技术实现建议
| 模块 | 推荐技术 |
|---|---|
| PC Web 地图站 | Next.js + React + TypeScript |
| 微信小程序 | Taro + React + TypeScript,后续移动端补充 |
| 管理后台 | Ant Design Pro |
| 地图引擎 | MapLibre GL JS / 腾讯地图 / 高德地图 |
| 后端服务 | NestJS + TypeScript |
| API | REST + OpenAPI |
| 主数据库 | PostgreSQL + PostGIS |
| 缓存 | Redis |
| 搜索 | OpenSearch / Elasticsearch |
| 向量检索 | pgvector,后续可升级 Milvus |
| 对象存储 | OSS / COS / S3 |
| 队列 | BullMQ + Redis |
| AI/RAG | Embedding + Reranker + LLM + 已审核知识库 |
| 3D 展示 | Three.js + glTF / GLB |
| 部署 | Docker + 云服务,后续 Kubernetes |
| 监控 | Sentry + Prometheus + Grafana |
---
## 15. 版本规划
## 15.1 v1.0 国内版
目标:建立可信国内文物地图。
核心能力:
- 机构后台
- 文物主数据
- 文物位置数据
- 基础标签
- PC Web 地图
- 文物详情页
- 在家视图
## 15.2 v2.0 全球版
目标:建立海外文物发现与审核机制。
核心能力:
- 海外标记
- AI 初筛
- 专家审核
- 社区公示
- 发现者署名
- 离家视图
- 全图对比
- 南迁路线专题
## 15.3 v3.0 内容生态版
目标:建立标签驱动内容衍生能力。
核心能力:
- 内容模板库
- 标签规则引擎
- AI 故事初稿
- 角色卡
- 有声故事
- 研学任务
- 3D 文物展示
- 创作者素材入口
---
## 16. 待确认问题
- 第一批合作机构名单与数据授权方式。
- 国内地图服务商选择与地图合规方案。
- 文物数据是否需要支持批量导入标准模板。
- 机构是否允许精确到展厅、展柜级别展示。
- 海外文物线索的证据等级标准。
- 专家审核组织方式、费用和 SLA。
- AI 模型是否采用公有云 API、私有化部署或混合方案。
- 数字资产版权和授权展示范围。
- 是否需要多语言版本,尤其是英文。
- 是否需要开放公共 API 或仅供合作机构使用。
+196
View File
@@ -0,0 +1,196 @@
# 中华文明全图鉴——阶段进度与测试记录
## 1. 记录说明
本文档用于记录每个阶段的实际完成内容、测试结果、文档更新和后续问题。后续每完成一个阶段或关键模块,都需要同步更新本文档与 `2-task.md`
## 2. 状态说明
| 状态 | 含义 |
|---|---|
| 未开始 | 尚未执行 |
| 进行中 | 已开始但未完成 |
| 已完成 | 已完成并通过当前阶段测试 |
| 阻塞 | 依赖外部信息或资源,暂不能继续 |
---
# 阶段 0:项目启动与基础准备
## 0.1 当前进度
| 模块 | 状态 | 说明 |
|---|---|---|
| 详细方案 | 已完成 | 已有 `0-中华文明全图鉴.md` |
| PRD | 已完成 | 已有 `1-prd.md`,并调整为 PC Web 优先 |
| 任务拆解 | 已完成 | 已有 `2-task.md`,并补充 PC 端专业美观与趣味性任务 |
| 技术架构文档 | 已完成 | 已创建 `3-architecture.md` |
| 数据模型文档 | 已完成 | 已创建 `4-data-model.md` |
| API 设计文档 | 已完成 | 已创建 `5-api.md` |
| 协作工具准备 | 未开始 | 需要确认代码仓库、Issue、任务看板等 |
| 合规与授权准备 | 未开始 | 需要确认机构授权、地图合规、图片授权等 |
## 0.2 本阶段已完成文档
- `0-中华文明全图鉴.md`
- `1-prd.md`
- `2-task.md`
- `3-architecture.md`
- `4-data-model.md`
- `5-api.md`
- `11-progress-log.md`
## 0.3 本阶段测试记录
| 测试项 | 测试方式 | 结果 | 说明 |
|---|---|---|---|
| 文档存在性检查 | 检查阶段 0 P0 文档是否存在 | 通过 | 已检查 7 个核心文档 |
| 文档关键词检查 | 检查 PC Web、PostGIS、API、数据模型等关键词 | 通过 | 已覆盖 PC Web、PostGIS、API、数据模型、测试记录等关键词 |
| 任务状态一致性检查 | 检查 `2-task.md` 与已创建文档是否一致 | 通过 | `3-architecture.md``4-data-model.md``5-api.md``11-progress-log.md` 已在任务清单中标记完成 |
测试命令:
```bash
python3 - <<'PY'
from pathlib import Path
root = Path('/Users/freedak/Documents/AIDashboard/wenwumap')
required = {
'0-中华文明全图鉴.md': ['中华文明'],
'1-prd.md': ['PC Web', '专业美观', '趣味'],
'2-task.md': ['3-architecture.md', '11-progress-log.md', 'PC Web'],
'3-architecture.md': ['Next.js', 'NestJS', 'PostGIS', '测试策略'],
'4-data-model.md': ['artifacts', 'artifact_locations', 'PostGIS', 'operation_logs'],
'5-api.md': ['/api/v1', 'Map API', 'Artifact API', '权限矩阵'],
'11-progress-log.md': ['阶段 0', '测试记录', '下一步'],
}
failed = []
for name, keywords in required.items():
path = root / name
if not path.exists():
failed.append(f'缺失文件: {name}')
continue
text = path.read_text(encoding='utf-8')
for keyword in keywords:
if keyword not in text:
failed.append(f'{name} 缺少关键词: {keyword}')
if failed:
print('FAILED')
for item in failed:
print('-', item)
raise SystemExit(1)
print('PASSED')
print(f'checked_files={len(required)}')
PY
```
测试输出:
```txt
PASSED
checked_files=7
```
## 0.4 阻塞项
- 项目负责人、产品负责人、技术负责人、设计负责人、内容负责人、数据负责人尚未由用户确认。
- 代码仓库地址尚未确认。
- 第一批合作机构与种子文物范围尚未确认。
- 地图服务合规方案尚未最终确认。
## 0.5 下一步
- 更新 `2-task.md` 阶段 0 文档任务状态。
- 执行阶段 0 文档一致性测试。
- 开始阶段 1 工程初始化前的技术选型确认。
- 根据确认结果初始化代码仓库结构和基础工程。
---
# 阶段 1MVP 国内文物地图
## 1.1 当前进度
| 模块 | 状态 | 说明 |
|---|---|---|
| 技术选型 | 进行中 | 已在 `3-architecture.md` 中给出建议,地图服务商、对象存储待确认 |
| 数据模型 | 已完成 | `4-data-model.md` 设计完成,`001_init.sql` migration 已编写 |
| API 设计 | 已完成 | `5-api.md` 第一版完成,待转为 OpenAPI/Swagger |
| Monorepo 工程骨架 | 已完成 | pnpm workspace + `apps/web``apps/admin``apps/api``packages/shared``packages/db` 均已创建 |
| 共享类型包 | 已完成 | `packages/shared` 包含所有枚举和 TS 类型定义 |
| 数据库 migration | 已完成 | `001_init.sql` 包含 PostGIS、全表结构、枚举、索引 |
| 数据库 seed | 已完成 | 角色、标签类别、测试机构、测试文物、测试位置 seed 已编写 |
| 本地基础设施 | 已准备 | `infra/docker-compose.yml` 包含 PostgreSQL+PostGIS、Redis、MinIO |
| PC Web 地图站 | 骨架已就位 | `apps/web` 占位页已建立,地图引擎集成待地图 Key 确认后开展 |
| 管理后台 | 骨架已就位 | `apps/admin` 占位页已建立 |
| 后端 API | 骨架已就位 | `apps/api` NestJS 入口 + 健康检查接口已就位 |
## 1.2 本次进度测试记录
| 测试项 | 测试方式 | 结果 | 说明 |
|---|---|---|---|
| 工程结构校验 | `scripts/check-structure.py` | 通过 | 49 个文件全部存在 |
| 文档关键词校验 | `scripts/check-structure.py` | 通过 | 19 项关键词均满足 |
| SQL 结构检查 | 肥眼核查 migration | 通过 | PostGIS/枚举/全表/索引均存在 |
| 共享类型包检查 | 肥眼核查 enums/types | 通过 | 全枚举、全类型均已定义 |
| TS 类型误误 | IDE 反馈 | 预安装错误 | 全部为未执行 `pnpm install` 导致,安装依赖后自动消除 |
测试命令:
```bash
python3 scripts/check-structure.py
```
测试输出:
```txt
PASSED — 共检查 49 个文件,所有关键词验证通过
```
## 1.2 阶段 1 测试要求
- 工程初始化后必须能执行 lint。
- 数据库 migration 必须能从空库完整执行。
- seed 必须能生成基础角色、标签、机构、文物和位置测试数据。
- 后端必须提供健康检查接口。
- PC Web 必须能打开地图页并展示测试点位。
- 管理后台必须能登录并查看基础列表页。
## 1.3 后端 API 开发记录
### 本次完成内容
| 模块 | 文件 | 状态 |
|---|---|---|
| DatabaseModule | `apps/api/src/database/` | ✅ |
| AuthModule | `apps/api/src/auth/` | ✅ |
| MapModule | `apps/api/src/map/` | ✅ |
| ArtifactsModule | `apps/api/src/artifacts/` | ✅ |
| InstitutionsModule | `apps/api/src/institutions/` | ✅ |
| 工程规范 | `.prettierrc``commitlint.config.js` | ✅ |
### 接口验证(本地数据库 PostgreSQL 16 + PostGIS 3.4.4
```txt
GET /api/v1/health → {"status":"ok","service":"wenwumap-api"}
GET /api/v1/map/stats → {"total_artifacts":3,"total_institutions":3,"total_locations":3}
GET /api/v1/map/points → 3 个文物点位(含经纬度、机构名、故事钩子)
GET /api/v1/artifacts → {"total":3, data:[千里江山图,清明上河图,司母戊鼎]}
GET /api/v1/institutions → {"total":3, data:[故宫,国博,上博]}
POST /api/v1/auth/login → 待写入测试用户 hash 后验证
GET /api/v1/api/docs → Swagger UI 可用
```
### 已知待完善
- 尚未写入测试用户(users 表为空),auth/login 需要先插入管理员账号
- 退出登录(JWT 黑名单)待实现
- 文物/机构 CRUD 写接口待实现
- 标签接口待实现
## 1.4 下一步
- 插入管理员测试用户,验证 JWT 登录完整流程
- 实现文物/机构写接口(新增、编辑、发布)
- 实现标签接口
- 开发 PC Web 地图站 MapLibre 集成(待确认地图 Key
- 开发 Admin 后台登录页和文物管理列表页
+1416
View File
File diff suppressed because it is too large Load Diff
+236
View File
@@ -0,0 +1,236 @@
# 中华文明全图鉴——技术架构文档
## 1. 架构目标
本项目第一阶段优先建设 **PC Web 地图站 MVP**,目标是在 PC 大屏上提供专业、美观、有探索趣味的文物地图体验,并配套机构/专家/编辑后台、后端 API、PostGIS 地理数据底座和可扩展的 AI/内容能力。
## 2. 架构原则
- **PC Web 优先**:第一阶段不做完整微信小程序,优先保证大屏地图、筛选、详情抽屉、图层和统计体验。
- **数据可信优先**:所有文物、位置、标签、故事、数字资产均记录来源、审核状态和操作日志。
- **PostGIS 优先**:文物位置、模糊区域、地图范围查询、附近查询、路线查询统一基于 PostgreSQL + PostGIS。
- **前后端类型一致**:优先采用 TypeScript 技术栈,减少接口字段漂移。
- **模块化演进**:MVP 采用单体 API + 模块化目录,后续可拆分搜索、AI、内容、审核等服务。
- **AI 后置增强**:MVP 先预留 AI 接口与数据结构,AI 标签推荐、RAG、AIGC 在后续阶段逐步接入。
- **测试随阶段推进**:每个阶段必须保留接口测试、数据测试、前端测试和文档更新记录。
## 3. 推荐技术栈
| 模块 | 技术选型 | 说明 |
|---|---|---|
| PC Web 地图站 | Next.js + React + TypeScript | 面向公众的大屏地图、详情、搜索、筛选 |
| 管理后台 | React + TypeScript + Ant Design | 机构、文物、标签、位置、审核、日志管理 |
| 后端 API | NestJS + TypeScript | REST API、权限、业务服务、OpenAPI 文档 |
| 主数据库 | PostgreSQL + PostGIS | 结构化数据与地理数据统一存储 |
| 缓存 | Redis | 热点地图点位、会话、限流、异步任务状态 |
| 搜索 | PostgreSQL 基础搜索,后续 OpenSearch | MVP 先用数据库搜索,数据增长后升级 |
| 向量检索 | pgvector,后续 Milvus | AI/RAG 阶段启用 |
| 对象存储 | 本地 MinIO / 云 OSS / COS / S3 | 图片、音频、视频、3D 模型、资料附件 |
| 地图引擎 | MapLibre GL JS | PC Web 大屏地图、图层、点位聚合、路线 |
| 队列 | BullMQ + Redis | 后续 AI 生成、导入、图片处理等异步任务 |
| 部署 | Docker Compose,后续 Kubernetes | MVP 简化部署,中后期可扩展 |
| 监控 | Sentry + OpenTelemetry + Prometheus/Grafana | 前后端错误、接口耗时、服务健康 |
## 4. 推荐仓库结构
```txt
wenwumap/
apps/
web/ # PC Web 地图站
admin/ # 管理后台
api/ # NestJS 后端 API
packages/
shared/ # 共享类型、枚举、工具函数
db/ # 数据库 schema、migration、seed
ui/ # 可复用 UI 组件,后续抽取
docs/ # 后续补充的设计、接口、测试文档
infra/
docker-compose.yml # 本地 PostgreSQL/PostGIS、Redis、MinIO
nginx/ # 反向代理配置,后续使用
scripts/ # 数据导入、校验、测试脚本
0-中华文明全图鉴.md
1-prd.md
2-task.md
3-architecture.md
4-data-model.md
5-api.md
```
## 5. 系统逻辑架构
```txt
┌───────────────────────────────────────────────┐
│ 用户访问层 │
│ PC Web 地图站 / 管理后台 / 后续小程序 │
└──────────────────────┬────────────────────────┘
│ HTTPS REST API
┌──────────────────────▼────────────────────────┐
│ API 服务层 │
│ Auth / Artifact / Institution / Location / Tag │
│ Map / Asset / AuditLog / Search / Admin │
└───────────────┬───────────────┬───────────────┘
│ │
┌───────────────▼───────┐ ┌─────▼────────────────┐
│ PostgreSQL + PostGIS │ │ Redis / Queue │
│ 文物、机构、位置、标签 │ │ 缓存、限流、异步任务 │
└───────────────┬───────┘ └─────┬────────────────┘
│ │
┌───────────────▼───────────────▼────────────────┐
│ 扩展能力层 │
│ 对象存储 / 搜索 / pgvector / AI/RAG / 内容生成 │
└───────────────────────────────────────────────┘
```
## 6. 前端架构
## 6.1 PC Web 地图站
核心页面:
- 首页
- 地图页
- 文物详情页
- 机构详情页
- 搜索结果页
- 专题页占位
核心组件:
- 地图主画布
- 左侧筛选栏
- 右侧详情抽屉
- 顶部数据统计栏
- 图层控制面板
- 地图图例
- 文物卡片
- 机构卡片
- 文物故事钩子
- 数据来源与可信度标识
PC 端设计要求:
- 专业:信息层级、可信度、来源、审核状态清晰。
- 美观:东方色彩、文博纹样、克制质感、现代卡片。
- 趣味:点位 hover、聚合展开、故事短句、探索反馈、路线叙事。
- 克制:避免过度卡通化,不削弱文物信息可信度。
## 6.2 管理后台
后台模块:
- 登录与权限
- 仪表盘
- 机构管理
- 文物管理
- 位置管理
- 标签管理
- 数字资产管理
- 操作日志
- 批量导入
- 审核任务占位
## 7. 后端模块划分
| 模块 | 职责 |
|---|---|
| AuthModule | 登录、鉴权、角色权限、机构数据隔离 |
| UserModule | 用户、后台账号、角色绑定 |
| InstitutionModule | 机构信息、机构点位、机构文物列表 |
| ArtifactModule | 文物主数据、详情、状态、搜索筛选 |
| LocationModule | 文物位置、当前位置、历史位置、地图范围查询 |
| TagModule | 标签分类、标签定义、文物标签绑定 |
| AssetModule | 图片、文件、3D 模型等数字资产元数据 |
| MapModule | 地图点位、聚合、统计、图层数据 |
| AuditLogModule | 操作日志、关键字段变更记录 |
| ImportModule | 批量导入、数据校验、导入报告 |
| HealthModule | 服务健康检查 |
## 8. 数据流
## 8.1 PC 地图浏览数据流
```txt
用户打开地图页
→ Web 请求 /map/summary 获取顶部统计
→ Web 请求 /map/points?bbox&zoom&filters 获取地图点位
→ 用户点击点位
→ Web 请求 /artifacts/{id} 或 /institutions/{id}/artifacts
→ 右侧详情抽屉展示文物或机构信息
```
## 8.2 后台录入数据流
```txt
机构用户登录后台
→ 新增或编辑文物
→ 保存草稿
→ 录入位置、标签、图片
→ 提交发布
→ 系统校验字段和坐标
→ 写入操作日志
→ 前台地图可见
```
## 8.3 位置展示数据流
```txt
artifact_locations 保存多条位置记录
→ 后端根据 valid_until、verified_at、display_status 计算最新有效位置
→ 地图接口只返回当前用户权限可见的精度
→ 私密或敏感位置返回模糊区域
```
## 9. 权限模型
| 角色 | 权限范围 |
|---|---|
| 游客 | 浏览公开地图、文物详情、机构信息 |
| 注册用户 | 收藏、分享、纠错、后续海外线索提交 |
| 机构用户 | 管理本机构文物、位置、标签、资产 |
| 编辑 | 管理故事、角色卡、标签、内容模板 |
| 专家 | 审核海外线索、校验史实 |
| 运营 | 用户、贡献体系、专题、活动 |
| 管理员 | 全局管理、权限、审计、系统配置 |
MVP 优先实现:
- 管理员
- 机构用户
- 游客
## 10. 地图与地理策略
MVP 地图策略:
- 使用 MapLibre GL JS 承载 PC Web 地图交互。
- 后端通过 PostGIS 提供 bbox 范围查询。
- 低缩放级别返回聚合点或机构点。
- 高缩放级别返回具体文物点。
- 敏感位置返回模糊区域或城市级中心点。
后续增强:
- 矢量瓦片 MVT。
- H3/Geohash 预聚合。
- 南迁路线 LineString。
- 室内展厅平面图叠加。
## 11. 测试策略
| 阶段 | 测试类型 | 内容 |
|---|---|---|
| 阶段 0 | 文档测试 | 架构、数据模型、API 与 PRD/任务清单一致性检查 |
| 阶段 1 | 单元测试 | 后端 service、权限、数据校验、地图查询 |
| 阶段 1 | 接口测试 | Auth、机构、文物、位置、标签、地图接口 |
| 阶段 1 | 数据测试 | migration、seed、PostGIS 索引、范围查询 |
| 阶段 1 | 前端测试 | 地图加载、点位交互、筛选、详情抽屉 |
| 阶段 1 | 视觉验收 | PC 专业美观、趣味交互、大屏适配 |
| 阶段 1 | E2E 测试 | 管理后台录入文物后,PC 地图可展示 |
## 12. 阶段 0 当前结论
- 第一阶段不做完整小程序,优先 PC Web 地图站。
- MVP 后端采用 NestJS + PostgreSQL/PostGIS。
- MVP 搜索先用数据库能力,保留 OpenSearch 扩展点。
- MVP AI 能力先预留,不阻塞核心地图上线。
- 所有阶段完成后必须更新 `2-task.md` 状态,并记录测试结果。
+458
View File
@@ -0,0 +1,458 @@
# 中华文明全图鉴——数据模型文档
## 1. 数据模型目标
本数据模型服务于第一阶段 PC Web 地图站 MVP,覆盖文物、机构、位置、标签、数字资产、权限、操作日志和地图查询所需的核心数据结构。
## 2. 设计原则
- **PostgreSQL + PostGIS**:结构化业务数据与地理数据统一存储。
- **多位置记录**:一件文物可有多条位置记录,前台默认展示最新有效位置。
- **敏感坐标分级**:同一位置可保存精确坐标和公开展示坐标或模糊区域。
- **标签可扩展**:基础标签固定,扩展标签通过标签分类和标签类型支持动态扩展。
- **操作可追溯**:核心对象修改必须记录操作日志。
- **字段可演进**:对角色卡、故事、AI 推荐等中后期能力保留扩展空间。
## 3. 枚举定义
## 3.1 artifact_category
| 值 | 含义 |
|---|---|
| bronze | 青铜 |
| painting_calligraphy | 书画 |
| porcelain | 陶瓷 |
| jade | 玉器 |
| gold_silver | 金银器 |
| lacquer | 漆器 |
| textile | 织绣 |
| stone_carving | 石刻 |
| wood_carving | 木雕 |
| dunhuang | 敦煌 |
| ancient_book | 古籍 |
| other | 其他 |
## 3.2 artifact_level
| 值 | 含义 |
|---|---|
| level_1 | 一级 |
| level_2 | 二级 |
| level_3 | 三级 |
| general | 一般 |
| unknown | 未定级 |
## 3.3 artifact_status
| 值 | 含义 |
|---|---|
| at_home | 在家 |
| away | 离家 |
| in_transit | 在途 |
| unknown | 未知 |
## 3.4 location_type
| 值 | 含义 |
|---|---|
| domestic | 国内 |
| overseas | 海外 |
| unknown | 未知 |
| in_transit | 在途 |
## 3.5 location_precision
| 值 | 含义 |
|---|---|
| exact_room | 展厅 |
| exact_building | 建筑 |
| city | 城市 |
| country | 国家 |
| region | 区域 |
## 3.6 display_status
| 值 | 含义 |
|---|---|
| on_display | 在展 |
| in_storage | 库藏 |
| loaned | 外借 |
| repairing | 修复中 |
| touring | 巡展中 |
| unknown | 未知 |
## 3.7 source_type
| 值 | 含义 |
|---|---|
| institution_feed | 机构直供 |
| manual_entry | 后台录入 |
| user_report | 用户报告 |
| expert_verify | 专家验证 |
| public_source | 公开资料 |
## 3.8 publish_status
| 值 | 含义 |
|---|---|
| draft | 草稿 |
| pending | 待发布 |
| published | 已发布 |
| archived | 已归档 |
| rejected | 已驳回 |
## 3.9 tag_value_type
| 值 | 含义 |
|---|---|
| single | 单选 |
| multiple | 多选 |
| boolean | 布尔 |
| text | 文本 |
| number | 数字 |
## 4. 核心表设计
## 4.1 users
后台账号和前台用户统一存储,MVP 优先支持后台账号。
| 字段 | 类型 | 约束 | 说明 |
|---|---|---|---|
| id | uuid | pk | 用户 ID |
| username | varchar(100) | unique | 登录名 |
| phone | varchar(30) | nullable | 手机号 |
| email | varchar(255) | nullable | 邮箱 |
| password_hash | varchar(255) | nullable | 密码 hash |
| nickname | varchar(100) | nullable | 昵称 |
| avatar_url | text | nullable | 头像 |
| user_type | varchar(30) | not null | admin、institution、public、expert、editor |
| institution_id | uuid | fk nullable | 绑定机构 |
| is_active | boolean | default true | 是否启用 |
| created_at | timestamptz | not null | 创建时间 |
| updated_at | timestamptz | not null | 更新时间 |
## 4.2 roles
| 字段 | 类型 | 约束 | 说明 |
|---|---|---|---|
| id | uuid | pk | 角色 ID |
| code | varchar(50) | unique | 角色编码 |
| name | varchar(100) | not null | 角色名称 |
| description | text | nullable | 描述 |
| created_at | timestamptz | not null | 创建时间 |
## 4.3 permissions
| 字段 | 类型 | 约束 | 说明 |
|---|---|---|---|
| id | uuid | pk | 权限 ID |
| code | varchar(100) | unique | 权限编码 |
| name | varchar(100) | not null | 权限名称 |
| module | varchar(50) | not null | 模块 |
| created_at | timestamptz | not null | 创建时间 |
## 4.4 user_roles
| 字段 | 类型 | 约束 | 说明 |
|---|---|---|---|
| user_id | uuid | pk fk | 用户 ID |
| role_id | uuid | pk fk | 角色 ID |
| created_at | timestamptz | not null | 创建时间 |
## 4.5 institutions
| 字段 | 类型 | 约束 | 说明 |
|---|---|---|---|
| id | uuid | pk | 机构 ID |
| name | varchar(255) | not null | 机构名称 |
| short_name | varchar(100) | nullable | 简称 |
| code | varchar(100) | unique nullable | 机构编码 |
| institution_type | varchar(50) | not null | museum、research、private、other |
| country | varchar(100) | not null | 国家 |
| province | varchar(100) | nullable | 省份 |
| city | varchar(100) | nullable | 城市 |
| address | text | nullable | 地址 |
| location | geography(Point, 4326) | nullable | 机构坐标 |
| official_website | text | nullable | 官网 |
| description | text | nullable | 简介 |
| is_verified | boolean | default false | 是否认证 |
| publish_status | publish_status | not null | 发布状态 |
| created_at | timestamptz | not null | 创建时间 |
| updated_at | timestamptz | not null | 更新时间 |
索引:
- `idx_institutions_location_gist``GIST(location)`
- `idx_institutions_name``name`
- `idx_institutions_country_city``country, city`
## 4.6 artifacts
| 字段 | 类型 | 约束 | 说明 |
|---|---|---|---|
| id | uuid | pk | 文物 ID |
| unified_map_id | varchar(50) | unique | 全图唯一编码 |
| name | varchar(255) | not null | 文物名称 |
| alternative_names | text[] | nullable | 别名 |
| category | artifact_category | not null | 门类 |
| dynasty | varchar(100) | nullable | 年代 |
| level | artifact_level | default unknown | 文物级别 |
| material | varchar(255) | nullable | 材质 |
| dimensions | varchar(255) | nullable | 尺寸 |
| current_status | artifact_status | default unknown | 在家、离家、在途、未知 |
| home_institution_id | uuid | fk nullable | 原属或现属国内机构 |
| summary | text | nullable | 简介 |
| story_hook | varchar(255) | nullable | 一句话故事钩子 |
| persona_quote | varchar(255) | nullable | 趣味化角色短句 |
| publish_status | publish_status | not null | 发布状态 |
| created_by | uuid | fk nullable | 创建人 |
| updated_by | uuid | fk nullable | 更新人 |
| created_at | timestamptz | not null | 创建时间 |
| updated_at | timestamptz | not null | 更新时间 |
索引:
- `idx_artifacts_name``name`
- `idx_artifacts_category``category`
- `idx_artifacts_dynasty``dynasty`
- `idx_artifacts_status``current_status`
- `idx_artifacts_home_institution``home_institution_id`
## 4.7 artifact_locations
| 字段 | 类型 | 约束 | 说明 |
|---|---|---|---|
| id | uuid | pk | 位置记录 ID |
| artifact_id | uuid | fk not null | 文物 ID |
| location_type | location_type | not null | 国内、海外、未知、在途 |
| institution_id | uuid | fk nullable | 关联机构 |
| precise_location | geography(Point, 4326) | nullable | 精确坐标 |
| public_location | geography(Point, 4326) | nullable | 前台可展示坐标 |
| fuzzy_area | geography(Polygon, 4326) | nullable | 模糊区域 |
| precision | location_precision | not null | 坐标精度 |
| floor_plan_ref | varchar(255) | nullable | 展厅平面图编号 |
| room_name | varchar(255) | nullable | 展厅名称 |
| cabinet_no | varchar(100) | nullable | 展柜编号 |
| display_status | display_status | default unknown | 展出状态 |
| source_type | source_type | not null | 来源类型 |
| source_description | text | nullable | 来源说明 |
| discoverer_user_id | uuid | fk nullable | 发现者 |
| is_current | boolean | default false | 是否当前有效位置 |
| verified_at | timestamptz | nullable | 审核通过时间 |
| valid_from | timestamptz | nullable | 有效开始 |
| valid_until | timestamptz | nullable | 有效截止 |
| created_by | uuid | fk nullable | 创建人 |
| created_at | timestamptz | not null | 创建时间 |
| updated_at | timestamptz | not null | 更新时间 |
索引:
- `idx_artifact_locations_artifact_id``artifact_id`
- `idx_artifact_locations_institution_id``institution_id`
- `idx_artifact_locations_public_location_gist``GIST(public_location)`
- `idx_artifact_locations_precise_location_gist``GIST(precise_location)`
- `idx_artifact_locations_fuzzy_area_gist``GIST(fuzzy_area)`
- `idx_artifact_locations_current``artifact_id, is_current`
当前位置规则:
1. 优先取 `is_current = true` 的位置记录。
2. 若存在多条,则取 `verified_at` 最新的一条。
3.`valid_until` 已过期,则不作为前台当前位置展示。
4. 无权限用户只能读取 `public_location``fuzzy_area`
## 4.8 tag_categories
| 字段 | 类型 | 约束 | 说明 |
|---|---|---|---|
| id | uuid | pk | 标签分类 ID |
| code | varchar(100) | unique | 分类编码 |
| name | varchar(100) | not null | 分类名称 |
| description | text | nullable | 描述 |
| sort_order | int | default 0 | 排序 |
| is_system | boolean | default false | 是否系统内置 |
| created_at | timestamptz | not null | 创建时间 |
## 4.9 tags
| 字段 | 类型 | 约束 | 说明 |
|---|---|---|---|
| id | uuid | pk | 标签 ID |
| category_id | uuid | fk not null | 分类 ID |
| code | varchar(100) | unique | 标签编码 |
| name | varchar(100) | not null | 展示名 |
| value_type | tag_value_type | not null | 值类型 |
| description | text | nullable | 描述 |
| color | varchar(20) | nullable | 展示颜色 |
| icon | varchar(100) | nullable | 图标 |
| is_system | boolean | default false | 是否系统标签 |
| is_active | boolean | default true | 是否启用 |
| sort_order | int | default 0 | 排序 |
| created_at | timestamptz | not null | 创建时间 |
| updated_at | timestamptz | not null | 更新时间 |
## 4.10 artifact_tags
| 字段 | 类型 | 约束 | 说明 |
|---|---|---|---|
| id | uuid | pk | 关系 ID |
| artifact_id | uuid | fk not null | 文物 ID |
| tag_id | uuid | fk not null | 标签 ID |
| value_text | text | nullable | 文本值 |
| value_number | numeric | nullable | 数字值 |
| confidence | numeric(5,2) | nullable | 置信度 |
| source_type | source_type | not null | 来源 |
| review_status | varchar(30) | default approved | pending、approved、rejected |
| created_by | uuid | fk nullable | 创建人 |
| created_at | timestamptz | not null | 创建时间 |
唯一约束:
- `artifact_id, tag_id, value_text`
## 4.11 digital_assets
| 字段 | 类型 | 约束 | 说明 |
|---|---|---|---|
| id | uuid | pk | 资产 ID |
| artifact_id | uuid | fk nullable | 文物 ID |
| institution_id | uuid | fk nullable | 机构 ID |
| asset_type | varchar(50) | not null | image、audio、video、model_3d、document |
| title | varchar(255) | nullable | 标题 |
| url | text | not null | 资源地址 |
| thumbnail_url | text | nullable | 缩略图 |
| mime_type | varchar(100) | nullable | MIME 类型 |
| size_bytes | bigint | nullable | 文件大小 |
| copyright_owner | varchar(255) | nullable | 版权方 |
| license_scope | text | nullable | 授权范围 |
| sort_order | int | default 0 | 排序 |
| created_by | uuid | fk nullable | 上传人 |
| created_at | timestamptz | not null | 创建时间 |
## 4.12 operation_logs
| 字段 | 类型 | 约束 | 说明 |
|---|---|---|---|
| id | uuid | pk | 日志 ID |
| operator_id | uuid | fk nullable | 操作人 |
| operator_role | varchar(50) | nullable | 操作角色 |
| action | varchar(100) | not null | 操作类型 |
| target_type | varchar(100) | not null | 目标类型 |
| target_id | uuid | nullable | 目标 ID |
| before_data | jsonb | nullable | 变更前 |
| after_data | jsonb | nullable | 变更后 |
| ip_address | varchar(50) | nullable | IP |
| user_agent | text | nullable | UA |
| created_at | timestamptz | not null | 创建时间 |
索引:
- `idx_operation_logs_operator_id`
- `idx_operation_logs_target`
- `idx_operation_logs_created_at`
## 5. 地图接口视图建议
## 5.1 artifact_current_locations_view
用途:地图点位与文物详情快速读取。
字段:
- artifact_id
- artifact_name
- category
- dynasty
- level
- current_status
- story_hook
- persona_quote
- institution_id
- institution_name
- public_location
- fuzzy_area
- precision
- display_status
- source_type
- verified_at
## 5.2 map_summary_view
用途:顶部统计栏。
字段:
- total_artifacts
- domestic_count
- overseas_count
- on_display_count
- in_storage_count
- loaned_count
- unknown_location_count
## 6. Seed 数据建议
## 6.1 系统角色
- admin
- institution_user
- editor
- expert
- public_user
## 6.2 标签分类
- basic_attribute
- circulation_experience
- emotional_attribute
- content_derivation
## 6.3 基础标签
- 门类
- 年代
- 级别
- 材质
- 功能
- 流失状态
- 回归状态
- 南迁北归
- 修复经历
- 数字化经历
- 情绪主调
- 适合年龄
## 7. 数据校验规则
## 7.1 文物校验
- `name` 必填。
- `category` 必须在枚举范围内。
- `unified_map_id` 必须唯一。
- `current_status` 必须在枚举范围内。
- 发布时至少需要一条当前有效位置或明确标记为未知位置。
## 7.2 位置校验
- `location_type = domestic` 时优先要求 `institution_id`
- `precision = exact_room` 时需要 `room_name``floor_plan_ref`
- 公开坐标必须符合经纬度范围。
- 存在敏感位置时,前台不得返回 `precise_location`
- `valid_until` 小于当前时间时不作为当前有效位置。
## 7.3 标签校验
- 系统标签不可随意删除。
- 禁用标签不允许新增绑定。
- AI 推荐标签必须人工确认后才能公开展示。
## 8. 测试要点
- migration 可从空库完整执行。
- PostGIS 扩展可启用。
- 空间索引创建成功。
- 1000 件文物点位范围查询响应时间符合要求。
- 敏感坐标在游客权限下不可见。
- 机构用户无法访问其他机构文物。
- 文物标签绑定、解绑和筛选结果正确。
+657
View File
@@ -0,0 +1,657 @@
# 中华文明全图鉴——API 设计文档
## 1. API 设计目标
本文档定义 PC Web 地图站 MVP、管理后台和后端服务之间的 REST API 契约。第一阶段优先覆盖:登录鉴权、机构、文物、位置、标签、地图点位、数字资产和操作日志。
## 2. 通用约定
## 2.1 Base URL
```txt
/api/v1
```
## 2.2 响应格式
成功:
```json
{
"success": true,
"data": {},
"request_id": "req_20260612_xxx"
}
```
失败:
```json
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "字段校验失败",
"details": []
},
"request_id": "req_20260612_xxx"
}
```
## 2.3 分页格式
请求参数:
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---:|---|
| page | number | 1 | 页码 |
| page_size | number | 20 | 每页数量,最大 100 |
响应:
```json
{
"items": [],
"pagination": {
"page": 1,
"page_size": 20,
"total": 100,
"total_pages": 5
}
}
```
## 2.4 鉴权方式
后台和需要登录的接口使用:
```txt
Authorization: Bearer <access_token>
```
游客地图接口可匿名访问,但会根据匿名权限隐藏敏感坐标。
## 2.5 常用错误码
| code | HTTP 状态 | 说明 |
|---|---:|---|
| UNAUTHORIZED | 401 | 未登录或 token 无效 |
| FORBIDDEN | 403 | 无权限 |
| NOT_FOUND | 404 | 资源不存在 |
| VALIDATION_ERROR | 422 | 参数校验失败 |
| CONFLICT | 409 | 数据冲突 |
| INTERNAL_ERROR | 500 | 服务异常 |
---
# 3. Auth API
## 3.1 登录
```http
POST /api/v1/auth/login
```
请求:
```json
{
"username": "admin",
"password": "password"
}
```
响应:
```json
{
"access_token": "jwt-token",
"user": {
"id": "uuid",
"username": "admin",
"nickname": "管理员",
"user_type": "admin",
"institution_id": null,
"roles": ["admin"]
}
}
```
## 3.2 当前用户
```http
GET /api/v1/auth/me
```
## 3.3 退出登录
```http
POST /api/v1/auth/logout
```
---
# 4. Map API
## 4.1 地图顶部统计
```http
GET /api/v1/map/summary
```
查询参数:
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| view | string | 否 | all、at_home、away、south_migration |
| category | string | 否 | 门类 |
| dynasty | string | 否 | 年代 |
| institution_id | uuid | 否 | 机构 |
| tags | string | 否 | 标签 code,逗号分隔 |
响应:
```json
{
"total_artifacts": 1000,
"domestic_count": 920,
"overseas_count": 80,
"on_display_count": 600,
"in_storage_count": 300,
"loaned_count": 20,
"unknown_location_count": 80
}
```
## 4.2 地图点位查询
```http
GET /api/v1/map/points
```
查询参数:
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| bbox | string | 是 | `minLng,minLat,maxLng,maxLat` |
| zoom | number | 是 | 地图缩放级别 |
| view | string | 否 | all、at_home、away、south_migration |
| category | string | 否 | 门类 |
| dynasty | string | 否 | 年代 |
| level | string | 否 | 级别 |
| display_status | string | 否 | 展出状态 |
| tags | string | 否 | 标签 code,逗号分隔 |
响应:
```json
{
"points": [
{
"id": "uuid",
"type": "artifact",
"artifact_id": "uuid",
"name": "千里江山图",
"category": "painting_calligraphy",
"dynasty": "北宋",
"level": "level_1",
"current_status": "at_home",
"display_status": "on_display",
"precision": "exact_building",
"coordinates": [116.397, 39.916],
"story_hook": "18 岁天才少年留下的唯一作品",
"persona_quote": "我只画了一次,但你们看了一千年。",
"institution": {
"id": "uuid",
"name": "故宫博物院"
}
}
],
"clusters": [
{
"id": "cluster_1",
"type": "cluster",
"count": 28,
"coordinates": [116.397, 39.916]
}
]
}
```
## 4.3 附近文物
```http
GET /api/v1/map/nearby
```
查询参数:
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| lng | number | 是 | 经度 |
| lat | number | 是 | 纬度 |
| radius_km | number | 否 | 半径,默认 10 |
| limit | number | 否 | 数量,默认 20 |
---
# 5. Institution API
## 5.1 机构列表
```http
GET /api/v1/institutions
```
查询参数:
| 参数 | 类型 | 说明 |
|---|---|---|
| keyword | string | 名称关键词 |
| country | string | 国家 |
| province | string | 省份 |
| city | string | 城市 |
| publish_status | string | 发布状态 |
| page | number | 页码 |
| page_size | number | 每页数量 |
## 5.2 机构详情
```http
GET /api/v1/institutions/{id}
```
## 5.3 创建机构
```http
POST /api/v1/institutions
```
权限:管理员。
请求:
```json
{
"name": "故宫博物院",
"short_name": "故宫",
"institution_type": "museum",
"country": "中国",
"province": "北京",
"city": "北京",
"address": "北京市东城区景山前街4号",
"location": {
"lng": 116.397,
"lat": 39.916
},
"official_website": "https://www.dpm.org.cn",
"description": "明清两代皇家宫殿博物馆"
}
```
## 5.4 编辑机构
```http
PATCH /api/v1/institutions/{id}
```
## 5.5 启用/禁用机构
```http
PATCH /api/v1/institutions/{id}/status
```
## 5.6 机构文物列表
```http
GET /api/v1/institutions/{id}/artifacts
```
---
# 6. Artifact API
## 6.1 文物列表
```http
GET /api/v1/artifacts
```
查询参数:
| 参数 | 类型 | 说明 |
|---|---|---|
| keyword | string | 名称关键词 |
| category | string | 门类 |
| dynasty | string | 年代 |
| level | string | 级别 |
| current_status | string | 当前状态 |
| institution_id | uuid | 机构 ID |
| tags | string | 标签 code,逗号分隔 |
| publish_status | string | 发布状态 |
| page | number | 页码 |
| page_size | number | 每页数量 |
## 6.2 文物详情
```http
GET /api/v1/artifacts/{id}
```
响应:
```json
{
"id": "uuid",
"unified_map_id": "CN-2026-001234",
"name": "千里江山图",
"category": "painting_calligraphy",
"dynasty": "北宋",
"level": "level_1",
"material": "绢本设色",
"dimensions": "纵51.5厘米,横1191.5厘米",
"current_status": "at_home",
"summary": "北宋青绿山水长卷。",
"story_hook": "18 岁天才少年留下的唯一作品",
"persona_quote": "我只画了一次,但你们看了一千年。",
"current_location": {},
"tags": [],
"assets": []
}
```
## 6.3 创建文物
```http
POST /api/v1/artifacts
```
权限:管理员、机构用户。
请求:
```json
{
"name": "千里江山图",
"category": "painting_calligraphy",
"dynasty": "北宋",
"level": "level_1",
"material": "绢本设色",
"dimensions": "纵51.5厘米,横1191.5厘米",
"current_status": "at_home",
"home_institution_id": "uuid",
"summary": "北宋青绿山水长卷。",
"story_hook": "18 岁天才少年留下的唯一作品",
"persona_quote": "我只画了一次,但你们看了一千年。"
}
```
## 6.4 编辑文物
```http
PATCH /api/v1/artifacts/{id}
```
## 6.5 发布文物
```http
POST /api/v1/artifacts/{id}/publish
```
## 6.6 撤回文物
```http
POST /api/v1/artifacts/{id}/unpublish
```
## 6.7 归档文物
```http
POST /api/v1/artifacts/{id}/archive
```
---
# 7. Location API
## 7.1 文物位置列表
```http
GET /api/v1/artifacts/{artifact_id}/locations
```
## 7.2 新增文物位置
```http
POST /api/v1/artifacts/{artifact_id}/locations
```
请求:
```json
{
"location_type": "domestic",
"institution_id": "uuid",
"precise_location": {
"lng": 116.397,
"lat": 39.916
},
"public_location": {
"lng": 116.397,
"lat": 39.916
},
"precision": "exact_building",
"room_name": "武英殿",
"cabinet_no": "A-01",
"display_status": "on_display",
"source_type": "institution_feed",
"source_description": "机构直供"
}
```
## 7.3 编辑位置
```http
PATCH /api/v1/locations/{id}
```
## 7.4 设置当前位置
```http
POST /api/v1/locations/{id}/set-current
```
---
# 8. Tag API
## 8.1 标签分类列表
```http
GET /api/v1/tag-categories
```
## 8.2 标签列表
```http
GET /api/v1/tags
```
查询参数:
| 参数 | 类型 | 说明 |
|---|---|---|
| category_id | uuid | 分类 ID |
| keyword | string | 标签名 |
| is_active | boolean | 是否启用 |
## 8.3 新增标签
```http
POST /api/v1/tags
```
## 8.4 编辑标签
```http
PATCH /api/v1/tags/{id}
```
## 8.5 启用/禁用标签
```http
PATCH /api/v1/tags/{id}/status
```
## 8.6 绑定文物标签
```http
POST /api/v1/artifacts/{artifact_id}/tags
```
请求:
```json
{
"tag_id": "uuid",
"value_text": "南迁北归",
"source_type": "manual_entry"
}
```
## 8.7 解绑文物标签
```http
DELETE /api/v1/artifact-tags/{id}
```
---
# 9. Asset API
## 9.1 获取上传签名
```http
POST /api/v1/assets/upload-token
```
请求:
```json
{
"filename": "artifact.jpg",
"mime_type": "image/jpeg",
"size_bytes": 1024000
}
```
## 9.2 创建数字资产记录
```http
POST /api/v1/assets
```
请求:
```json
{
"artifact_id": "uuid",
"asset_type": "image",
"title": "文物主图",
"url": "https://oss.example.com/artifact.jpg",
"thumbnail_url": "https://oss.example.com/artifact-thumb.jpg",
"mime_type": "image/jpeg",
"size_bytes": 1024000,
"copyright_owner": "故宫博物院",
"license_scope": "平台展示"
}
```
## 9.3 数字资产列表
```http
GET /api/v1/assets
```
## 9.4 删除数字资产
```http
DELETE /api/v1/assets/{id}
```
---
# 10. Operation Log API
## 10.1 操作日志列表
```http
GET /api/v1/operation-logs
```
查询参数:
| 参数 | 类型 | 说明 |
|---|---|---|
| operator_id | uuid | 操作人 |
| action | string | 操作类型 |
| target_type | string | 目标类型 |
| target_id | uuid | 目标 ID |
| start_time | string | 开始时间 |
| end_time | string | 结束时间 |
## 10.2 操作日志详情
```http
GET /api/v1/operation-logs/{id}
```
---
# 11. Health API
## 11.1 服务健康检查
```http
GET /api/v1/health
```
响应:
```json
{
"status": "ok",
"time": "2026-06-12T15:20:00.000Z",
"dependencies": {
"database": "ok",
"redis": "ok"
}
}
```
---
# 12. 权限矩阵
| API 模块 | 游客 | 注册用户 | 机构用户 | 管理员 |
|---|---|---|---|---|
| Map 读取 | 允许 | 允许 | 允许 | 允许 |
| Artifact 读取公开数据 | 允许 | 允许 | 允许 | 允许 |
| Artifact 写入 | 不允许 | 不允许 | 本机构 | 全部 |
| Institution 读取 | 允许 | 允许 | 允许 | 允许 |
| Institution 写入 | 不允许 | 不允许 | 本机构部分字段 | 全部 |
| Location 写入 | 不允许 | 不允许 | 本机构文物 | 全部 |
| Tag 读取 | 允许 | 允许 | 允许 | 允许 |
| Tag 管理 | 不允许 | 不允许 | 部分绑定 | 全部 |
| Asset 管理 | 不允许 | 不允许 | 本机构文物 | 全部 |
| Operation Log | 不允许 | 不允许 | 本机构 | 全部 |
# 13. API 测试要点
- 未登录访问后台写接口应返回 401。
- 机构用户访问其他机构文物写接口应返回 403。
- 地图点位接口在 bbox 缺失时应返回 422。
- 游客访问地图点位时不得返回 precise_location。
- 文物发布前缺少必要字段时应返回 422。
- 标签筛选、门类筛选、年代筛选应支持组合查询。
- 操作写接口必须产生 operation_logs 记录。
+88
View File
@@ -0,0 +1,88 @@
# 中华文明全图鉴——文物全图系统
PC Web 优先的文物全球位置地图平台。
## 项目结构
```
wenwumap/
apps/
web/ # PC Web 地图站(Next.js + React + TypeScript
admin/ # 管理后台(React + Vite + Ant Design
api/ # 后端 APINestJS + TypeScript
packages/
shared/ # 共享类型与枚举
db/ # 数据库 migration 与 seedPostgreSQL + PostGIS
infra/ # Docker Compose 本地基础设施
scripts/ # 工程校验脚本
docs/ # 设计、接口、测试文档(规划中)
```
## 快速开始
### 前置依赖
- Node.js >= 20
- pnpm >= 9
- Docker & Docker Compose(用于本地 PostgreSQL/PostGIS、Redis、MinIO
### 启动本地基础设施
```bash
cd infra
docker compose up -d
```
### 安装依赖
```bash
pnpm install
```
### 复制环境变量
```bash
cp .env.example .env
# 根据实际情况修改 .env
```
### 执行数据库 migration
```bash
pnpm --filter @wenwumap/db migrate
```
### 启动开发服务
```bash
# 后端 API
pnpm dev:api
# PC Web 地图站
pnpm dev:web
# 管理后台
pnpm dev:admin
```
### 结构校验
```bash
pnpm check-structure
```
## 文档
- [产品需求文档](./1-prd.md)
- [任务拆解](./2-task.md)
- [技术架构](./3-architecture.md)
- [数据模型](./4-data-model.md)
- [API 设计](./5-api.md)
- [阶段进度与测试记录](./11-progress-log.md)
## 设计原则
- **PC 优先**:第一阶段优先建设 PC Web 大屏地图体验
- **专业美观**:东方审美 + 现代地图产品质感
- **趣味可探索**:文物故事钩子 + 点位动效 + 路线叙事
- **可信优先**:数据来源、审核状态、位置精度均可追溯
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>中华文明全图鉴 · 管理后台</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+29
View File
@@ -0,0 +1,29 @@
{
"name": "@wenwumap/admin",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "vite --port 3001",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint src --ext .ts,.tsx",
"type-check": "tsc --noEmit"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.25.1",
"antd": "^5.19.3",
"@ant-design/icons": "^5.3.7",
"swr": "^2.2.5",
"@wenwumap/shared": "workspace:*"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"typescript": "^5.5.3",
"vite": "^5.3.5"
}
}
+48
View File
@@ -0,0 +1,48 @@
import { Routes, Route, Navigate } from "react-router-dom";
import { ConfigProvider, theme } from "antd";
import zhCN from "antd/locale/zh_CN";
import Login from "./pages/Login";
import AdminLayout from "./components/AdminLayout";
import Dashboard from "./pages/Dashboard";
import ArtifactList from "./pages/artifacts/ArtifactList";
import { getStoredUser } from "./store/auth";
function RequireAuth({ children }: { children: React.ReactNode }) {
const user = getStoredUser();
return user ? <>{children}</> : <Navigate to="/login" replace />;
}
export default function App() {
return (
<ConfigProvider
locale={zhCN}
theme={{
algorithm: theme.darkAlgorithm,
token: {
colorPrimary: "#c9a84c",
colorBgBase: "#14121e",
colorBgContainer: "#1a1825",
colorBgLayout: "#0f0d18",
fontFamily: "\"Noto Sans SC\", \"PingFang SC\", sans-serif",
},
}}
>
<Routes>
<Route path="/login" element={<Login />} />
<Route
path="/"
element={
<RequireAuth>
<AdminLayout />
</RequireAuth>
}
>
<Route index element={<Navigate to="/dashboard" replace />} />
<Route path="dashboard" element={<Dashboard />} />
<Route path="artifacts" element={<ArtifactList />} />
</Route>
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes>
</ConfigProvider>
);
}
+31
View File
@@ -0,0 +1,31 @@
const BASE_URL = import.meta.env.VITE_API_URL ?? "http://localhost:3002";
function getToken() {
return localStorage.getItem("access_token");
}
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const token = getToken();
const res = await fetch(`${BASE_URL}${path}`, {
...options,
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(options.headers ?? {}),
},
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error((err as { message?: string }).message ?? `HTTP ${res.status}`);
}
return res.json() as Promise<T>;
}
export const api = {
get: <T>(path: string) => request<T>(path),
post: <T>(path: string, body: unknown) =>
request<T>(path, { method: "POST", body: JSON.stringify(body) }),
patch: <T>(path: string, body: unknown) =>
request<T>(path, { method: "PATCH", body: JSON.stringify(body) }),
delete: <T>(path: string) => request<T>(path, { method: "DELETE" }),
};
+124
View File
@@ -0,0 +1,124 @@
import { useState } from "react";
import { Layout, Menu, Avatar, Dropdown, theme, Typography } from "antd";
import {
DashboardOutlined,
PicLeftOutlined,
BankOutlined,
TagsOutlined,
LogoutOutlined,
UserOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
} from "@ant-design/icons";
import { Outlet, useNavigate, useLocation } from "react-router-dom";
import { useAuth } from "../store/auth";
const { Header, Sider, Content } = Layout;
const NAV_ITEMS = [
{ key: "/dashboard", icon: <DashboardOutlined />, label: "概览" },
{ key: "/artifacts", icon: <PicLeftOutlined />, label: "文物管理" },
{ key: "/institutions", icon: <BankOutlined />, label: "机构管理" },
{ key: "/tags", icon: <TagsOutlined />, label: "标签管理" },
];
export default function AdminLayout() {
const [collapsed, setCollapsed] = useState(false);
const navigate = useNavigate();
const location = useLocation();
const { user, logout } = useAuth();
const { token } = theme.useToken();
const userMenuItems = [
{
key: "logout",
icon: <LogoutOutlined />,
label: "退出登录",
onClick: () => {
logout();
navigate("/login");
},
},
];
return (
<Layout style={{ minHeight: "100vh" }}>
<Sider
collapsible
collapsed={collapsed}
onCollapse={setCollapsed}
trigger={null}
style={{
background: token.colorBgContainer,
borderRight: `1px solid ${token.colorBorderSecondary}`,
}}
>
<div
style={{
height: 56,
display: "flex",
alignItems: "center",
justifyContent: collapsed ? "center" : "flex-start",
padding: collapsed ? 0 : "0 20px",
borderBottom: `1px solid ${token.colorBorderSecondary}`,
}}
>
{collapsed ? (
<span style={{ fontSize: 18 }}>🏛</span>
) : (
<Typography.Text strong style={{ color: token.colorPrimary, fontSize: 13, letterSpacing: 1 }}>
</Typography.Text>
)}
</div>
<Menu
mode="inline"
selectedKeys={[location.pathname]}
items={NAV_ITEMS}
onClick={({ key }) => navigate(key)}
style={{ border: "none", marginTop: 8 }}
/>
</Sider>
<Layout>
<Header
style={{
background: token.colorBgContainer,
borderBottom: `1px solid ${token.colorBorderSecondary}`,
height: 56,
padding: "0 20px",
display: "flex",
alignItems: "center",
gap: 16,
}}
>
<button
onClick={() => setCollapsed(!collapsed)}
style={{
background: "none",
border: "none",
cursor: "pointer",
color: token.colorText,
fontSize: 16,
}}
>
{collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
</button>
<div style={{ flex: 1 }} />
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight">
<div style={{ display: "flex", alignItems: "center", gap: 8, cursor: "pointer" }}>
<Avatar size={28} icon={<UserOutlined />} style={{ background: token.colorPrimary }} />
<Typography.Text style={{ fontSize: 13 }}>
{user?.nickname ?? user?.username}
</Typography.Text>
</div>
</Dropdown>
</Header>
<Content style={{ padding: 24, background: token.colorBgLayout }}>
<Outlet />
</Content>
</Layout>
</Layout>
);
}
+10
View File
@@ -0,0 +1,10 @@
/* 全站隐藏滚动条但仍可滚动 */
* {
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* 旧版 Edge / IE */
}
*::-webkit-scrollbar {
width: 0;
height: 0;
display: none; /* Chrome / Safari / 新版 Edge */
}
+16
View File
@@ -0,0 +1,16 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
import "./index.css";
const root = document.getElementById("root");
if (!root) throw new Error("找不到 #root 挂载点");
ReactDOM.createRoot(root).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
);
+67
View File
@@ -0,0 +1,67 @@
import { useEffect, useState } from "react";
import { Card, Statistic, Row, Col, Typography, Spin } from "antd";
import { PicLeftOutlined, BankOutlined, EnvironmentOutlined } from "@ant-design/icons";
import { api } from "../api/client";
interface Stats {
total_artifacts: number;
total_institutions: number;
total_locations: number;
}
export default function Dashboard() {
const [stats, setStats] = useState<Stats | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
api
.get<Stats>("/api/v1/map/stats")
.then(setStats)
.catch(() => null)
.finally(() => setLoading(false));
}, []);
return (
<div>
<Typography.Title level={4} style={{ marginBottom: 24 }}>
</Typography.Title>
{loading ? (
<Spin />
) : (
<Row gutter={16}>
<Col span={8}>
<Card>
<Statistic
title="已发布文物"
value={stats?.total_artifacts ?? 0}
prefix={<PicLeftOutlined />}
valueStyle={{ color: "#c9a84c" }}
/>
</Card>
</Col>
<Col span={8}>
<Card>
<Statistic
title="合作机构"
value={stats?.total_institutions ?? 0}
prefix={<BankOutlined />}
valueStyle={{ color: "#c9a84c" }}
/>
</Card>
</Col>
<Col span={8}>
<Card>
<Statistic
title="地图点位"
value={stats?.total_locations ?? 0}
prefix={<EnvironmentOutlined />}
valueStyle={{ color: "#c9a84c" }}
/>
</Card>
</Col>
</Row>
)}
</div>
);
}
+103
View File
@@ -0,0 +1,103 @@
import { useState } from "react";
import { Form, Input, Button, message, Typography } from "antd";
import { UserOutlined, LockOutlined } from "@ant-design/icons";
import { useNavigate } from "react-router-dom";
import { api } from "../api/client";
import { saveAuth, AuthUser } from "../store/auth";
interface LoginResponse {
access_token: string;
user: AuthUser;
}
export default function Login() {
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
const [form] = Form.useForm();
const onFinish = async (values: { username: string; password: string }) => {
setLoading(true);
try {
const res = await api.post<LoginResponse>("/api/v1/auth/login", values);
saveAuth(res.access_token, res.user);
message.success(`欢迎回来,${res.user.nickname ?? res.user.username}`);
navigate("/dashboard");
} catch (err) {
message.error((err as Error).message || "登录失败,请检查账号密码");
} finally {
setLoading(false);
}
};
return (
<div
style={{
minHeight: "100vh",
background: "linear-gradient(135deg, #0f0c1a 0%, #1a1225 50%, #0d1117 100%)",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<div
style={{
width: 380,
background: "rgba(255,255,255,0.04)",
border: "1px solid rgba(201,168,76,0.2)",
borderRadius: 12,
padding: "40px 36px",
boxShadow: "0 8px 40px rgba(0,0,0,0.5)",
}}
>
<div style={{ textAlign: "center", marginBottom: 32 }}>
<div style={{ fontSize: 28, marginBottom: 8 }}>🏛</div>
<Typography.Title
level={3}
style={{ color: "#c9a84c", margin: 0, fontWeight: 700, letterSpacing: 2 }}
>
</Typography.Title>
<Typography.Text style={{ color: "rgba(255,255,255,0.4)", fontSize: 13 }}>
</Typography.Text>
</div>
<Form form={form} onFinish={onFinish} size="large" requiredMark={false}>
<Form.Item
name="username"
rules={[{ required: true, message: "请输入邮箱或用户名" }]}
>
<Input
prefix={<UserOutlined style={{ color: "rgba(255,255,255,0.3)" }} />}
placeholder="邮箱 / 用户名"
autoComplete="username"
/>
</Form.Item>
<Form.Item
name="password"
rules={[{ required: true, message: "请输入密码" }]}
>
<Input.Password
prefix={<LockOutlined style={{ color: "rgba(255,255,255,0.3)" }} />}
placeholder="密码"
autoComplete="current-password"
/>
</Form.Item>
<Form.Item style={{ marginBottom: 0 }}>
<Button
type="primary"
htmlType="submit"
block
loading={loading}
style={{ height: 44, fontWeight: 600, letterSpacing: 1 }}
>
</Button>
</Form.Item>
</Form>
</div>
</div>
);
}
@@ -0,0 +1,220 @@
import { useState, useEffect, useCallback } from "react";
import {
Table,
Input,
Select,
Space,
Tag,
Typography,
Button,
message,
Tooltip,
} from "antd";
import { SearchOutlined, ReloadOutlined } from "@ant-design/icons";
import type { ColumnsType } from "antd/es/table";
import { api } from "../../api/client";
interface Artifact {
id: string;
name: string;
category: string;
level: string;
dynasty: string;
story_hook: string;
current_institution: string;
}
interface PageMeta {
total: number;
page: number;
limit: number;
total_pages: number;
}
interface ArtifactListResponse {
data: Artifact[];
meta: PageMeta;
}
const CATEGORY_LABELS: Record<string, string> = {
bronze: "青铜器",
painting_calligraphy: "书画",
porcelain: "陶瓷",
jade: "玉器",
gold_silver: "金银器",
lacquer: "漆木器",
textile: "织绣",
stone_carving: "石刻造像",
wood_carving: "木雕",
dunhuang: "敦煌遗珍",
ancient_book: "古籍文献",
other: "其他",
};
const LEVEL_COLORS: Record<string, string> = {
level_1: "gold",
level_2: "blue",
level_3: "default",
unknown: "default",
};
const LEVEL_LABELS: Record<string, string> = {
level_1: "国家一级",
level_2: "国家二级",
level_3: "国家三级",
unknown: "未定级",
};
export default function ArtifactList() {
const [data, setData] = useState<Artifact[]>([]);
const [meta, setMeta] = useState<PageMeta>({ total: 0, page: 1, limit: 20, total_pages: 1 });
const [loading, setLoading] = useState(false);
const [q, setQ] = useState("");
const [category, setCategory] = useState<string | undefined>();
const [dynasty, setDynasty] = useState<string | undefined>();
const fetchData = useCallback(
async (page = 1) => {
setLoading(true);
try {
const params = new URLSearchParams({ page: String(page), limit: "20" });
if (q) params.set("q", q);
if (category) params.set("category", category);
if (dynasty) params.set("dynasty", dynasty);
const res = await api.get<ArtifactListResponse>(
`/api/v1/artifacts?${params}`
);
setData(res.data);
setMeta(res.meta);
} catch (err) {
message.error((err as Error).message);
} finally {
setLoading(false);
}
},
[q, category, dynasty]
);
useEffect(() => {
fetchData(1);
}, [fetchData]);
const columns: ColumnsType<Artifact> = [
{
title: "文物名称",
dataIndex: "name",
key: "name",
render: (text: string, record) => (
<div>
<Typography.Text strong>{text}</Typography.Text>
{record.story_hook && (
<Tooltip title={record.story_hook}>
<Typography.Text
type="secondary"
style={{ display: "block", fontSize: 12, maxWidth: 200 }}
ellipsis
>
{record.story_hook}
</Typography.Text>
</Tooltip>
)}
</div>
),
},
{
title: "门类",
dataIndex: "category",
key: "category",
width: 80,
render: (cat: string) => CATEGORY_LABELS[cat] ?? cat,
},
{
title: "级别",
dataIndex: "level",
key: "level",
width: 100,
render: (lv: string) => (
<Tag color={LEVEL_COLORS[lv] ?? "default"}>{LEVEL_LABELS[lv] ?? lv}</Tag>
),
},
{
title: "朝代",
dataIndex: "dynasty",
key: "dynasty",
width: 90,
},
{
title: "所在机构",
dataIndex: "current_institution",
key: "current_institution",
render: (v: string) => v ?? <Typography.Text type="secondary"></Typography.Text>,
},
];
return (
<div>
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 16 }}>
<Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title>
</div>
<Space style={{ marginBottom: 16 }} wrap>
<Input
placeholder="搜索文物名称"
prefix={<SearchOutlined />}
value={q}
onChange={(e) => setQ(e.target.value)}
onPressEnter={() => fetchData(1)}
style={{ width: 200 }}
allowClear
onClear={() => setQ("")}
/>
<Select
placeholder="门类"
style={{ width: 100 }}
allowClear
value={category}
onChange={setCategory}
options={Object.entries(CATEGORY_LABELS).map(([value, label]) => ({
value,
label,
}))}
/>
<Select
placeholder="朝代"
style={{ width: 140 }}
allowClear
value={dynasty}
onChange={setDynasty}
options={["先秦", "秦汉", "魏晋南北朝", "隋唐", "宋元", "明清"].map((d) => ({
value: d,
label: d,
}))}
/>
<Button
icon={<ReloadOutlined />}
onClick={() => fetchData(1)}
loading={loading}
>
</Button>
</Space>
<Table
rowKey="id"
columns={columns}
dataSource={data}
loading={loading}
pagination={{
total: meta.total,
current: meta.page,
pageSize: meta.limit,
showTotal: (t) => `${t}`,
onChange: (page) => fetchData(page),
showSizeChanger: false,
}}
size="middle"
/>
</div>
);
}
+47
View File
@@ -0,0 +1,47 @@
import { useState, useEffect } from "react";
export interface AuthUser {
id: string;
username: string;
nickname: string;
role: string;
}
const USER_KEY = "admin_user";
const TOKEN_KEY = "access_token";
export function saveAuth(token: string, user: AuthUser) {
localStorage.setItem(TOKEN_KEY, token);
localStorage.setItem(USER_KEY, JSON.stringify(user));
}
export function clearAuth() {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_KEY);
}
export function getStoredUser(): AuthUser | null {
try {
const s = localStorage.getItem(USER_KEY);
return s ? (JSON.parse(s) as AuthUser) : null;
} catch {
return null;
}
}
export function useAuth() {
const [user, setUser] = useState<AuthUser | null>(getStoredUser);
useEffect(() => {
const handler = () => setUser(getStoredUser());
window.addEventListener("storage", handler);
return () => window.removeEventListener("storage", handler);
}, []);
const logout = () => {
clearAuth();
setUser(null);
};
return { user, setUser, logout };
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"paths": {
"@wenwumap/shared": ["../../packages/shared/src/index.ts"]
}
},
"include": ["src"],
"exclude": ["node_modules"]
}
+21
View File
@@ -0,0 +1,21 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import path from "path";
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@wenwumap/shared": path.resolve(__dirname, "../../packages/shared/src/index.ts"),
},
},
server: {
port: 3001,
proxy: {
"/api": {
target: "http://localhost:3002",
changeOrigin: true,
},
},
},
});
+14
View File
@@ -0,0 +1,14 @@
PORT=3002
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/wenwumap
REDIS_URL=redis://localhost:6379
JWT_SECRET=change-me-in-production
JWT_EXPIRES_IN=7d
OSS_ENDPOINT=http://localhost:9000
OSS_ACCESS_KEY=minioadmin
OSS_SECRET_KEY=minioadmin
OSS_BUCKET=wenwumap
# AI 对话(通义千问 DashScopeOpenAI 兼容模式)
AI_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
AI_API_KEY=your-dashscope-api-key
AI_MODEL=qwen-plus
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}
+44
View File
@@ -0,0 +1,44 @@
{
"name": "@wenwumap/api",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "nest start --watch",
"build": "nest build",
"start": "node dist/main",
"lint": "eslint src --ext .ts",
"type-check": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@nestjs/common": "^10.3.10",
"@nestjs/config": "^3.2.3",
"@nestjs/core": "^10.3.10",
"@nestjs/jwt": "^10.2.0",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.3.10",
"@nestjs/swagger": "^7.4.0",
"@nestjs/terminus": "^10.2.3",
"bcryptjs": "^2.4.3",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0",
"pg": "^8.12.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@nestjs/cli": "^10.4.5",
"@nestjs/testing": "^10.3.10",
"@types/bcryptjs": "^2.4.6",
"@types/express": "^4.17.21",
"@types/node": "^22.0.0",
"@types/passport-jwt": "^4.0.1",
"@types/passport-local": "^1.0.38",
"@types/pg": "^8.11.6",
"typescript": "^5.5.3",
"vitest": "^2.1.9"
}
}
+42
View File
@@ -0,0 +1,42 @@
import { Body, Controller, Post, Res, UseGuards } from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { Response } from "express";
import { AiService } from "./ai.service";
import { ChatDto } from "./dto/chat.dto";
import { RateLimitGuard } from "../common/rate-limit.guard";
@ApiTags("ai")
@Controller("ai")
@UseGuards(RateLimitGuard)
export class AiController {
constructor(private readonly ai: AiService) {}
@Post("chat")
@ApiOperation({ summary: "与文物进行 AI 角色对话(SSE 流式输出)" })
async chat(@Body() dto: ChatDto, @Res() res: Response): Promise<void> {
res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
res.setHeader("Cache-Control", "no-cache, no-transform");
res.setHeader("Connection", "keep-alive");
res.setHeader("X-Accel-Buffering", "no");
res.flushHeaders?.();
try {
await this.ai.streamChat(dto, (token) => {
res.write(`data: ${JSON.stringify({ t: token })}\n\n`);
});
res.write("data: [DONE]\n\n");
} catch (err) {
const message = err instanceof Error ? err.message : "AI 服务异常";
res.write(`data: ${JSON.stringify({ error: message })}\n\n`);
} finally {
res.end();
}
}
@Post("suggestions")
@ApiOperation({ summary: "生成 4 个下一步追问建议" })
async suggestions(@Body() dto: ChatDto): Promise<{ suggestions: string[] }> {
const suggestions = await this.ai.getSuggestions(dto);
return { suggestions };
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Module } from "@nestjs/common";
import { AiService } from "./ai.service";
import { AiController } from "./ai.controller";
@Module({
providers: [AiService],
controllers: [AiController],
})
export class AiModule {}
+284
View File
@@ -0,0 +1,284 @@
import { Injectable, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { DatabaseService } from "../database/database.service";
import { ChatDto } from "./dto/chat.dto";
import { parseSuggestions } from "./suggestions.util";
interface ArtifactContext {
name: string;
category: string;
dynasty: string | null;
level: string | null;
material: string | null;
summary: string | null;
story_hook: string | null;
persona_quote: string | null;
current_status: string | null;
institution_name: string | null;
city: string | null;
province: string | null;
country: string | null;
location_type: string | null;
}
const CATEGORY_LABELS: Record<string, string> = {
bronze: "青铜器",
painting_calligraphy: "书画",
porcelain: "陶瓷",
jade: "玉器",
gold_silver: "金银器",
lacquer: "漆木器",
textile: "织绣",
stone_carving: "石刻造像",
wood_carving: "木雕",
dunhuang: "敦煌遗珍",
ancient_book: "古籍文献",
other: "其他",
};
const LEVEL_LABELS: Record<string, string> = {
level_1: "国家一级文物",
level_2: "国家二级文物",
level_3: "国家三级文物",
general: "一般文物",
unknown: "未定级",
};
export type ChatPersona = "artifact" | "guide" | "scholar" | "migration" | "repatriation" | "youth";
@Injectable()
export class AiService {
private readonly logger = new Logger(AiService.name);
constructor(
private readonly config: ConfigService,
private readonly db: DatabaseService
) {}
private async getArtifactContext(artifactId: string): Promise<ArtifactContext | null> {
const { rows } = await this.db.query<ArtifactContext>(
`SELECT a.name, a.category, a.dynasty, a.level, a.material, a.summary,
a.story_hook, a.persona_quote, a.current_status,
i.name AS institution_name, i.city, i.province, i.country,
al.location_type
FROM artifacts a
LEFT JOIN artifact_locations al ON a.id = al.artifact_id AND al.is_current = true
LEFT JOIN institutions i ON al.institution_id = i.id
WHERE a.id = $1
LIMIT 1`,
[artifactId]
);
return rows[0] ?? null;
}
private buildSystemPrompt(ctx: ArtifactContext, persona: ChatPersona): string {
const category = CATEGORY_LABELS[ctx.category] ?? ctx.category;
const level = ctx.level ? LEVEL_LABELS[ctx.level] ?? ctx.level : "未定级";
const place = [ctx.country, ctx.province, ctx.city].filter(Boolean).join(" · ") || "未知";
const overseas = ctx.location_type === "overseas";
const facts = [
`名称:${ctx.name}`,
`门类:${category}`,
ctx.dynasty ? `年代:${ctx.dynasty}` : null,
`级别:${level}`,
ctx.material ? `材质:${ctx.material}` : null,
ctx.institution_name ? `收藏机构:${ctx.institution_name}` : null,
`所在地:${place}${overseas ? "(流失海外)" : ""}`,
ctx.summary ? `简介:${ctx.summary}` : null,
ctx.story_hook ? `故事钩子:${ctx.story_hook}` : null,
ctx.persona_quote ? `拟人化自白:「${ctx.persona_quote}` : null,
]
.filter(Boolean)
.join("\n");
const common = `你掌握以下这件文物的真实资料,回答必须严格基于这些事实,不可编造未提供的史实、出土地、尺寸或价格等具体数据;不确定时要坦诚说明。
【文物档案】
${facts}
输出要求:
- 使用 Markdown 排版(适当用标题、**加粗**、列表、引用 > 来组织内容),让回答精美易读。
- 回答用中文,语言生动、有人文温度,篇幅适中(一般 3 段以内,除非用户要求展开)。
- 涉及不确定或学界有争议的内容时明确标注。`;
switch (persona) {
case "artifact":
return `你现在就是这件文物本身,请以第一人称「我」与观众对话,用拟人化、有性格、略带诗意与幽默的口吻讲述自己的故事,仿佛跨越千年与今人攀谈。可参考下方的「拟人化自白」基调。
${common}
- 始终以文物第一人称「我」叙述,不要跳出角色。${overseas ? "\n- 你目前流落海外,可以在合适时流露一丝乡愁,但保持克制与尊严。" : ""}`;
case "scholar":
return `你是一位严谨的文物与历史学者,以专业、客观、考据的方式为提问者讲解这件文物,必要时点明研究背景与学术意义。
${common}`;
case "migration":
return `你现在就是这件文物本身,请以第一人称「我」讲述"文物南迁"中的亲历——1933 至 1947 年间,为躲避战火,无数文物被装箱辗转上海、南京、宝鸡、汉中、成都、峨眉、重庆等地,行程逾万里。请把自己代入这段颠沛流离又被普通文保人以生命守护的历史,语气深沉而有温度,突出"平凡人守护文明火种"的主题。
${common}
- 始终以文物第一人称「我」叙述;可描写木箱、车马、江轮、防空洞、护送者的细节。
- 不可虚构具体史料数字;不确定处用"据说/相传"等措辞。`;
case "repatriation":
return `你现在就是这件文物本身,请以第一人称「我」讲述"流失与回归"的历程——从离散海外、辗转飘零,到被国家与同胞接回故土的心路。语气饱含乡愁与归家的激动,呼应"国宝回归、文化自信"的主题。
${common}
- 始终以文物第一人称「我」叙述。${overseas ? "\n- 你目前仍流落海外,讲述时可表达对回家的期盼。" : "\n- 若你已回归,可讲述重返故土的喜悦与不易。"}`;
case "youth":
return `你是一位面向中小学生的"小小讲解员",用活泼、亲切、好懂的语言介绍这件文物,多用类比和提问,适当穿插一个小知识点或趣味问答,激发青少年对文物保护的兴趣。
${common}
- 语言浅显生动,避免生僻术语;可以在结尾抛出一个引导思考的小问题。`;
case "guide":
default:
return `你是博物馆里一位亲切的资深讲解员,面向普通观众,用通俗易懂又引人入胜的方式介绍这件文物,善于用类比和故事吸引兴趣。
${common}`;
}
}
/**
* 以流式方式与 DashScopeOpenAI 兼容)对话,逐 token 回调。
*/
async streamChat(dto: ChatDto, onToken: (token: string) => void): Promise<void> {
const apiKey = this.config.get<string>("AI_API_KEY");
const baseUrl =
this.config.get<string>("AI_BASE_URL") ??
"https://dashscope.aliyuncs.com/compatible-mode/v1";
const model = this.config.get<string>("AI_MODEL") ?? "qwen-plus";
if (!apiKey) {
throw new Error("AI 服务未配置:请在 .env 中设置 AI_API_KEY");
}
const ctx = await this.getArtifactContext(dto.artifactId);
if (!ctx) {
throw new Error("未找到该文物");
}
const systemPrompt = this.buildSystemPrompt(ctx, dto.persona ?? "artifact");
const payload = {
model,
stream: true,
temperature: 0.8,
messages: [
{ role: "system", content: systemPrompt },
...dto.messages.map((m) => ({ role: m.role, content: m.content })),
],
};
const resp = await fetch(`${baseUrl.replace(/\/$/, "")}/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(payload),
});
if (!resp.ok || !resp.body) {
const text = await resp.text().catch(() => "");
this.logger.error(`上游 AI 接口错误 ${resp.status}: ${text}`);
throw new Error(`AI 接口返回 ${resp.status}`);
}
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
// 解析 OpenAI 兼容的 SSE 流:每个事件以 \n\n 分隔,数据行以 "data: " 开头
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let sepIndex: number;
while ((sepIndex = buffer.indexOf("\n\n")) >= 0) {
const rawEvent = buffer.slice(0, sepIndex);
buffer = buffer.slice(sepIndex + 2);
for (const line of rawEvent.split("\n")) {
const trimmed = line.trim();
if (!trimmed.startsWith("data:")) continue;
const data = trimmed.slice(5).trim();
if (data === "[DONE]") return;
try {
const json = JSON.parse(data);
const token: string | undefined = json?.choices?.[0]?.delta?.content;
if (token) onToken(token);
} catch {
// 忽略无法解析的心跳/分片
}
}
}
}
}
/**
* 基于文物资料与已有对话,生成 4 个“下一步追问”建议(非流式)。
*/
async getSuggestions(dto: ChatDto): Promise<string[]> {
const apiKey = this.config.get<string>("AI_API_KEY");
if (!apiKey) throw new Error("AI 服务未配置:请在 .env 中设置 AI_API_KEY");
const ctx = await this.getArtifactContext(dto.artifactId);
if (!ctx) throw new Error("未找到该文物");
const category = CATEGORY_LABELS[ctx.category] ?? ctx.category;
const facts = [
`名称:${ctx.name}`,
`门类:${category}`,
ctx.dynasty ? `年代:${ctx.dynasty}` : null,
ctx.institution_name ? `收藏机构:${ctx.institution_name}` : null,
]
.filter(Boolean)
.join("");
const convo = dto.messages
.slice(-6)
.map((m) => `${m.role === "user" ? "观众" : "文物"}${m.content}`)
.join("\n");
const sys = `你在为一个文物科普对话生成"下一步追问"建议。请站在观众视角,结合文物资料与已有对话,提出 4 个简短(每个不超过 16 个字)、具体、能引发兴趣且彼此不重复的中文追问。
只输出一个 JSON 字符串数组,形如 ["问题1","问题2","问题3","问题4"],不要任何额外说明或编号。
【文物】${facts}`;
const userMsg = convo
? `已有对话:\n${convo}\n\n请生成 4 个自然的后续追问。`
: `请生成 4 个适合作为开场的追问。`;
const content = await this.chatComplete(
[
{ role: "system", content: sys },
{ role: "user", content: userMsg },
],
0.9
);
return parseSuggestions(content);
}
private async chatComplete(
messages: { role: string; content: string }[],
temperature = 0.7
): Promise<string> {
const apiKey = this.config.get<string>("AI_API_KEY");
const baseUrl =
this.config.get<string>("AI_BASE_URL") ??
"https://dashscope.aliyuncs.com/compatible-mode/v1";
const model = this.config.get<string>("AI_MODEL") ?? "qwen-plus";
const resp = await fetch(`${baseUrl.replace(/\/$/, "")}/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({ model, temperature, stream: false, messages }),
});
if (!resp.ok) {
const t = await resp.text().catch(() => "");
this.logger.error(`上游 AI 接口错误 ${resp.status}: ${t}`);
throw new Error(`AI 接口返回 ${resp.status}`);
}
const json = (await resp.json()) as {
choices?: { message?: { content?: string } }[];
};
return json?.choices?.[0]?.message?.content ?? "";
}
}
+35
View File
@@ -0,0 +1,35 @@
import { Type } from "class-transformer";
import {
ArrayMaxSize,
IsArray,
IsIn,
IsOptional,
IsString,
MaxLength,
ValidateNested,
} from "class-validator";
export class ChatMessageDto {
@IsIn(["user", "assistant"])
role!: "user" | "assistant";
@IsString()
@MaxLength(4000)
content!: string;
}
export class ChatDto {
@IsString()
artifactId!: string;
/** 角色设置:文物自述 / 博物馆讲解员 / 历史学者 / 南迁亲历 / 回归叙事 / 青少年讲解员 */
@IsOptional()
@IsIn(["artifact", "guide", "scholar", "migration", "repatriation", "youth"])
persona?: "artifact" | "guide" | "scholar" | "migration" | "repatriation" | "youth";
@IsArray()
@ArrayMaxSize(40)
@ValidateNested({ each: true })
@Type(() => ChatMessageDto)
messages!: ChatMessageDto[];
}
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { parseSuggestions } from "./suggestions.util";
describe("parseSuggestions", () => {
it("解析标准 JSON 数组", () => {
const out = parseSuggestions('["问题一","问题二","问题三","问题四"]');
expect(out).toEqual(["问题一", "问题二", "问题三", "问题四"]);
});
it("从夹带文字中提取 JSON 数组", () => {
const out = parseSuggestions('好的,建议如下:\n["A","B"]\n谢谢');
expect(out).toEqual(["A", "B"]);
});
it("最多返回 4 条", () => {
const out = parseSuggestions('["1","2","3","4","5","6"]');
expect(out).toHaveLength(4);
});
it("JSON 失败时按行解析并清理编号", () => {
const out = parseSuggestions("1. 它是谁\n2、它从哪来\n- 它去哪");
expect(out).toEqual(["它是谁", "它从哪来", "它去哪"]);
});
it("过滤非字符串与空白", () => {
const out = parseSuggestions('["有效", "", " "]');
expect(out).toEqual(["有效"]);
});
});
+33
View File
@@ -0,0 +1,33 @@
/**
* 从模型输出中解析“下一步追问”建议。
* 优先解析 JSON 数组,失败则按行解析并清理编号/符号,最多返回 4 条。
*/
export function parseSuggestions(text: string): string[] {
let items: string[] = [];
const match = text.match(/\[[\s\S]*\]/);
if (match) {
try {
const arr: unknown = JSON.parse(match[0]);
if (Array.isArray(arr)) {
items = arr.filter((x): x is string => typeof x === "string");
}
} catch {
/* fall through to line parsing */
}
}
if (items.length === 0) {
items = text
.split("\n")
.map((l) =>
l
.replace(/^[\s\-*0-9.、)"“”]+/, "")
.replace(/["“”]+$/, "")
.trim()
)
.filter(Boolean);
}
return items.map((s) => s.trim()).filter(Boolean).slice(0, 4);
}
+29
View File
@@ -0,0 +1,29 @@
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { TerminusModule } from "@nestjs/terminus";
import { DatabaseModule } from "./database/database.module";
import { AuthModule } from "./auth/auth.module";
import { MapModule } from "./map/map.module";
import { ArtifactsModule } from "./artifacts/artifacts.module";
import { InstitutionsModule } from "./institutions/institutions.module";
import { AiModule } from "./ai/ai.module";
import { AssetsModule } from "./assets/assets.module";
import { RoutesModule } from "./routes/routes.module";
import { HealthController } from "./health/health.controller";
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true, envFilePath: ["../../.env", ".env"] }),
TerminusModule,
DatabaseModule,
AuthModule,
MapModule,
ArtifactsModule,
InstitutionsModule,
AiModule,
AssetsModule,
RoutesModule,
],
controllers: [HealthController],
})
export class AppModule {}
@@ -0,0 +1,22 @@
import { Controller, Get, Param, Query } from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { ArtifactsService } from "./artifacts.service";
import { ArtifactQueryDto } from "./dto/artifact-query.dto";
@ApiTags("Artifacts")
@Controller("artifacts")
export class ArtifactsController {
constructor(private artifacts: ArtifactsService) {}
@Get()
@ApiOperation({ summary: "文物列表(支持搜索/筛选/分页)" })
findAll(@Query() query: ArtifactQueryDto) {
return this.artifacts.findAll(query);
}
@Get(":id")
@ApiOperation({ summary: "文物详情" })
findOne(@Param("id") id: string) {
return this.artifacts.findOne(id);
}
}
@@ -0,0 +1,9 @@
import { Module } from "@nestjs/common";
import { ArtifactsService } from "./artifacts.service";
import { ArtifactsController } from "./artifacts.controller";
@Module({
providers: [ArtifactsService],
controllers: [ArtifactsController],
})
export class ArtifactsModule {}
+117
View File
@@ -0,0 +1,117 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { DatabaseService } from "../database/database.service";
import { ArtifactQueryDto } from "./dto/artifact-query.dto";
@Injectable()
export class ArtifactsService {
constructor(private db: DatabaseService) {}
async findAll(query: ArtifactQueryDto) {
const page = query.page ?? 1;
const limit = query.limit ?? 20;
const offset = (page - 1) * limit;
const conditions: string[] = ["a.publish_status = 'published'"];
const params: unknown[] = [];
let idx = 1;
if (query.q) {
conditions.push(`(a.name ILIKE $${idx} OR a.summary ILIKE $${idx})`);
params.push(`%${query.q}%`);
idx++;
}
if (query.category) {
conditions.push(`a.category = $${idx++}`);
params.push(query.category);
}
if (query.level) {
conditions.push(`a.level = $${idx++}`);
params.push(query.level);
}
if (query.dynasty) {
conditions.push(`a.dynasty ILIKE $${idx++}`);
params.push(`%${query.dynasty}%`);
}
if (query.institution_id) {
conditions.push(
`EXISTS (SELECT 1 FROM artifact_locations al WHERE al.artifact_id = a.id AND al.institution_id = $${idx++} AND al.is_current = true)`
);
params.push(query.institution_id);
}
if (query.tag_ids) {
const ids = query.tag_ids.split(",").filter(Boolean);
if (ids.length) {
conditions.push(
`EXISTS (SELECT 1 FROM artifact_tags at2 WHERE at2.artifact_id = a.id AND at2.tag_id = ANY($${idx++}::uuid[]))`
);
params.push(ids);
}
}
const where = conditions.join(" AND ");
const countParams = [...params];
const countResult = await this.db.query<{ count: string }>(
`SELECT COUNT(*) as count FROM artifacts a WHERE ${where}`,
countParams
);
const total = parseInt(countResult.rows[0].count);
params.push(limit, offset);
const { rows } = await this.db.query<{
id: string;
name: string;
category: string;
level: string;
dynasty: string;
current_institution: string;
story_hook: string;
}>(
`SELECT a.id, a.name, a.category, a.level, a.dynasty,
a.story_hook,
i.name AS current_institution
FROM artifacts a
LEFT JOIN artifact_locations al ON a.id = al.artifact_id AND al.is_current = true
LEFT JOIN institutions i ON al.institution_id = i.id
WHERE ${where}
ORDER BY a.level DESC, a.created_at DESC
LIMIT $${idx++} OFFSET $${idx++}`,
params
);
return {
data: rows,
meta: { total, page, limit, total_pages: Math.ceil(total / limit) },
};
}
async findOne(id: string) {
const { rows } = await this.db.query<Record<string, unknown>>(
`SELECT a.*,
i.name AS current_institution_name,
i.id AS current_institution_id,
ST_X(al.public_location::geometry) AS lng,
ST_Y(al.public_location::geometry) AS lat,
al.location_type,
al.precision AS location_precision
FROM artifacts a
LEFT JOIN artifact_locations al ON a.id = al.artifact_id AND al.is_current = true
LEFT JOIN institutions i ON al.institution_id = i.id
WHERE a.id = $1 AND a.publish_status = 'published'`,
[id]
);
if (!rows.length) throw new NotFoundException("文物不存在");
const [artifact] = rows;
const tagResult = await this.db.query<{ id: string; name: string; category_name: string }>(
`SELECT t.id, t.name, tc.name AS category_name
FROM tags t
JOIN artifact_tags at2 ON t.id = at2.tag_id
JOIN tag_categories tc ON t.category_id = tc.id
WHERE at2.artifact_id = $1`,
[id]
);
return { ...artifact, tags: tagResult.rows };
}
}
@@ -0,0 +1,50 @@
import { IsInt, IsOptional, IsString, Max, Min } from "class-validator";
import { Type } from "class-transformer";
import { ApiPropertyOptional } from "@nestjs/swagger";
export class ArtifactQueryDto {
@ApiPropertyOptional({ description: "关键词搜索(名称/描述)" })
@IsOptional()
@IsString()
q?: string;
@ApiPropertyOptional({ description: "文物门类", example: "bronze" })
@IsOptional()
@IsString()
category?: string;
@ApiPropertyOptional({ description: "文物级别", example: "national_first" })
@IsOptional()
@IsString()
level?: string;
@ApiPropertyOptional({ description: "朝代" })
@IsOptional()
@IsString()
dynasty?: string;
@ApiPropertyOptional({ description: "机构ID" })
@IsOptional()
@IsString()
institution_id?: string;
@ApiPropertyOptional({ description: "标签ID(逗号分隔)" })
@IsOptional()
@IsString()
tag_ids?: string;
@ApiPropertyOptional({ description: "页码", default: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;
@ApiPropertyOptional({ description: "每页数量", default: 20 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
limit?: number = 20;
}
+27
View File
@@ -0,0 +1,27 @@
import { Controller, Get, Param, Query, Res } from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { Response } from "express";
import { AssetsService } from "./assets.service";
@ApiTags("assets")
@Controller("assets")
export class AssetsController {
constructor(private readonly assets: AssetsService) {}
@Get("image/:artifactId")
@ApiOperation({ summary: "文物图片代理(同源 + 本地缓存),失败返回 404" })
async image(
@Param("artifactId") artifactId: string,
@Query("hd") hd: string | undefined,
@Res() res: Response
): Promise<void> {
const img = await this.assets.getArtifactImage(artifactId, hd === "1");
if (!img) {
res.status(404).end();
return;
}
res.setHeader("Content-Type", img.contentType);
res.setHeader("Cache-Control", "public, max-age=86400");
res.end(img.buffer);
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Module } from "@nestjs/common";
import { AssetsService } from "./assets.service";
import { AssetsController } from "./assets.controller";
@Module({
providers: [AssetsService],
controllers: [AssetsController],
})
export class AssetsModule {}
+72
View File
@@ -0,0 +1,72 @@
import { Injectable, Logger } from "@nestjs/common";
import * as fs from "fs";
import * as path from "path";
import { DatabaseService } from "../database/database.service";
interface CachedImage {
buffer: Buffer;
contentType: string;
}
/**
* 文物图片代理 + 本地磁盘缓存。
* 浏览器统一从本服务的同源地址取图,首次访问时从上游(如 Wikimedia)拉取并落盘缓存,
* 之后直接由本地提供,实现“自建托管”。上游不可达时返回 null,前端回退到示意图。
*/
@Injectable()
export class AssetsService {
private readonly logger = new Logger(AssetsService.name);
private readonly cacheDir = path.resolve(process.cwd(), ".cache/images");
constructor(private readonly db: DatabaseService) {
fs.mkdirSync(this.cacheDir, { recursive: true });
}
private async getImageUrl(artifactId: string): Promise<string | null> {
const { rows } = await this.db.query<{ image_url: string | null }>(
"SELECT image_url FROM artifacts WHERE id = $1 LIMIT 1",
[artifactId]
);
return rows[0]?.image_url ?? null;
}
async getArtifactImage(artifactId: string, hd: boolean): Promise<CachedImage | null> {
const key = `${artifactId}${hd ? "_hd" : ""}`;
const binPath = path.join(this.cacheDir, key);
const typePath = path.join(this.cacheDir, `${key}.type`);
// 命中本地缓存
if (fs.existsSync(binPath) && fs.existsSync(typePath)) {
return {
buffer: fs.readFileSync(binPath),
contentType: fs.readFileSync(typePath, "utf-8") || "image/jpeg",
};
}
const rawUrl = await this.getImageUrl(artifactId);
if (!rawUrl) return null;
const url = hd
? /\?width=\d+/.test(rawUrl)
? rawUrl.replace(/\?width=\d+/, "?width=2000")
: `${rawUrl}${rawUrl.includes("?") ? "&" : "?"}width=2000`
: rawUrl;
try {
const resp = await fetch(url, { signal: AbortSignal.timeout(15_000) });
if (!resp.ok) {
this.logger.warn(`拉取图片失败 ${resp.status}: ${url}`);
return null;
}
const contentType = resp.headers.get("content-type") ?? "image/jpeg";
const buffer = Buffer.from(await resp.arrayBuffer());
// 落盘缓存
fs.writeFileSync(binPath, buffer);
fs.writeFileSync(typePath, contentType);
return { buffer, contentType };
} catch (err) {
this.logger.warn(`图片代理异常:${(err as Error).message}`);
return null;
}
}
}
+25
View File
@@ -0,0 +1,25 @@
import { Body, Controller, Get, Post, Request, UseGuards } from "@nestjs/common";
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import { AuthService } from "./auth.service";
import { LoginDto } from "./dto/login.dto";
import { JwtAuthGuard } from "./jwt-auth.guard";
@ApiTags("Auth")
@Controller("auth")
export class AuthController {
constructor(private auth: AuthService) {}
@Post("login")
@ApiOperation({ summary: "管理员登录" })
login(@Body() dto: LoginDto) {
return this.auth.login(dto);
}
@Get("me")
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: "获取当前用户信息" })
me(@Request() req: { user: { id: string } }) {
return this.auth.me(req.user.id);
}
}
+25
View File
@@ -0,0 +1,25 @@
import { Module } from "@nestjs/common";
import { JwtModule } from "@nestjs/jwt";
import { PassportModule } from "@nestjs/passport";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { AuthService } from "./auth.service";
import { AuthController } from "./auth.controller";
import { JwtStrategy } from "./jwt.strategy";
@Module({
imports: [
PassportModule,
JwtModule.registerAsync({
imports: [ConfigModule],
useFactory: (config: ConfigService) => ({
secret: config.get<string>("JWT_SECRET"),
signOptions: { expiresIn: config.get<string>("JWT_EXPIRES_IN") ?? "7d" },
}),
inject: [ConfigService],
}),
],
providers: [AuthService, JwtStrategy],
controllers: [AuthController],
exports: [JwtModule],
})
export class AuthModule {}
+62
View File
@@ -0,0 +1,62 @@
import { Injectable, UnauthorizedException } from "@nestjs/common";
import { JwtService } from "@nestjs/jwt";
import { DatabaseService } from "../database/database.service";
import { LoginDto } from "./dto/login.dto";
import * as bcrypt from "bcryptjs";
@Injectable()
export class AuthService {
constructor(
private db: DatabaseService,
private jwt: JwtService
) {}
async login(dto: LoginDto) {
const { rows } = await this.db.query<{
id: string;
username: string;
password_hash: string;
nickname: string;
role: string;
}>(
`SELECT u.id, u.username, u.password_hash, u.nickname, r.name as role
FROM users u
JOIN user_roles ur ON u.id = ur.user_id
JOIN roles r ON ur.role_id = r.id
WHERE (u.username = $1 OR u.email = $1) AND u.is_active = true
LIMIT 1`,
[dto.username]
);
if (!rows.length) throw new UnauthorizedException("用户名或密码错误");
const user = rows[0];
const valid = await bcrypt.compare(dto.password, user.password_hash);
if (!valid) throw new UnauthorizedException("用户名或密码错误");
const payload = { sub: user.id, username: user.username, role: user.role };
return {
access_token: this.jwt.sign(payload),
user: { id: user.id, username: user.username, nickname: user.nickname, role: user.role },
};
}
async me(userId: string) {
const { rows } = await this.db.query<{
id: string;
username: string;
nickname: string;
email: string;
role: string;
}>(
`SELECT u.id, u.username, u.nickname, u.email, r.name as role
FROM users u
JOIN user_roles ur ON u.id = ur.user_id
JOIN roles r ON ur.role_id = r.id
WHERE u.id = $1 LIMIT 1`,
[userId]
);
if (!rows.length) throw new UnauthorizedException();
return rows[0];
}
}
+13
View File
@@ -0,0 +1,13 @@
import { IsString, MinLength } from "class-validator";
import { ApiProperty } from "@nestjs/swagger";
export class LoginDto {
@ApiProperty({ example: "admin" })
@IsString()
username!: string;
@ApiProperty({ example: "password123" })
@IsString()
@MinLength(6)
password!: string;
}
+5
View File
@@ -0,0 +1,5 @@
import { Injectable } from "@nestjs/common";
import { AuthGuard } from "@nestjs/passport";
@Injectable()
export class JwtAuthGuard extends AuthGuard("jwt") {}
+33
View File
@@ -0,0 +1,33 @@
import { Injectable, UnauthorizedException } from "@nestjs/common";
import { PassportStrategy } from "@nestjs/passport";
import { ExtractJwt, Strategy } from "passport-jwt";
import { ConfigService } from "@nestjs/config";
import { DatabaseService } from "../database/database.service";
export interface JwtPayload {
sub: string;
username: string;
role: string;
}
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(config: ConfigService, private db: DatabaseService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: config.get<string>("JWT_SECRET") ?? "fallback-secret",
});
}
async validate(payload: JwtPayload) {
const { rows } = await this.db.query(
`SELECT u.id, u.username, u.nickname, r.name as role
FROM users u JOIN user_roles ur ON u.id = ur.user_id
JOIN roles r ON ur.role_id = r.id
WHERE u.id = $1 AND u.is_active = true LIMIT 1`,
[payload.sub]
);
if (!rows.length) throw new UnauthorizedException();
return rows[0];
}
}
+44
View File
@@ -0,0 +1,44 @@
import {
CanActivate,
ExecutionContext,
HttpException,
HttpStatus,
Injectable,
} from "@nestjs/common";
import { Request } from "express";
/**
* 简单的内存级 IP 限流守卫:滑动窗口。
* 用于保护成本敏感的 AI 接口,防止被刷爆额度。
*/
@Injectable()
export class RateLimitGuard implements CanActivate {
private static readonly WINDOW_MS = 60_000;
private static readonly MAX_REQUESTS = 20;
private static readonly buckets = new Map<string, number[]>();
canActivate(context: ExecutionContext): boolean {
const req = context.switchToHttp().getRequest<Request>();
const forwarded = req.headers["x-forwarded-for"];
const ip =
(Array.isArray(forwarded) ? forwarded[0] : forwarded?.split(",")[0])?.trim() ||
req.ip ||
"unknown";
const now = Date.now();
const recent = (RateLimitGuard.buckets.get(ip) ?? []).filter(
(t) => now - t < RateLimitGuard.WINDOW_MS
);
if (recent.length >= RateLimitGuard.MAX_REQUESTS) {
throw new HttpException(
"请求过于频繁,请稍后再试",
HttpStatus.TOO_MANY_REQUESTS
);
}
recent.push(now);
RateLimitGuard.buckets.set(ip, recent);
return true;
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Global, Module } from "@nestjs/common";
import { DatabaseService } from "./database.service";
@Global()
@Module({
providers: [DatabaseService],
exports: [DatabaseService],
})
export class DatabaseModule {}
+41
View File
@@ -0,0 +1,41 @@
import { Injectable, OnModuleDestroy, OnModuleInit, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Pool, PoolClient, QueryResult } from "pg";
@Injectable()
export class DatabaseService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(DatabaseService.name);
private pool!: Pool;
constructor(private config: ConfigService) {}
async onModuleInit() {
this.pool = new Pool({ connectionString: this.config.get<string>("DATABASE_URL") });
const client = await this.pool.connect();
client.release();
this.logger.log("PostgreSQL connection pool ready");
}
async onModuleDestroy() {
await this.pool.end();
}
async query<T extends object = Record<string, unknown>>(sql: string, params?: unknown[]): Promise<QueryResult<T>> {
return this.pool.query<T>(sql, params);
}
async transaction<T>(fn: (client: PoolClient) => Promise<T>): Promise<T> {
const client = await this.pool.connect();
try {
await client.query("BEGIN");
const result = await fn(client);
await client.query("COMMIT");
return result;
} catch (err) {
await client.query("ROLLBACK");
throw err;
} finally {
client.release();
}
}
}
+16
View File
@@ -0,0 +1,16 @@
import { Controller, Get } from "@nestjs/common";
import { ApiTags, ApiOperation } from "@nestjs/swagger";
@ApiTags("health")
@Controller("health")
export class HealthController {
@Get()
@ApiOperation({ summary: "服务健康检查" })
check() {
return {
status: "ok",
time: new Date().toISOString(),
service: "wenwumap-api",
};
}
}
@@ -0,0 +1,26 @@
import { Controller, Get, Param, Query } from "@nestjs/common";
import { ApiOperation, ApiQuery, ApiTags } from "@nestjs/swagger";
import { InstitutionsService } from "./institutions.service";
@ApiTags("Institutions")
@Controller("institutions")
export class InstitutionsController {
constructor(private institutions: InstitutionsService) {}
@Get()
@ApiOperation({ summary: "机构列表" })
@ApiQuery({ name: "page", required: false, type: Number })
@ApiQuery({ name: "limit", required: false, type: Number })
findAll(
@Query("page") page?: string,
@Query("limit") limit?: string
) {
return this.institutions.findAll(page ? parseInt(page) : 1, limit ? parseInt(limit) : 20);
}
@Get(":id")
@ApiOperation({ summary: "机构详情" })
findOne(@Param("id") id: string) {
return this.institutions.findOne(id);
}
}
@@ -0,0 +1,9 @@
import { Module } from "@nestjs/common";
import { InstitutionsService } from "./institutions.service";
import { InstitutionsController } from "./institutions.controller";
@Module({
providers: [InstitutionsService],
controllers: [InstitutionsController],
})
export class InstitutionsModule {}
@@ -0,0 +1,58 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { DatabaseService } from "../database/database.service";
@Injectable()
export class InstitutionsService {
constructor(private db: DatabaseService) {}
async findAll(page = 1, limit = 20) {
const offset = (page - 1) * limit;
const countResult = await this.db.query<{ count: string }>(
`SELECT COUNT(*) as count FROM institutions WHERE publish_status = 'published'`
);
const total = parseInt(countResult.rows[0].count);
const { rows } = await this.db.query<{
id: string;
name: string;
country: string;
city: string;
artifact_count: string;
lng: number;
lat: number;
}>(
`SELECT i.id, i.name, i.country, i.city,
ST_X(i.location::geometry) AS lng,
ST_Y(i.location::geometry) AS lat,
COUNT(al.artifact_id) AS artifact_count
FROM institutions i
LEFT JOIN artifact_locations al ON i.id = al.institution_id AND al.is_current = true
WHERE i.publish_status = 'published'
GROUP BY i.id
ORDER BY artifact_count DESC, i.name
LIMIT $1 OFFSET $2`,
[limit, offset]
);
return {
data: rows.map((r) => ({ ...r, artifact_count: parseInt(r.artifact_count) })),
meta: { total, page, limit, total_pages: Math.ceil(total / limit) },
};
}
async findOne(id: string) {
const { rows } = await this.db.query<Record<string, unknown>>(
`SELECT i.*,
ST_X(i.location::geometry) AS lng,
ST_Y(i.location::geometry) AS lat,
COUNT(al.artifact_id) AS artifact_count
FROM institutions i
LEFT JOIN artifact_locations al ON i.id = al.institution_id AND al.is_current = true
WHERE i.id = $1 AND i.publish_status = 'published'
GROUP BY i.id`,
[id]
);
if (!rows.length) throw new NotFoundException("机构不存在");
return { ...rows[0], artifact_count: parseInt(rows[0].artifact_count as string) };
}
}
+42
View File
@@ -0,0 +1,42 @@
import { NestFactory } from "@nestjs/core";
import { ValidationPipe } from "@nestjs/common";
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
import { AppModule } from "./app.module";
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.setGlobalPrefix("api/v1");
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
forbidNonWhitelisted: true,
})
);
app.enableCors({
origin: [
process.env["WEB_URL"] ?? "http://localhost:3000",
process.env["ADMIN_URL"] ?? "http://localhost:3001",
],
credentials: true,
});
const config = new DocumentBuilder()
.setTitle("中华文明全图鉴 API")
.setDescription("文物全图系统后端 API")
.setVersion("1.0")
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup("api/docs", app, document);
const port = process.env["PORT"] ?? 3002;
await app.listen(port);
console.log(`API 服务启动:http://localhost:${port}`);
console.log(`Swagger 文档:http://localhost:${port}/api/docs`);
}
bootstrap();
+57
View File
@@ -0,0 +1,57 @@
import { IsNumber, IsOptional, IsString, Max, Min } from "class-validator";
import { Type } from "class-transformer";
import { ApiPropertyOptional } from "@nestjs/swagger";
export class MapPointsQueryDto {
@ApiPropertyOptional({ description: "西经(左边界)", example: 73.5 })
@IsOptional()
@Type(() => Number)
@IsNumber()
west?: number;
@ApiPropertyOptional({ description: "东经(右边界)", example: 135.0 })
@IsOptional()
@Type(() => Number)
@IsNumber()
east?: number;
@ApiPropertyOptional({ description: "南纬(下边界)", example: 18.0 })
@IsOptional()
@Type(() => Number)
@IsNumber()
south?: number;
@ApiPropertyOptional({ description: "北纬(上边界)", example: 53.5 })
@IsOptional()
@Type(() => Number)
@IsNumber()
north?: number;
@ApiPropertyOptional({ description: "地图缩放级别", example: 5 })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
@Max(20)
zoom?: number;
@ApiPropertyOptional({ description: "文物门类筛选", example: "bronze" })
@IsOptional()
@IsString()
category?: string;
@ApiPropertyOptional({ description: "朝代/年代筛选", example: "唐" })
@IsOptional()
@IsString()
dynasty?: string;
@ApiPropertyOptional({ description: "机构ID筛选" })
@IsOptional()
@IsString()
institution_id?: string;
@ApiPropertyOptional({ description: "标签ID(逗号分隔)" })
@IsOptional()
@IsString()
tag_ids?: string;
}
+35
View File
@@ -0,0 +1,35 @@
import { Controller, Get, Query } from "@nestjs/common";
import { ApiOperation, ApiQuery, ApiTags } from "@nestjs/swagger";
import { MapService } from "./map.service";
import { MapPointsQueryDto } from "./dto/map-query.dto";
@ApiTags("Map")
@Controller("map")
export class MapController {
constructor(private map: MapService) {}
@Get("stats")
@ApiOperation({ summary: "地图统计数据(文物总数、机构总数、位置总数)" })
getStats() {
return this.map.getStats();
}
@Get("points")
@ApiOperation({ summary: "获取地图点位(支持视口范围 + 多维筛选)" })
getPoints(@Query() query: MapPointsQueryDto) {
return this.map.getPoints(query);
}
@Get("nearby")
@ApiOperation({ summary: "附近文物查询" })
@ApiQuery({ name: "lng", type: Number, example: 116.4 })
@ApiQuery({ name: "lat", type: Number, example: 39.9 })
@ApiQuery({ name: "radius_km", type: Number, required: false, example: 50 })
getNearby(
@Query("lng") lng: string,
@Query("lat") lat: string,
@Query("radius_km") radius?: string
) {
return this.map.getNearby(parseFloat(lng), parseFloat(lat), radius ? parseFloat(radius) : 50);
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Module } from "@nestjs/common";
import { MapService } from "./map.service";
import { MapController } from "./map.controller";
@Module({
providers: [MapService],
controllers: [MapController],
})
export class MapModule {}
+125
View File
@@ -0,0 +1,125 @@
import { Injectable } from "@nestjs/common";
import { DatabaseService } from "../database/database.service";
import { MapPointsQueryDto } from "./dto/map-query.dto";
@Injectable()
export class MapService {
constructor(private db: DatabaseService) {}
async getStats() {
const { rows } = await this.db.query<{ total_artifacts: string; total_institutions: string; total_locations: string }>(
`SELECT
(SELECT COUNT(*) FROM artifacts WHERE publish_status = 'published') AS total_artifacts,
(SELECT COUNT(*) FROM institutions WHERE publish_status = 'published') AS total_institutions,
(SELECT COUNT(*) FROM artifact_locations WHERE is_current = true) AS total_locations`
);
const row = rows[0];
return {
total_artifacts: parseInt(row.total_artifacts),
total_institutions: parseInt(row.total_institutions),
total_locations: parseInt(row.total_locations),
};
}
async getPoints(query: MapPointsQueryDto) {
const conditions: string[] = [
"a.publish_status = 'published'",
"al.is_current = true",
"al.public_location IS NOT NULL",
];
const params: unknown[] = [];
let idx = 1;
if (query.west != null && query.east != null && query.south != null && query.north != null) {
conditions.push(
`al.public_location && ST_MakeEnvelope($${idx++}, $${idx++}, $${idx++}, $${idx++}, 4326)`
);
params.push(query.west, query.south, query.east, query.north);
}
if (query.category) {
conditions.push(`a.category = $${idx++}`);
params.push(query.category);
}
if (query.dynasty) {
conditions.push(`a.dynasty ILIKE $${idx++}`);
params.push(`%${query.dynasty}%`);
}
if (query.institution_id) {
conditions.push(`al.institution_id = $${idx++}`);
params.push(query.institution_id);
}
const where = conditions.join(" AND ");
const { rows } = await this.db.query<{
id: string;
name: string;
category: string;
level: string;
dynasty: string;
story_hook: string;
lng: number;
lat: number;
institution_name: string;
province: string;
city: string;
location_type: string;
image_url: string | null;
repatriation_status: string;
}>(
`SELECT DISTINCT ON (a.id)
a.id, a.name, a.category, a.level, a.dynasty, a.story_hook,
ST_X(al.public_location::geometry) AS lng,
ST_Y(al.public_location::geometry) AS lat,
i.name AS institution_name,
i.province,
i.city,
al.location_type,
a.image_url,
a.repatriation_status
FROM artifacts a
JOIN artifact_locations al ON a.id = al.artifact_id
LEFT JOIN institutions i ON al.institution_id = i.id
WHERE ${where}
ORDER BY a.id, a.level DESC, a.name
LIMIT 2000`,
params
);
return rows;
}
async getNearby(lng: number, lat: number, radiusKm = 50) {
const { rows } = await this.db.query<{
id: string;
name: string;
category: string;
level: string;
distance_km: number;
institution_name: string;
}>(
`SELECT
a.id, a.name, a.category, a.level,
ROUND(ST_Distance(al.public_location, ST_SetSRID(ST_MakePoint($1,$2),4326)::geography) / 1000)::float AS distance_km,
i.name AS institution_name
FROM artifacts a
JOIN artifact_locations al ON a.id = al.artifact_id
LEFT JOIN institutions i ON al.institution_id = i.id
WHERE a.publish_status = 'published'
AND al.is_current = true
AND al.public_location IS NOT NULL
AND ST_DWithin(
al.public_location,
ST_SetSRID(ST_MakePoint($1,$2),4326)::geography,
$3 * 1000
)
ORDER BY distance_km
LIMIT 20`,
[lng, lat, radiusKm]
);
return rows;
}
}
+21
View File
@@ -0,0 +1,21 @@
import { Controller, Get, Param } from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { RoutesService } from "./routes.service";
@ApiTags("routes")
@Controller("routes")
export class RoutesController {
constructor(private readonly routes: RoutesService) {}
@Get()
@ApiOperation({ summary: "叙事路线列表(南迁 / 回归)" })
list() {
return this.routes.list();
}
@Get(":code")
@ApiOperation({ summary: "按 code 获取路线及途经点" })
getByCode(@Param("code") code: string) {
return this.routes.getByCode(code);
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Module } from "@nestjs/common";
import { RoutesService } from "./routes.service";
import { RoutesController } from "./routes.controller";
@Module({
providers: [RoutesService],
controllers: [RoutesController],
})
export class RoutesModule {}
+66
View File
@@ -0,0 +1,66 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { DatabaseService } from "../database/database.service";
export interface RouteRow {
code: string;
title: string;
type: string;
color: string | null;
summary: string | null;
artifact_id: string | null;
artifact_name: string | null;
artifact_dynasty: string | null;
artifact_category: string | null;
institution_name: string | null;
}
export interface StopRow {
seq: number;
name: string;
lng: number;
lat: number;
year_label: string | null;
event: string | null;
}
export type RouteDetail = RouteRow & { stops: StopRow[] };
const ROUTE_SELECT = `
SELECT nr.code, nr.title, nr.type, nr.color, nr.summary, nr.artifact_id,
a.name AS artifact_name, a.dynasty AS artifact_dynasty, a.category AS artifact_category,
i.name AS institution_name
FROM narrative_routes nr
LEFT JOIN artifacts a ON a.id = nr.artifact_id
LEFT JOIN artifact_locations al ON al.artifact_id = a.id AND al.is_current = true
LEFT JOIN institutions i ON i.id = al.institution_id`;
@Injectable()
export class RoutesService {
constructor(private db: DatabaseService) {}
async list(): Promise<RouteRow[]> {
const { rows } = await this.db.query<RouteRow>(
`${ROUTE_SELECT} ORDER BY nr.type, nr.code`
);
return rows;
}
async getByCode(code: string): Promise<RouteDetail> {
const { rows } = await this.db.query<RouteRow>(
`${ROUTE_SELECT} WHERE nr.code = $1 LIMIT 1`,
[code]
);
const route = rows[0];
if (!route) throw new NotFoundException("路线不存在");
const { rows: stops } = await this.db.query<StopRow>(
`SELECT seq, name, lng, lat, year_label, event
FROM route_stops rs
JOIN narrative_routes nr ON nr.id = rs.route_id
WHERE nr.code = $1
ORDER BY rs.seq`,
[code]
);
return { ...route, stops };
}
}
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"module": "CommonJS",
"moduleResolution": "node",
"target": "ES2022",
"lib": ["ES2022"],
"types": ["node"],
"strict": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src",
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"paths": {
"@wenwumap/shared": ["../../packages/shared/src/index.ts"]
}
},
"include": ["src"],
"exclude": ["node_modules", "dist", "**/*.spec.ts"]
}
+3
View File
@@ -0,0 +1,3 @@
NEXT_PUBLIC_API_URL=http://localhost:3002
# 地图样式 URLMapLibre GL 格式,需申请后填入)
NEXT_PUBLIC_MAP_STYLE=
+5
View File
@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
+11
View File
@@ -0,0 +1,11 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
transpilePackages: ["@wenwumap/shared"],
env: {
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || "http://localhost:3002",
NEXT_PUBLIC_MAP_STYLE: process.env.NEXT_PUBLIC_MAP_STYLE || "",
},
};
module.exports = nextConfig;
+42
View File
@@ -0,0 +1,42 @@
{
"name": "@wenwumap/web",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev -p 3000",
"build": "next build",
"start": "next start -p 3000",
"lint": "next lint",
"type-check": "tsc --noEmit"
},
"dependencies": {
"@googlemaps/markerclusterer": "^2.6.2",
"@radix-ui/react-dialog": "^1.1.1",
"@radix-ui/react-select": "^2.1.1",
"@radix-ui/react-slider": "^1.2.0",
"@radix-ui/react-tooltip": "^1.1.2",
"@vis.gl/react-google-maps": "^1.4.0",
"@wenwumap/shared": "workspace:*",
"clsx": "^2.1.1",
"lucide-react": "^0.414.0",
"maplibre-gl": "^4.5.0",
"next": "^14.2.5",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-map-gl": "^7.1.7",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"swr": "^2.2.5",
"tailwind-merge": "^2.4.0"
},
"devDependencies": {
"@types/google.maps": "^3.65.1",
"@types/node": "^22.0.0",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"autoprefixer": "^10.4.19",
"postcss": "^8.4.40",
"tailwindcss": "^3.4.7",
"typescript": "^5.5.3"
}
}
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 381 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 430 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 356 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 280 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 990 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 614 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 302 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 334 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 710 KiB

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

Some files were not shown because too many files have changed in this diff Show More