chore: 初始化仓库
中华文明全图鉴——文物全图系统(PC Web 地图 + NestJS API + 管理后台)。 含三大 IP(文物南迁北归 / 国宝海外回归 / 博物馆手艺人)、AI 文物对话、 文物地图与详情、以及 demo-video-kit 演示视频生成工具。
@@ -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- 组件化架构设计,可复用 composables(use*.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- 数据库操作使用 ORM(Prisma / 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 Cache(ETag / Cache-Control)+ SWR(stale-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 Vitals:LCP < 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- 密码使用 bcrypt(cost ≥ 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- 数据传输使用 HTTPS(TLS 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 merge,release → merge commit\n\n## 工具集成\n\n- 提交前自动运行:lint-staged + husky(格式化 + 检查)\n- 提交信息校验:commitlint(确保遵循 Conventional Commits)\n- 自动生成 CHANGELOG:conventional-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 Query(React 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 Flow(main / develop / feature / fix / release)\n- **提交规范**:Conventional Commits(feat / 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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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/
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
dist
|
||||
.next
|
||||
build
|
||||
coverage
|
||||
*.sql
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"trailingComma": "es5",
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
{
|
||||
}
|
||||
@@ -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体验,都是这套基础设施的自然生长。
|
||||
@@ -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 或仅供合作机构使用。
|
||||
@@ -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 工程初始化前的技术选型确认。
|
||||
- 根据确认结果初始化代码仓库结构和基础工程。
|
||||
|
||||
---
|
||||
|
||||
# 阶段 1:MVP 国内文物地图
|
||||
|
||||
## 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 后台登录页和文物管理列表页
|
||||
@@ -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` 状态,并记录测试结果。
|
||||
@@ -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 件文物点位范围查询响应时间符合要求。
|
||||
- 敏感坐标在游客权限下不可见。
|
||||
- 机构用户无法访问其他机构文物。
|
||||
- 文物标签绑定、解绑和筛选结果正确。
|
||||
@@ -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 记录。
|
||||
@@ -0,0 +1,88 @@
|
||||
# 中华文明全图鉴——文物全图系统
|
||||
|
||||
PC Web 优先的文物全球位置地图平台。
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
wenwumap/
|
||||
apps/
|
||||
web/ # PC Web 地图站(Next.js + React + TypeScript)
|
||||
admin/ # 管理后台(React + Vite + Ant Design)
|
||||
api/ # 后端 API(NestJS + TypeScript)
|
||||
packages/
|
||||
shared/ # 共享类型与枚举
|
||||
db/ # 数据库 migration 与 seed(PostgreSQL + 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 大屏地图体验
|
||||
- **专业美观**:东方审美 + 现代地图产品质感
|
||||
- **趣味可探索**:文物故事钩子 + 点位动效 + 路线叙事
|
||||
- **可信优先**:数据来源、审核状态、位置精度均可追溯
|
||||
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>中华文明全图鉴 · 管理后台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "@wenwumap/admin",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite --port 3001",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint src --ext .ts,.tsx",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.25.1",
|
||||
"antd": "^5.19.3",
|
||||
"@ant-design/icons": "^5.3.7",
|
||||
"swr": "^2.2.5",
|
||||
"@wenwumap/shared": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"typescript": "^5.5.3",
|
||||
"vite": "^5.3.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Routes, Route, Navigate } from "react-router-dom";
|
||||
import { ConfigProvider, theme } from "antd";
|
||||
import zhCN from "antd/locale/zh_CN";
|
||||
import Login from "./pages/Login";
|
||||
import AdminLayout from "./components/AdminLayout";
|
||||
import Dashboard from "./pages/Dashboard";
|
||||
import ArtifactList from "./pages/artifacts/ArtifactList";
|
||||
import { getStoredUser } from "./store/auth";
|
||||
|
||||
function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||
const user = getStoredUser();
|
||||
return user ? <>{children}</> : <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<ConfigProvider
|
||||
locale={zhCN}
|
||||
theme={{
|
||||
algorithm: theme.darkAlgorithm,
|
||||
token: {
|
||||
colorPrimary: "#c9a84c",
|
||||
colorBgBase: "#14121e",
|
||||
colorBgContainer: "#1a1825",
|
||||
colorBgLayout: "#0f0d18",
|
||||
fontFamily: "\"Noto Sans SC\", \"PingFang SC\", sans-serif",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<AdminLayout />
|
||||
</RequireAuth>
|
||||
}
|
||||
>
|
||||
<Route index element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="dashboard" element={<Dashboard />} />
|
||||
<Route path="artifacts" element={<ArtifactList />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||
</Routes>
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
const BASE_URL = import.meta.env.VITE_API_URL ?? "http://localhost:3002";
|
||||
|
||||
function getToken() {
|
||||
return localStorage.getItem("access_token");
|
||||
}
|
||||
|
||||
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const token = getToken();
|
||||
const res = await fetch(`${BASE_URL}${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...(options.headers ?? {}),
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error((err as { message?: string }).message ?? `HTTP ${res.status}`);
|
||||
}
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(path: string) => request<T>(path),
|
||||
post: <T>(path: string, body: unknown) =>
|
||||
request<T>(path, { method: "POST", body: JSON.stringify(body) }),
|
||||
patch: <T>(path: string, body: unknown) =>
|
||||
request<T>(path, { method: "PATCH", body: JSON.stringify(body) }),
|
||||
delete: <T>(path: string) => request<T>(path, { method: "DELETE" }),
|
||||
};
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useState } from "react";
|
||||
import { Layout, Menu, Avatar, Dropdown, theme, Typography } from "antd";
|
||||
import {
|
||||
DashboardOutlined,
|
||||
PicLeftOutlined,
|
||||
BankOutlined,
|
||||
TagsOutlined,
|
||||
LogoutOutlined,
|
||||
UserOutlined,
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { Outlet, useNavigate, useLocation } from "react-router-dom";
|
||||
import { useAuth } from "../store/auth";
|
||||
|
||||
const { Header, Sider, Content } = Layout;
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ key: "/dashboard", icon: <DashboardOutlined />, label: "概览" },
|
||||
{ key: "/artifacts", icon: <PicLeftOutlined />, label: "文物管理" },
|
||||
{ key: "/institutions", icon: <BankOutlined />, label: "机构管理" },
|
||||
{ key: "/tags", icon: <TagsOutlined />, label: "标签管理" },
|
||||
];
|
||||
|
||||
export default function AdminLayout() {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { user, logout } = useAuth();
|
||||
const { token } = theme.useToken();
|
||||
|
||||
const userMenuItems = [
|
||||
{
|
||||
key: "logout",
|
||||
icon: <LogoutOutlined />,
|
||||
label: "退出登录",
|
||||
onClick: () => {
|
||||
logout();
|
||||
navigate("/login");
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Layout style={{ minHeight: "100vh" }}>
|
||||
<Sider
|
||||
collapsible
|
||||
collapsed={collapsed}
|
||||
onCollapse={setCollapsed}
|
||||
trigger={null}
|
||||
style={{
|
||||
background: token.colorBgContainer,
|
||||
borderRight: `1px solid ${token.colorBorderSecondary}`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: 56,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: collapsed ? "center" : "flex-start",
|
||||
padding: collapsed ? 0 : "0 20px",
|
||||
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
||||
}}
|
||||
>
|
||||
{collapsed ? (
|
||||
<span style={{ fontSize: 18 }}>🏛️</span>
|
||||
) : (
|
||||
<Typography.Text strong style={{ color: token.colorPrimary, fontSize: 13, letterSpacing: 1 }}>
|
||||
文明全图鉴
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
<Menu
|
||||
mode="inline"
|
||||
selectedKeys={[location.pathname]}
|
||||
items={NAV_ITEMS}
|
||||
onClick={({ key }) => navigate(key)}
|
||||
style={{ border: "none", marginTop: 8 }}
|
||||
/>
|
||||
</Sider>
|
||||
|
||||
<Layout>
|
||||
<Header
|
||||
style={{
|
||||
background: token.colorBgContainer,
|
||||
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
||||
height: 56,
|
||||
padding: "0 20px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
color: token.colorText,
|
||||
fontSize: 16,
|
||||
}}
|
||||
>
|
||||
{collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
||||
</button>
|
||||
<div style={{ flex: 1 }} />
|
||||
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight">
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, cursor: "pointer" }}>
|
||||
<Avatar size={28} icon={<UserOutlined />} style={{ background: token.colorPrimary }} />
|
||||
<Typography.Text style={{ fontSize: 13 }}>
|
||||
{user?.nickname ?? user?.username}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</Dropdown>
|
||||
</Header>
|
||||
|
||||
<Content style={{ padding: 24, background: token.colorBgLayout }}>
|
||||
<Outlet />
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/* 全站隐藏滚动条但仍可滚动 */
|
||||
* {
|
||||
scrollbar-width: none; /* Firefox */
|
||||
-ms-overflow-style: none; /* 旧版 Edge / IE */
|
||||
}
|
||||
*::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
display: none; /* Chrome / Safari / 新版 Edge */
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import App from "./App";
|
||||
import "./index.css";
|
||||
|
||||
const root = document.getElementById("root");
|
||||
if (!root) throw new Error("找不到 #root 挂载点");
|
||||
|
||||
ReactDOM.createRoot(root).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Card, Statistic, Row, Col, Typography, Spin } from "antd";
|
||||
import { PicLeftOutlined, BankOutlined, EnvironmentOutlined } from "@ant-design/icons";
|
||||
import { api } from "../api/client";
|
||||
|
||||
interface Stats {
|
||||
total_artifacts: number;
|
||||
total_institutions: number;
|
||||
total_locations: number;
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const [stats, setStats] = useState<Stats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.get<Stats>("/api/v1/map/stats")
|
||||
.then(setStats)
|
||||
.catch(() => null)
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography.Title level={4} style={{ marginBottom: 24 }}>
|
||||
概览
|
||||
</Typography.Title>
|
||||
{loading ? (
|
||||
<Spin />
|
||||
) : (
|
||||
<Row gutter={16}>
|
||||
<Col span={8}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="已发布文物"
|
||||
value={stats?.total_artifacts ?? 0}
|
||||
prefix={<PicLeftOutlined />}
|
||||
valueStyle={{ color: "#c9a84c" }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="合作机构"
|
||||
value={stats?.total_institutions ?? 0}
|
||||
prefix={<BankOutlined />}
|
||||
valueStyle={{ color: "#c9a84c" }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="地图点位"
|
||||
value={stats?.total_locations ?? 0}
|
||||
prefix={<EnvironmentOutlined />}
|
||||
valueStyle={{ color: "#c9a84c" }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { useState } from "react";
|
||||
import { Form, Input, Button, message, Typography } from "antd";
|
||||
import { UserOutlined, LockOutlined } from "@ant-design/icons";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { api } from "../api/client";
|
||||
import { saveAuth, AuthUser } from "../store/auth";
|
||||
|
||||
interface LoginResponse {
|
||||
access_token: string;
|
||||
user: AuthUser;
|
||||
}
|
||||
|
||||
export default function Login() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const onFinish = async (values: { username: string; password: string }) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.post<LoginResponse>("/api/v1/auth/login", values);
|
||||
saveAuth(res.access_token, res.user);
|
||||
message.success(`欢迎回来,${res.user.nickname ?? res.user.username}`);
|
||||
navigate("/dashboard");
|
||||
} catch (err) {
|
||||
message.error((err as Error).message || "登录失败,请检查账号密码");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
minHeight: "100vh",
|
||||
background: "linear-gradient(135deg, #0f0c1a 0%, #1a1225 50%, #0d1117 100%)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 380,
|
||||
background: "rgba(255,255,255,0.04)",
|
||||
border: "1px solid rgba(201,168,76,0.2)",
|
||||
borderRadius: 12,
|
||||
padding: "40px 36px",
|
||||
boxShadow: "0 8px 40px rgba(0,0,0,0.5)",
|
||||
}}
|
||||
>
|
||||
<div style={{ textAlign: "center", marginBottom: 32 }}>
|
||||
<div style={{ fontSize: 28, marginBottom: 8 }}>🏛️</div>
|
||||
<Typography.Title
|
||||
level={3}
|
||||
style={{ color: "#c9a84c", margin: 0, fontWeight: 700, letterSpacing: 2 }}
|
||||
>
|
||||
中华文明全图鉴
|
||||
</Typography.Title>
|
||||
<Typography.Text style={{ color: "rgba(255,255,255,0.4)", fontSize: 13 }}>
|
||||
管理后台
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
<Form form={form} onFinish={onFinish} size="large" requiredMark={false}>
|
||||
<Form.Item
|
||||
name="username"
|
||||
rules={[{ required: true, message: "请输入邮箱或用户名" }]}
|
||||
>
|
||||
<Input
|
||||
prefix={<UserOutlined style={{ color: "rgba(255,255,255,0.3)" }} />}
|
||||
placeholder="邮箱 / 用户名"
|
||||
autoComplete="username"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="password"
|
||||
rules={[{ required: true, message: "请输入密码" }]}
|
||||
>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined style={{ color: "rgba(255,255,255,0.3)" }} />}
|
||||
placeholder="密码"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item style={{ marginBottom: 0 }}>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
block
|
||||
loading={loading}
|
||||
style={{ height: 44, fontWeight: 600, letterSpacing: 1 }}
|
||||
>
|
||||
登 录
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
Table,
|
||||
Input,
|
||||
Select,
|
||||
Space,
|
||||
Tag,
|
||||
Typography,
|
||||
Button,
|
||||
message,
|
||||
Tooltip,
|
||||
} from "antd";
|
||||
import { SearchOutlined, ReloadOutlined } from "@ant-design/icons";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import { api } from "../../api/client";
|
||||
|
||||
interface Artifact {
|
||||
id: string;
|
||||
name: string;
|
||||
category: string;
|
||||
level: string;
|
||||
dynasty: string;
|
||||
story_hook: string;
|
||||
current_institution: string;
|
||||
}
|
||||
|
||||
interface PageMeta {
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
interface ArtifactListResponse {
|
||||
data: Artifact[];
|
||||
meta: PageMeta;
|
||||
}
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
bronze: "青铜器",
|
||||
painting_calligraphy: "书画",
|
||||
porcelain: "陶瓷",
|
||||
jade: "玉器",
|
||||
gold_silver: "金银器",
|
||||
lacquer: "漆木器",
|
||||
textile: "织绣",
|
||||
stone_carving: "石刻造像",
|
||||
wood_carving: "木雕",
|
||||
dunhuang: "敦煌遗珍",
|
||||
ancient_book: "古籍文献",
|
||||
other: "其他",
|
||||
};
|
||||
|
||||
const LEVEL_COLORS: Record<string, string> = {
|
||||
level_1: "gold",
|
||||
level_2: "blue",
|
||||
level_3: "default",
|
||||
unknown: "default",
|
||||
};
|
||||
const LEVEL_LABELS: Record<string, string> = {
|
||||
level_1: "国家一级",
|
||||
level_2: "国家二级",
|
||||
level_3: "国家三级",
|
||||
unknown: "未定级",
|
||||
};
|
||||
|
||||
export default function ArtifactList() {
|
||||
const [data, setData] = useState<Artifact[]>([]);
|
||||
const [meta, setMeta] = useState<PageMeta>({ total: 0, page: 1, limit: 20, total_pages: 1 });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [q, setQ] = useState("");
|
||||
const [category, setCategory] = useState<string | undefined>();
|
||||
const [dynasty, setDynasty] = useState<string | undefined>();
|
||||
|
||||
const fetchData = useCallback(
|
||||
async (page = 1) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams({ page: String(page), limit: "20" });
|
||||
if (q) params.set("q", q);
|
||||
if (category) params.set("category", category);
|
||||
if (dynasty) params.set("dynasty", dynasty);
|
||||
const res = await api.get<ArtifactListResponse>(
|
||||
`/api/v1/artifacts?${params}`
|
||||
);
|
||||
setData(res.data);
|
||||
setMeta(res.meta);
|
||||
} catch (err) {
|
||||
message.error((err as Error).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[q, category, dynasty]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData(1);
|
||||
}, [fetchData]);
|
||||
|
||||
const columns: ColumnsType<Artifact> = [
|
||||
{
|
||||
title: "文物名称",
|
||||
dataIndex: "name",
|
||||
key: "name",
|
||||
render: (text: string, record) => (
|
||||
<div>
|
||||
<Typography.Text strong>{text}</Typography.Text>
|
||||
{record.story_hook && (
|
||||
<Tooltip title={record.story_hook}>
|
||||
<Typography.Text
|
||||
type="secondary"
|
||||
style={{ display: "block", fontSize: 12, maxWidth: 200 }}
|
||||
ellipsis
|
||||
>
|
||||
{record.story_hook}
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "门类",
|
||||
dataIndex: "category",
|
||||
key: "category",
|
||||
width: 80,
|
||||
render: (cat: string) => CATEGORY_LABELS[cat] ?? cat,
|
||||
},
|
||||
{
|
||||
title: "级别",
|
||||
dataIndex: "level",
|
||||
key: "level",
|
||||
width: 100,
|
||||
render: (lv: string) => (
|
||||
<Tag color={LEVEL_COLORS[lv] ?? "default"}>{LEVEL_LABELS[lv] ?? lv}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "朝代",
|
||||
dataIndex: "dynasty",
|
||||
key: "dynasty",
|
||||
width: 90,
|
||||
},
|
||||
{
|
||||
title: "所在机构",
|
||||
dataIndex: "current_institution",
|
||||
key: "current_institution",
|
||||
render: (v: string) => v ?? <Typography.Text type="secondary">—</Typography.Text>,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 16 }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
文物管理
|
||||
</Typography.Title>
|
||||
</div>
|
||||
|
||||
<Space style={{ marginBottom: 16 }} wrap>
|
||||
<Input
|
||||
placeholder="搜索文物名称"
|
||||
prefix={<SearchOutlined />}
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
onPressEnter={() => fetchData(1)}
|
||||
style={{ width: 200 }}
|
||||
allowClear
|
||||
onClear={() => setQ("")}
|
||||
/>
|
||||
<Select
|
||||
placeholder="门类"
|
||||
style={{ width: 100 }}
|
||||
allowClear
|
||||
value={category}
|
||||
onChange={setCategory}
|
||||
options={Object.entries(CATEGORY_LABELS).map(([value, label]) => ({
|
||||
value,
|
||||
label,
|
||||
}))}
|
||||
/>
|
||||
<Select
|
||||
placeholder="朝代"
|
||||
style={{ width: 140 }}
|
||||
allowClear
|
||||
value={dynasty}
|
||||
onChange={setDynasty}
|
||||
options={["先秦", "秦汉", "魏晋南北朝", "隋唐", "宋元", "明清"].map((d) => ({
|
||||
value: d,
|
||||
label: d,
|
||||
}))}
|
||||
/>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={() => fetchData(1)}
|
||||
loading={loading}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
loading={loading}
|
||||
pagination={{
|
||||
total: meta.total,
|
||||
current: meta.page,
|
||||
pageSize: meta.limit,
|
||||
showTotal: (t) => `共 ${t} 件`,
|
||||
onChange: (page) => fetchData(page),
|
||||
showSizeChanger: false,
|
||||
}}
|
||||
size="middle"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
username: string;
|
||||
nickname: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
const USER_KEY = "admin_user";
|
||||
const TOKEN_KEY = "access_token";
|
||||
|
||||
export function saveAuth(token: string, user: AuthUser) {
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(user));
|
||||
}
|
||||
|
||||
export function clearAuth() {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(USER_KEY);
|
||||
}
|
||||
|
||||
export function getStoredUser(): AuthUser | null {
|
||||
try {
|
||||
const s = localStorage.getItem(USER_KEY);
|
||||
return s ? (JSON.parse(s) as AuthUser) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const [user, setUser] = useState<AuthUser | null>(getStoredUser);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => setUser(getStoredUser());
|
||||
window.addEventListener("storage", handler);
|
||||
return () => window.removeEventListener("storage", handler);
|
||||
}, []);
|
||||
|
||||
const logout = () => {
|
||||
clearAuth();
|
||||
setUser(null);
|
||||
};
|
||||
|
||||
return { user, setUser, logout };
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"paths": {
|
||||
"@wenwumap/shared": ["../../packages/shared/src/index.ts"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import path from "path";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@wenwumap/shared": path.resolve(__dirname, "../../packages/shared/src/index.ts"),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 3001,
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://localhost:3002",
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
PORT=3002
|
||||
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/wenwumap
|
||||
REDIS_URL=redis://localhost:6379
|
||||
JWT_SECRET=change-me-in-production
|
||||
JWT_EXPIRES_IN=7d
|
||||
OSS_ENDPOINT=http://localhost:9000
|
||||
OSS_ACCESS_KEY=minioadmin
|
||||
OSS_SECRET_KEY=minioadmin
|
||||
OSS_BUCKET=wenwumap
|
||||
|
||||
# AI 对话(通义千问 DashScope,OpenAI 兼容模式)
|
||||
AI_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
|
||||
AI_API_KEY=your-dashscope-api-key
|
||||
AI_MODEL=qwen-plus
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "@wenwumap/api",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "nest start --watch",
|
||||
"build": "nest build",
|
||||
"start": "node dist/main",
|
||||
"lint": "eslint src --ext .ts",
|
||||
"type-check": "tsc --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^10.3.10",
|
||||
"@nestjs/config": "^3.2.3",
|
||||
"@nestjs/core": "^10.3.10",
|
||||
"@nestjs/jwt": "^10.2.0",
|
||||
"@nestjs/passport": "^10.0.3",
|
||||
"@nestjs/platform-express": "^10.3.10",
|
||||
"@nestjs/swagger": "^7.4.0",
|
||||
"@nestjs/terminus": "^10.2.3",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"passport-local": "^1.0.0",
|
||||
"pg": "^8.12.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.4.5",
|
||||
"@nestjs/testing": "^10.3.10",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/passport-local": "^1.0.38",
|
||||
"@types/pg": "^8.11.6",
|
||||
"typescript": "^5.5.3",
|
||||
"vitest": "^2.1.9"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Body, Controller, Post, Res, UseGuards } from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import { Response } from "express";
|
||||
import { AiService } from "./ai.service";
|
||||
import { ChatDto } from "./dto/chat.dto";
|
||||
import { RateLimitGuard } from "../common/rate-limit.guard";
|
||||
|
||||
@ApiTags("ai")
|
||||
@Controller("ai")
|
||||
@UseGuards(RateLimitGuard)
|
||||
export class AiController {
|
||||
constructor(private readonly ai: AiService) {}
|
||||
|
||||
@Post("chat")
|
||||
@ApiOperation({ summary: "与文物进行 AI 角色对话(SSE 流式输出)" })
|
||||
async chat(@Body() dto: ChatDto, @Res() res: Response): Promise<void> {
|
||||
res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
|
||||
res.setHeader("Cache-Control", "no-cache, no-transform");
|
||||
res.setHeader("Connection", "keep-alive");
|
||||
res.setHeader("X-Accel-Buffering", "no");
|
||||
res.flushHeaders?.();
|
||||
|
||||
try {
|
||||
await this.ai.streamChat(dto, (token) => {
|
||||
res.write(`data: ${JSON.stringify({ t: token })}\n\n`);
|
||||
});
|
||||
res.write("data: [DONE]\n\n");
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "AI 服务异常";
|
||||
res.write(`data: ${JSON.stringify({ error: message })}\n\n`);
|
||||
} finally {
|
||||
res.end();
|
||||
}
|
||||
}
|
||||
|
||||
@Post("suggestions")
|
||||
@ApiOperation({ summary: "生成 4 个下一步追问建议" })
|
||||
async suggestions(@Body() dto: ChatDto): Promise<{ suggestions: string[] }> {
|
||||
const suggestions = await this.ai.getSuggestions(dto);
|
||||
return { suggestions };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { AiService } from "./ai.service";
|
||||
import { AiController } from "./ai.controller";
|
||||
|
||||
@Module({
|
||||
providers: [AiService],
|
||||
controllers: [AiController],
|
||||
})
|
||||
export class AiModule {}
|
||||
@@ -0,0 +1,284 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { DatabaseService } from "../database/database.service";
|
||||
import { ChatDto } from "./dto/chat.dto";
|
||||
import { parseSuggestions } from "./suggestions.util";
|
||||
|
||||
interface ArtifactContext {
|
||||
name: string;
|
||||
category: string;
|
||||
dynasty: string | null;
|
||||
level: string | null;
|
||||
material: string | null;
|
||||
summary: string | null;
|
||||
story_hook: string | null;
|
||||
persona_quote: string | null;
|
||||
current_status: string | null;
|
||||
institution_name: string | null;
|
||||
city: string | null;
|
||||
province: string | null;
|
||||
country: string | null;
|
||||
location_type: string | null;
|
||||
}
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
bronze: "青铜器",
|
||||
painting_calligraphy: "书画",
|
||||
porcelain: "陶瓷",
|
||||
jade: "玉器",
|
||||
gold_silver: "金银器",
|
||||
lacquer: "漆木器",
|
||||
textile: "织绣",
|
||||
stone_carving: "石刻造像",
|
||||
wood_carving: "木雕",
|
||||
dunhuang: "敦煌遗珍",
|
||||
ancient_book: "古籍文献",
|
||||
other: "其他",
|
||||
};
|
||||
|
||||
const LEVEL_LABELS: Record<string, string> = {
|
||||
level_1: "国家一级文物",
|
||||
level_2: "国家二级文物",
|
||||
level_3: "国家三级文物",
|
||||
general: "一般文物",
|
||||
unknown: "未定级",
|
||||
};
|
||||
|
||||
export type ChatPersona = "artifact" | "guide" | "scholar" | "migration" | "repatriation" | "youth";
|
||||
|
||||
@Injectable()
|
||||
export class AiService {
|
||||
private readonly logger = new Logger(AiService.name);
|
||||
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
private readonly db: DatabaseService
|
||||
) {}
|
||||
|
||||
private async getArtifactContext(artifactId: string): Promise<ArtifactContext | null> {
|
||||
const { rows } = await this.db.query<ArtifactContext>(
|
||||
`SELECT a.name, a.category, a.dynasty, a.level, a.material, a.summary,
|
||||
a.story_hook, a.persona_quote, a.current_status,
|
||||
i.name AS institution_name, i.city, i.province, i.country,
|
||||
al.location_type
|
||||
FROM artifacts a
|
||||
LEFT JOIN artifact_locations al ON a.id = al.artifact_id AND al.is_current = true
|
||||
LEFT JOIN institutions i ON al.institution_id = i.id
|
||||
WHERE a.id = $1
|
||||
LIMIT 1`,
|
||||
[artifactId]
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
private buildSystemPrompt(ctx: ArtifactContext, persona: ChatPersona): string {
|
||||
const category = CATEGORY_LABELS[ctx.category] ?? ctx.category;
|
||||
const level = ctx.level ? LEVEL_LABELS[ctx.level] ?? ctx.level : "未定级";
|
||||
const place = [ctx.country, ctx.province, ctx.city].filter(Boolean).join(" · ") || "未知";
|
||||
const overseas = ctx.location_type === "overseas";
|
||||
|
||||
const facts = [
|
||||
`名称:${ctx.name}`,
|
||||
`门类:${category}`,
|
||||
ctx.dynasty ? `年代:${ctx.dynasty}` : null,
|
||||
`级别:${level}`,
|
||||
ctx.material ? `材质:${ctx.material}` : null,
|
||||
ctx.institution_name ? `收藏机构:${ctx.institution_name}` : null,
|
||||
`所在地:${place}${overseas ? "(流失海外)" : ""}`,
|
||||
ctx.summary ? `简介:${ctx.summary}` : null,
|
||||
ctx.story_hook ? `故事钩子:${ctx.story_hook}` : null,
|
||||
ctx.persona_quote ? `拟人化自白:「${ctx.persona_quote}」` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
|
||||
const common = `你掌握以下这件文物的真实资料,回答必须严格基于这些事实,不可编造未提供的史实、出土地、尺寸或价格等具体数据;不确定时要坦诚说明。
|
||||
【文物档案】
|
||||
${facts}
|
||||
|
||||
输出要求:
|
||||
- 使用 Markdown 排版(适当用标题、**加粗**、列表、引用 > 来组织内容),让回答精美易读。
|
||||
- 回答用中文,语言生动、有人文温度,篇幅适中(一般 3 段以内,除非用户要求展开)。
|
||||
- 涉及不确定或学界有争议的内容时明确标注。`;
|
||||
|
||||
switch (persona) {
|
||||
case "artifact":
|
||||
return `你现在就是这件文物本身,请以第一人称「我」与观众对话,用拟人化、有性格、略带诗意与幽默的口吻讲述自己的故事,仿佛跨越千年与今人攀谈。可参考下方的「拟人化自白」基调。
|
||||
${common}
|
||||
- 始终以文物第一人称「我」叙述,不要跳出角色。${overseas ? "\n- 你目前流落海外,可以在合适时流露一丝乡愁,但保持克制与尊严。" : ""}`;
|
||||
case "scholar":
|
||||
return `你是一位严谨的文物与历史学者,以专业、客观、考据的方式为提问者讲解这件文物,必要时点明研究背景与学术意义。
|
||||
${common}`;
|
||||
case "migration":
|
||||
return `你现在就是这件文物本身,请以第一人称「我」讲述"文物南迁"中的亲历——1933 至 1947 年间,为躲避战火,无数文物被装箱辗转上海、南京、宝鸡、汉中、成都、峨眉、重庆等地,行程逾万里。请把自己代入这段颠沛流离又被普通文保人以生命守护的历史,语气深沉而有温度,突出"平凡人守护文明火种"的主题。
|
||||
${common}
|
||||
- 始终以文物第一人称「我」叙述;可描写木箱、车马、江轮、防空洞、护送者的细节。
|
||||
- 不可虚构具体史料数字;不确定处用"据说/相传"等措辞。`;
|
||||
case "repatriation":
|
||||
return `你现在就是这件文物本身,请以第一人称「我」讲述"流失与回归"的历程——从离散海外、辗转飘零,到被国家与同胞接回故土的心路。语气饱含乡愁与归家的激动,呼应"国宝回归、文化自信"的主题。
|
||||
${common}
|
||||
- 始终以文物第一人称「我」叙述。${overseas ? "\n- 你目前仍流落海外,讲述时可表达对回家的期盼。" : "\n- 若你已回归,可讲述重返故土的喜悦与不易。"}`;
|
||||
case "youth":
|
||||
return `你是一位面向中小学生的"小小讲解员",用活泼、亲切、好懂的语言介绍这件文物,多用类比和提问,适当穿插一个小知识点或趣味问答,激发青少年对文物保护的兴趣。
|
||||
${common}
|
||||
- 语言浅显生动,避免生僻术语;可以在结尾抛出一个引导思考的小问题。`;
|
||||
case "guide":
|
||||
default:
|
||||
return `你是博物馆里一位亲切的资深讲解员,面向普通观众,用通俗易懂又引人入胜的方式介绍这件文物,善于用类比和故事吸引兴趣。
|
||||
${common}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 以流式方式与 DashScope(OpenAI 兼容)对话,逐 token 回调。
|
||||
*/
|
||||
async streamChat(dto: ChatDto, onToken: (token: string) => void): Promise<void> {
|
||||
const apiKey = this.config.get<string>("AI_API_KEY");
|
||||
const baseUrl =
|
||||
this.config.get<string>("AI_BASE_URL") ??
|
||||
"https://dashscope.aliyuncs.com/compatible-mode/v1";
|
||||
const model = this.config.get<string>("AI_MODEL") ?? "qwen-plus";
|
||||
|
||||
if (!apiKey) {
|
||||
throw new Error("AI 服务未配置:请在 .env 中设置 AI_API_KEY");
|
||||
}
|
||||
|
||||
const ctx = await this.getArtifactContext(dto.artifactId);
|
||||
if (!ctx) {
|
||||
throw new Error("未找到该文物");
|
||||
}
|
||||
|
||||
const systemPrompt = this.buildSystemPrompt(ctx, dto.persona ?? "artifact");
|
||||
|
||||
const payload = {
|
||||
model,
|
||||
stream: true,
|
||||
temperature: 0.8,
|
||||
messages: [
|
||||
{ role: "system", content: systemPrompt },
|
||||
...dto.messages.map((m) => ({ role: m.role, content: m.content })),
|
||||
],
|
||||
};
|
||||
|
||||
const resp = await fetch(`${baseUrl.replace(/\/$/, "")}/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!resp.ok || !resp.body) {
|
||||
const text = await resp.text().catch(() => "");
|
||||
this.logger.error(`上游 AI 接口错误 ${resp.status}: ${text}`);
|
||||
throw new Error(`AI 接口返回 ${resp.status}`);
|
||||
}
|
||||
|
||||
const reader = resp.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
// 解析 OpenAI 兼容的 SSE 流:每个事件以 \n\n 分隔,数据行以 "data: " 开头
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
let sepIndex: number;
|
||||
while ((sepIndex = buffer.indexOf("\n\n")) >= 0) {
|
||||
const rawEvent = buffer.slice(0, sepIndex);
|
||||
buffer = buffer.slice(sepIndex + 2);
|
||||
|
||||
for (const line of rawEvent.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed.startsWith("data:")) continue;
|
||||
const data = trimmed.slice(5).trim();
|
||||
if (data === "[DONE]") return;
|
||||
try {
|
||||
const json = JSON.parse(data);
|
||||
const token: string | undefined = json?.choices?.[0]?.delta?.content;
|
||||
if (token) onToken(token);
|
||||
} catch {
|
||||
// 忽略无法解析的心跳/分片
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 基于文物资料与已有对话,生成 4 个“下一步追问”建议(非流式)。
|
||||
*/
|
||||
async getSuggestions(dto: ChatDto): Promise<string[]> {
|
||||
const apiKey = this.config.get<string>("AI_API_KEY");
|
||||
if (!apiKey) throw new Error("AI 服务未配置:请在 .env 中设置 AI_API_KEY");
|
||||
|
||||
const ctx = await this.getArtifactContext(dto.artifactId);
|
||||
if (!ctx) throw new Error("未找到该文物");
|
||||
|
||||
const category = CATEGORY_LABELS[ctx.category] ?? ctx.category;
|
||||
const facts = [
|
||||
`名称:${ctx.name}`,
|
||||
`门类:${category}`,
|
||||
ctx.dynasty ? `年代:${ctx.dynasty}` : null,
|
||||
ctx.institution_name ? `收藏机构:${ctx.institution_name}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(";");
|
||||
|
||||
const convo = dto.messages
|
||||
.slice(-6)
|
||||
.map((m) => `${m.role === "user" ? "观众" : "文物"}:${m.content}`)
|
||||
.join("\n");
|
||||
|
||||
const sys = `你在为一个文物科普对话生成"下一步追问"建议。请站在观众视角,结合文物资料与已有对话,提出 4 个简短(每个不超过 16 个字)、具体、能引发兴趣且彼此不重复的中文追问。
|
||||
只输出一个 JSON 字符串数组,形如 ["问题1","问题2","问题3","问题4"],不要任何额外说明或编号。
|
||||
【文物】${facts}`;
|
||||
|
||||
const userMsg = convo
|
||||
? `已有对话:\n${convo}\n\n请生成 4 个自然的后续追问。`
|
||||
: `请生成 4 个适合作为开场的追问。`;
|
||||
|
||||
const content = await this.chatComplete(
|
||||
[
|
||||
{ role: "system", content: sys },
|
||||
{ role: "user", content: userMsg },
|
||||
],
|
||||
0.9
|
||||
);
|
||||
|
||||
return parseSuggestions(content);
|
||||
}
|
||||
|
||||
private async chatComplete(
|
||||
messages: { role: string; content: string }[],
|
||||
temperature = 0.7
|
||||
): Promise<string> {
|
||||
const apiKey = this.config.get<string>("AI_API_KEY");
|
||||
const baseUrl =
|
||||
this.config.get<string>("AI_BASE_URL") ??
|
||||
"https://dashscope.aliyuncs.com/compatible-mode/v1";
|
||||
const model = this.config.get<string>("AI_MODEL") ?? "qwen-plus";
|
||||
|
||||
const resp = await fetch(`${baseUrl.replace(/\/$/, "")}/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({ model, temperature, stream: false, messages }),
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const t = await resp.text().catch(() => "");
|
||||
this.logger.error(`上游 AI 接口错误 ${resp.status}: ${t}`);
|
||||
throw new Error(`AI 接口返回 ${resp.status}`);
|
||||
}
|
||||
|
||||
const json = (await resp.json()) as {
|
||||
choices?: { message?: { content?: string } }[];
|
||||
};
|
||||
return json?.choices?.[0]?.message?.content ?? "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Type } from "class-transformer";
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
IsArray,
|
||||
IsIn,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
ValidateNested,
|
||||
} from "class-validator";
|
||||
|
||||
export class ChatMessageDto {
|
||||
@IsIn(["user", "assistant"])
|
||||
role!: "user" | "assistant";
|
||||
|
||||
@IsString()
|
||||
@MaxLength(4000)
|
||||
content!: string;
|
||||
}
|
||||
|
||||
export class ChatDto {
|
||||
@IsString()
|
||||
artifactId!: string;
|
||||
|
||||
/** 角色设置:文物自述 / 博物馆讲解员 / 历史学者 / 南迁亲历 / 回归叙事 / 青少年讲解员 */
|
||||
@IsOptional()
|
||||
@IsIn(["artifact", "guide", "scholar", "migration", "repatriation", "youth"])
|
||||
persona?: "artifact" | "guide" | "scholar" | "migration" | "repatriation" | "youth";
|
||||
|
||||
@IsArray()
|
||||
@ArrayMaxSize(40)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => ChatMessageDto)
|
||||
messages!: ChatMessageDto[];
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseSuggestions } from "./suggestions.util";
|
||||
|
||||
describe("parseSuggestions", () => {
|
||||
it("解析标准 JSON 数组", () => {
|
||||
const out = parseSuggestions('["问题一","问题二","问题三","问题四"]');
|
||||
expect(out).toEqual(["问题一", "问题二", "问题三", "问题四"]);
|
||||
});
|
||||
|
||||
it("从夹带文字中提取 JSON 数组", () => {
|
||||
const out = parseSuggestions('好的,建议如下:\n["A","B"]\n谢谢');
|
||||
expect(out).toEqual(["A", "B"]);
|
||||
});
|
||||
|
||||
it("最多返回 4 条", () => {
|
||||
const out = parseSuggestions('["1","2","3","4","5","6"]');
|
||||
expect(out).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("JSON 失败时按行解析并清理编号", () => {
|
||||
const out = parseSuggestions("1. 它是谁\n2、它从哪来\n- 它去哪");
|
||||
expect(out).toEqual(["它是谁", "它从哪来", "它去哪"]);
|
||||
});
|
||||
|
||||
it("过滤非字符串与空白", () => {
|
||||
const out = parseSuggestions('["有效", "", " "]');
|
||||
expect(out).toEqual(["有效"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* 从模型输出中解析“下一步追问”建议。
|
||||
* 优先解析 JSON 数组,失败则按行解析并清理编号/符号,最多返回 4 条。
|
||||
*/
|
||||
export function parseSuggestions(text: string): string[] {
|
||||
let items: string[] = [];
|
||||
|
||||
const match = text.match(/\[[\s\S]*\]/);
|
||||
if (match) {
|
||||
try {
|
||||
const arr: unknown = JSON.parse(match[0]);
|
||||
if (Array.isArray(arr)) {
|
||||
items = arr.filter((x): x is string => typeof x === "string");
|
||||
}
|
||||
} catch {
|
||||
/* fall through to line parsing */
|
||||
}
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
items = text
|
||||
.split("\n")
|
||||
.map((l) =>
|
||||
l
|
||||
.replace(/^[\s\-*0-9.、))"“”]+/, "")
|
||||
.replace(/["“”]+$/, "")
|
||||
.trim()
|
||||
)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
return items.map((s) => s.trim()).filter(Boolean).slice(0, 4);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
import { TerminusModule } from "@nestjs/terminus";
|
||||
import { DatabaseModule } from "./database/database.module";
|
||||
import { AuthModule } from "./auth/auth.module";
|
||||
import { MapModule } from "./map/map.module";
|
||||
import { ArtifactsModule } from "./artifacts/artifacts.module";
|
||||
import { InstitutionsModule } from "./institutions/institutions.module";
|
||||
import { AiModule } from "./ai/ai.module";
|
||||
import { AssetsModule } from "./assets/assets.module";
|
||||
import { RoutesModule } from "./routes/routes.module";
|
||||
import { HealthController } from "./health/health.controller";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({ isGlobal: true, envFilePath: ["../../.env", ".env"] }),
|
||||
TerminusModule,
|
||||
DatabaseModule,
|
||||
AuthModule,
|
||||
MapModule,
|
||||
ArtifactsModule,
|
||||
InstitutionsModule,
|
||||
AiModule,
|
||||
AssetsModule,
|
||||
RoutesModule,
|
||||
],
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class AppModule {}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Controller, Get, Param, Query } from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import { ArtifactsService } from "./artifacts.service";
|
||||
import { ArtifactQueryDto } from "./dto/artifact-query.dto";
|
||||
|
||||
@ApiTags("Artifacts")
|
||||
@Controller("artifacts")
|
||||
export class ArtifactsController {
|
||||
constructor(private artifacts: ArtifactsService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: "文物列表(支持搜索/筛选/分页)" })
|
||||
findAll(@Query() query: ArtifactQueryDto) {
|
||||
return this.artifacts.findAll(query);
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
@ApiOperation({ summary: "文物详情" })
|
||||
findOne(@Param("id") id: string) {
|
||||
return this.artifacts.findOne(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ArtifactsService } from "./artifacts.service";
|
||||
import { ArtifactsController } from "./artifacts.controller";
|
||||
|
||||
@Module({
|
||||
providers: [ArtifactsService],
|
||||
controllers: [ArtifactsController],
|
||||
})
|
||||
export class ArtifactsModule {}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { DatabaseService } from "../database/database.service";
|
||||
import { ArtifactQueryDto } from "./dto/artifact-query.dto";
|
||||
|
||||
@Injectable()
|
||||
export class ArtifactsService {
|
||||
constructor(private db: DatabaseService) {}
|
||||
|
||||
async findAll(query: ArtifactQueryDto) {
|
||||
const page = query.page ?? 1;
|
||||
const limit = query.limit ?? 20;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const conditions: string[] = ["a.publish_status = 'published'"];
|
||||
const params: unknown[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (query.q) {
|
||||
conditions.push(`(a.name ILIKE $${idx} OR a.summary ILIKE $${idx})`);
|
||||
params.push(`%${query.q}%`);
|
||||
idx++;
|
||||
}
|
||||
if (query.category) {
|
||||
conditions.push(`a.category = $${idx++}`);
|
||||
params.push(query.category);
|
||||
}
|
||||
if (query.level) {
|
||||
conditions.push(`a.level = $${idx++}`);
|
||||
params.push(query.level);
|
||||
}
|
||||
if (query.dynasty) {
|
||||
conditions.push(`a.dynasty ILIKE $${idx++}`);
|
||||
params.push(`%${query.dynasty}%`);
|
||||
}
|
||||
if (query.institution_id) {
|
||||
conditions.push(
|
||||
`EXISTS (SELECT 1 FROM artifact_locations al WHERE al.artifact_id = a.id AND al.institution_id = $${idx++} AND al.is_current = true)`
|
||||
);
|
||||
params.push(query.institution_id);
|
||||
}
|
||||
if (query.tag_ids) {
|
||||
const ids = query.tag_ids.split(",").filter(Boolean);
|
||||
if (ids.length) {
|
||||
conditions.push(
|
||||
`EXISTS (SELECT 1 FROM artifact_tags at2 WHERE at2.artifact_id = a.id AND at2.tag_id = ANY($${idx++}::uuid[]))`
|
||||
);
|
||||
params.push(ids);
|
||||
}
|
||||
}
|
||||
|
||||
const where = conditions.join(" AND ");
|
||||
const countParams = [...params];
|
||||
const countResult = await this.db.query<{ count: string }>(
|
||||
`SELECT COUNT(*) as count FROM artifacts a WHERE ${where}`,
|
||||
countParams
|
||||
);
|
||||
const total = parseInt(countResult.rows[0].count);
|
||||
|
||||
params.push(limit, offset);
|
||||
const { rows } = await this.db.query<{
|
||||
id: string;
|
||||
name: string;
|
||||
category: string;
|
||||
level: string;
|
||||
dynasty: string;
|
||||
current_institution: string;
|
||||
story_hook: string;
|
||||
}>(
|
||||
`SELECT a.id, a.name, a.category, a.level, a.dynasty,
|
||||
a.story_hook,
|
||||
i.name AS current_institution
|
||||
FROM artifacts a
|
||||
LEFT JOIN artifact_locations al ON a.id = al.artifact_id AND al.is_current = true
|
||||
LEFT JOIN institutions i ON al.institution_id = i.id
|
||||
WHERE ${where}
|
||||
ORDER BY a.level DESC, a.created_at DESC
|
||||
LIMIT $${idx++} OFFSET $${idx++}`,
|
||||
params
|
||||
);
|
||||
|
||||
return {
|
||||
data: rows,
|
||||
meta: { total, page, limit, total_pages: Math.ceil(total / limit) },
|
||||
};
|
||||
}
|
||||
|
||||
async findOne(id: string) {
|
||||
const { rows } = await this.db.query<Record<string, unknown>>(
|
||||
`SELECT a.*,
|
||||
i.name AS current_institution_name,
|
||||
i.id AS current_institution_id,
|
||||
ST_X(al.public_location::geometry) AS lng,
|
||||
ST_Y(al.public_location::geometry) AS lat,
|
||||
al.location_type,
|
||||
al.precision AS location_precision
|
||||
FROM artifacts a
|
||||
LEFT JOIN artifact_locations al ON a.id = al.artifact_id AND al.is_current = true
|
||||
LEFT JOIN institutions i ON al.institution_id = i.id
|
||||
WHERE a.id = $1 AND a.publish_status = 'published'`,
|
||||
[id]
|
||||
);
|
||||
if (!rows.length) throw new NotFoundException("文物不存在");
|
||||
|
||||
const [artifact] = rows;
|
||||
|
||||
const tagResult = await this.db.query<{ id: string; name: string; category_name: string }>(
|
||||
`SELECT t.id, t.name, tc.name AS category_name
|
||||
FROM tags t
|
||||
JOIN artifact_tags at2 ON t.id = at2.tag_id
|
||||
JOIN tag_categories tc ON t.category_id = tc.id
|
||||
WHERE at2.artifact_id = $1`,
|
||||
[id]
|
||||
);
|
||||
|
||||
return { ...artifact, tags: tagResult.rows };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { IsInt, IsOptional, IsString, Max, Min } from "class-validator";
|
||||
import { Type } from "class-transformer";
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
|
||||
export class ArtifactQueryDto {
|
||||
@ApiPropertyOptional({ description: "关键词搜索(名称/描述)" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
q?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: "文物门类", example: "bronze" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
category?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: "文物级别", example: "national_first" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
level?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: "朝代" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
dynasty?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: "机构ID" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
institution_id?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: "标签ID(逗号分隔)" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
tag_ids?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: "页码", default: 1 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number = 1;
|
||||
|
||||
@ApiPropertyOptional({ description: "每页数量", default: 20 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
limit?: number = 20;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Controller, Get, Param, Query, Res } from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import { Response } from "express";
|
||||
import { AssetsService } from "./assets.service";
|
||||
|
||||
@ApiTags("assets")
|
||||
@Controller("assets")
|
||||
export class AssetsController {
|
||||
constructor(private readonly assets: AssetsService) {}
|
||||
|
||||
@Get("image/:artifactId")
|
||||
@ApiOperation({ summary: "文物图片代理(同源 + 本地缓存),失败返回 404" })
|
||||
async image(
|
||||
@Param("artifactId") artifactId: string,
|
||||
@Query("hd") hd: string | undefined,
|
||||
@Res() res: Response
|
||||
): Promise<void> {
|
||||
const img = await this.assets.getArtifactImage(artifactId, hd === "1");
|
||||
if (!img) {
|
||||
res.status(404).end();
|
||||
return;
|
||||
}
|
||||
res.setHeader("Content-Type", img.contentType);
|
||||
res.setHeader("Cache-Control", "public, max-age=86400");
|
||||
res.end(img.buffer);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { AssetsService } from "./assets.service";
|
||||
import { AssetsController } from "./assets.controller";
|
||||
|
||||
@Module({
|
||||
providers: [AssetsService],
|
||||
controllers: [AssetsController],
|
||||
})
|
||||
export class AssetsModule {}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import { DatabaseService } from "../database/database.service";
|
||||
|
||||
interface CachedImage {
|
||||
buffer: Buffer;
|
||||
contentType: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 文物图片代理 + 本地磁盘缓存。
|
||||
* 浏览器统一从本服务的同源地址取图,首次访问时从上游(如 Wikimedia)拉取并落盘缓存,
|
||||
* 之后直接由本地提供,实现“自建托管”。上游不可达时返回 null,前端回退到示意图。
|
||||
*/
|
||||
@Injectable()
|
||||
export class AssetsService {
|
||||
private readonly logger = new Logger(AssetsService.name);
|
||||
private readonly cacheDir = path.resolve(process.cwd(), ".cache/images");
|
||||
|
||||
constructor(private readonly db: DatabaseService) {
|
||||
fs.mkdirSync(this.cacheDir, { recursive: true });
|
||||
}
|
||||
|
||||
private async getImageUrl(artifactId: string): Promise<string | null> {
|
||||
const { rows } = await this.db.query<{ image_url: string | null }>(
|
||||
"SELECT image_url FROM artifacts WHERE id = $1 LIMIT 1",
|
||||
[artifactId]
|
||||
);
|
||||
return rows[0]?.image_url ?? null;
|
||||
}
|
||||
|
||||
async getArtifactImage(artifactId: string, hd: boolean): Promise<CachedImage | null> {
|
||||
const key = `${artifactId}${hd ? "_hd" : ""}`;
|
||||
const binPath = path.join(this.cacheDir, key);
|
||||
const typePath = path.join(this.cacheDir, `${key}.type`);
|
||||
|
||||
// 命中本地缓存
|
||||
if (fs.existsSync(binPath) && fs.existsSync(typePath)) {
|
||||
return {
|
||||
buffer: fs.readFileSync(binPath),
|
||||
contentType: fs.readFileSync(typePath, "utf-8") || "image/jpeg",
|
||||
};
|
||||
}
|
||||
|
||||
const rawUrl = await this.getImageUrl(artifactId);
|
||||
if (!rawUrl) return null;
|
||||
|
||||
const url = hd
|
||||
? /\?width=\d+/.test(rawUrl)
|
||||
? rawUrl.replace(/\?width=\d+/, "?width=2000")
|
||||
: `${rawUrl}${rawUrl.includes("?") ? "&" : "?"}width=2000`
|
||||
: rawUrl;
|
||||
|
||||
try {
|
||||
const resp = await fetch(url, { signal: AbortSignal.timeout(15_000) });
|
||||
if (!resp.ok) {
|
||||
this.logger.warn(`拉取图片失败 ${resp.status}: ${url}`);
|
||||
return null;
|
||||
}
|
||||
const contentType = resp.headers.get("content-type") ?? "image/jpeg";
|
||||
const buffer = Buffer.from(await resp.arrayBuffer());
|
||||
// 落盘缓存
|
||||
fs.writeFileSync(binPath, buffer);
|
||||
fs.writeFileSync(typePath, contentType);
|
||||
return { buffer, contentType };
|
||||
} catch (err) {
|
||||
this.logger.warn(`图片代理异常:${(err as Error).message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Body, Controller, Get, Post, Request, UseGuards } from "@nestjs/common";
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import { AuthService } from "./auth.service";
|
||||
import { LoginDto } from "./dto/login.dto";
|
||||
import { JwtAuthGuard } from "./jwt-auth.guard";
|
||||
|
||||
@ApiTags("Auth")
|
||||
@Controller("auth")
|
||||
export class AuthController {
|
||||
constructor(private auth: AuthService) {}
|
||||
|
||||
@Post("login")
|
||||
@ApiOperation({ summary: "管理员登录" })
|
||||
login(@Body() dto: LoginDto) {
|
||||
return this.auth.login(dto);
|
||||
}
|
||||
|
||||
@Get("me")
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: "获取当前用户信息" })
|
||||
me(@Request() req: { user: { id: string } }) {
|
||||
return this.auth.me(req.user.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { JwtModule } from "@nestjs/jwt";
|
||||
import { PassportModule } from "@nestjs/passport";
|
||||
import { ConfigModule, ConfigService } from "@nestjs/config";
|
||||
import { AuthService } from "./auth.service";
|
||||
import { AuthController } from "./auth.controller";
|
||||
import { JwtStrategy } from "./jwt.strategy";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
PassportModule,
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
secret: config.get<string>("JWT_SECRET"),
|
||||
signOptions: { expiresIn: config.get<string>("JWT_EXPIRES_IN") ?? "7d" },
|
||||
}),
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
],
|
||||
providers: [AuthService, JwtStrategy],
|
||||
controllers: [AuthController],
|
||||
exports: [JwtModule],
|
||||
})
|
||||
export class AuthModule {}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Injectable, UnauthorizedException } from "@nestjs/common";
|
||||
import { JwtService } from "@nestjs/jwt";
|
||||
import { DatabaseService } from "../database/database.service";
|
||||
import { LoginDto } from "./dto/login.dto";
|
||||
import * as bcrypt from "bcryptjs";
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private db: DatabaseService,
|
||||
private jwt: JwtService
|
||||
) {}
|
||||
|
||||
async login(dto: LoginDto) {
|
||||
const { rows } = await this.db.query<{
|
||||
id: string;
|
||||
username: string;
|
||||
password_hash: string;
|
||||
nickname: string;
|
||||
role: string;
|
||||
}>(
|
||||
`SELECT u.id, u.username, u.password_hash, u.nickname, r.name as role
|
||||
FROM users u
|
||||
JOIN user_roles ur ON u.id = ur.user_id
|
||||
JOIN roles r ON ur.role_id = r.id
|
||||
WHERE (u.username = $1 OR u.email = $1) AND u.is_active = true
|
||||
LIMIT 1`,
|
||||
[dto.username]
|
||||
);
|
||||
|
||||
if (!rows.length) throw new UnauthorizedException("用户名或密码错误");
|
||||
|
||||
const user = rows[0];
|
||||
const valid = await bcrypt.compare(dto.password, user.password_hash);
|
||||
if (!valid) throw new UnauthorizedException("用户名或密码错误");
|
||||
|
||||
const payload = { sub: user.id, username: user.username, role: user.role };
|
||||
return {
|
||||
access_token: this.jwt.sign(payload),
|
||||
user: { id: user.id, username: user.username, nickname: user.nickname, role: user.role },
|
||||
};
|
||||
}
|
||||
|
||||
async me(userId: string) {
|
||||
const { rows } = await this.db.query<{
|
||||
id: string;
|
||||
username: string;
|
||||
nickname: string;
|
||||
email: string;
|
||||
role: string;
|
||||
}>(
|
||||
`SELECT u.id, u.username, u.nickname, u.email, r.name as role
|
||||
FROM users u
|
||||
JOIN user_roles ur ON u.id = ur.user_id
|
||||
JOIN roles r ON ur.role_id = r.id
|
||||
WHERE u.id = $1 LIMIT 1`,
|
||||
[userId]
|
||||
);
|
||||
if (!rows.length) throw new UnauthorizedException();
|
||||
return rows[0];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { IsString, MinLength } from "class-validator";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
|
||||
export class LoginDto {
|
||||
@ApiProperty({ example: "admin" })
|
||||
@IsString()
|
||||
username!: string;
|
||||
|
||||
@ApiProperty({ example: "password123" })
|
||||
@IsString()
|
||||
@MinLength(6)
|
||||
password!: string;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { AuthGuard } from "@nestjs/passport";
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard("jwt") {}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Injectable, UnauthorizedException } from "@nestjs/common";
|
||||
import { PassportStrategy } from "@nestjs/passport";
|
||||
import { ExtractJwt, Strategy } from "passport-jwt";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { DatabaseService } from "../database/database.service";
|
||||
|
||||
export interface JwtPayload {
|
||||
sub: string;
|
||||
username: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(config: ConfigService, private db: DatabaseService) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
secretOrKey: config.get<string>("JWT_SECRET") ?? "fallback-secret",
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: JwtPayload) {
|
||||
const { rows } = await this.db.query(
|
||||
`SELECT u.id, u.username, u.nickname, r.name as role
|
||||
FROM users u JOIN user_roles ur ON u.id = ur.user_id
|
||||
JOIN roles r ON ur.role_id = r.id
|
||||
WHERE u.id = $1 AND u.is_active = true LIMIT 1`,
|
||||
[payload.sub]
|
||||
);
|
||||
if (!rows.length) throw new UnauthorizedException();
|
||||
return rows[0];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Injectable,
|
||||
} from "@nestjs/common";
|
||||
import { Request } from "express";
|
||||
|
||||
/**
|
||||
* 简单的内存级 IP 限流守卫:滑动窗口。
|
||||
* 用于保护成本敏感的 AI 接口,防止被刷爆额度。
|
||||
*/
|
||||
@Injectable()
|
||||
export class RateLimitGuard implements CanActivate {
|
||||
private static readonly WINDOW_MS = 60_000;
|
||||
private static readonly MAX_REQUESTS = 20;
|
||||
private static readonly buckets = new Map<string, number[]>();
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const req = context.switchToHttp().getRequest<Request>();
|
||||
const forwarded = req.headers["x-forwarded-for"];
|
||||
const ip =
|
||||
(Array.isArray(forwarded) ? forwarded[0] : forwarded?.split(",")[0])?.trim() ||
|
||||
req.ip ||
|
||||
"unknown";
|
||||
|
||||
const now = Date.now();
|
||||
const recent = (RateLimitGuard.buckets.get(ip) ?? []).filter(
|
||||
(t) => now - t < RateLimitGuard.WINDOW_MS
|
||||
);
|
||||
|
||||
if (recent.length >= RateLimitGuard.MAX_REQUESTS) {
|
||||
throw new HttpException(
|
||||
"请求过于频繁,请稍后再试",
|
||||
HttpStatus.TOO_MANY_REQUESTS
|
||||
);
|
||||
}
|
||||
|
||||
recent.push(now);
|
||||
RateLimitGuard.buckets.set(ip, recent);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from "@nestjs/common";
|
||||
import { DatabaseService } from "./database.service";
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [DatabaseService],
|
||||
exports: [DatabaseService],
|
||||
})
|
||||
export class DatabaseModule {}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Injectable, OnModuleDestroy, OnModuleInit, Logger } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Pool, PoolClient, QueryResult } from "pg";
|
||||
|
||||
@Injectable()
|
||||
export class DatabaseService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(DatabaseService.name);
|
||||
private pool!: Pool;
|
||||
|
||||
constructor(private config: ConfigService) {}
|
||||
|
||||
async onModuleInit() {
|
||||
this.pool = new Pool({ connectionString: this.config.get<string>("DATABASE_URL") });
|
||||
const client = await this.pool.connect();
|
||||
client.release();
|
||||
this.logger.log("PostgreSQL connection pool ready");
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
await this.pool.end();
|
||||
}
|
||||
|
||||
async query<T extends object = Record<string, unknown>>(sql: string, params?: unknown[]): Promise<QueryResult<T>> {
|
||||
return this.pool.query<T>(sql, params);
|
||||
}
|
||||
|
||||
async transaction<T>(fn: (client: PoolClient) => Promise<T>): Promise<T> {
|
||||
const client = await this.pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
const result = await fn(client);
|
||||
await client.query("COMMIT");
|
||||
return result;
|
||||
} catch (err) {
|
||||
await client.query("ROLLBACK");
|
||||
throw err;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Controller, Get } from "@nestjs/common";
|
||||
import { ApiTags, ApiOperation } from "@nestjs/swagger";
|
||||
|
||||
@ApiTags("health")
|
||||
@Controller("health")
|
||||
export class HealthController {
|
||||
@Get()
|
||||
@ApiOperation({ summary: "服务健康检查" })
|
||||
check() {
|
||||
return {
|
||||
status: "ok",
|
||||
time: new Date().toISOString(),
|
||||
service: "wenwumap-api",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Controller, Get, Param, Query } from "@nestjs/common";
|
||||
import { ApiOperation, ApiQuery, ApiTags } from "@nestjs/swagger";
|
||||
import { InstitutionsService } from "./institutions.service";
|
||||
|
||||
@ApiTags("Institutions")
|
||||
@Controller("institutions")
|
||||
export class InstitutionsController {
|
||||
constructor(private institutions: InstitutionsService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: "机构列表" })
|
||||
@ApiQuery({ name: "page", required: false, type: Number })
|
||||
@ApiQuery({ name: "limit", required: false, type: Number })
|
||||
findAll(
|
||||
@Query("page") page?: string,
|
||||
@Query("limit") limit?: string
|
||||
) {
|
||||
return this.institutions.findAll(page ? parseInt(page) : 1, limit ? parseInt(limit) : 20);
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
@ApiOperation({ summary: "机构详情" })
|
||||
findOne(@Param("id") id: string) {
|
||||
return this.institutions.findOne(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { InstitutionsService } from "./institutions.service";
|
||||
import { InstitutionsController } from "./institutions.controller";
|
||||
|
||||
@Module({
|
||||
providers: [InstitutionsService],
|
||||
controllers: [InstitutionsController],
|
||||
})
|
||||
export class InstitutionsModule {}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { DatabaseService } from "../database/database.service";
|
||||
|
||||
@Injectable()
|
||||
export class InstitutionsService {
|
||||
constructor(private db: DatabaseService) {}
|
||||
|
||||
async findAll(page = 1, limit = 20) {
|
||||
const offset = (page - 1) * limit;
|
||||
const countResult = await this.db.query<{ count: string }>(
|
||||
`SELECT COUNT(*) as count FROM institutions WHERE publish_status = 'published'`
|
||||
);
|
||||
const total = parseInt(countResult.rows[0].count);
|
||||
|
||||
const { rows } = await this.db.query<{
|
||||
id: string;
|
||||
name: string;
|
||||
country: string;
|
||||
city: string;
|
||||
artifact_count: string;
|
||||
lng: number;
|
||||
lat: number;
|
||||
}>(
|
||||
`SELECT i.id, i.name, i.country, i.city,
|
||||
ST_X(i.location::geometry) AS lng,
|
||||
ST_Y(i.location::geometry) AS lat,
|
||||
COUNT(al.artifact_id) AS artifact_count
|
||||
FROM institutions i
|
||||
LEFT JOIN artifact_locations al ON i.id = al.institution_id AND al.is_current = true
|
||||
WHERE i.publish_status = 'published'
|
||||
GROUP BY i.id
|
||||
ORDER BY artifact_count DESC, i.name
|
||||
LIMIT $1 OFFSET $2`,
|
||||
[limit, offset]
|
||||
);
|
||||
|
||||
return {
|
||||
data: rows.map((r) => ({ ...r, artifact_count: parseInt(r.artifact_count) })),
|
||||
meta: { total, page, limit, total_pages: Math.ceil(total / limit) },
|
||||
};
|
||||
}
|
||||
|
||||
async findOne(id: string) {
|
||||
const { rows } = await this.db.query<Record<string, unknown>>(
|
||||
`SELECT i.*,
|
||||
ST_X(i.location::geometry) AS lng,
|
||||
ST_Y(i.location::geometry) AS lat,
|
||||
COUNT(al.artifact_id) AS artifact_count
|
||||
FROM institutions i
|
||||
LEFT JOIN artifact_locations al ON i.id = al.institution_id AND al.is_current = true
|
||||
WHERE i.id = $1 AND i.publish_status = 'published'
|
||||
GROUP BY i.id`,
|
||||
[id]
|
||||
);
|
||||
if (!rows.length) throw new NotFoundException("机构不存在");
|
||||
return { ...rows[0], artifact_count: parseInt(rows[0].artifact_count as string) };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { NestFactory } from "@nestjs/core";
|
||||
import { ValidationPipe } from "@nestjs/common";
|
||||
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
|
||||
import { AppModule } from "./app.module";
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
app.setGlobalPrefix("api/v1");
|
||||
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
transform: true,
|
||||
forbidNonWhitelisted: true,
|
||||
})
|
||||
);
|
||||
|
||||
app.enableCors({
|
||||
origin: [
|
||||
process.env["WEB_URL"] ?? "http://localhost:3000",
|
||||
process.env["ADMIN_URL"] ?? "http://localhost:3001",
|
||||
],
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
const config = new DocumentBuilder()
|
||||
.setTitle("中华文明全图鉴 API")
|
||||
.setDescription("文物全图系统后端 API")
|
||||
.setVersion("1.0")
|
||||
.addBearerAuth()
|
||||
.build();
|
||||
const document = SwaggerModule.createDocument(app, config);
|
||||
SwaggerModule.setup("api/docs", app, document);
|
||||
|
||||
const port = process.env["PORT"] ?? 3002;
|
||||
await app.listen(port);
|
||||
console.log(`API 服务启动:http://localhost:${port}`);
|
||||
console.log(`Swagger 文档:http://localhost:${port}/api/docs`);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
@@ -0,0 +1,57 @@
|
||||
import { IsNumber, IsOptional, IsString, Max, Min } from "class-validator";
|
||||
import { Type } from "class-transformer";
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
|
||||
export class MapPointsQueryDto {
|
||||
@ApiPropertyOptional({ description: "西经(左边界)", example: 73.5 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
west?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: "东经(右边界)", example: 135.0 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
east?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: "南纬(下边界)", example: 18.0 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
south?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: "北纬(上边界)", example: 53.5 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
north?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: "地图缩放级别", example: 5 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Max(20)
|
||||
zoom?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: "文物门类筛选", example: "bronze" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
category?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: "朝代/年代筛选", example: "唐" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
dynasty?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: "机构ID筛选" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
institution_id?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: "标签ID(逗号分隔)" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
tag_ids?: string;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Controller, Get, Query } from "@nestjs/common";
|
||||
import { ApiOperation, ApiQuery, ApiTags } from "@nestjs/swagger";
|
||||
import { MapService } from "./map.service";
|
||||
import { MapPointsQueryDto } from "./dto/map-query.dto";
|
||||
|
||||
@ApiTags("Map")
|
||||
@Controller("map")
|
||||
export class MapController {
|
||||
constructor(private map: MapService) {}
|
||||
|
||||
@Get("stats")
|
||||
@ApiOperation({ summary: "地图统计数据(文物总数、机构总数、位置总数)" })
|
||||
getStats() {
|
||||
return this.map.getStats();
|
||||
}
|
||||
|
||||
@Get("points")
|
||||
@ApiOperation({ summary: "获取地图点位(支持视口范围 + 多维筛选)" })
|
||||
getPoints(@Query() query: MapPointsQueryDto) {
|
||||
return this.map.getPoints(query);
|
||||
}
|
||||
|
||||
@Get("nearby")
|
||||
@ApiOperation({ summary: "附近文物查询" })
|
||||
@ApiQuery({ name: "lng", type: Number, example: 116.4 })
|
||||
@ApiQuery({ name: "lat", type: Number, example: 39.9 })
|
||||
@ApiQuery({ name: "radius_km", type: Number, required: false, example: 50 })
|
||||
getNearby(
|
||||
@Query("lng") lng: string,
|
||||
@Query("lat") lat: string,
|
||||
@Query("radius_km") radius?: string
|
||||
) {
|
||||
return this.map.getNearby(parseFloat(lng), parseFloat(lat), radius ? parseFloat(radius) : 50);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { MapService } from "./map.service";
|
||||
import { MapController } from "./map.controller";
|
||||
|
||||
@Module({
|
||||
providers: [MapService],
|
||||
controllers: [MapController],
|
||||
})
|
||||
export class MapModule {}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { DatabaseService } from "../database/database.service";
|
||||
import { MapPointsQueryDto } from "./dto/map-query.dto";
|
||||
|
||||
@Injectable()
|
||||
export class MapService {
|
||||
constructor(private db: DatabaseService) {}
|
||||
|
||||
async getStats() {
|
||||
const { rows } = await this.db.query<{ total_artifacts: string; total_institutions: string; total_locations: string }>(
|
||||
`SELECT
|
||||
(SELECT COUNT(*) FROM artifacts WHERE publish_status = 'published') AS total_artifacts,
|
||||
(SELECT COUNT(*) FROM institutions WHERE publish_status = 'published') AS total_institutions,
|
||||
(SELECT COUNT(*) FROM artifact_locations WHERE is_current = true) AS total_locations`
|
||||
);
|
||||
const row = rows[0];
|
||||
return {
|
||||
total_artifacts: parseInt(row.total_artifacts),
|
||||
total_institutions: parseInt(row.total_institutions),
|
||||
total_locations: parseInt(row.total_locations),
|
||||
};
|
||||
}
|
||||
|
||||
async getPoints(query: MapPointsQueryDto) {
|
||||
const conditions: string[] = [
|
||||
"a.publish_status = 'published'",
|
||||
"al.is_current = true",
|
||||
"al.public_location IS NOT NULL",
|
||||
];
|
||||
const params: unknown[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (query.west != null && query.east != null && query.south != null && query.north != null) {
|
||||
conditions.push(
|
||||
`al.public_location && ST_MakeEnvelope($${idx++}, $${idx++}, $${idx++}, $${idx++}, 4326)`
|
||||
);
|
||||
params.push(query.west, query.south, query.east, query.north);
|
||||
}
|
||||
|
||||
if (query.category) {
|
||||
conditions.push(`a.category = $${idx++}`);
|
||||
params.push(query.category);
|
||||
}
|
||||
|
||||
if (query.dynasty) {
|
||||
conditions.push(`a.dynasty ILIKE $${idx++}`);
|
||||
params.push(`%${query.dynasty}%`);
|
||||
}
|
||||
|
||||
if (query.institution_id) {
|
||||
conditions.push(`al.institution_id = $${idx++}`);
|
||||
params.push(query.institution_id);
|
||||
}
|
||||
|
||||
const where = conditions.join(" AND ");
|
||||
|
||||
const { rows } = await this.db.query<{
|
||||
id: string;
|
||||
name: string;
|
||||
category: string;
|
||||
level: string;
|
||||
dynasty: string;
|
||||
story_hook: string;
|
||||
lng: number;
|
||||
lat: number;
|
||||
institution_name: string;
|
||||
province: string;
|
||||
city: string;
|
||||
location_type: string;
|
||||
image_url: string | null;
|
||||
repatriation_status: string;
|
||||
}>(
|
||||
`SELECT DISTINCT ON (a.id)
|
||||
a.id, a.name, a.category, a.level, a.dynasty, a.story_hook,
|
||||
ST_X(al.public_location::geometry) AS lng,
|
||||
ST_Y(al.public_location::geometry) AS lat,
|
||||
i.name AS institution_name,
|
||||
i.province,
|
||||
i.city,
|
||||
al.location_type,
|
||||
a.image_url,
|
||||
a.repatriation_status
|
||||
FROM artifacts a
|
||||
JOIN artifact_locations al ON a.id = al.artifact_id
|
||||
LEFT JOIN institutions i ON al.institution_id = i.id
|
||||
WHERE ${where}
|
||||
ORDER BY a.id, a.level DESC, a.name
|
||||
LIMIT 2000`,
|
||||
params
|
||||
);
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
async getNearby(lng: number, lat: number, radiusKm = 50) {
|
||||
const { rows } = await this.db.query<{
|
||||
id: string;
|
||||
name: string;
|
||||
category: string;
|
||||
level: string;
|
||||
distance_km: number;
|
||||
institution_name: string;
|
||||
}>(
|
||||
`SELECT
|
||||
a.id, a.name, a.category, a.level,
|
||||
ROUND(ST_Distance(al.public_location, ST_SetSRID(ST_MakePoint($1,$2),4326)::geography) / 1000)::float AS distance_km,
|
||||
i.name AS institution_name
|
||||
FROM artifacts a
|
||||
JOIN artifact_locations al ON a.id = al.artifact_id
|
||||
LEFT JOIN institutions i ON al.institution_id = i.id
|
||||
WHERE a.publish_status = 'published'
|
||||
AND al.is_current = true
|
||||
AND al.public_location IS NOT NULL
|
||||
AND ST_DWithin(
|
||||
al.public_location,
|
||||
ST_SetSRID(ST_MakePoint($1,$2),4326)::geography,
|
||||
$3 * 1000
|
||||
)
|
||||
ORDER BY distance_km
|
||||
LIMIT 20`,
|
||||
[lng, lat, radiusKm]
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Controller, Get, Param } from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import { RoutesService } from "./routes.service";
|
||||
|
||||
@ApiTags("routes")
|
||||
@Controller("routes")
|
||||
export class RoutesController {
|
||||
constructor(private readonly routes: RoutesService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: "叙事路线列表(南迁 / 回归)" })
|
||||
list() {
|
||||
return this.routes.list();
|
||||
}
|
||||
|
||||
@Get(":code")
|
||||
@ApiOperation({ summary: "按 code 获取路线及途经点" })
|
||||
getByCode(@Param("code") code: string) {
|
||||
return this.routes.getByCode(code);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { RoutesService } from "./routes.service";
|
||||
import { RoutesController } from "./routes.controller";
|
||||
|
||||
@Module({
|
||||
providers: [RoutesService],
|
||||
controllers: [RoutesController],
|
||||
})
|
||||
export class RoutesModule {}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { DatabaseService } from "../database/database.service";
|
||||
|
||||
export interface RouteRow {
|
||||
code: string;
|
||||
title: string;
|
||||
type: string;
|
||||
color: string | null;
|
||||
summary: string | null;
|
||||
artifact_id: string | null;
|
||||
artifact_name: string | null;
|
||||
artifact_dynasty: string | null;
|
||||
artifact_category: string | null;
|
||||
institution_name: string | null;
|
||||
}
|
||||
|
||||
export interface StopRow {
|
||||
seq: number;
|
||||
name: string;
|
||||
lng: number;
|
||||
lat: number;
|
||||
year_label: string | null;
|
||||
event: string | null;
|
||||
}
|
||||
|
||||
export type RouteDetail = RouteRow & { stops: StopRow[] };
|
||||
|
||||
const ROUTE_SELECT = `
|
||||
SELECT nr.code, nr.title, nr.type, nr.color, nr.summary, nr.artifact_id,
|
||||
a.name AS artifact_name, a.dynasty AS artifact_dynasty, a.category AS artifact_category,
|
||||
i.name AS institution_name
|
||||
FROM narrative_routes nr
|
||||
LEFT JOIN artifacts a ON a.id = nr.artifact_id
|
||||
LEFT JOIN artifact_locations al ON al.artifact_id = a.id AND al.is_current = true
|
||||
LEFT JOIN institutions i ON i.id = al.institution_id`;
|
||||
|
||||
@Injectable()
|
||||
export class RoutesService {
|
||||
constructor(private db: DatabaseService) {}
|
||||
|
||||
async list(): Promise<RouteRow[]> {
|
||||
const { rows } = await this.db.query<RouteRow>(
|
||||
`${ROUTE_SELECT} ORDER BY nr.type, nr.code`
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
async getByCode(code: string): Promise<RouteDetail> {
|
||||
const { rows } = await this.db.query<RouteRow>(
|
||||
`${ROUTE_SELECT} WHERE nr.code = $1 LIMIT 1`,
|
||||
[code]
|
||||
);
|
||||
const route = rows[0];
|
||||
if (!route) throw new NotFoundException("路线不存在");
|
||||
|
||||
const { rows: stops } = await this.db.query<StopRow>(
|
||||
`SELECT seq, name, lng, lat, year_label, event
|
||||
FROM route_stops rs
|
||||
JOIN narrative_routes nr ON nr.id = rs.route_id
|
||||
WHERE nr.code = $1
|
||||
ORDER BY rs.seq`,
|
||||
[code]
|
||||
);
|
||||
return { ...route, stops };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "node",
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"],
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"paths": {
|
||||
"@wenwumap/shared": ["../../packages/shared/src/index.ts"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist", "**/*.spec.ts"]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
NEXT_PUBLIC_API_URL=http://localhost:3002
|
||||
# 地图样式 URL(MapLibre GL 格式,需申请后填入)
|
||||
NEXT_PUBLIC_MAP_STYLE=
|
||||
@@ -0,0 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
|
||||
@@ -0,0 +1,11 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
transpilePackages: ["@wenwumap/shared"],
|
||||
env: {
|
||||
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || "http://localhost:3002",
|
||||
NEXT_PUBLIC_MAP_STYLE: process.env.NEXT_PUBLIC_MAP_STYLE || "",
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "@wenwumap/web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3000",
|
||||
"build": "next build",
|
||||
"start": "next start -p 3000",
|
||||
"lint": "next lint",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@googlemaps/markerclusterer": "^2.6.2",
|
||||
"@radix-ui/react-dialog": "^1.1.1",
|
||||
"@radix-ui/react-select": "^2.1.1",
|
||||
"@radix-ui/react-slider": "^1.2.0",
|
||||
"@radix-ui/react-tooltip": "^1.1.2",
|
||||
"@vis.gl/react-google-maps": "^1.4.0",
|
||||
"@wenwumap/shared": "workspace:*",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.414.0",
|
||||
"maplibre-gl": "^4.5.0",
|
||||
"next": "^14.2.5",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-map-gl": "^7.1.7",
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"swr": "^2.2.5",
|
||||
"tailwind-merge": "^2.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/google.maps": "^3.65.1",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"postcss": "^8.4.40",
|
||||
"tailwindcss": "^3.4.7",
|
||||
"typescript": "^5.5.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 107 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 381 KiB |
|
After Width: | Height: | Size: 238 KiB |
|
After Width: | Height: | Size: 430 KiB |
|
After Width: | Height: | Size: 356 KiB |
|
After Width: | Height: | Size: 3.4 MiB |
|
After Width: | Height: | Size: 586 KiB |
|
After Width: | Height: | Size: 280 KiB |
|
After Width: | Height: | Size: 990 KiB |
|
After Width: | Height: | Size: 614 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 302 KiB |
|
After Width: | Height: | Size: 242 KiB |
|
After Width: | Height: | Size: 334 KiB |
|
After Width: | Height: | Size: 710 KiB |
@@ -0,0 +1,140 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=Noto+Serif+SC:wght@400;500;600;700&family=Noto+Sans+SC:wght@300;400;500&display=swap');
|
||||
|
||||
:root {
|
||||
--color-ink: #1a1a2e;
|
||||
--color-gold: #c9a84c;
|
||||
--color-celadon: #8fbcb0;
|
||||
--color-vermilion: #c94b4b;
|
||||
--color-parchment: #f5f0e8;
|
||||
}
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: var(--color-ink);
|
||||
color: var(--color-parchment);
|
||||
font-family: 'Noto Sans SC', sans-serif;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#map {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* 全站隐藏滚动条但仍可滚动 */
|
||||
* {
|
||||
scrollbar-width: none; /* Firefox */
|
||||
-ms-overflow-style: none; /* 旧版 Edge / IE */
|
||||
}
|
||||
*::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
display: none; /* Chrome / Safari / 新版 Edge */
|
||||
}
|
||||
|
||||
/* 兼容旧用法:保留 .no-scrollbar 工具类 */
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.maplibregl-popup-content {
|
||||
background: rgba(26, 26, 46, 0.95);
|
||||
border: 1px solid var(--color-gold);
|
||||
border-radius: 4px;
|
||||
color: var(--color-parchment);
|
||||
padding: 0;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
.maplibregl-popup-tip {
|
||||
border-top-color: var(--color-gold);
|
||||
}
|
||||
|
||||
/* AI 对话气泡中的 Markdown 排版 */
|
||||
.md-chat {
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
color: #ecdfc6;
|
||||
word-break: break-word;
|
||||
}
|
||||
.md-chat > :first-child { margin-top: 0; }
|
||||
.md-chat > :last-child { margin-bottom: 0; }
|
||||
.md-chat p { margin: 0.5em 0; }
|
||||
.md-chat h1, .md-chat h2, .md-chat h3, .md-chat h4 {
|
||||
margin: 0.8em 0 0.4em;
|
||||
font-weight: 600;
|
||||
color: #f2cf83;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.md-chat h1 { font-size: 1.25em; }
|
||||
.md-chat h2 { font-size: 1.15em; }
|
||||
.md-chat h3 { font-size: 1.05em; }
|
||||
.md-chat strong { color: #f6d48e; font-weight: 600; }
|
||||
.md-chat em { color: #cce7de; }
|
||||
.md-chat a { color: #e7ad52; text-decoration: underline; text-underline-offset: 2px; }
|
||||
.md-chat ul, .md-chat ol { margin: 0.5em 0; padding-left: 1.25em; }
|
||||
.md-chat li { margin: 0.25em 0; }
|
||||
.md-chat ul li { list-style: disc; }
|
||||
.md-chat ol li { list-style: decimal; }
|
||||
.md-chat blockquote {
|
||||
margin: 0.6em 0;
|
||||
padding: 0.2em 0.9em;
|
||||
border-left: 3px solid var(--color-gold);
|
||||
background: rgba(214, 170, 91, 0.08);
|
||||
color: #d8c6a0;
|
||||
border-radius: 0 6px 6px 0;
|
||||
}
|
||||
.md-chat code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 0.9em;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
border: 1px solid rgba(214, 170, 91, 0.18);
|
||||
border-radius: 4px;
|
||||
padding: 0.05em 0.35em;
|
||||
color: #f0d39a;
|
||||
}
|
||||
.md-chat pre {
|
||||
margin: 0.6em 0;
|
||||
padding: 0.8em;
|
||||
overflow-x: auto;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border: 1px solid rgba(214, 170, 91, 0.18);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.md-chat pre code { background: none; border: none; padding: 0; }
|
||||
.md-chat table {
|
||||
width: 100%;
|
||||
margin: 0.6em 0;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
.md-chat th, .md-chat td {
|
||||
border: 1px solid rgba(214, 170, 91, 0.2);
|
||||
padding: 0.35em 0.6em;
|
||||
text-align: left;
|
||||
}
|
||||
.md-chat th { background: rgba(214, 170, 91, 0.12); color: #f2cf83; }
|
||||
.md-chat hr { margin: 0.8em 0; border: none; border-top: 1px solid rgba(214, 170, 91, 0.2); }
|
||||
|
||||
/* 修正浏览器自动填充把输入框背景变白、文字看不清的问题 */
|
||||
input:-webkit-autofill,
|
||||
input:-webkit-autofill:hover,
|
||||
input:-webkit-autofill:focus,
|
||||
input:-webkit-autofill:active {
|
||||
-webkit-text-fill-color: #f6eddc !important;
|
||||
-webkit-box-shadow: 0 0 0 1000px #11100d inset !important;
|
||||
caret-color: #f6eddc;
|
||||
transition: background-color 99999s ease-in-out 0s;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "中华文明全图鉴 · 文物全图",
|
||||
description: "探索中华文明珍贵文物的全球分布,追溯它们跨越千年的流转故事。",
|
||||
keywords: ["文物", "中华文明", "博物馆", "地图", "文化遗产"],
|
||||
openGraph: {
|
||||
title: "中华文明全图鉴 · 文物全图",
|
||||
description: "探索中华文明珍贵文物的全球分布",
|
||||
type: "website",
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="zh-CN">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function Home() {
|
||||
redirect("/map");
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { Sparkles, ChevronRight } from "lucide-react";
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3002";
|
||||
|
||||
type Persona = "artifact" | "guide" | "scholar" | "migration" | "repatriation" | "youth";
|
||||
type Role = "user" | "assistant";
|
||||
|
||||
interface ChatMessage {
|
||||
role: Role;
|
||||
content: string;
|
||||
}
|
||||
|
||||
const PERSONAS: { key: Persona; label: string; desc: string }[] = [
|
||||
{ key: "artifact", label: "文物自述", desc: "以「我」第一人称,拟人化讲述自己的故事" },
|
||||
{ key: "guide", label: "讲解员", desc: "亲切的资深讲解员,通俗生动" },
|
||||
{ key: "scholar", label: "历史学者", desc: "严谨客观,考据式讲解" },
|
||||
{ key: "migration", label: "南迁亲历", desc: "以文物视角讲述文物南迁的颠沛与守护" },
|
||||
{ key: "repatriation", label: "回归叙事", desc: "讲述流失海外与回归故土的心路" },
|
||||
{ key: "youth", label: "少年讲解", desc: "面向青少年的活泼科普讲解" },
|
||||
];
|
||||
|
||||
const STARTERS: Record<Persona, string[]> = {
|
||||
artifact: ["你是谁?", "讲讲你的身世", "你最特别的地方是什么?", "你经历过什么劫难?"],
|
||||
guide: ["带我认识一下这件文物", "它为什么珍贵?", "有什么有趣的故事?", "它是怎么被发现的?"],
|
||||
scholar: ["它的历史背景是什么?", "学术上有哪些争议?", "它的工艺有何特点?", "它有何研究价值?"],
|
||||
migration: ["南迁时你经历了什么?", "谁在守护你?", "路上最危险的一刻?", "你想对护送你的人说什么?"],
|
||||
repatriation: ["你是怎么流落海外的?", "回家那天什么心情?", "漂泊时最想念什么?", "你想对同胞说什么?"],
|
||||
youth: ["你几岁啦?", "用一句话介绍自己", "你身上有什么小秘密?", "我能从你身上学到什么?"],
|
||||
};
|
||||
|
||||
interface ArtifactChatProps {
|
||||
artifactId: string;
|
||||
artifactName: string;
|
||||
onConversationChange?: (active: boolean) => void;
|
||||
fill?: boolean;
|
||||
}
|
||||
|
||||
export default function ArtifactChat({ artifactId, artifactName, onConversationChange, fill }: ArtifactChatProps) {
|
||||
const [persona, setPersona] = useState<Persona>("artifact");
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [input, setInput] = useState("");
|
||||
const [streaming, setStreaming] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [suggestions, setSuggestions] = useState<string[]>([]);
|
||||
const [loadingSuggestions, setLoadingSuggestions] = useState(false);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const storageKey = `wenwu_chat_${artifactId}_${persona}`;
|
||||
|
||||
const persist = useCallback(
|
||||
(msgs: ChatMessage[]) => {
|
||||
try {
|
||||
if (msgs.length > 0) localStorage.setItem(storageKey, JSON.stringify(msgs));
|
||||
else localStorage.removeItem(storageKey);
|
||||
} catch {
|
||||
/* localStorage 不可用时忽略 */
|
||||
}
|
||||
},
|
||||
[storageKey]
|
||||
);
|
||||
|
||||
// 切换文物或角色时:从本地存储恢复该组合的历史对话
|
||||
useEffect(() => {
|
||||
abortRef.current?.abort();
|
||||
setError(null);
|
||||
setStreaming(false);
|
||||
setSuggestions([]);
|
||||
let restored: ChatMessage[] = [];
|
||||
try {
|
||||
const raw = localStorage.getItem(storageKey);
|
||||
if (raw) {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (Array.isArray(parsed)) {
|
||||
restored = parsed.filter(
|
||||
(m): m is ChatMessage =>
|
||||
!!m &&
|
||||
(m.role === "user" || m.role === "assistant") &&
|
||||
typeof m.content === "string"
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
setMessages(restored);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [storageKey]);
|
||||
|
||||
useEffect(() => {
|
||||
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" });
|
||||
}, [messages, streaming, suggestions]);
|
||||
|
||||
// 通知父组件对话是否已开始(用于折叠上方文物信息)
|
||||
useEffect(() => {
|
||||
onConversationChange?.(messages.length > 0);
|
||||
}, [messages.length, onConversationChange]);
|
||||
|
||||
const fetchSuggestions = useCallback(
|
||||
async (history: ChatMessage[]) => {
|
||||
setLoadingSuggestions(true);
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/v1/ai/suggestions`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ artifactId, persona, messages: history }),
|
||||
});
|
||||
if (!res.ok) return;
|
||||
const data = (await res.json()) as { suggestions?: string[] };
|
||||
if (Array.isArray(data.suggestions) && data.suggestions.length > 0) {
|
||||
setSuggestions(data.suggestions.slice(0, 4));
|
||||
}
|
||||
} catch {
|
||||
/* 建议生成失败不影响主流程 */
|
||||
} finally {
|
||||
setLoadingSuggestions(false);
|
||||
}
|
||||
},
|
||||
[artifactId, persona]
|
||||
);
|
||||
|
||||
const send = useCallback(
|
||||
async (text: string) => {
|
||||
const content = text.trim();
|
||||
if (!content || streaming) return;
|
||||
|
||||
const history: ChatMessage[] = [...messages, { role: "user", content }];
|
||||
setMessages([...history, { role: "assistant", content: "" }]);
|
||||
setInput("");
|
||||
setStreaming(true);
|
||||
setError(null);
|
||||
setSuggestions([]);
|
||||
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
let assistantContent = "";
|
||||
let ok = false;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/v1/ai/chat`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ artifactId, persona, messages: history }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!res.ok || !res.body) {
|
||||
throw new Error(`请求失败(${res.status})`);
|
||||
}
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
let sep: number;
|
||||
while ((sep = buffer.indexOf("\n\n")) >= 0) {
|
||||
const evt = buffer.slice(0, sep);
|
||||
buffer = buffer.slice(sep + 2);
|
||||
for (const line of evt.split("\n")) {
|
||||
const t = line.trim();
|
||||
if (!t.startsWith("data:")) continue;
|
||||
const data = t.slice(5).trim();
|
||||
if (data === "[DONE]") continue;
|
||||
try {
|
||||
const json = JSON.parse(data) as { t?: string; error?: string };
|
||||
if (json.error) {
|
||||
setError(json.error);
|
||||
} else if (json.t) {
|
||||
assistantContent += json.t;
|
||||
setMessages((prev) => {
|
||||
const next = [...prev];
|
||||
const last = next[next.length - 1];
|
||||
if (last && last.role === "assistant") {
|
||||
next[next.length - 1] = { ...last, content: last.content + json.t };
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
/* 忽略 */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ok = assistantContent.length > 0;
|
||||
} catch (e) {
|
||||
if ((e as Error).name !== "AbortError") {
|
||||
setError((e as Error).message || "对话出错了");
|
||||
}
|
||||
} finally {
|
||||
setStreaming(false);
|
||||
abortRef.current = null;
|
||||
}
|
||||
|
||||
// 每轮回答后生成 4 个下一步追问
|
||||
if (ok) {
|
||||
const finalHistory: ChatMessage[] = [
|
||||
...history,
|
||||
{ role: "assistant", content: assistantContent },
|
||||
];
|
||||
persist(finalHistory);
|
||||
void fetchSuggestions(finalHistory);
|
||||
}
|
||||
},
|
||||
[artifactId, persona, messages, streaming, fetchSuggestions, persist]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${
|
||||
fill ? "flex h-full min-h-0 flex-col" : "mt-5"
|
||||
} rounded-2xl border border-[#d6aa5b]/[0.07] bg-black/20`}
|
||||
>
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-[#d6aa5b]/[0.06] px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-full border border-[#d6aa5b]/35 bg-[#d6aa5b]/12 text-[#f2cf83]">
|
||||
<Sparkles size={13} />
|
||||
</span>
|
||||
<span className="text-[11px] uppercase tracking-[0.24em] text-[#a99566]">与文物对话</span>
|
||||
</div>
|
||||
{messages.length > 0 && (
|
||||
<button
|
||||
onClick={() => {
|
||||
abortRef.current?.abort();
|
||||
setMessages([]);
|
||||
setError(null);
|
||||
setSuggestions([]);
|
||||
persist([]);
|
||||
}}
|
||||
className="text-[11px] text-[#8f8066] transition hover:text-[#f2cf83]"
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 角色设置 */}
|
||||
<div className="shrink-0 border-b border-[#d6aa5b]/[0.05] px-4 py-3">
|
||||
<div className="mb-2 text-[10px] uppercase tracking-[0.22em] text-[#8f8066]">角色设置</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{PERSONAS.map((p) => (
|
||||
<button
|
||||
key={p.key}
|
||||
title={p.desc}
|
||||
onClick={() => setPersona(p.key)}
|
||||
className={`rounded-full border px-2.5 py-1 text-[11px] transition ${
|
||||
persona === p.key
|
||||
? "border-[#d6aa5b]/55 bg-[#d6aa5b]/18 text-[#f2cf83]"
|
||||
: "border-[#d6aa5b]/12 bg-white/[0.03] text-[#b5aa94] hover:border-[#d6aa5b]/30 hover:text-[#f2cf83]"
|
||||
}`}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-2 text-[10px] leading-4 text-[#7c7058]">
|
||||
{PERSONAS.find((p) => p.key === persona)?.desc}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 消息区 */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className={`no-scrollbar space-y-3 overflow-y-auto px-4 py-3 ${
|
||||
fill ? "flex-1 min-h-0" : "max-h-72"
|
||||
}`}
|
||||
>
|
||||
{messages.length === 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-[11px] leading-5 text-[#9d927c]">
|
||||
{persona === "artifact"
|
||||
? `我是「${artifactName}」,问我点什么吧。`
|
||||
: `关于「${artifactName}」,你想了解什么?`}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{STARTERS[persona].map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => send(s)}
|
||||
className="rounded-full border border-[#d6aa5b]/15 bg-white/[0.03] px-2.5 py-1 text-[11px] text-[#c7baa0] transition hover:border-[#d6aa5b]/30 hover:text-[#f2cf83]"
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((m, i) =>
|
||||
m.role === "user" ? (
|
||||
<div key={i} className="flex justify-end">
|
||||
<div className="max-w-[85%] rounded-2xl rounded-br-sm bg-[#d99b3d] px-3 py-2 text-xs leading-5 text-[#1a1005]">
|
||||
{m.content}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div key={i} className="flex justify-start">
|
||||
<div className="max-w-[92%] rounded-2xl rounded-bl-sm border border-[#d6aa5b]/14 bg-[#11100d]/80 px-3 py-2 text-[#ecdfc6]">
|
||||
{m.content ? (
|
||||
<div className="md-chat">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{m.content}</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<span className="inline-flex gap-1 py-1 align-middle">
|
||||
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-[#d6aa5b] [animation-delay:-0.2s]" />
|
||||
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-[#d6aa5b] [animation-delay:-0.1s]" />
|
||||
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-[#d6aa5b]" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-[#c94b4b]/35 bg-[#c94b4b]/10 px-3 py-2 text-[11px] text-[#f0b9b9]">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 下一步追问建议 */}
|
||||
{!streaming && (loadingSuggestions || suggestions.length > 0) && messages.length > 0 && (
|
||||
<div className="pt-1">
|
||||
<div className="mb-1.5 text-[10px] uppercase tracking-[0.22em] text-[#8f8066]">
|
||||
下一步 · 你可以问
|
||||
</div>
|
||||
{loadingSuggestions && suggestions.length === 0 ? (
|
||||
<div className="flex items-center gap-1.5 text-[11px] text-[#7c7058]">
|
||||
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-[#d6aa5b] [animation-delay:-0.2s]" />
|
||||
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-[#d6aa5b] [animation-delay:-0.1s]" />
|
||||
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-[#d6aa5b]" />
|
||||
<span className="ml-1">正在想问题…</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{suggestions.map((s, i) => (
|
||||
<button
|
||||
key={`${s}-${i}`}
|
||||
onClick={() => send(s)}
|
||||
className="group flex items-center gap-2 rounded-xl border border-[#d6aa5b]/15 bg-white/[0.03] px-3 py-2 text-left text-[11px] leading-4 text-[#c7baa0] transition hover:border-[#d6aa5b]/35 hover:bg-[#d6aa5b]/8 hover:text-[#f2cf83]"
|
||||
>
|
||||
<span className="text-[#a99566] group-hover:text-[#f2cf83]">
|
||||
<ChevronRight size={13} />
|
||||
</span>
|
||||
<span className="flex-1">{s}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 输入区 */}
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
send(input);
|
||||
}}
|
||||
className="flex shrink-0 items-center gap-2 border-t border-[#d6aa5b]/[0.06] px-3 py-2.5"
|
||||
>
|
||||
<input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder={streaming ? "回答生成中…" : "输入你的问题…"}
|
||||
disabled={streaming}
|
||||
className="flex-1 rounded-full border border-[#d6aa5b]/16 bg-black/30 px-3 py-2 text-xs text-[#f6eddc] outline-none transition placeholder:text-[#6f6656] focus:border-[#d6aa5b]/50 disabled:opacity-60"
|
||||
/>
|
||||
{streaming ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => abortRef.current?.abort()}
|
||||
className="flex-shrink-0 rounded-full border border-[#d6aa5b]/30 px-3 py-2 text-xs text-[#f2cf83] transition hover:bg-[#d6aa5b]/12"
|
||||
>
|
||||
停止
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!input.trim()}
|
||||
className="flex-shrink-0 rounded-full bg-[#d99b3d] px-4 py-2 text-xs font-semibold text-[#1a1005] transition hover:bg-[#e7ad52] disabled:opacity-40"
|
||||
>
|
||||
发送
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { ZoomIn, Minus, Plus, RotateCcw, X } from "lucide-react";
|
||||
import {
|
||||
artifactImageSrc,
|
||||
CATEGORY_LABELS,
|
||||
CATEGORY_MARKS,
|
||||
type MapPoint,
|
||||
} from "../lib/artifact";
|
||||
|
||||
const clamp = (v: number, a: number, b: number) => Math.max(a, Math.min(b, v));
|
||||
|
||||
export default function ArtifactImage({ point }: { point: MapPoint }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [useProxy, setUseProxy] = useState(true);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [scale, setScale] = useState(1);
|
||||
const [tx, setTx] = useState(0);
|
||||
const [ty, setTy] = useState(0);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const dragRef = useRef<{ x: number; y: number; tx: number; ty: number } | null>(null);
|
||||
|
||||
const showReal = Boolean(point.image_url) && !failed;
|
||||
const mark = CATEGORY_MARKS[point.category] ?? "物";
|
||||
|
||||
// 本地静态图(/artifacts/<id>.jpg);加载失败回退到原始直链,再失败显示示意图
|
||||
const directHd = point.image_url
|
||||
? /\?width=\d+/.test(point.image_url)
|
||||
? point.image_url.replace(/\?width=\d+/, "?width=2000")
|
||||
: `${point.image_url}${point.image_url.includes("?") ? "&" : "?"}width=2000`
|
||||
: "";
|
||||
const coverSrc = useProxy ? artifactImageSrc(point.id) : (point.image_url as string);
|
||||
const modalSrc = useProxy ? artifactImageSrc(point.id, true) : directHd;
|
||||
|
||||
const onImgError = () => {
|
||||
if (useProxy) setUseProxy(false);
|
||||
else setFailed(true);
|
||||
};
|
||||
|
||||
useEffect(() => setMounted(true), []);
|
||||
// 切换文物时重置图片加载态
|
||||
useEffect(() => {
|
||||
setFailed(false);
|
||||
setUseProxy(true);
|
||||
}, [point.id]);
|
||||
|
||||
const resetZoom = () => {
|
||||
setScale(1);
|
||||
setTx(0);
|
||||
setTy(0);
|
||||
};
|
||||
const openModal = () => {
|
||||
resetZoom();
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [open]);
|
||||
|
||||
// 以鼠标位置为锚点缩放
|
||||
const onWheel = (e: React.WheelEvent) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const mx = e.clientX - rect.left - rect.width / 2;
|
||||
const my = e.clientY - rect.top - rect.height / 2;
|
||||
const factor = e.deltaY < 0 ? 1.12 : 0.892;
|
||||
setScale((prev) => {
|
||||
const next = clamp(prev * factor, 0.3, 12);
|
||||
const ratio = next / prev;
|
||||
setTx((x) => mx - ratio * (mx - x));
|
||||
setTy((y) => my - ratio * (my - y));
|
||||
return next;
|
||||
});
|
||||
};
|
||||
const onDown = (e: React.MouseEvent) => {
|
||||
dragRef.current = { x: e.clientX, y: e.clientY, tx, ty };
|
||||
setDragging(true);
|
||||
};
|
||||
const onMove = (e: React.MouseEvent) => {
|
||||
const d = dragRef.current;
|
||||
if (!d) return;
|
||||
setTx(d.tx + (e.clientX - d.x));
|
||||
setTy(d.ty + (e.clientY - d.y));
|
||||
};
|
||||
const onUp = () => {
|
||||
dragRef.current = null;
|
||||
setDragging(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative h-44 w-full overflow-hidden rounded-2xl border border-[#d6aa5b]/[0.08] bg-[#0c0a06]">
|
||||
{showReal ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={coverSrc}
|
||||
alt={point.name}
|
||||
loading="lazy"
|
||||
onError={onImgError}
|
||||
onClick={openModal}
|
||||
className="h-full w-full cursor-zoom-in object-cover transition hover:opacity-90"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center bg-[radial-gradient(circle_at_30%_25%,rgba(214,170,91,0.18),transparent_60%),linear-gradient(135deg,#17120a,#0b1413)]">
|
||||
<span className="flex h-16 w-16 items-center justify-center rounded-full border border-[#d6aa5b]/30 bg-[#d6aa5b]/10 font-serif text-3xl text-[#f2cf83]">
|
||||
{mark}
|
||||
</span>
|
||||
<span className="mt-2 text-[10px] tracking-[0.3em] text-[#8f8066]">文物示意图</span>
|
||||
</div>
|
||||
)}
|
||||
<span className="absolute left-2 top-2 rounded-full bg-black/55 px-2 py-0.5 text-[10px] text-[#f6d48e] backdrop-blur">
|
||||
{CATEGORY_LABELS[point.category] ?? point.category}
|
||||
</span>
|
||||
{showReal && (
|
||||
<span className="pointer-events-none absolute bottom-2 right-2 flex items-center gap-1 rounded-full bg-black/55 px-2 py-0.5 text-[10px] text-[#f6d48e] backdrop-blur">
|
||||
<ZoomIn size={11} /> 点击放大
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{mounted &&
|
||||
open &&
|
||||
showReal &&
|
||||
createPortal(
|
||||
<div
|
||||
onClick={() => setOpen(false)}
|
||||
className="fixed inset-0 z-[2000] flex flex-col bg-black/92 backdrop-blur-sm"
|
||||
>
|
||||
<div
|
||||
className="flex shrink-0 items-center justify-between px-5 py-3 text-[#f6eddc]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-serif text-base font-semibold text-[#f2cf83]">{point.name}</div>
|
||||
<div className="truncate text-[11px] text-[#9d927c]">
|
||||
{point.institution_name || ""} · 滚轮缩放 · 拖动平移 · 双击复位
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setOpen(false)}
|
||||
className="ml-3 flex flex-shrink-0 items-center gap-1 rounded-full border border-[#d6aa5b]/30 px-3 py-1 text-xs text-[#f2cf83] transition hover:bg-[#d6aa5b]/15"
|
||||
>
|
||||
<X size={13} /> 关闭
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="relative flex-1 overflow-hidden"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onWheel={onWheel}
|
||||
onMouseDown={onDown}
|
||||
onMouseMove={onMove}
|
||||
onMouseUp={onUp}
|
||||
onMouseLeave={onUp}
|
||||
onDoubleClick={resetZoom}
|
||||
style={{ cursor: dragging ? "grabbing" : "grab" }}
|
||||
>
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={modalSrc}
|
||||
alt={point.name}
|
||||
draggable={false}
|
||||
onError={onImgError}
|
||||
className="max-h-[86vh] max-w-[92vw] select-none object-contain"
|
||||
style={{
|
||||
transform: `translate(${tx}px, ${ty}px) scale(${scale})`,
|
||||
transition: dragging ? "none" : "transform 0.08s ease-out",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="absolute bottom-4 left-1/2 flex -translate-x-1/2 items-center gap-3 rounded-full border border-[#d6aa5b]/20 bg-black/65 px-4 py-1.5 text-[#f2cf83] backdrop-blur"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
onClick={() => setScale((s) => clamp(s * 0.8, 0.3, 12))}
|
||||
className="flex items-center transition hover:text-[#ffe2a0]"
|
||||
>
|
||||
<Minus size={16} />
|
||||
</button>
|
||||
<span className="w-12 text-center text-[11px] tabular-nums">{Math.round(scale * 100)}%</span>
|
||||
<button
|
||||
onClick={() => setScale((s) => clamp(s * 1.25, 0.3, 12))}
|
||||
className="flex items-center transition hover:text-[#ffe2a0]"
|
||||
>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={resetZoom}
|
||||
className="ml-1 flex items-center gap-1 text-[11px] text-[#c7baa0] transition hover:text-[#f2cf83]"
|
||||
>
|
||||
<RotateCcw size={12} /> 复位
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { AdvancedMarker, useMap } from "@vis.gl/react-google-maps";
|
||||
import type { RouteStop } from "../lib/artifact";
|
||||
|
||||
interface RouteLayerProps {
|
||||
stops: RouteStop[];
|
||||
color: string;
|
||||
visibleCount: number; // 时间轴:显示前 N 个途经点
|
||||
activeIndex: number;
|
||||
onStopClick?: (index: number) => void;
|
||||
}
|
||||
|
||||
export default function RouteLayer({ stops, color, visibleCount, activeIndex, onStopClick }: RouteLayerProps) {
|
||||
const map = useMap();
|
||||
const fullRef = useRef<google.maps.Polyline | null>(null); // 全程虚线预览
|
||||
const progRef = useRef<google.maps.Polyline | null>(null); // 已走过实线
|
||||
const segRef = useRef<google.maps.Polyline | null>(null); // 当前段(承载流动箭头)
|
||||
|
||||
const shown = stops.slice(0, Math.max(1, visibleCount));
|
||||
|
||||
// 激活路线时自动缩放到全程
|
||||
useEffect(() => {
|
||||
if (!map || typeof google === "undefined" || stops.length === 0) return;
|
||||
const bounds = new google.maps.LatLngBounds();
|
||||
stops.forEach((s) => bounds.extend({ lat: s.lat, lng: s.lng }));
|
||||
map.fitBounds(bounds, 150);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [map, stops]);
|
||||
|
||||
// 全程:虚线预览(未走过的部分)
|
||||
useEffect(() => {
|
||||
if (!map || typeof google === "undefined") return;
|
||||
if (!fullRef.current) {
|
||||
fullRef.current = new google.maps.Polyline({ clickable: false, strokeOpacity: 0 });
|
||||
}
|
||||
fullRef.current.setOptions({
|
||||
strokeOpacity: 0,
|
||||
icons: [
|
||||
{
|
||||
icon: { path: "M 0,-1 0,1", strokeOpacity: 0.5, strokeColor: color, scale: 2.4 },
|
||||
offset: "0",
|
||||
repeat: "13px",
|
||||
},
|
||||
],
|
||||
});
|
||||
fullRef.current.setPath(stops.map((s) => ({ lat: s.lat, lng: s.lng })));
|
||||
fullRef.current.setMap(map);
|
||||
return () => fullRef.current?.setMap(null);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [map, stops, color]);
|
||||
|
||||
// 已走过:实线
|
||||
useEffect(() => {
|
||||
if (!map || typeof google === "undefined") return;
|
||||
if (!progRef.current) {
|
||||
progRef.current = new google.maps.Polyline({ clickable: false, geodesic: false });
|
||||
}
|
||||
progRef.current.setOptions({ strokeColor: color, strokeOpacity: 0.95, strokeWeight: 4, zIndex: 5 });
|
||||
progRef.current.setPath(shown.map((s) => ({ lat: s.lat, lng: s.lng })));
|
||||
progRef.current.setMap(map);
|
||||
return () => progRef.current?.setMap(null);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [map, visibleCount, stops, color]);
|
||||
|
||||
// 流动动画:箭头只沿「当前段」走一趟(约 1 秒),到站后自动移除——
|
||||
// 既满足"完成后去掉箭头",也避免持续刷新地图导致 GPU 长期占用
|
||||
useEffect(() => {
|
||||
if (!map || typeof google === "undefined") return;
|
||||
if (segRef.current) {
|
||||
segRef.current.setMap(null);
|
||||
segRef.current = null;
|
||||
}
|
||||
if (activeIndex <= 0) return; // 第一站没有来向段
|
||||
const a = stops[activeIndex - 1];
|
||||
const b = stops[activeIndex];
|
||||
if (!a || !b) return;
|
||||
|
||||
const line = new google.maps.Polyline({
|
||||
clickable: false,
|
||||
strokeOpacity: 0,
|
||||
zIndex: 7,
|
||||
path: [
|
||||
{ lat: a.lat, lng: a.lng },
|
||||
{ lat: b.lat, lng: b.lng },
|
||||
],
|
||||
});
|
||||
line.setMap(map);
|
||||
segRef.current = line;
|
||||
|
||||
const arrow = {
|
||||
path: google.maps.SymbolPath.FORWARD_CLOSED_ARROW,
|
||||
strokeColor: "#fff7e6",
|
||||
strokeWeight: 1,
|
||||
fillColor: color,
|
||||
fillOpacity: 1,
|
||||
scale: 3.6,
|
||||
};
|
||||
let off = 0;
|
||||
const timer = window.setInterval(() => {
|
||||
off += 5;
|
||||
if (off >= 100) {
|
||||
window.clearInterval(timer);
|
||||
line.setMap(null); // 到站后移除箭头,仅留实线 + 落点脉冲
|
||||
if (segRef.current === line) segRef.current = null;
|
||||
return;
|
||||
}
|
||||
line.setOptions({ icons: [{ icon: arrow, offset: `${off}%`, repeat: "0" }] });
|
||||
}, 40);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(timer);
|
||||
line.setMap(null);
|
||||
if (segRef.current === line) segRef.current = null;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [map, activeIndex, stops, color]);
|
||||
|
||||
// 卸载清理
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
fullRef.current?.setMap(null);
|
||||
progRef.current?.setMap(null);
|
||||
segRef.current?.setMap(null);
|
||||
fullRef.current = null;
|
||||
progRef.current = null;
|
||||
segRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{shown.map((s, i) => {
|
||||
const isActive = i === activeIndex;
|
||||
const isPast = i < activeIndex;
|
||||
return (
|
||||
<AdvancedMarker
|
||||
key={`${s.seq}-${i}`}
|
||||
position={{ lat: s.lat, lng: s.lng }}
|
||||
zIndex={1000 + i}
|
||||
onClick={() => onStopClick?.(i)}
|
||||
>
|
||||
<div className="flex -translate-y-1 cursor-pointer flex-col items-center gap-0.5">
|
||||
{isActive && (
|
||||
<span
|
||||
className="mb-0.5 whitespace-nowrap rounded-md px-2 py-0.5 text-[11px] font-semibold text-[#1a1005] shadow-[0_4px_14px_rgba(0,0,0,0.5)]"
|
||||
style={{ background: color }}
|
||||
>
|
||||
{s.year_label ? `${s.year_label} · ` : ""}
|
||||
{s.name}
|
||||
</span>
|
||||
)}
|
||||
<div className="relative flex items-center justify-center">
|
||||
{isActive && (
|
||||
<span
|
||||
className="absolute inline-flex h-8 w-8 animate-ping rounded-full opacity-60"
|
||||
style={{ background: color }}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className="relative flex items-center justify-center rounded-full border-2 font-serif font-bold leading-none shadow-[0_6px_18px_rgba(0,0,0,0.55)] transition-all"
|
||||
style={{
|
||||
width: isActive ? 32 : 20,
|
||||
height: isActive ? 32 : 20,
|
||||
fontSize: isActive ? 14 : 10,
|
||||
background: isActive || isPast ? color : "#1a160d",
|
||||
borderColor: isActive ? "#fff7e6" : color,
|
||||
color: isActive || isPast ? "#1a1005" : color,
|
||||
opacity: isPast ? 0.9 : 1,
|
||||
}}
|
||||
>
|
||||
{s.seq}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AdvancedMarker>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
export const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3002";
|
||||
|
||||
export interface MapPoint {
|
||||
id: string;
|
||||
name: string;
|
||||
category: string;
|
||||
level: string;
|
||||
dynasty: string;
|
||||
story_hook: string;
|
||||
lng: number;
|
||||
lat: number;
|
||||
institution_name: string;
|
||||
province?: string;
|
||||
city?: string;
|
||||
location_type?: string;
|
||||
image_url?: string | null;
|
||||
repatriation_status?: string;
|
||||
}
|
||||
|
||||
export interface RouteStop {
|
||||
seq: number;
|
||||
name: string;
|
||||
lng: number;
|
||||
lat: number;
|
||||
year_label: string | null;
|
||||
event: string | null;
|
||||
}
|
||||
export interface RouteSummary {
|
||||
code: string;
|
||||
title: string;
|
||||
type: string; // migration | repatriation
|
||||
color: string | null;
|
||||
summary: string | null;
|
||||
artifact_id?: string | null;
|
||||
artifact_name?: string | null;
|
||||
artifact_dynasty?: string | null;
|
||||
artifact_category?: string | null;
|
||||
institution_name?: string | null;
|
||||
}
|
||||
export interface RouteDetail extends RouteSummary {
|
||||
stops: RouteStop[];
|
||||
}
|
||||
|
||||
export type Theme = "domestic" | "overseas" | "repatriated";
|
||||
|
||||
export function themeOf(p: { location_type?: string; repatriation_status?: string }): Theme {
|
||||
if (p.repatriation_status === "repatriated") return "repatriated";
|
||||
if (p.repatriation_status === "lost_overseas" || p.location_type === "overseas") return "overseas";
|
||||
return "domestic";
|
||||
}
|
||||
|
||||
export const REPATRIATION_LABELS: Record<string, string> = {
|
||||
domestic: "国内传承",
|
||||
lost_overseas: "流失海外",
|
||||
repatriated: "已回归",
|
||||
in_transit: "在途",
|
||||
};
|
||||
|
||||
export const CATEGORY_LABELS: Record<string, string> = {
|
||||
bronze: "青铜器",
|
||||
painting_calligraphy: "书画",
|
||||
porcelain: "陶瓷",
|
||||
jade: "玉器",
|
||||
gold_silver: "金银器",
|
||||
lacquer: "漆木器",
|
||||
textile: "织绣",
|
||||
stone_carving: "石刻造像",
|
||||
wood_carving: "木雕",
|
||||
dunhuang: "敦煌遗珍",
|
||||
ancient_book: "古籍文献",
|
||||
other: "其他",
|
||||
};
|
||||
|
||||
export const CATEGORY_MARKS: Record<string, string> = {
|
||||
bronze: "铜",
|
||||
painting_calligraphy: "画",
|
||||
porcelain: "瓷",
|
||||
jade: "玉",
|
||||
gold_silver: "金",
|
||||
lacquer: "漆",
|
||||
textile: "织",
|
||||
stone_carving: "石",
|
||||
wood_carving: "木",
|
||||
dunhuang: "敦",
|
||||
ancient_book: "书",
|
||||
other: "物",
|
||||
};
|
||||
|
||||
export const LEVEL_LABELS: Record<string, string> = {
|
||||
level_1: "国家一级",
|
||||
level_2: "国家二级",
|
||||
level_3: "国家三级",
|
||||
general: "一般文物",
|
||||
unknown: "未定级",
|
||||
};
|
||||
|
||||
export const DYNASTY_OPTIONS = [
|
||||
"商代",
|
||||
"西周",
|
||||
"春秋",
|
||||
"战国",
|
||||
"秦代",
|
||||
"汉代",
|
||||
"唐代",
|
||||
"五代",
|
||||
"北宋",
|
||||
"南宋",
|
||||
"元代",
|
||||
"明代",
|
||||
"清代",
|
||||
];
|
||||
|
||||
// 根据缩放层级与聚合数量计算 marker 直径(像素)。
|
||||
export function markerSizeFor(zoom: number, count: number): number {
|
||||
const z = Math.max(2, Math.min(18, zoom));
|
||||
const base = 13 + (z - 2) * 2.3;
|
||||
const countBonus = Math.min(15, Math.log2(count + 1) * 4);
|
||||
return Math.round(base + countBonus);
|
||||
}
|
||||
|
||||
// 文物图片:优先用已下载到本地的静态图(同源、无需外网),
|
||||
// 加载失败时组件会回退到原始直链,再回退到统一示意图。
|
||||
export function artifactImageSrc(id: string, _hd = false): string {
|
||||
return `/artifacts/${id}.jpg`;
|
||||
}
|
||||
|
||||
export function isOverseas(locationType?: string): boolean {
|
||||
return locationType === "overseas";
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { Config } from "tailwindcss";
|
||||
|
||||
const config: Config = {
|
||||
content: ["./src/**/*.{ts,tsx}"],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
// 东方审美主色
|
||||
ink: {
|
||||
DEFAULT: "#1a1a2e",
|
||||
light: "#2d2d4a",
|
||||
},
|
||||
gold: {
|
||||
DEFAULT: "#c9a84c",
|
||||
light: "#e8cc7a",
|
||||
dark: "#9e7a28",
|
||||
},
|
||||
celadon: {
|
||||
DEFAULT: "#8fbcb0",
|
||||
light: "#b5d4cd",
|
||||
dark: "#5f9088",
|
||||
},
|
||||
vermilion: {
|
||||
DEFAULT: "#c94b4b",
|
||||
light: "#e07070",
|
||||
dark: "#9e2a2a",
|
||||
},
|
||||
parchment: {
|
||||
DEFAULT: "#f5f0e8",
|
||||
dark: "#e8dfc8",
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
serif: ["Noto Serif SC", "serif"],
|
||||
sans: ["Noto Sans SC", "sans-serif"],
|
||||
mono: ["JetBrains Mono", "monospace"],
|
||||
},
|
||||
animation: {
|
||||
"pulse-slow": "pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite",
|
||||
"fade-in": "fadeIn 0.4s ease-in-out",
|
||||
"slide-up": "slideUp 0.3s ease-out",
|
||||
},
|
||||
keyframes: {
|
||||
fadeIn: {
|
||||
"0%": { opacity: "0" },
|
||||
"100%": { opacity: "1" },
|
||||
},
|
||||
slideUp: {
|
||||
"0%": { transform: "translateY(12px)", opacity: "0" },
|
||||
"100%": { transform: "translateY(0)", opacity: "1" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["dom", "dom.iterable", "ES2022"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"@wenwumap/shared": ["../../packages/shared/src/index.ts"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||