Compare commits
31 Commits
db40d04dc5
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 12edf0b545 | |||
| 034496e946 | |||
| d02abce58d | |||
| 74266dc5cc | |||
| b4e05a4a16 | |||
| 2713897bdb | |||
| a1e2897364 | |||
| 90fdb44b20 | |||
| a389888eb4 | |||
| 1d82f4f961 | |||
| 074ea0bb1a | |||
| 43b14a94a3 | |||
| 1f0c7608f4 | |||
| 6c4d9affae | |||
| c0384f9a07 | |||
| e3a9a6b47f | |||
| dd2835bb94 | |||
| 09845e0515 | |||
| e0466f9b99 | |||
| eccc3f62d2 | |||
| 91ae52320b | |||
| f4318c7643 | |||
| b469662760 | |||
| 5c0f02f1f8 | |||
| 121c6f764f | |||
| 3f88e33bd1 | |||
| e2eba6dc1f | |||
| 72a71818e7 | |||
| 3b77041100 | |||
| 98712cf047 | |||
| 85abc2a515 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -400,7 +400,6 @@ cython_debug/
|
|||||||
|
|
||||||
# Custom
|
# Custom
|
||||||
.opencode
|
.opencode
|
||||||
.codex
|
|
||||||
openspec/changes/archive
|
openspec/changes/archive
|
||||||
temp
|
temp
|
||||||
.agents
|
.agents
|
||||||
@@ -411,6 +410,7 @@ backend/bin
|
|||||||
backend/server
|
backend/server
|
||||||
backend/desktop
|
backend/desktop
|
||||||
!src/**/*
|
!src/**/*
|
||||||
|
docs/superpowers
|
||||||
|
|
||||||
# Embedfs generated
|
# Embedfs generated
|
||||||
embedfs/assets/
|
embedfs/assets/
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
"*.{ts,tsx}": ["eslint --fix"],
|
"*.{ts,tsx,js,jsx}": ["oxlint --fix", "oxfmt"],
|
||||||
"*.{md,json,yaml,yml}": ["prettier --write"]
|
"*.{md,json,yaml,yml}": ["oxfmt"],
|
||||||
|
"!openspec/**": []
|
||||||
}
|
}
|
||||||
|
|||||||
18
.oxfmtrc.json
Normal file
18
.oxfmtrc.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
||||||
|
"printWidth": 120,
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": false,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"arrowParens": "always",
|
||||||
|
"endOfLine": "lf",
|
||||||
|
"tabWidth": 2,
|
||||||
|
"useTabs": false,
|
||||||
|
"sortPackageJson": false,
|
||||||
|
"sortImports": {
|
||||||
|
"partitionByNewline": true,
|
||||||
|
"newlinesBetween": false
|
||||||
|
},
|
||||||
|
"ignorePatterns": ["openspec/**", "bun.lock", "bin/**", "eslint-rules/**", "skills-lock.json"]
|
||||||
|
}
|
||||||
274
.oxlintrc.json
Normal file
274
.oxlintrc.json
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||||
|
"plugins": ["typescript", "import", "unicorn"],
|
||||||
|
"jsPlugins": ["./eslint-rules/local-rules.js"],
|
||||||
|
"categories": {
|
||||||
|
"correctness": "off"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"typeAware": true
|
||||||
|
},
|
||||||
|
"env": {
|
||||||
|
"builtin": true,
|
||||||
|
"es2018": true
|
||||||
|
},
|
||||||
|
"ignorePatterns": ["openspec/**", "bun.lock", "bin/**", "eslint-rules/**", "skills-lock.json"],
|
||||||
|
"rules": {
|
||||||
|
"constructor-super": "error",
|
||||||
|
"for-direction": "error",
|
||||||
|
"getter-return": "error",
|
||||||
|
"no-async-promise-executor": "error",
|
||||||
|
"no-case-declarations": "error",
|
||||||
|
"no-class-assign": "error",
|
||||||
|
"no-compare-neg-zero": "error",
|
||||||
|
"no-cond-assign": "error",
|
||||||
|
"no-const-assign": "error",
|
||||||
|
"no-constant-binary-expression": "error",
|
||||||
|
"no-constant-condition": "error",
|
||||||
|
"no-control-regex": "error",
|
||||||
|
"no-debugger": "error",
|
||||||
|
"no-delete-var": "error",
|
||||||
|
"no-dupe-class-members": "error",
|
||||||
|
"no-dupe-else-if": "error",
|
||||||
|
"no-dupe-keys": "error",
|
||||||
|
"no-duplicate-case": "error",
|
||||||
|
"no-empty": "error",
|
||||||
|
"no-empty-character-class": "error",
|
||||||
|
"no-empty-pattern": "error",
|
||||||
|
"no-empty-static-block": "error",
|
||||||
|
"no-ex-assign": "error",
|
||||||
|
"no-extra-boolean-cast": "error",
|
||||||
|
"no-fallthrough": "error",
|
||||||
|
"no-func-assign": "error",
|
||||||
|
"no-global-assign": "error",
|
||||||
|
"no-import-assign": "error",
|
||||||
|
"no-invalid-regexp": "error",
|
||||||
|
"no-irregular-whitespace": "error",
|
||||||
|
"no-loss-of-precision": "error",
|
||||||
|
"no-misleading-character-class": "error",
|
||||||
|
"no-new-native-nonconstructor": "error",
|
||||||
|
"no-nonoctal-decimal-escape": "error",
|
||||||
|
"no-obj-calls": "error",
|
||||||
|
"no-prototype-builtins": "error",
|
||||||
|
"no-redeclare": "error",
|
||||||
|
"no-regex-spaces": "error",
|
||||||
|
"no-self-assign": "error",
|
||||||
|
"no-setter-return": "error",
|
||||||
|
"no-shadow-restricted-names": "error",
|
||||||
|
"no-sparse-arrays": "error",
|
||||||
|
"no-this-before-super": "error",
|
||||||
|
"no-unassigned-vars": "error",
|
||||||
|
"no-unreachable": "error",
|
||||||
|
"no-unsafe-finally": "error",
|
||||||
|
"no-unsafe-negation": "error",
|
||||||
|
"no-unsafe-optional-chaining": "error",
|
||||||
|
"no-unused-labels": "error",
|
||||||
|
"no-unused-private-class-members": "error",
|
||||||
|
"no-unused-vars": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"argsIgnorePattern": "^_"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"no-useless-backreference": "error",
|
||||||
|
"no-useless-catch": "error",
|
||||||
|
"no-useless-escape": "error",
|
||||||
|
"no-with": "error",
|
||||||
|
"preserve-caught-error": "error",
|
||||||
|
"require-yield": "error",
|
||||||
|
"use-isnan": "error",
|
||||||
|
"valid-typeof": "error",
|
||||||
|
"no-array-constructor": "error",
|
||||||
|
"no-unused-expressions": "error",
|
||||||
|
"import/namespace": "error",
|
||||||
|
"import/default": "error",
|
||||||
|
"import/no-named-as-default": "warn",
|
||||||
|
"import/no-named-as-default-member": "warn",
|
||||||
|
"import/no-duplicates": "warn",
|
||||||
|
"typescript/await-thenable": "error",
|
||||||
|
"typescript/ban-ts-comment": "error",
|
||||||
|
"typescript/no-array-delete": "error",
|
||||||
|
"typescript/no-base-to-string": "error",
|
||||||
|
"typescript/no-duplicate-enum-values": "error",
|
||||||
|
"typescript/no-duplicate-type-constituents": "error",
|
||||||
|
"typescript/no-empty-object-type": "error",
|
||||||
|
"typescript/no-explicit-any": "error",
|
||||||
|
"typescript/no-extra-non-null-assertion": "error",
|
||||||
|
"typescript/no-floating-promises": "error",
|
||||||
|
"typescript/no-for-in-array": "error",
|
||||||
|
"typescript/no-implied-eval": "error",
|
||||||
|
"typescript/no-misused-new": "error",
|
||||||
|
"typescript/no-misused-promises": "error",
|
||||||
|
"typescript/no-namespace": "error",
|
||||||
|
"typescript/no-non-null-asserted-optional-chain": "error",
|
||||||
|
"typescript/no-redundant-type-constituents": "error",
|
||||||
|
"typescript/no-require-imports": "error",
|
||||||
|
"typescript/no-this-alias": "error",
|
||||||
|
"typescript/no-unnecessary-type-assertion": "error",
|
||||||
|
"typescript/no-unnecessary-type-constraint": "error",
|
||||||
|
"typescript/no-unsafe-argument": "error",
|
||||||
|
"typescript/no-unsafe-assignment": "error",
|
||||||
|
"typescript/no-unsafe-call": "error",
|
||||||
|
"typescript/no-unsafe-declaration-merging": "error",
|
||||||
|
"typescript/no-unsafe-enum-comparison": "error",
|
||||||
|
"typescript/no-unsafe-function-type": "error",
|
||||||
|
"typescript/no-unsafe-member-access": "error",
|
||||||
|
"typescript/no-unsafe-return": "error",
|
||||||
|
"typescript/no-unsafe-unary-minus": "error",
|
||||||
|
"typescript/no-wrapper-object-types": "error",
|
||||||
|
"typescript/only-throw-error": "error",
|
||||||
|
"typescript/prefer-as-const": "error",
|
||||||
|
"typescript/prefer-namespace-keyword": "error",
|
||||||
|
"typescript/prefer-promise-reject-errors": "error",
|
||||||
|
"typescript/require-await": "error",
|
||||||
|
"typescript/restrict-plus-operands": "error",
|
||||||
|
"typescript/restrict-template-expressions": "error",
|
||||||
|
"typescript/triple-slash-reference": "error",
|
||||||
|
"typescript/unbound-method": "error",
|
||||||
|
"typescript/adjacent-overload-signatures": "error",
|
||||||
|
"typescript/array-type": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"default": "array-simple"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"typescript/ban-tslint-comment": "error",
|
||||||
|
"typescript/class-literal-property-style": "error",
|
||||||
|
"typescript/consistent-generic-constructors": "error",
|
||||||
|
"typescript/consistent-indexed-object-style": "error",
|
||||||
|
"typescript/consistent-type-assertions": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"assertionStyle": "as"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"typescript/consistent-type-definitions": "error",
|
||||||
|
"typescript/dot-notation": "error",
|
||||||
|
"typescript/no-confusing-non-null-assertion": "error",
|
||||||
|
"typescript/no-inferrable-types": "error",
|
||||||
|
"typescript/non-nullable-type-assertion-style": "error",
|
||||||
|
"typescript/prefer-find": "error",
|
||||||
|
"typescript/prefer-for-of": "error",
|
||||||
|
"typescript/prefer-function-type": "error",
|
||||||
|
"typescript/prefer-includes": "error",
|
||||||
|
"typescript/prefer-nullish-coalescing": "error",
|
||||||
|
"typescript/prefer-regexp-exec": "error",
|
||||||
|
"typescript/prefer-string-starts-ends-with": "error",
|
||||||
|
"typescript/consistent-type-imports": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"prefer": "type-imports"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": ["**/*.ts", "**/*.tsx", "**/*.mts", "**/*.cts"],
|
||||||
|
"rules": {
|
||||||
|
"constructor-super": "off",
|
||||||
|
"getter-return": "off",
|
||||||
|
"no-class-assign": "off",
|
||||||
|
"no-const-assign": "off",
|
||||||
|
"no-dupe-class-members": "off",
|
||||||
|
"no-dupe-keys": "off",
|
||||||
|
"no-func-assign": "off",
|
||||||
|
"no-import-assign": "off",
|
||||||
|
"no-new-native-nonconstructor": "off",
|
||||||
|
"no-obj-calls": "off",
|
||||||
|
"no-redeclare": "off",
|
||||||
|
"no-setter-return": "off",
|
||||||
|
"no-this-before-super": "off",
|
||||||
|
"no-unreachable": "off",
|
||||||
|
"no-unsafe-negation": "off",
|
||||||
|
"no-var": "error",
|
||||||
|
"no-with": "off",
|
||||||
|
"prefer-const": "error",
|
||||||
|
"prefer-rest-params": "error",
|
||||||
|
"prefer-spread": "error"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"files": ["src/**/*.{ts,tsx}"],
|
||||||
|
"rules": {
|
||||||
|
"local-rules/enforce-catch-type": "warn",
|
||||||
|
"local-rules/no-empty-function": "error"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"files": ["src/server/db/**/*.ts"],
|
||||||
|
"rules": {
|
||||||
|
"no-restricted-imports": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"paths": [
|
||||||
|
{
|
||||||
|
"importNames": ["sqliteTable"],
|
||||||
|
"message": "请从 ./helpers.ts 导入 sqliteTable,并在列定义中展开 baseColumns。参见 src/server/db/helpers.ts。",
|
||||||
|
"name": "drizzle-orm/sqlite-core"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"files": ["src/server/db/helpers.ts"],
|
||||||
|
"rules": {
|
||||||
|
"no-restricted-imports": "off"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"files": ["src/server/**/*.ts"],
|
||||||
|
"rules": {
|
||||||
|
"no-console": "error"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"files": ["src/server/logger.ts"],
|
||||||
|
"rules": {
|
||||||
|
"no-console": "off"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"files": ["src/web/**/*.{ts,tsx}"],
|
||||||
|
"rules": {
|
||||||
|
"no-console": "error",
|
||||||
|
"no-restricted-imports": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"patterns": [
|
||||||
|
{
|
||||||
|
"group": [
|
||||||
|
"../server/*",
|
||||||
|
"../server/**",
|
||||||
|
"../**/server/*",
|
||||||
|
"../**/server/**",
|
||||||
|
"../../server/*",
|
||||||
|
"../../server/**",
|
||||||
|
"src/server/*",
|
||||||
|
"src/server/**"
|
||||||
|
],
|
||||||
|
"message": "前端不得导入 src/server 后端运行时实现;请改用 src/shared 类型或 HTTP API。"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"react/rules-of-hooks": "error",
|
||||||
|
"react/exhaustive-deps": "warn",
|
||||||
|
"react/only-export-components": [
|
||||||
|
"warn",
|
||||||
|
{
|
||||||
|
"allowConstantExport": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"plugins": ["react"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"files": ["src/web/**/logger.ts"],
|
||||||
|
"rules": {
|
||||||
|
"no-console": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
node_modules/
|
|
||||||
dist/
|
|
||||||
.build/
|
|
||||||
*.bun-build
|
|
||||||
openspec/
|
|
||||||
bun.lock
|
|
||||||
.opencode/
|
|
||||||
.claude/
|
|
||||||
.codex/
|
|
||||||
.agents/
|
|
||||||
skills-lock.json
|
|
||||||
data/
|
|
||||||
probe-config.schema.json
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"printWidth": 120,
|
|
||||||
"semi": true,
|
|
||||||
"singleQuote": false,
|
|
||||||
"trailingComma": "all",
|
|
||||||
"bracketSpacing": true,
|
|
||||||
"arrowParens": "always",
|
|
||||||
"endOfLine": "lf",
|
|
||||||
"tabWidth": 2,
|
|
||||||
"useTabs": false
|
|
||||||
}
|
|
||||||
8
.vscode/settings.json
vendored
8
.vscode/settings.json
vendored
@@ -6,5 +6,11 @@
|
|||||||
"files.eol": "\n",
|
"files.eol": "\n",
|
||||||
"files.encoding": "utf8",
|
"files.encoding": "utf8",
|
||||||
"files.insertFinalNewline": true,
|
"files.insertFinalNewline": true,
|
||||||
"files.trimTrailingWhitespace": true
|
"files.trimTrailingWhitespace": true,
|
||||||
|
|
||||||
|
"[javascript][typescript][javascriptreact][typescriptreact]": {
|
||||||
|
"editor.defaultFormatter": "oxc.oxc"
|
||||||
|
},
|
||||||
|
|
||||||
|
"eslint.enable": false
|
||||||
}
|
}
|
||||||
|
|||||||
33
AGENTS.md
33
AGENTS.md
@@ -1 +1,32 @@
|
|||||||
严格遵守openspec/config.yaml中context声明的项目规范
|
## 项目概览
|
||||||
|
|
||||||
|
- 本项目为 Bun 全栈应用(Alfred·阿福),Bun 是唯一包管理器和运行时,严禁使用 npm、pnpm、yarn、npx、pnpx
|
||||||
|
- docs/user/ 记录用户使用方法,docs/development/ 记录开发技术细节
|
||||||
|
- 使用中文(注释、文档、交流),面向中文开发者
|
||||||
|
- 本项目无需考虑向前兼容性
|
||||||
|
|
||||||
|
## 文档入口(按顺序阅读)
|
||||||
|
|
||||||
|
- **优先阅读 docs/README.md** 获取文档路由、归属矩阵和影响分析规则
|
||||||
|
- **其次阅读 docs/development/README.md** 获取完整开发规范、常用命令和质量门禁
|
||||||
|
|
||||||
|
## 全局红线
|
||||||
|
|
||||||
|
- 前端禁止导入 src/server/ 的后端运行时实现
|
||||||
|
- 后端运行时代码禁止直接使用 console.\*,通过 Logger 实例输出
|
||||||
|
- 新增逻辑必须编写完善的测试,不允许跳过任何测试
|
||||||
|
- 每次代码变更必须执行文档影响分析(详见 docs/README.md)
|
||||||
|
- 新增代码优先复用已有组件、工具、依赖库,不轻易引入新依赖
|
||||||
|
|
||||||
|
## Git 规范
|
||||||
|
|
||||||
|
- 提交信息中文,格式"类型: 简短描述",类型:feat/fix/refactor/docs/style/test/chore
|
||||||
|
- 禁止创建 git 操作 task
|
||||||
|
|
||||||
|
## 工作方式
|
||||||
|
|
||||||
|
- 积极使用 subagent 并行独立子任务,节省上下文空间;能并行的步骤明确并行
|
||||||
|
- subagent 仅用于只读收集和分析,禁止用于文件修改、代码生成、git 操作或依赖安装
|
||||||
|
- 单个文件或目录只分配给一个 subagent,不重复分配;subagent 输出文件路径、行号和问题摘要,不输出大段源码
|
||||||
|
- 主 agent 负责最终结论:去重、交叉验证、合并同根因问题
|
||||||
|
- 优先使用提问工具对用户确认
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
[test]
|
[test]
|
||||||
preload = ["./tests/setup.ts"]
|
preload = ["./tests/happydom.ts", "./tests/setup.ts"]
|
||||||
exclude = ["./tests/e2e/**"]
|
exclude = ["./tests/e2e/**"]
|
||||||
|
|||||||
@@ -219,7 +219,7 @@ features/<name>/
|
|||||||
- 列名 snake_case,TS 类型 camelCase,Drizzle schema 映射。
|
- 列名 snake_case,TS 类型 camelCase,Drizzle schema 映射。
|
||||||
- 软删除:所有业务表使用 `deleted_at` 列,通过 `notDeleted(table)` 或 `paginateQuery({ softDelete })` 过滤。`deleted_at` 不暴露到 API 层。
|
- 软删除:所有业务表使用 `deleted_at` 列,通过 `notDeleted(table)` 或 `paginateQuery({ softDelete })` 过滤。`deleted_at` 不暴露到 API 层。
|
||||||
- 唯一性:无数据库级 UNIQUE 约束,DAO 层应用校验(同字段 + `deleted_at IS NULL`)。
|
- 唯一性:无数据库级 UNIQUE 约束,DAO 层应用校验(同字段 + `deleted_at IS NULL`)。
|
||||||
- 表定义:通过 `helpers.ts` 的 `baseColumns` 展开 id/created_at/updated_at/deleted_at,禁止直接 `sqliteTable()`(ESLint 强制)。
|
- 表定义:通过 `helpers.ts` 的 `baseColumns` 展开 id/created_at/updated_at/deleted_at,禁止直接 `sqliteTable()`(oxlint 强制)。
|
||||||
|
|
||||||
### AI 调用层
|
### AI 调用层
|
||||||
|
|
||||||
@@ -293,7 +293,7 @@ features/<name>/
|
|||||||
### 前端
|
### 前端
|
||||||
|
|
||||||
- 目录 `tests/web/`,结构对应 `src/web/`。
|
- 目录 `tests/web/`,结构对应 `src/web/`。
|
||||||
- 用 jsdom + `@testing-library/react` 测试用户行为,断言基于可见文本/role/按钮。
|
- 用 happy-dom + `@testing-library/react` 测试用户行为,断言基于可见文本/role/按钮。
|
||||||
- 系统边界复用 `tests/web/test-utils.tsx`。
|
- 系统边界复用 `tests/web/test-utils.tsx`。
|
||||||
- 数据页面覆盖:请求参数、成功可见结果、关键错误路径。
|
- 数据页面覆盖:请求参数、成功可见结果、关键错误路径。
|
||||||
- ErrorBoundary/hooks/fetch helper 用单元测试覆盖异常,页面测试只保留用户路径。
|
- ErrorBoundary/hooks/fetch helper 用单元测试覆盖异常,页面测试只保留用户路径。
|
||||||
@@ -313,8 +313,8 @@ features/<name>/
|
|||||||
| `bun run schema` | 生成 config.schema.json |
|
| `bun run schema` | 生成 config.schema.json |
|
||||||
| `bun run schema:check` | 检查 schema 同步 |
|
| `bun run schema:check` | 检查 schema 同步 |
|
||||||
| `bun run typecheck` | TypeScript 类型检查 |
|
| `bun run typecheck` | TypeScript 类型检查 |
|
||||||
| `bun run lint` | ESLint + Prettier 检查 |
|
| `bun run lint` | oxlint 检查(--deny-warnings) |
|
||||||
| `bun run format` | Prettier 格式化 |
|
| `bun run format` | oxfmt 格式化 |
|
||||||
| `bun test` | 运行全部测试 |
|
| `bun test` | 运行全部测试 |
|
||||||
| `bun run clean` | 清理构建缓存 |
|
| `bun run clean` | 清理构建缓存 |
|
||||||
| `bun run version:patch` | 升迁 patch 版本 |
|
| `bun run version:patch` | 升迁 patch 版本 |
|
||||||
|
|||||||
@@ -31,35 +31,45 @@ Request -> Bun.serve routes 声明式匹配 -> routes/*.ts handler -> helpers/
|
|||||||
- 共享类型在 `src/shared/`。
|
- 共享类型在 `src/shared/`。
|
||||||
- 前端禁止 import `src/server/` 运行时实现;后端禁止依赖 `src/web/` 运行时代码(HTML import 集成除外)。
|
- 前端禁止 import `src/server/` 运行时实现;后端禁止依赖 `src/web/` 运行时代码(HTML import 集成除外)。
|
||||||
|
|
||||||
|
## 配置定位
|
||||||
|
|
||||||
|
| 配置类型 | 来源 | 内容 | 可变性 |
|
||||||
|
| -------- | ------------------------------------------------------------------------ | ------------------------------ | ---------- |
|
||||||
|
| 启动配置 | CLI 传入的 `config.yaml` | 监听地址、端口、日志、数据目录 | 进程内只读 |
|
||||||
|
| 业务设置 | 管理台 `/settings` 页面 → `GET/PUT /api/settings` → SQLite `settings` 表 | 主题偏好等 UI/业务开关 | 运行时可变 |
|
||||||
|
|
||||||
|
启动配置由 `src/server/config.ts` 的 `loadServerConfig()` 在启动时加载并校验,运行时不可更改。业务设置通过独立 API 持久化,与启动配置在存储和生命周期上完全解耦。
|
||||||
|
|
||||||
## 主要模块
|
## 主要模块
|
||||||
|
|
||||||
| 模块 | 职责 |
|
| 模块 | 职责 |
|
||||||
| ------------------------- | -------------------------------------------------------- |
|
| ------------------------- | --------------------------------------------------------------- |
|
||||||
| `src/server/bootstrap.ts` | 统一启动引导、DB 初始化、shutdown 编排 |
|
| `src/server/bootstrap.ts` | 统一启动引导、DB 初始化、shutdown 编排 |
|
||||||
| `src/server/config.ts` | 配置加载入口:YAML 解析、规范化、契约校验、运行时校验 |
|
| `src/server/config.ts` | 配置加载入口:YAML 解析、规范化、契约校验、运行时校验 |
|
||||||
| `src/server/config/` | 配置子系统:types、variables、issues、normalizer、schema |
|
| `src/server/config/` | 配置子系统:types、variables、issues、normalizer、schema |
|
||||||
| `src/server/logger.ts` | Logger 接口 + Pino 实现 + 测试替身 |
|
| `src/server/logger.ts` | Logger 接口 + Pino 实现 + 测试替身 |
|
||||||
| `src/server/server.ts` | Bun HTTP server + routes 注册 |
|
| `src/server/server.ts` | Bun HTTP server + routes 注册 |
|
||||||
| `src/server/routes/` | API handler,按资源端点拆分 |
|
| `src/server/routes/` | API handler,按资源端点拆分 |
|
||||||
| `src/server/db/` | SQLite 连接、schema、migration、data access |
|
| `src/server/db/` | SQLite 连接、schema、migration、data access |
|
||||||
| `src/server/ai/` | AI Provider Registry + Agent + 工具 |
|
| `src/server/ai/` | AI Provider Registry + Agent + 工具 |
|
||||||
| `src/server/helpers/` | 响应格式化、URL 工具 |
|
| `src/server/helpers/` | 响应格式化、URL 工具 |
|
||||||
| `src/server/middleware/` | 参数校验 + 错误处理 |
|
| `src/server/middleware/` | 参数校验 + 错误处理 |
|
||||||
| `src/web/layouts/` | 前端布局组件(AdminLayout / WorkbenchLayout) |
|
| `src/web/layouts/` | 前端布局组件(AdminLayout / WorkbenchLayout) |
|
||||||
| `src/web/features/` | 前端功能模块(dashboard / projects / models / chat) |
|
| `src/web/features/` | 前端功能模块(dashboard / projects / models / chat / settings) |
|
||||||
| `src/web/shared/` | 前端共享代码(components / hooks / utils) |
|
| `src/web/shared/` | 前端共享代码(components / hooks / utils) |
|
||||||
| `src/shared/api.ts` | 前后端共享 API 类型 |
|
| `src/shared/api.ts` | 前后端共享 API 类型 |
|
||||||
| `src/shared/app.ts` | 应用全局常量 |
|
| `src/shared/app.ts` | 应用全局常量 |
|
||||||
|
|
||||||
## 路由分组
|
## 路由分组
|
||||||
|
|
||||||
| 资源 | 路径前缀 | 文件目录 |
|
| 资源 | 路径前缀 | 文件目录 |
|
||||||
| --------- | ----------------------------------------------- | ------------------- |
|
| --------- | ----------------------------------------------- | -------------------- |
|
||||||
| meta | `/api/meta` | `routes/meta.ts` |
|
| meta | `/api/meta` | `routes/meta.ts` |
|
||||||
| providers | `/api/providers` | `routes/providers/` |
|
| providers | `/api/providers` | `routes/providers/` |
|
||||||
| models | `/api/models` | `routes/models/` |
|
| models | `/api/models` | `routes/models/` |
|
||||||
| projects | `/api/projects` | `routes/projects/` |
|
| projects | `/api/projects` | `routes/projects/` |
|
||||||
| chat | `/api/projects/:id/conversations` 和 `:id/chat` | `routes/chat/` |
|
| chat | `/api/projects/:id/conversations` 和 `:id/chat` | `routes/chat/` |
|
||||||
|
| settings | `/api/settings` | `routes/settings.ts` |
|
||||||
|
|
||||||
## 更新触发条件
|
## 更新触发条件
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ SQLite + bun:sqlite + Drizzle ORM。
|
|||||||
| `providers.ts` | createProvider、getProvider、listProviders、listProviderOptions、updateProvider、deleteProvider |
|
| `providers.ts` | createProvider、getProvider、listProviders、listProviderOptions、updateProvider、deleteProvider |
|
||||||
| `models.ts` | createModel、getModel、listModels、getModelWithProvider、getModelsByProviderId、updateModel、deleteModel |
|
| `models.ts` | createModel、getModel、listModels、getModelWithProvider、getModelsByProviderId、updateModel、deleteModel |
|
||||||
| `conversations.ts` | createConversation、getConversation、listConversations、updateConversation、updateConversationTimestamp、deleteConversation、createMessage、createMessages、listMessages |
|
| `conversations.ts` | createConversation、getConversation、listConversations、updateConversation、updateConversationTimestamp、deleteConversation、createMessage、createMessages、listMessages |
|
||||||
| `materials.ts` | createMaterial、getMaterial、listMaterials、deleteMaterial |
|
| `materials.ts` | createMaterial、getMaterial、listMaterials、deleteMaterial、approveMaterial、discardMaterial、retryMaterial |
|
||||||
|
|
||||||
输入输出类型来自 `src/shared/api.ts`。
|
输入输出类型来自 `src/shared/api.ts`。
|
||||||
|
|
||||||
@@ -66,14 +66,31 @@ SQLite + bun:sqlite + Drizzle ORM。
|
|||||||
|
|
||||||
## 素材 API
|
## 素材 API
|
||||||
|
|
||||||
| 方法 | 路径 | 说明 |
|
| 方法 | 路径 | 说明 |
|
||||||
| ------ | ---------------------------------- | ---------------------- |
|
| ------ | ------------------------------------------ | ------------------------------- |
|
||||||
| GET | `/api/projects/:id/materials` | 列出项目下素材(分页) |
|
| GET | `/api/projects/:id/materials` | 列出项目下素材(分页+状态筛选) |
|
||||||
| POST | `/api/projects/:id/materials` | 创建素材 |
|
| POST | `/api/projects/:id/materials` | 创建素材 |
|
||||||
| GET | `/api/projects/:id/materials/:mid` | 获取素材详情 |
|
| GET | `/api/projects/:id/materials/:mid` | 获取素材详情 |
|
||||||
| DELETE | `/api/projects/:id/materials/:mid` | 删除素材(软删除) |
|
| DELETE | `/api/projects/:id/materials/:mid` | 删除素材(软删除) |
|
||||||
|
| POST | `/api/projects/:id/materials/:mid/approve` | 审核通过(需 review 状态) |
|
||||||
|
| POST | `/api/projects/:id/materials/:mid/discard` | 审核放弃(需 review 状态) |
|
||||||
|
| POST | `/api/projects/:id/materials/:mid/retry` | 重试处理(需 failed 状态) |
|
||||||
|
|
||||||
校验:description 必填非空,associatedDate 必填 YYYY-MM-DD,项目须存在且 active 且未删除,素材归属校验不匹配返回 403。
|
素材状态流转:`pending → processing → review → approved/discarded`,失败分支 `processing → failed`,用户重试 `failed → pending`。共 6 种状态:`pending`、`processing`、`review`、`approved`、`discarded`、`failed`。
|
||||||
|
|
||||||
|
素材类型:`general`(通用)和 `meeting`(会议),创建时可选,默认 `general`。
|
||||||
|
|
||||||
|
校验:description 必填非空,associatedDate 必填 YYYY-MM-DD,项目须存在且 active 且未删除,素材归属校验不匹配返回 403。processing 状态禁止删除(409)。approve/discard 仅限 review 状态(409),retry 仅限 failed 状态(409)。
|
||||||
|
|
||||||
|
## 素材处理引擎
|
||||||
|
|
||||||
|
`src/server/processing/`:
|
||||||
|
|
||||||
|
- `processor.ts`:`MaterialProcessor` 类 — 后台定时扫描 pending 素材,按 FIFO 顺序处理(每 5 秒扫描一次),每次处理最多重试 3 次,成功后 status=review 并设置 processedContent,全部失败后 status=failed。启动时自动恢复所有 processing 状态的素材为 pending(`recoverStuckMaterials`)。
|
||||||
|
- `templates/`:处理模板目录 — `general.ts`(通用模板)和 `meeting.ts`(会议模板),当前为占位实现(原样回显 description)。`index.ts` 导出 `ProcessingTemplate` 类型和 `getTemplate(type)` 映射函数。
|
||||||
|
- `index.ts`:`startMaterialProcessor(db, logger)` — 工厂函数,创建并启动处理器实例。
|
||||||
|
|
||||||
|
处理器在 `bootstrap.ts` 中启动,shutdown 时先停止处理器再关闭数据库。
|
||||||
|
|
||||||
## 聊天 API
|
## 聊天 API
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
- **AdminLayout**(`src/web/layouts/admin-layout/`):路由 `/`(总览)、`/projects`、`/models`、`/models/providers`。
|
- **AdminLayout**(`src/web/layouts/admin-layout/`):路由 `/`(总览)、`/projects`、`/models`、`/models/providers`。
|
||||||
- **WorkbenchLayout**(`src/web/layouts/workbench-layout/`):路由 `/workbench/:projectId`、`/workbench/:projectId/chat`。`WorkbenchProjectGate` 从 URL 读 projectId,通过 `ProjectContext` 提供项目上下文,仅 active 项目渲染。
|
- **WorkbenchLayout**(`src/web/layouts/workbench-layout/`):路由 `/workbench/:projectId`、`/workbench/:projectId/chat`。`WorkbenchProjectGate` 从 URL 读 projectId,通过 `ProjectContext` 提供项目上下文,仅 active 项目渲染。
|
||||||
|
|
||||||
ConsoleShell 包含:`XProvider(zhCN + zhCN_X)` + `AntApp` + `Layout`(Header/Sider/Content) + 主题切换(明亮/黑暗/系统)+ 侧边栏折叠。Header 显示品牌名、版本号和布局标题。
|
ConsoleShell 包含:`XProvider(zhCN + zhCN_X)` + `AntApp` + `Layout`(Header/Sider/Content),主题配置由 `shared/theme/theme-config.ts` 的 `buildThemeConfig({ compact, effectiveTheme })` 集中构建(含 `algorithm` 数组组合 `compactAlgorithm`、`cssVar`、`borderRadius`、`controlHeight`、`components.Layout` 配色);侧边栏折叠。主题与紧凑模式切换在设置页(`/settings`)。Header 显示品牌名、版本号和布局标题。
|
||||||
|
|
||||||
`Sidebar`(`src/web/shared/components/Sidebar/`)纯展示组件,通过 `menuItems` props 接收配置。
|
`Sidebar`(`src/web/shared/components/Sidebar/`)纯展示组件,通过 `menuItems` props 接收配置。
|
||||||
|
|
||||||
@@ -22,24 +22,26 @@ ConsoleShell 包含:`XProvider(zhCN + zhCN_X)` + `AntApp` + `Layout`(Header/Si
|
|||||||
| 模型管理 | `features/models/` | 供应商/模型管理、连通性测试 |
|
| 模型管理 | `features/models/` | 供应商/模型管理、连通性测试 |
|
||||||
| 聊天 | `features/chat/` | 会话管理、消息渲染、AI 对话 |
|
| 聊天 | `features/chat/` | 会话管理、消息渲染、AI 对话 |
|
||||||
| 收集箱 | `features/inbox/` | 素材 CRUD、持久化、列表管理 |
|
| 收集箱 | `features/inbox/` | 素材 CRUD、持久化、列表管理 |
|
||||||
|
| 设置 | `features/settings/` | 平台业务设置,卡片式布局 |
|
||||||
|
|
||||||
## 页面
|
## 页面
|
||||||
|
|
||||||
| 页面 | 路径 | 入口 |
|
| 页面 | 路径 | 入口 |
|
||||||
| -------- | -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| -------- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
| 总览 | `/` | `features/dashboard/index.tsx` |
|
| 总览 | `/` | `features/dashboard/index.tsx` |
|
||||||
| 项目管理 | `/projects` | `features/projects/index.tsx` — FilterToolbar(状态 Select + 搜索 + 新建/归档恢复删除) + ProjectTable + ProjectFormModal。支持创建/编辑/归档/恢复/删除、列表排序、URL 同步筛选参数。 |
|
| 项目管理 | `/projects` | `features/projects/index.tsx` — FilterToolbar(状态 Select + 搜索 + 新建/归档恢复删除) + ProjectTable + ProjectFormModal。支持创建/编辑/归档/恢复/删除、列表排序、URL 同步筛选参数。 |
|
||||||
| 模型管理 | `/models` 和 `/models/providers` | 独立路由页面:`ModelListPage.tsx`(FilterToolbar + ModelTable) + `ProviderListPage.tsx`(FilterToolbar + ProviderTable)。模型支持供应商/能力筛选和列表排序,供应商支持类型筛选和列表排序。模型表单使用 `GET /api/providers/options`。供应商表单支持预保存连通性测试(`POST /api/providers/test`)。 |
|
| 模型管理 | `/models` 和 `/models/providers` | 独立路由页面:`ModelListPage.tsx`(FilterToolbar + ModelTable) + `ProviderListPage.tsx`(FilterToolbar + ProviderTable)。模型支持供应商/能力筛选和列表排序,供应商支持类型筛选和列表排序。模型表单使用 `GET /api/providers/options`。供应商表单支持预保存连通性测试(`POST /api/providers/test`)。 |
|
||||||
| 聊天室 | `/workbench/:id` | `features/chat/index.tsx` |
|
| 设置 | `/settings` | `features/settings/index.tsx` — 卡片式布局分区管理平台业务设置。"主题"卡片使用 antd Form 水平布局,包含主题模式(Radio.Group 按钮风:系统/明亮/黑暗)和紧凑模式(Switch 开关),使用 `useSettings` hook 通过 `GET/PUT /api/settings` 实时保存,`message` toast 反馈。 |
|
||||||
| 收集箱 | `/workbench/:id/inbox` | `features/inbox/index.tsx` — 协调层(selectedId + modalOpen)+ MaterialSidebar(列表容器)+ MaterialDetailPanel(详情容器)+ AddMaterialModal。素材 CRUD 通过 TanStack Query hooks 接入后端 API。 |
|
| 聊天室 | `/workbench/:id` | `features/chat/index.tsx` |
|
||||||
| 404 | `*` | `features/not-found/index.tsx` |
|
| 收集箱 | `/workbench/:id/inbox` | `features/inbox/index.tsx` — 协调层(selectedId + modalOpen)+ MaterialSidebar(列表容器)+ MaterialDetailPanel(透明内容区+OS滚动+操作区卡片)+ MaterialContent(纵向卡片流,基本信息Card)+ AddMaterialModal。操作区始终渲染,按钮 fill 样式 + disabled 控制。素材 CRUD 通过 TanStack Query hooks 接入后端 API。 |
|
||||||
|
| 404 | `*` | `features/not-found/index.tsx` |
|
||||||
|
|
||||||
### 聊天页面
|
### 聊天页面
|
||||||
|
|
||||||
`ChatPage` = `ConversationSidebar`(自定义组件)+ `ChatPanel`。
|
`ChatPage` = `ConversationSidebar`(自定义组件)+ `ChatPanel`。
|
||||||
|
|
||||||
- **ConversationSidebar**:会话侧边栏数据加载层(useQuery + 错误处理)。内部渲染 `ConversationList`(搜索框 + OverlayScrollbars 滚动 + 日期分组 + ConversationCard 列表)。对话按 `updatedAt` 分组(今天/昨天/本周/本月/更早),支持搜索过滤和 hover 删除(Popconfirm)。
|
- **ConversationSidebar**:会话侧边栏数据加载层(useQuery + 错误处理)。内部渲染 `ConversationList`(搜索框 + OverlayScrollbars 滚动 + 日期分组 + ConversationCard 列表)。对话按 `updatedAt` 分组(今天/昨天/本周/本月/更早),支持搜索过滤和 hover 删除(Popconfirm)。
|
||||||
- **ChatPanel**:`useChat`(@ai-sdk/react)+ `DefaultChatTransport`(ai 包)与后端 SSE 通信。按 `part.type` 分派渲染:TextPart(markdown-to-jsx 含自定义 overrides:CodeBlock 提供 Shiki 语法高亮和复制按钮、MarkdownTable 提供类 antd 表格样式)、ReasoningPart、ToolPart(四态)。支持编辑重发、重新生成、复制。
|
- **ChatPanel**:`useChat`(@ai-sdk/react)+ `DefaultChatTransport`(ai 包)与后端 SSE 通信。按 `part.type` 分派渲染:TextPart(markdown-to-jsx 含自定义 overrides:CodeBlock 提供 Shiki 语法高亮和复制按钮、MarkdownTable 提供类 antd 表格样式)、ReasoningPart(markdown-to-jsx 渲染,流式优化)、ToolPart(四态,入参/出参分层卡片展示,通过 HighlightBlock 提供 Shiki 语法高亮和复制按钮)。支持编辑重发、重新生成、复制。
|
||||||
- **Sender**(@ant-design/x):输入框 + 发送/停止按钮 + 模型 Select(footer slot)。
|
- **Sender**(@ant-design/x):输入框 + 发送/停止按钮 + 模型 Select(footer slot)。
|
||||||
|
|
||||||
## 共享代码
|
## 共享代码
|
||||||
@@ -66,11 +68,18 @@ ConsoleShell 包含:`XProvider(zhCN + zhCN_X)` + `AntApp` + `Layout`(Header/Si
|
|||||||
| `use-projects.ts` | `shared/hooks/use-projects.ts` | 项目 CRUD + archive/restore |
|
| `use-projects.ts` | `shared/hooks/use-projects.ts` | 项目 CRUD + archive/restore |
|
||||||
| `use-conversations.ts` | `shared/hooks/use-conversations.ts` | 会话和消息 fetch 函数(不含 Query hooks) |
|
| `use-conversations.ts` | `shared/hooks/use-conversations.ts` | 会话和消息 fetch 函数(不含 Query hooks) |
|
||||||
| `use-logger` | `shared/hooks/use-logger.ts` | Logger hook(组件内使用) |
|
| `use-logger` | `shared/hooks/use-logger.ts` | Logger hook(组件内使用) |
|
||||||
| `use-theme-preference` | `shared/hooks/use-theme-preference.ts` | 主题偏好 localStorage 持久化 |
|
| `use-theme-preference` | `shared/hooks/use-theme-preference.ts` | 主题与紧凑模式偏好管理(localStorage + API 同步) |
|
||||||
|
| `use-settings` | `shared/hooks/use-settings.ts` | 平台设置读写(react-query: GET/PUT /api/settings) |
|
||||||
| `use-sidebar-collapsed` | `shared/hooks/use-sidebar-collapsed.ts` | 侧边栏折叠 localStorage 持久化 |
|
| `use-sidebar-collapsed` | `shared/hooks/use-sidebar-collapsed.ts` | 侧边栏折叠 localStorage 持久化 |
|
||||||
| `use-is-dark` | `shared/hooks/use-is-dark.ts` | 当前是否暗色主题 |
|
| `use-is-dark` | `shared/hooks/use-is-dark.ts` | 当前是否暗色主题 |
|
||||||
| `use-current-project` | `shared/hooks/use-current-project.ts` | 当前工作台项目 + ProjectContext(需在 ProjectProvider 内) |
|
|
||||||
| `use-materials.ts` | `shared/hooks/use-materials.ts` | 素材 CRUD(create/delete/fetch/list + Query hooks) |
|
### 共享主题配置
|
||||||
|
|
||||||
|
| 文件 | 导出 |
|
||||||
|
| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- |
|
||||||
|
| `theme/theme-config.ts` | `buildThemeConfig({ compact, effectiveTheme })` — 构建 antd ThemeConfig(algorithm 数组、cssVar、token、components.Layout),compact 时组合 compactAlgorithm 并降低 controlHeight |
|
||||||
|
| `use-current-project` | `shared/hooks/use-current-project.ts` | 当前工作台项目 + ProjectContext(需在 ProjectProvider 内) |
|
||||||
|
| `use-materials.ts` | `shared/hooks/use-materials.ts` | 素材 CRUD(create/delete/fetch/list + Query hooks) |
|
||||||
|
|
||||||
### 共享工具函数
|
### 共享工具函数
|
||||||
|
|
||||||
|
|||||||
7
drizzle/0005_add_settings.sql
Normal file
7
drizzle/0005_add_settings.sql
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
CREATE TABLE settings (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
deleted_at TEXT,
|
||||||
|
data TEXT NOT NULL DEFAULT '{}'
|
||||||
|
);
|
||||||
24
drizzle/0006_material_processing.sql
Normal file
24
drizzle/0006_material_processing.sql
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
-- 扩展 materials 表:新增 material_type 和 processed_content 字段,更新 status CHECK 约束以支持处理流水线状态
|
||||||
|
|
||||||
|
CREATE TABLE `materials_new` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`project_id` text NOT NULL,
|
||||||
|
`associated_date` text NOT NULL,
|
||||||
|
`description` text NOT NULL,
|
||||||
|
`material_type` text NOT NULL DEFAULT 'general' CHECK (`material_type` IN ('general', 'meeting')),
|
||||||
|
`processed_content` text,
|
||||||
|
`status` text NOT NULL DEFAULT 'pending' CHECK (`status` IN ('pending', 'processing', 'review', 'approved', 'discarded', 'failed')),
|
||||||
|
`created_at` text NOT NULL,
|
||||||
|
`updated_at` text NOT NULL,
|
||||||
|
`deleted_at` text,
|
||||||
|
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE no action
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
INSERT INTO `materials_new` (`id`, `project_id`, `associated_date`, `description`, `material_type`, `processed_content`, `status`, `created_at`, `updated_at`, `deleted_at`)
|
||||||
|
SELECT `id`, `project_id`, `associated_date`, `description`, 'general', NULL, `status`, `created_at`, `updated_at`, `deleted_at` FROM `materials`;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DROP TABLE `materials`;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `materials_new` RENAME TO `materials`;
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX `materials_project_id_idx` ON `materials` (`project_id`);
|
||||||
15
drizzle/0007_create_entities.sql
Normal file
15
drizzle/0007_create_entities.sql
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS `entities` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`created_at` text NOT NULL,
|
||||||
|
`updated_at` text NOT NULL,
|
||||||
|
`deleted_at` text,
|
||||||
|
`project_id` text NOT NULL REFERENCES `projects`(`id`),
|
||||||
|
`name` text NOT NULL,
|
||||||
|
`type` text NOT NULL DEFAULT 'other',
|
||||||
|
`description` text NOT NULL DEFAULT '',
|
||||||
|
`aliases` text NOT NULL DEFAULT '[]'
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS `entities_project_id_idx` ON `entities` (`project_id`);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS `entities_name_idx` ON `entities` (`name`);
|
||||||
12
eslint-rules/local-rules.js
Normal file
12
eslint-rules/local-rules.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { enforceCatchType } from "./enforce-catch-type.js";
|
||||||
|
import { noEmptyFunction } from "./no-empty-function.js";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
meta: {
|
||||||
|
name: "local-rules",
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
"enforce-catch-type": enforceCatchType,
|
||||||
|
"no-empty-function": noEmptyFunction,
|
||||||
|
},
|
||||||
|
};
|
||||||
170
eslint.config.js
170
eslint.config.js
@@ -1,170 +0,0 @@
|
|||||||
import js from "@eslint/js";
|
|
||||||
import importPlugin from "eslint-plugin-import";
|
|
||||||
import perfectionist from "eslint-plugin-perfectionist";
|
|
||||||
import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended";
|
|
||||||
import reactHooks from "eslint-plugin-react-hooks";
|
|
||||||
import reactRefresh from "eslint-plugin-react-refresh";
|
|
||||||
import tseslint from "typescript-eslint";
|
|
||||||
|
|
||||||
import { enforceCatchType } from "./eslint-rules/enforce-catch-type.js";
|
|
||||||
import { noEmptyFunction } from "./eslint-rules/no-empty-function.js";
|
|
||||||
|
|
||||||
const noDirectConsoleMessage =
|
|
||||||
"后端运行时代码禁止直接使用 console.*;请通过注入的 Logger 实例输出日志,配置加载失败前使用 createConsoleFallback()。";
|
|
||||||
|
|
||||||
const noDirectConsoleFrontendMessage =
|
|
||||||
"前端代码禁止直接使用 console.*;请使用 useLogger() hook(组件内)或 createConsoleLogger()(非组件纯函数)。";
|
|
||||||
|
|
||||||
export default tseslint.config(
|
|
||||||
{
|
|
||||||
ignores: [
|
|
||||||
"node_modules/**",
|
|
||||||
"dist/**",
|
|
||||||
".build/**",
|
|
||||||
"*.bun-build",
|
|
||||||
"openspec/**",
|
|
||||||
".opencode/**",
|
|
||||||
".claude/**",
|
|
||||||
".codex/**",
|
|
||||||
".agents/**",
|
|
||||||
".worktrees/**",
|
|
||||||
"bin/**",
|
|
||||||
"bun.lock",
|
|
||||||
"data/**",
|
|
||||||
"eslint-rules/**",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
js.configs.recommended,
|
|
||||||
...tseslint.configs.recommendedTypeChecked,
|
|
||||||
...tseslint.configs.stylisticTypeChecked,
|
|
||||||
importPlugin.flatConfigs.recommended,
|
|
||||||
importPlugin.flatConfigs.typescript,
|
|
||||||
perfectionist.configs["recommended-natural"],
|
|
||||||
{
|
|
||||||
languageOptions: {
|
|
||||||
parserOptions: {
|
|
||||||
projectService: true,
|
|
||||||
tsconfigRootDir: import.meta.dirname,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
settings: {
|
|
||||||
"import/resolver": { node: true, typescript: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
rules: {
|
|
||||||
"@typescript-eslint/array-type": ["error", { default: "array-simple" }],
|
|
||||||
"@typescript-eslint/consistent-type-assertions": ["error", { assertionStyle: "as" }],
|
|
||||||
"@typescript-eslint/consistent-type-imports": ["error", { prefer: "type-imports" }],
|
|
||||||
"@typescript-eslint/no-empty-function": "off",
|
|
||||||
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
|
|
||||||
"@typescript-eslint/only-throw-error": "error",
|
|
||||||
"@typescript-eslint/prefer-nullish-coalescing": "error",
|
|
||||||
"@typescript-eslint/prefer-optional-chain": "error",
|
|
||||||
"import/no-unresolved": ["error", { ignore: ["^bun:"] }],
|
|
||||||
"no-restricted-syntax": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
message:
|
|
||||||
"禁止 throw 字面量。项目约定只允许 throw new Error(...) 或 throw new AppError(msg, statusCode)。Re-throw 已捕获的 Error 实例时使用 throw e。",
|
|
||||||
selector: "ThrowStatement > Literal",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"no-undef": "off",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
files: ["src/**/*.{ts,tsx}"],
|
|
||||||
plugins: {
|
|
||||||
local: {
|
|
||||||
rules: {
|
|
||||||
"enforce-catch-type": enforceCatchType,
|
|
||||||
"no-empty-function": noEmptyFunction,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
rules: {
|
|
||||||
"local/enforce-catch-type": "warn",
|
|
||||||
"local/no-empty-function": "error",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
files: ["eslint.config.js"],
|
|
||||||
rules: {
|
|
||||||
"import/no-named-as-default": "off",
|
|
||||||
"import/no-named-as-default-member": "off",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
files: ["src/server/db/**/*.ts"],
|
|
||||||
ignores: ["src/server/db/helpers.ts"],
|
|
||||||
rules: {
|
|
||||||
"no-restricted-imports": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
paths: [
|
|
||||||
{
|
|
||||||
importNames: ["sqliteTable"],
|
|
||||||
message:
|
|
||||||
"请从 ./helpers.ts 导入 sqliteTable,并在列定义中展开 baseColumns。参见 src/server/db/helpers.ts。",
|
|
||||||
name: "drizzle-orm/sqlite-core",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
files: ["src/server/**/*.ts"],
|
|
||||||
ignores: ["src/server/logger.ts"],
|
|
||||||
rules: {
|
|
||||||
"no-restricted-syntax": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
message: noDirectConsoleMessage,
|
|
||||||
selector: "MemberExpression[object.name='console']",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
files: ["src/web/**/*.{ts,tsx}"],
|
|
||||||
ignores: ["src/web/**/logger.ts"],
|
|
||||||
plugins: {
|
|
||||||
"react-hooks": reactHooks,
|
|
||||||
"react-refresh": reactRefresh,
|
|
||||||
},
|
|
||||||
rules: {
|
|
||||||
...reactHooks.configs.recommended.rules,
|
|
||||||
"no-restricted-imports": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
patterns: [
|
|
||||||
{
|
|
||||||
group: [
|
|
||||||
"../server/*",
|
|
||||||
"../server/**",
|
|
||||||
"../**/server/*",
|
|
||||||
"../**/server/**",
|
|
||||||
"../../server/*",
|
|
||||||
"../../server/**",
|
|
||||||
"src/server/*",
|
|
||||||
"src/server/**",
|
|
||||||
],
|
|
||||||
message: "前端不得导入 src/server 后端运行时实现;请改用 src/shared 类型或 HTTP API。",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"no-restricted-syntax": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
message: noDirectConsoleFrontendMessage,
|
|
||||||
selector: "MemberExpression[object.name='console']",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
eslintPluginPrettierRecommended,
|
|
||||||
);
|
|
||||||
@@ -1,11 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://opencode.ai/config.json",
|
"$schema": "https://opencode.ai/config.json",
|
||||||
"mcp": {
|
"mcp": {
|
||||||
"tdesign-mcp-server": {
|
|
||||||
"enabled": true,
|
|
||||||
"type": "local",
|
|
||||||
"command": ["bunx", "tdesign-mcp-server@latest"]
|
|
||||||
},
|
|
||||||
"antd": {
|
"antd": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"type": "local",
|
"type": "local",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
schema: fast-drive
|
schema: code-drive
|
||||||
|
|
||||||
context: |
|
context: |
|
||||||
## 项目概览
|
## 项目概览
|
||||||
@@ -30,12 +30,7 @@ context: |
|
|||||||
- 优先使用提问工具对用户确认
|
- 优先使用提问工具对用户确认
|
||||||
|
|
||||||
rules:
|
rules:
|
||||||
design:
|
|
||||||
- fast-drive的design.md章节标题和正文使用中文;仅OpenSpec术语、文件名、schema字段名、命令和代码符号保留英文
|
|
||||||
- 先前的讨论技术方案要尽可能体现在设计文档中,便于指导实现阶段不偏离已定的技术路线
|
|
||||||
tasks:
|
tasks:
|
||||||
- fast-drive的tasks.md分组标题、任务描述和验证说明使用中文;每个任务必须保留OpenSpec CLI可解析的单行checkbox格式
|
|
||||||
- 一行一个任务,严禁任务内容跨行
|
|
||||||
- 如果是代码存在更新必须
|
- 如果是代码存在更新必须
|
||||||
- 执行完整的测试、代码检查、格式检查等质量保障手段
|
- 执行完整的测试、代码检查、格式检查等质量保障手段
|
||||||
- 执行文档影响分析,更新 README.md 和/或 docs/ 下对应文档
|
- 执行文档影响分析,更新 README.md 和/或 docs/ 下对应文档
|
||||||
|
|||||||
234
openspec/schemas/code-drive/prompts/blocker-revise.md
Normal file
234
openspec/schemas/code-drive/prompts/blocker-revise.md
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
# blocker-revise
|
||||||
|
|
||||||
|
当 OpenSpec `code-drive` 的 apply 阶段生成 `blocker.md` 并暂停时,按照本提示词修订规划 artifacts。目标是修正不成立的部分,而不是强行继续实现。
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
读取 `blocker.md` 及上下游 artifacts,识别阻塞本质,定位最上游的修订入口,通过用户决策流程选定修订方向,按 code-drive 各 artifact 的 instruction 逐层修订受影响的部分,最终让 apply 可以从修订后的待办任务继续。
|
||||||
|
|
||||||
|
本提示词是 code-drive 中除 requirements 之外**唯一允许向用户发起决策型提问**的入口,决策能力受"用户决策流程(强制协议)"约束。
|
||||||
|
|
||||||
|
## 输入
|
||||||
|
|
||||||
|
- 当前 OpenSpec change 目录
|
||||||
|
- `blocker.md`
|
||||||
|
- `requirements.md`
|
||||||
|
- `design.md`
|
||||||
|
- `plan.md`
|
||||||
|
- `tasks.md`
|
||||||
|
- `openspec/schemas/code-drive/schema.yaml`(用于读取各 artifact 的 instruction)
|
||||||
|
- `openspec/schemas/code-drive/templates/*.md`(用于读取各 artifact 的模板结构)
|
||||||
|
|
||||||
|
## 工具使用(先决说明)
|
||||||
|
|
||||||
|
- **todo 工具**:用于跟踪步骤 5/6/7 内的子步骤进度;子步骤颗粒度(如 5.1 / 5.2 / 5.3)直接对应 todo 条目;todo 只是过程跟踪,最终事实必须写回对应 artifact 文件
|
||||||
|
- **question / choice 工具**:触发用户决策流程时必须优先使用(如工具支持),按"用户决策格式(强制)"组织候选与说明
|
||||||
|
- **读写工具**:本提示词需要写入多个 artifact,但写入前必须先读取对应 artifact 的当前内容;禁止凭印象重写
|
||||||
|
- **只读探索工具**:用于步骤 2 的代码库调查(仅在阻塞点涉及未被探索过的代码时使用)
|
||||||
|
|
||||||
|
## 用户决策流程(强制协议)
|
||||||
|
|
||||||
|
本协议是 blocker-revise 阶段的**唯一决策出口**。任何超出"修订执行细节"层级的方向选择必须走本协议,不得 AI 自决。
|
||||||
|
|
||||||
|
### 触发条件(命中即必须启动)
|
||||||
|
|
||||||
|
1. **核心修订方向存在多种可行路径**:blocker.md 列出的可选方案 ≥ 2 个,或 AI 补充的方案与原方案构成真正的取舍(不是同一思路的两种写法)
|
||||||
|
2. **blocker.md 建议的修订方向会扩展本次业务范围或引入核心新依赖**:与原 requirements / design 不一致,必须显式征得用户同意
|
||||||
|
3. **修订入口在不同 artifact 之间犹豫**:例如 blocker 表面指向 plan,但根因可能在 design 或 requirements,需要用户判断修订起点
|
||||||
|
4. **blocker.md 未列出可选方案,仅描述了阻塞现象**:AI 必须主动补充 2-3 个候选方向并请用户选择
|
||||||
|
|
||||||
|
未命中以上任何条件时,**不得**主动发起决策型提问;AI 自决范围参见下文。
|
||||||
|
|
||||||
|
### AI 自决范围(无需启动用户决策流程)
|
||||||
|
|
||||||
|
以下类型的修订属于执行细节,AI 自决后直接执行,无需启动用户决策流程:
|
||||||
|
|
||||||
|
1. **执行步骤的局部调整**:plan.md 阶段内步骤的拆分、合并、重排
|
||||||
|
2. **任务粒度细化或合并**:tasks.md checkbox 的拆分/合并,但不删除整个分组
|
||||||
|
3. **描述措辞修正**:澄清歧义、补充缺失细节、修正笔误
|
||||||
|
4. **已完成任务的保留决策**:阻塞未证明无效时,已完成 checkbox 必须保留
|
||||||
|
5. **修订记录的措辞与格式**:blocker.md 末尾的修订记录按本提示词模板填写
|
||||||
|
6. **工具使用顺序**:todo / 读写工具的使用顺序与时机
|
||||||
|
|
||||||
|
### 用户决策格式(强制)
|
||||||
|
|
||||||
|
每次启动用户决策流程时,输出必须包含:
|
||||||
|
|
||||||
|
1. **2-3 个候选选项**:每个选项含义明确,避免"方案 A / 方案 B"这类无信息标签
|
||||||
|
2. **推荐方案**:AI 必须明确推荐其中一项,不得回避或"中立呈现"
|
||||||
|
3. **取舍说明**:
|
||||||
|
- 每个非推荐方案:说明未选它的核心理由(一句话即可)
|
||||||
|
- 推荐方案:说明选择它的核心理由 + 主要代价
|
||||||
|
4. **影响范围预测**(blocker-revise 特有):每个选项预测将影响哪些 artifact 需要修订,并粗估修订量(小改 / 中改 / 重写章节)
|
||||||
|
5. **使用工具**:优先使用 question/choice 工具;工具不可用时以 markdown 形式直接呈现
|
||||||
|
|
||||||
|
### 强制语义(不得跳过)
|
||||||
|
|
||||||
|
- 触发条件命中时**必须**启动用户决策流程,即使你倾向"自己决定"或"按 blocker.md 建议执行"
|
||||||
|
- 即使用户回复"你看着办",也必须回复"推荐方案 + 主要代价",请用户**显式确认**推荐方案,不得默认接受"你看着办"作为决策
|
||||||
|
- 决策方向涉及扩展本次业务范围或引入核心新依赖时(触发条件 2),必须**额外显式提示**"本选项将扩展本次范围 / 引入新依赖",并征得用户的明确同意(不接受默认)
|
||||||
|
- 用户未决策前**不得**进入步骤 5 的实际修订
|
||||||
|
|
||||||
|
### 决策归档规则
|
||||||
|
|
||||||
|
用户给出决策后:
|
||||||
|
|
||||||
|
1. **决策结论融入对应 artifact 的相关章节**——不设独立的"决策记录"章节
|
||||||
|
2. **在 `blocker.md` 末尾追加"修订记录"段**:记录选择方案、选择理由、修改的 artifacts 列表、被取消勾选的 tasks——这是审计线索,不是二次决策入口
|
||||||
|
3. **决策引发的修订如果触及 requirements / design 的关键决策**,按各 artifact instruction 中"决策归档规则"融入对应章节
|
||||||
|
4. **决策结论应可在 apply 恢复后直接使用**——明确到 apply 阶段无需重新发起决策
|
||||||
|
|
||||||
|
## 工作流
|
||||||
|
|
||||||
|
### 步骤 1: 阅读并理解阻塞
|
||||||
|
|
||||||
|
阅读 `blocker.md`,识别:
|
||||||
|
|
||||||
|
- 阻塞点的**本质**(不是症状)
|
||||||
|
- 当前位置:任务编号、`plan.md` 阶段、相关文件
|
||||||
|
- 已尝试的方案及失败原因(避免重复)
|
||||||
|
- `blocker.md` 建议的修订目标(如有)
|
||||||
|
|
||||||
|
完成本步骤后进入步骤 2。
|
||||||
|
|
||||||
|
### 步骤 2: 影响分析
|
||||||
|
|
||||||
|
根据 `blocker.md` 的影响范围,系统分析上下游影响链:
|
||||||
|
|
||||||
|
- 如果 `requirements.md` 需要修订 → 检查 `design.md` 的哪些决策依赖它,再检查 `plan.md` 的哪些阶段受影响,最后检查 `tasks.md` 的哪些 checkbox 需要取消
|
||||||
|
- 如果 `design.md` 需要修订 → 检查 `plan.md` 的哪些阶段依赖它,再检查 `tasks.md` 的哪些 checkbox 需要取消
|
||||||
|
- 如果 `plan.md` 需要修订 → 检查 `tasks.md` 的哪些 checkbox 依赖它,以及是否有下游阶段依赖被阻塞阶段的输出
|
||||||
|
- 如果 `tasks.md` 需要修订 → 只影响当前任务及其直接依赖
|
||||||
|
|
||||||
|
记录每个 artifact 的影响程度:**必须修订 / 可能受影响 / 无影响**。
|
||||||
|
|
||||||
|
如阻塞点涉及未被探索过的代码模块,使用只读探索工具补充上下文;否则不发起额外探索。
|
||||||
|
|
||||||
|
完成本步骤后进入步骤 3。
|
||||||
|
|
||||||
|
### 步骤 3: 确定修订入口
|
||||||
|
|
||||||
|
根据影响分析,确定需要修订的**最上游** artifact:
|
||||||
|
|
||||||
|
- 需要修订 `requirements.md` → 从 requirements 开始,依次修订 design、plan、tasks
|
||||||
|
- 需要修订 `design.md` → 从 design 开始,依次修订 plan、tasks
|
||||||
|
- 需要修订 `plan.md` → 从 plan 开始,修订 tasks
|
||||||
|
- 只需要修订 `tasks.md` → 只修订 tasks
|
||||||
|
|
||||||
|
如果"修订入口在不同 artifact 之间犹豫"(触发条件 3),先暂停并启动用户决策流程;用户决策后回到本步骤。
|
||||||
|
|
||||||
|
完成本步骤后进入步骤 4。
|
||||||
|
|
||||||
|
### 步骤 4: 用户决策
|
||||||
|
|
||||||
|
检查是否命中"用户决策流程(强制协议)"中任一触发条件:
|
||||||
|
|
||||||
|
- **命中** → 按强制格式输出候选 + 推荐 + 取舍 + 影响范围预测,等待用户决策
|
||||||
|
- **未命中** → 直接进入步骤 5(属于 AI 自决范围)
|
||||||
|
|
||||||
|
用户决策后或确认无需决策后,进入步骤 5。
|
||||||
|
|
||||||
|
### 步骤 5: 执行修订
|
||||||
|
|
||||||
|
从步骤 3 确定的修订入口开始,按 code-drive 正常流程逐层修订下游 artifacts。
|
||||||
|
|
||||||
|
**子步骤 5.1: 读取 instruction 与 template**
|
||||||
|
|
||||||
|
对每个待修订的 artifact:
|
||||||
|
|
||||||
|
1. 读取 `schema.yaml` 中该 artifact 的 `instruction`
|
||||||
|
2. 读取该 artifact 的 `template`
|
||||||
|
3. 后续修订必须遵循 instruction 工作流和 template 结构
|
||||||
|
|
||||||
|
**子步骤 5.2: 局部修订**
|
||||||
|
|
||||||
|
按修订范围原则执行:
|
||||||
|
|
||||||
|
- 只改错误的部分,不重写整个章节(除非整个章节的基础假设不成立)
|
||||||
|
- 改了 `plan.md` 阶段的实现步骤时,同步更新 `tasks.md` 对应 checkbox 的描述
|
||||||
|
- 改了 `design.md` 的关键决策时,检查 `plan.md` 的代码模式是否需要同步,但不自动重写
|
||||||
|
- 改了 `requirements.md` 时,逐层向下检查影响,每层只改受影响的部分
|
||||||
|
- 如果修订导致 `tasks.md` 分组结构变化,重新对齐 plan → tasks 映射
|
||||||
|
|
||||||
|
**子步骤 5.3: 保留已完成任务**
|
||||||
|
|
||||||
|
按以下规则处理 tasks.md 中已完成任务:
|
||||||
|
|
||||||
|
- 已完成且**不受**阻塞影响的 tasks → 保留 checkbox
|
||||||
|
- 已完成但被阻塞证明**无效**的 tasks → 取消 checkbox,并在修订记录中说明原因
|
||||||
|
- 未完成的 tasks → 根据修订结果更新描述或删除
|
||||||
|
- 如果阶段需要拆分 → 在 `plan.md` 中新增阶段,将已完成部分和待完成部分分开
|
||||||
|
|
||||||
|
完成本步骤后进入步骤 6。
|
||||||
|
|
||||||
|
### 步骤 6: 修订后验证
|
||||||
|
|
||||||
|
每个被修订的 artifact 完成后,按两层一致性检查。
|
||||||
|
|
||||||
|
**子步骤 6.1: Instruction 合规性检查**
|
||||||
|
|
||||||
|
- 每个被修订的 artifact 是否符合其 instruction 中的工作流和完成标准
|
||||||
|
- 每个被修订的 artifact 是否包含其 instruction / template 要求的章节
|
||||||
|
- 每个被修订的 artifact 是否符合其 template 结构
|
||||||
|
|
||||||
|
**子步骤 6.2: 上下游一致性检查**
|
||||||
|
|
||||||
|
- **需求覆盖**:`requirements.md` 的每条需求是否仍有 `design.md` 决策覆盖
|
||||||
|
- **决策落地**:`design.md` 的每个关键决策是否在 `plan.md` 中有实现路径
|
||||||
|
- **阶段覆盖**:`plan.md` 的每个阶段是否在 `tasks.md` 中有对应分组
|
||||||
|
- **任务可追溯**:`tasks.md` 的每个 checkbox 是否能回溯到 `plan.md` 的某个阶段
|
||||||
|
- **验证闭环**:`design.md` 的"验证方向"是否在 `plan.md` 的"验证策略"中有体现
|
||||||
|
|
||||||
|
任一项失败 → 回到步骤 5 局部修复,修复后从该项重跑。
|
||||||
|
|
||||||
|
全部通过后进入步骤 7。
|
||||||
|
|
||||||
|
### 步骤 7: 处理 blocker.md
|
||||||
|
|
||||||
|
按以下子步骤追加修订记录并归档。
|
||||||
|
|
||||||
|
**子步骤 7.1: 追加修订记录**
|
||||||
|
|
||||||
|
在 `blocker.md` 末尾追加:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## 修订记录
|
||||||
|
|
||||||
|
- 选择方案:
|
||||||
|
- 选择理由:
|
||||||
|
- 修改的 artifacts:
|
||||||
|
- `xxx.md`:具体变更描述
|
||||||
|
- `xxx.md`:具体变更描述
|
||||||
|
- 被取消勾选的 tasks:
|
||||||
|
- X.Y:取消原因
|
||||||
|
```
|
||||||
|
|
||||||
|
**子步骤 7.2: 保留或归档**
|
||||||
|
|
||||||
|
按项目约定保留或归档 `blocker.md`(建议保留作为审计线索)。
|
||||||
|
|
||||||
|
完成本步骤后进入步骤 8。
|
||||||
|
|
||||||
|
### 步骤 8: 完成
|
||||||
|
|
||||||
|
告诉用户重新运行 `/opsx:apply <change-name>`;apply 应跳过已完成 checkbox 并从修订后的待办任务继续。
|
||||||
|
|
||||||
|
## 完成标准
|
||||||
|
|
||||||
|
- 工作流步骤 1-8 全部走过
|
||||||
|
- 步骤 6 的 instruction 合规性 + 上下游一致性全部通过
|
||||||
|
- `blocker.md` 已追加修订记录,并被保留或归档
|
||||||
|
- 选定的修订方向已记录在受影响 artifact 中
|
||||||
|
- 每个被修订的 artifact 符合其 instruction 的工作流和 template 结构
|
||||||
|
- 已完成任务的 checkbox 被保留,除非明确失效
|
||||||
|
- 所有触发条件命中时都执行了用户决策流程(事后可被审计)
|
||||||
|
- 用户知道需要重新运行 apply 继续实现
|
||||||
|
|
||||||
|
## 规则速查
|
||||||
|
|
||||||
|
- 本提示词只负责**修订规划 artifacts**,除非用户明确要求,不要在本提示词中实现代码
|
||||||
|
- **最小修订**:只修订解决阻塞所需的最小上游点及其下游影响,不要默认重写全部 artifacts
|
||||||
|
- **不得擅自扩展**:未经用户确认不得新增需求、依赖、架构方向或范围
|
||||||
|
- **已完成任务保护**:不要取消已完成任务的勾选,除非阻塞证明该已完成工作不正确
|
||||||
|
- **遵循各 artifact 自身的 instruction**:每个被修订的 artifact 必须遵循其 instruction 和 template,即使只是局部修订
|
||||||
|
- **todo 跟踪修订流程**:如果工具支持,使用 todo/plan 工具跟踪修订流程,但最终事实必须写回 artifacts
|
||||||
1087
openspec/schemas/code-drive/schema.yaml
Normal file
1087
openspec/schemas/code-drive/schema.yaml
Normal file
File diff suppressed because it is too large
Load Diff
52
openspec/schemas/code-drive/templates/blocker.md
Normal file
52
openspec/schemas/code-drive/templates/blocker.md
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
## 阻塞点
|
||||||
|
|
||||||
|
<!-- 简述阻塞的本质,不是症状而是根因 -->
|
||||||
|
|
||||||
|
## 当前位置
|
||||||
|
|
||||||
|
- 任务编号:
|
||||||
|
- plan.md 阶段:
|
||||||
|
- 相关文件:
|
||||||
|
|
||||||
|
## 已尝试
|
||||||
|
|
||||||
|
<!-- 列出已尝试的方案和失败原因,避免重复尝试 -->
|
||||||
|
|
||||||
|
| 方案 | 失败原因 |
|
||||||
|
| ---- | -------- |
|
||||||
|
| | |
|
||||||
|
|
||||||
|
## 影响范围
|
||||||
|
|
||||||
|
<!-- 阻塞对上下游 artifacts 的系统性影响 -->
|
||||||
|
|
||||||
|
| Artifact | 影响内容 | 影响程度 |
|
||||||
|
| -------- | -------- | -------- |
|
||||||
|
| | | 必须修订 / 可能受影响 / 无影响 |
|
||||||
|
|
||||||
|
## 可选方案
|
||||||
|
|
||||||
|
### 方案 1:<!-- 语义化方案名称,例如“回退 design 调整接入方式” -->
|
||||||
|
|
||||||
|
- 描述:
|
||||||
|
- 需修订:
|
||||||
|
- 优势:
|
||||||
|
- 风险 / 代价:
|
||||||
|
|
||||||
|
### 方案 2:<!-- 语义化方案名称 -->
|
||||||
|
|
||||||
|
- 描述:
|
||||||
|
- 需修订:
|
||||||
|
- 优势:
|
||||||
|
- 风险 / 代价:
|
||||||
|
|
||||||
|
### 方案 3:<!-- 语义化方案名称,可选 -->
|
||||||
|
|
||||||
|
- 描述:
|
||||||
|
- 需修订:
|
||||||
|
- 优势:
|
||||||
|
- 风险 / 代价:
|
||||||
|
|
||||||
|
## 修订建议
|
||||||
|
|
||||||
|
<!-- 推荐方案及修订路径:从哪个 artifact 入口开始修订,下游需要同步哪些 -->
|
||||||
70
openspec/schemas/code-drive/templates/design.md
Normal file
70
openspec/schemas/code-drive/templates/design.md
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
## 代码库探索
|
||||||
|
|
||||||
|
<!-- 记录 requirements.md 未覆盖的技术探索发现,作为后续设计的依据。如果 requirements.md 中已有充分的探索结果,在此引用并仅补充缺失部分。 -->
|
||||||
|
|
||||||
|
### 已有代码与模式
|
||||||
|
|
||||||
|
<!-- 记录与本次变更相关的现有代码、组件、工具函数、架构模式;requirements.md 已覆盖则写“见 requirements.md”;无相关现有代码则写“无” -->
|
||||||
|
|
||||||
|
### 依赖状态
|
||||||
|
|
||||||
|
<!-- 记录相关依赖的安装状态、版本约束;requirements.md 已覆盖则引用;未安装的依赖需标注 -->
|
||||||
|
|
||||||
|
### 开发规范约束
|
||||||
|
|
||||||
|
<!-- 记录项目规范、代码风格、命名约定、架构模式等对设计的约束;无特殊约束则写“无” -->
|
||||||
|
|
||||||
|
## 整体方案
|
||||||
|
|
||||||
|
### 架构概览
|
||||||
|
|
||||||
|
<!-- 描述本次变更涉及的模块、组件或流程如何组织和协作,用概要方式说明为什么该方案满足 requirements.md -->
|
||||||
|
|
||||||
|
### 关键交互流程
|
||||||
|
|
||||||
|
<!-- 如果变更涉及模块间交互、API 调用、数据流变化或状态转换,在此描述关键路径;无则写“无” -->
|
||||||
|
|
||||||
|
## 目标 / 非目标
|
||||||
|
|
||||||
|
**目标:**
|
||||||
|
<!-- 记录本次技术设计要达成的目标 -->
|
||||||
|
|
||||||
|
**非目标:**
|
||||||
|
<!-- 记录明确不在范围内的内容 -->
|
||||||
|
|
||||||
|
## 关键决策
|
||||||
|
|
||||||
|
<!-- 引用 requirements.md 中已确认的技术决策,仅补充 requirements.md 未覆盖的实现层决策(如具体 API 设计、数据结构、代码组织方式) -->
|
||||||
|
|
||||||
|
| 决策 | 来源 | 理由 |
|
||||||
|
| ---- | ---- | ---- |
|
||||||
|
| <!-- 决策 --> | <!-- requirements.md T1 / 本阶段新增 --> | <!-- 理由或补充说明 --> |
|
||||||
|
|
||||||
|
## 影响范围
|
||||||
|
|
||||||
|
<!-- 记录会受影响的模块、文件、配置、接口、文档、流程或外部依赖 -->
|
||||||
|
|
||||||
|
| 范围 | 变更类型 | 原因 |
|
||||||
|
| ---- | -------- | ---- |
|
||||||
|
| <!-- 范围 --> | <!-- 新增 / 修改 / 删除 / 验证 --> | <!-- 原因 --> |
|
||||||
|
|
||||||
|
### 关键实现路径
|
||||||
|
|
||||||
|
<!-- 按优先级列出 plan.md 应首先处理的核心文件、模块或流程;简单变更可写“同上” -->
|
||||||
|
|
||||||
|
## 依赖与约束
|
||||||
|
|
||||||
|
<!-- 记录依赖限制、兼容性约束、项目规则、质量门禁和禁止事项 -->
|
||||||
|
|
||||||
|
- 依赖:
|
||||||
|
- 兼容性:
|
||||||
|
- 质量门禁:
|
||||||
|
- 禁止事项:
|
||||||
|
|
||||||
|
## 风险 / 权衡
|
||||||
|
|
||||||
|
<!-- 格式:[风险] -> 缓解措施 -->
|
||||||
|
|
||||||
|
## 验证方向
|
||||||
|
|
||||||
|
<!-- 概要说明本次变更应从哪些角度验证,作为 plan.md “验证策略”的输入 -->
|
||||||
66
openspec/schemas/code-drive/templates/plan.md
Normal file
66
openspec/schemas/code-drive/templates/plan.md
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
## 实现概览
|
||||||
|
|
||||||
|
<!-- 概述实现阶段、依赖顺序,以及各阶段如何对应 requirements.md 和 design.md -->
|
||||||
|
|
||||||
|
## 涉及文件
|
||||||
|
|
||||||
|
<!-- 按阶段列出本次变更涉及的核心文件路径,apply 阶段据此定位代码 -->
|
||||||
|
|
||||||
|
| 文件路径 | 变更类型 | 所属阶段 |
|
||||||
|
| -------- | -------- | -------- |
|
||||||
|
| <!-- 文件路径 --> | <!-- 新增 / 修改 / 删除 --> | <!-- 阶段编号 --> |
|
||||||
|
|
||||||
|
## 阶段 N: <!-- 阶段名称;按实际阶段重复本块,N 从 1 递增 -->
|
||||||
|
|
||||||
|
### 目标
|
||||||
|
|
||||||
|
<!-- 本阶段要完成什么 -->
|
||||||
|
|
||||||
|
### 前置条件
|
||||||
|
|
||||||
|
<!-- 本阶段开始前必须满足什么;没有则写“无” -->
|
||||||
|
|
||||||
|
### 详细实现步骤
|
||||||
|
|
||||||
|
<!-- 写清楚关键文件、函数、数据结构、流程或配置变化。不要使用 checkbox。 -->
|
||||||
|
|
||||||
|
### 关键代码模式
|
||||||
|
|
||||||
|
<!-- 记录本阶段的关键实现细节,apply 据此编写代码。至少覆盖以下内容中适用的部分: -->
|
||||||
|
|
||||||
|
**新增 / 修改的函数或方法:**
|
||||||
|
<!-- 函数签名、参数、返回值、核心逻辑;无则写“无” -->
|
||||||
|
|
||||||
|
**新增 / 修改的数据结构:**
|
||||||
|
<!-- 类型定义、字段、约束;无则写“无” -->
|
||||||
|
|
||||||
|
**调用顺序 / 流程:**
|
||||||
|
<!-- 关键调用链、异步流程、状态转换;无则写“无” -->
|
||||||
|
|
||||||
|
**约定 / 模式:**
|
||||||
|
<!-- 命名规范、错误处理模式、日志规范等;无则写“无” -->
|
||||||
|
|
||||||
|
### 验证方式
|
||||||
|
|
||||||
|
<!-- 本阶段如何独立验证 -->
|
||||||
|
|
||||||
|
### 验收标准
|
||||||
|
|
||||||
|
<!-- 本阶段完成的可验证标准;与 requirements.md 验收标准对齐 -->
|
||||||
|
|
||||||
|
### 关联需求
|
||||||
|
|
||||||
|
<!-- 例如:F1、F2 -->
|
||||||
|
|
||||||
|
## 验证策略
|
||||||
|
|
||||||
|
<!-- 汇总自动化测试、手动检查、文档检查、兼容性检查和验收方式 -->
|
||||||
|
|
||||||
|
## 回退 / 兼容性说明
|
||||||
|
|
||||||
|
<!-- 记录回退策略、错误处理策略、兼容性要求、迁移注意事项;没有则写“无” -->
|
||||||
|
|
||||||
|
- 回退策略:
|
||||||
|
- 错误处理:
|
||||||
|
- 兼容性:
|
||||||
|
- 迁移注意事项:
|
||||||
57
openspec/schemas/code-drive/templates/requirements.md
Normal file
57
openspec/schemas/code-drive/templates/requirements.md
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
## 背景与目标
|
||||||
|
|
||||||
|
<!-- 记录问题、当前状态、相关参考资料,以及触发本次变更的用户请求 -->
|
||||||
|
|
||||||
|
## 讨论记录
|
||||||
|
|
||||||
|
<!-- 记录 explore 或前序讨论中后续 design/plan/apply 必须保留的关键结论 -->
|
||||||
|
|
||||||
|
### 已确认结论
|
||||||
|
|
||||||
|
- <!-- 结论 1 -->
|
||||||
|
- <!-- 结论 2 -->
|
||||||
|
|
||||||
|
### 用户偏好
|
||||||
|
|
||||||
|
- <!-- 偏好 1 -->
|
||||||
|
|
||||||
|
### 被否决方案
|
||||||
|
|
||||||
|
- <!-- 方案及否决原因 -->
|
||||||
|
|
||||||
|
## 功能需求
|
||||||
|
|
||||||
|
<!-- 每条功能需求必须有明确验收标准 -->
|
||||||
|
|
||||||
|
| 编号 | 需求 | 验收标准 |
|
||||||
|
| ---- | ---- | -------- |
|
||||||
|
| F1 | <!-- 需求 --> | <!-- 验收标准 --> |
|
||||||
|
|
||||||
|
## 非功能需求
|
||||||
|
|
||||||
|
<!-- 只记录与本次变更相关的非功能要求 -->
|
||||||
|
|
||||||
|
| 类别 | 要求 |
|
||||||
|
| ---- | ---- |
|
||||||
|
| <!-- 性能 / 兼容性 / 安全 / 可维护性 / 运维 / 文档 --> | <!-- 要求 --> |
|
||||||
|
|
||||||
|
## 技术需求
|
||||||
|
|
||||||
|
<!-- 记录需要确认的技术选型、架构方向、集成边界、代码约束或禁止事项;详细设计属于 design.md -->
|
||||||
|
|
||||||
|
| 编号 | 类别 | 决策 | 理由 | 被否决方案 |
|
||||||
|
| ---- | ---- | ---- | ---- | ---------- |
|
||||||
|
| T1 | <!-- 选型 / 架构 / 约束 / 集成 --> | <!-- 已确认的决策 --> | <!-- 理由 --> | <!-- 被否决方案及原因 --> |
|
||||||
|
|
||||||
|
## 全局审查
|
||||||
|
|
||||||
|
<!-- 从系统边界、既有行为、相邻模块、配置、文档、迁移、兼容性、安全、性能和用户流程角度审查当前需求 -->
|
||||||
|
|
||||||
|
### 与现有系统的关联
|
||||||
|
|
||||||
|
<!-- 记录相关模块、流程、配置、文档、外部接口或用户路径 -->
|
||||||
|
|
||||||
|
### 前置条件
|
||||||
|
|
||||||
|
<!-- 记录执行前必须满足的条件;没有则写"无" -->
|
||||||
|
|
||||||
16
openspec/schemas/code-drive/templates/tasks.md
Normal file
16
openspec/schemas/code-drive/templates/tasks.md
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
## X. <!-- 对应 plan.md 阶段 X 的名称;按实际阶段重复本块,X 与 plan 阶段编号一致 -->
|
||||||
|
|
||||||
|
- [ ] X.1 阅读 plan.md 阶段 X,确认涉及文件、关键代码模式和验收标准
|
||||||
|
- [ ] X.2 <!-- 动词 + 文件路径:具体动作描述,详见 plan.md 阶段 X -->
|
||||||
|
- [ ] X.3 <!-- 动词 + 文件路径:具体动作描述,详见 plan.md 阶段 X -->
|
||||||
|
- [ ] X.4 <!-- 运行测试或验证命令,确认阶段 X 的关键行为 -->
|
||||||
|
- [ ] X.5 按 plan.md 阶段 X 的验收标准确认阶段完成
|
||||||
|
|
||||||
|
## N. 验证与收尾
|
||||||
|
|
||||||
|
- [ ] N.1 阅读 plan.md 验证策略,确认所有验证项已执行
|
||||||
|
- [ ] N.2 执行完整测试套件,确认无回归
|
||||||
|
- [ ] N.3 逐项对照 requirements.md 验收标准,确认全部满足
|
||||||
|
- [ ] N.4 检查 design.md 关键决策是否被正确实现
|
||||||
|
- [ ] N.5 如果行为、流程、接口、配置或使用方式发生变化,更新相关文档或交接说明
|
||||||
|
- [ ] N.6 确认所有任务已标记为 `[x]`,未完成或阻塞事项已记录
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
name: fast-drive
|
|
||||||
version: 1
|
|
||||||
description: 快速 OpenSpec workflow - design -> tasks -> apply
|
|
||||||
artifacts:
|
|
||||||
- id: design
|
|
||||||
generates: design.md
|
|
||||||
description: 自包含的方案说明和执行计划
|
|
||||||
template: design.md
|
|
||||||
instruction: |
|
|
||||||
创建 design.md,作为本次变更“改什么、为什么改、如何执行”的自包含事实来源。
|
|
||||||
|
|
||||||
本 workflow 不使用 proposal 或 specs artifacts。design.md MUST 保留前序探索和用户讨论中的重要结论,确保后续 apply 阶段即使经历上下文压缩或进入新会话,也能正确继续执行。
|
|
||||||
|
|
||||||
语言规则(强制):
|
|
||||||
|
|
||||||
- fast-drive 的 design.md 使用中文章节标题和中文正文;仅文件名、OpenSpec 术语、schema 字段名、命令、代码符号和必要技术名词保留英文
|
|
||||||
|
|
||||||
- 最终 design.md 不得残留英文模板句子或英文占位内容,除非该英文是 OpenSpec 术语、文件名、schema 字段名、代码符号、命令或必要技术名词
|
|
||||||
|
|
||||||
面向看不到早期对话的人编写。简单变更保持精炼,但必须包含足够细节让执行无歧义。遇到以下情况时增加细节:
|
|
||||||
|
|
||||||
- 跨多个系统、团队、工作流或 artifacts 的横切变更
|
|
||||||
|
|
||||||
- 新增依赖、集成、供应商、工具、策略或外部输入
|
|
||||||
|
|
||||||
- 重要的信息模型、流程模型、数据模型或归属关系变化
|
|
||||||
|
|
||||||
- 涉及安全、隐私、合规、性能、运维或迁移复杂度
|
|
||||||
|
|
||||||
- 执行前需要先做决策才能降低歧义
|
|
||||||
|
|
||||||
- 前序讨论已经确认非显而易见的需求、约束或被否决方案
|
|
||||||
|
|
||||||
必需章节(建议使用以下中文章节标题):
|
|
||||||
|
|
||||||
- **背景**:问题、当前状态、相关参考资料,以及触发本次变更的用户请求
|
|
||||||
|
|
||||||
- **讨论记录**:探索或前序讨论中必须保留的关键点,包括已确认结论、用户偏好、约束和重要的被否决方案
|
|
||||||
|
|
||||||
- **需求**:预期结果、行为/流程/接口/内容变化、连续性要求和验收标准
|
|
||||||
|
|
||||||
- **目标 / 非目标**:本次变更要达成的目标,以及明确不在范围内的内容
|
|
||||||
|
|
||||||
- **执行约束**:必须遵守的约束、禁止的做法、需保持的行为/流程、依赖限制,以及项目或 workflow 特有边界
|
|
||||||
|
|
||||||
- **影响范围**:与本次变更相关的具体 artifacts、参考资料、相关方、系统、工作流、文档、配置、资产或交接事项
|
|
||||||
|
|
||||||
- **决策**:关键选择及理由(为什么选 X 而不是 Y)。每个重要决策都要包含考虑过的替代方案,以及未选择它们的原因
|
|
||||||
|
|
||||||
- **执行计划**:主要工作流或待修改 artifacts、集成或交接点、执行顺序,以及必要的发布/落地说明
|
|
||||||
|
|
||||||
- **验证计划**:用于证明变更完成所需的验证检查、审查、批准、验收检查、文档检查、沟通检查和人工检查
|
|
||||||
|
|
||||||
- **风险 / 权衡**:已知限制和可能出错的事项
|
|
||||||
格式:[风险] -> 缓解措施
|
|
||||||
|
|
||||||
- **待解决问题**:执行前仍需解决的决策、假设或未知项。必须区分会阻塞 apply 的问题和非阻塞后续问题。没有未决问题时使用“无”
|
|
||||||
|
|
||||||
可选章节(相关时添加,建议使用中文章节标题):
|
|
||||||
|
|
||||||
- **迁移 / 发布计划**:发布步骤、沟通安排、归属、回滚或连续性策略
|
|
||||||
|
|
||||||
聚焦保留需求、理由、约束和方案。除非某个细节是讨论中明确做出的决策,否则避免逐行或逐步骤展开。
|
|
||||||
|
|
||||||
优先写可长期使用的摘要,而不是聊天记录转写。当具体 artifact 名称、数据/信息形状、示例、相关方、归属和边界场景会影响执行时,必须写清楚。
|
|
||||||
|
|
||||||
不要在 design.md 使用任务 checkbox;checkbox 只属于 tasks.md。
|
|
||||||
|
|
||||||
最终 design.md 不得包含未解决的模板注释、空表格行或占位文本。
|
|
||||||
|
|
||||||
如果信息缺失,写明假设和待解决问题,不要编造隐藏需求。不要依赖未写入文档的聊天上下文。
|
|
||||||
requires: []
|
|
||||||
- id: tasks
|
|
||||||
generates: tasks.md
|
|
||||||
description: 从 design.md 派生的可跟踪执行清单
|
|
||||||
template: tasks.md
|
|
||||||
instruction: |
|
|
||||||
创建 tasks.md,将 design.md 拆解为可执行工作项。
|
|
||||||
|
|
||||||
**重要:必须遵守以下模板中的 checkbox 行格式。** apply 阶段会解析 checkbox 格式跟踪进度。未使用 `- [ ]` 的任务不会被跟踪。
|
|
||||||
|
|
||||||
语言规则(强制):
|
|
||||||
|
|
||||||
- fast-drive 的 tasks.md 使用中文分组标题和中文任务描述;仅文件名、OpenSpec 术语、schema 字段名、命令、代码符号和必要技术名词保留英文
|
|
||||||
|
|
||||||
- 每个可跟踪任务必须保留 OpenSpec CLI 可解析的单行 checkbox 格式,例如 `- [ ] 1.1 任务描述` 或 `- [x] 1.1 已完成任务描述`
|
|
||||||
|
|
||||||
- 最终 tasks.md 不得残留英文模板任务或英文占位内容,除非该英文是 OpenSpec 术语、文件名、schema 字段名、代码符号、命令或必要技术名词
|
|
||||||
|
|
||||||
编写规则:
|
|
||||||
|
|
||||||
- 任务必须从 design.md 派生。不要依赖 proposal.md 或 specs artifacts;任何相关前序讨论都必须已经记录在 design.md 中
|
|
||||||
|
|
||||||
- 相关任务按 `##` 编号标题分组,分组标题使用中文
|
|
||||||
|
|
||||||
- 每个任务 MUST 是单行 checkbox:`- [ ] X.Y 任务描述`
|
|
||||||
|
|
||||||
- 任务粒度应足够小,能在一个会话内完成
|
|
||||||
|
|
||||||
- 按依赖顺序排序(先做必须先完成的事项)
|
|
||||||
|
|
||||||
- 当执行依赖执行约束、影响范围或待解决问题时,从上下文审查任务开始
|
|
||||||
|
|
||||||
- 需要时包含验证任务,覆盖检查、审查、批准、验收、文档、沟通和人工检查
|
|
||||||
|
|
||||||
- 除非仓库、版本控制或发布操作明确属于本次变更范围,否则不要包含这类任务
|
|
||||||
|
|
||||||
- 最终 tasks.md 不得包含未解决的模板注释、空表格行或占位任务文本
|
|
||||||
|
|
||||||
示例:
|
|
||||||
|
|
||||||
```
|
|
||||||
## 1. 上下文审查
|
|
||||||
|
|
||||||
- [ ] 1.1 阅读 design.md,识别范围、需求、决策、执行约束和待解决问题
|
|
||||||
- [ ] 1.2 审查“影响范围”中列出的相关 artifacts 和参考资料
|
|
||||||
|
|
||||||
## 2. 执行
|
|
||||||
|
|
||||||
- [ ] 2.1 执行 design.md 中的第一个具体工作项
|
|
||||||
- [ ] 2.2 执行 design.md 中的下一个具体工作项
|
|
||||||
|
|
||||||
## 3. 验证
|
|
||||||
|
|
||||||
- [ ] 3.1 执行“验证计划”中要求的验证
|
|
||||||
- [ ] 3.2 执行项目或 workflow 要求的质量检查
|
|
||||||
- [ ] 3.3 执行“验证计划”中要求的人工审查或验收检查
|
|
||||||
|
|
||||||
## 4. 文档 / 沟通
|
|
||||||
|
|
||||||
- [ ] 4.1 如果行为、流程、接口、配置或使用方式发生变化,更新相关文档、runbook、沟通材料或项目参考资料
|
|
||||||
```
|
|
||||||
|
|
||||||
以 design.md 中的范围、需求、决策、执行方向和验证预期为依据。
|
|
||||||
|
|
||||||
每个任务都应可验证:必须能明确判断任务何时完成。
|
|
||||||
requires:
|
|
||||||
- design
|
|
||||||
apply:
|
|
||||||
requires:
|
|
||||||
- design
|
|
||||||
- tasks
|
|
||||||
tracks: tasks.md
|
|
||||||
instruction: |
|
|
||||||
先阅读 design.md,再阅读 tasks.md。
|
|
||||||
同时遵守 workflow context/configuration,例如存在时读取 openspec/config.yaml,以及 design.md 引用的相关项目或 workflow 文档。
|
|
||||||
将 design.md 视为范围、需求、决策、执行约束、执行方向和验证预期的事实来源。
|
|
||||||
按依赖顺序处理待办任务,并在完成后及时标记。
|
|
||||||
只有任务执行完成且必要验证完成后,才能标记任务完成。
|
|
||||||
如果 tasks 与 design.md 冲突、design.md 存在阻塞性待解决问题,或需要澄清,必须暂停。
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
## 背景
|
|
||||||
|
|
||||||
<!-- 记录问题、当前状态、相关参考资料,以及触发本次变更的用户请求 -->
|
|
||||||
|
|
||||||
## 讨论记录
|
|
||||||
|
|
||||||
<!-- 记录探索或前序讨论中 apply 阶段必须保留的关键结论 -->
|
|
||||||
|
|
||||||
- 已确认结论:
|
|
||||||
- 用户偏好:
|
|
||||||
- 约束:
|
|
||||||
- 被否决方案:
|
|
||||||
|
|
||||||
## 需求
|
|
||||||
|
|
||||||
<!-- 记录预期结果、行为/流程/接口/内容变化、连续性要求和验收标准 -->
|
|
||||||
|
|
||||||
| 需求 | 验收标准 |
|
|
||||||
| ---- | -------- |
|
|
||||||
| | |
|
|
||||||
|
|
||||||
## 目标 / 非目标
|
|
||||||
|
|
||||||
**目标:**
|
|
||||||
<!-- 记录本次 design 要达成的目标 -->
|
|
||||||
|
|
||||||
**非目标:**
|
|
||||||
<!-- 记录明确不在范围内的内容 -->
|
|
||||||
|
|
||||||
## 执行约束
|
|
||||||
|
|
||||||
<!-- 记录必须遵守的约束、禁止的做法、需保持的行为/流程、依赖限制,以及项目或 workflow 特有边界 -->
|
|
||||||
|
|
||||||
- 依赖限制:
|
|
||||||
- 约束:
|
|
||||||
- 质量门禁:
|
|
||||||
- 相关方:
|
|
||||||
- 文档 / 沟通:
|
|
||||||
- 兼容性 / 连续性:
|
|
||||||
|
|
||||||
## 影响范围
|
|
||||||
|
|
||||||
<!-- 记录与本次变更相关的具体 artifacts、参考资料、相关方、系统、工作流、文档、配置、资产或交接事项 -->
|
|
||||||
|
|
||||||
| 范围 | Artifacts / 参考资料 | 预期变更 | 备注 |
|
|
||||||
| ---- | -------------------- | -------- | ---- |
|
|
||||||
| <!-- 范围 --> | <!-- Artifacts / 参考资料 --> | <!-- 预期变更 --> | <!-- 备注 --> |
|
|
||||||
|
|
||||||
## 决策
|
|
||||||
|
|
||||||
<!-- 记录关键决策、理由和考虑过的替代方案 -->
|
|
||||||
|
|
||||||
| 决策 | 理由 | 已否决替代方案 |
|
|
||||||
| ---- | ---- | ---------------- |
|
|
||||||
| | | |
|
|
||||||
|
|
||||||
## 执行计划
|
|
||||||
|
|
||||||
<!-- 记录主要工作流或待修改 artifacts、集成或交接点、执行顺序,以及必要的发布/落地说明 -->
|
|
||||||
|
|
||||||
## 验证计划
|
|
||||||
|
|
||||||
<!-- 记录用于证明变更完成所需的验证检查、审查、批准、验收检查、文档检查、沟通检查和人工检查 -->
|
|
||||||
|
|
||||||
| 需求 / 风险 | 验证方式 |
|
|
||||||
| ----------- | -------- |
|
|
||||||
| | |
|
|
||||||
|
|
||||||
## 风险 / 权衡
|
|
||||||
|
|
||||||
<!-- 格式:[风险] -> 缓解措施 -->
|
|
||||||
|
|
||||||
## 待解决问题
|
|
||||||
|
|
||||||
| 状态 | 问题 | 所需决策 |
|
|
||||||
| ---- | ---- | -------- |
|
|
||||||
| 无 | 无待解决问题。 | 无需决策 |
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
## 1. 上下文审查
|
|
||||||
|
|
||||||
- [ ] 1.1 阅读 design.md,识别范围、需求、决策、执行约束和待解决问题
|
|
||||||
- [ ] 1.2 审查“影响范围”中列出的相关 artifacts 和参考资料
|
|
||||||
|
|
||||||
## 2. 执行
|
|
||||||
|
|
||||||
- [ ] 2.1 执行 design.md 中的第一个具体工作项
|
|
||||||
- [ ] 2.2 执行 design.md 中的下一个具体工作项
|
|
||||||
|
|
||||||
## 3. 验证
|
|
||||||
|
|
||||||
- [ ] 3.1 执行“验证计划”中要求的验证
|
|
||||||
- [ ] 3.2 执行项目或 workflow 要求的质量检查
|
|
||||||
- [ ] 3.3 执行“验证计划”中要求的人工审查或验收检查
|
|
||||||
|
|
||||||
## 4. 文档 / 沟通
|
|
||||||
|
|
||||||
- [ ] 4.1 如果行为、流程、接口、配置或使用方式发生变化,更新相关文档、runbook、沟通材料或项目参考资料
|
|
||||||
51
package.json
51
package.json
@@ -8,9 +8,9 @@
|
|||||||
"dev:server": "bun --watch src/server/dev.ts",
|
"dev:server": "bun --watch src/server/dev.ts",
|
||||||
"dev:web": "bunx --bun vite --host",
|
"dev:web": "bunx --bun vite --host",
|
||||||
"build": "bun run scripts/build.ts",
|
"build": "bun run scripts/build.ts",
|
||||||
"lint": "eslint .",
|
"lint": "oxlint --deny-warnings",
|
||||||
"format": "prettier . --write",
|
"format": "oxfmt",
|
||||||
"format:check": "prettier . --check",
|
"format:check": "oxfmt --check",
|
||||||
"check": "bun run schema:check && bun run typecheck && bun run lint && bun test",
|
"check": "bun run schema:check && bun run typecheck && bun run lint && bun test",
|
||||||
"schema": "bun run scripts/generate-config-schema.ts",
|
"schema": "bun run scripts/generate-config-schema.ts",
|
||||||
"schema:check": "bun run scripts/generate-config-schema.ts -- --check",
|
"schema:check": "bun run scripts/generate-config-schema.ts -- --check",
|
||||||
@@ -25,43 +25,34 @@
|
|||||||
"version:set": "bun run scripts/bump-version.ts set"
|
"version:set": "bun run scripts/bump-version.ts set"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@commitlint/cli": "^21.0.1",
|
"@commitlint/cli": "^21.0.2",
|
||||||
"@commitlint/config-conventional": "^21.0.1",
|
"@commitlint/config-conventional": "^21.0.2",
|
||||||
"@eslint/js": "^10.0.1",
|
"@happy-dom/global-registrator": "^20.10.2",
|
||||||
"@tanstack/react-query-devtools": "^5.100.14",
|
"@tanstack/react-query-devtools": "^5.101.0",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@types/bun": "^1.3.14",
|
"@types/bun": "^1.3.14",
|
||||||
"@types/jsdom": "^28.0.3",
|
"@types/react": "^19.2.17",
|
||||||
"@types/react": "^19.2.15",
|
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^6.0.2",
|
"@vitejs/plugin-react": "^6.0.2",
|
||||||
"drizzle-kit": "^0.31.10",
|
"drizzle-kit": "^0.31.10",
|
||||||
"eslint": "^10.4.0",
|
|
||||||
"eslint-config-prettier": "^10.1.8",
|
|
||||||
"eslint-import-resolver-typescript": "^4.4.4",
|
|
||||||
"eslint-plugin-import": "^2.32.0",
|
|
||||||
"eslint-plugin-perfectionist": "^5.9.0",
|
|
||||||
"eslint-plugin-prettier": "^5.5.5",
|
|
||||||
"eslint-plugin-react-hooks": "^7.1.1",
|
|
||||||
"eslint-plugin-react-refresh": "^0.5.2",
|
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"jsdom": "^29.1.1",
|
"lint-staged": "^17.0.7",
|
||||||
"lint-staged": "^17.0.5",
|
"oxfmt": "^0.53.0",
|
||||||
"prettier": "^3.8.3",
|
"oxlint": "^1.68.0",
|
||||||
|
"oxlint-tsgolint": "^0.23.0",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"typescript-eslint": "^8.60.0",
|
"vite": "^8.0.16"
|
||||||
"vite": "^8.0.14"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/anthropic": "^3.0.81",
|
"@ai-sdk/anthropic": "^3.0.81",
|
||||||
"@ai-sdk/openai": "^3.0.66",
|
"@ai-sdk/openai": "^3.0.68",
|
||||||
"@ai-sdk/openai-compatible": "^2.0.48",
|
"@ai-sdk/openai-compatible": "^2.0.48",
|
||||||
"@ai-sdk/react": "^3.0.195",
|
"@ai-sdk/react": "^3.0.199",
|
||||||
"@ant-design/icons": "^6.2.3",
|
"@ant-design/icons": "^6.2.5",
|
||||||
"@ant-design/x": "^2.7.0",
|
"@ant-design/x": "^2.7.0",
|
||||||
"@sinclair/typebox": "^0.34.49",
|
"@sinclair/typebox": "^0.34.49",
|
||||||
"@tanstack/react-query": "^5.100.14",
|
"@tanstack/react-query": "^5.101.0",
|
||||||
"ai": "^6.0.193",
|
"ai": "^6.0.197",
|
||||||
"ajv": "^8.20.0",
|
"ajv": "^8.20.0",
|
||||||
"antd": "^6.4.3",
|
"antd": "^6.4.3",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
@@ -72,9 +63,9 @@
|
|||||||
"pino": "^10.3.1",
|
"pino": "^10.3.1",
|
||||||
"pino-pretty": "^13.1.3",
|
"pino-pretty": "^13.1.3",
|
||||||
"pino-roll": "^4.0.0",
|
"pino-roll": "^4.0.0",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.7",
|
||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.7",
|
||||||
"react-router": "^7.15.1",
|
"react-router": "^7.17.0",
|
||||||
"recharts": "^3.8.1",
|
"recharts": "^3.8.1",
|
||||||
"shiki": "^4.2.0",
|
"shiki": "^4.2.0",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
|
|||||||
@@ -5,37 +5,55 @@
|
|||||||
"source": "vercel/ai",
|
"source": "vercel/ai",
|
||||||
"sourceType": "github",
|
"sourceType": "github",
|
||||||
"skillPath": "skills/use-ai-sdk/SKILL.md",
|
"skillPath": "skills/use-ai-sdk/SKILL.md",
|
||||||
"computedHash": "f9381aea9aa207157c88348c6b0ae3551137955f2bd48c855c27fa86ac03cd56"
|
"computedHash": "2249889eb47ef0f61c4dba4cf2afe01c1c8dd793bb4c24230347c1ab909bb7dd"
|
||||||
},
|
},
|
||||||
"ant-design": {
|
"ant-design": {
|
||||||
"source": "ant-design/antd-skill",
|
"source": "ant-design/antd-skill",
|
||||||
"sourceType": "github",
|
"sourceType": "github",
|
||||||
"skillPath": "skills/ant-design/SKILL.md",
|
"skillPath": "skills/ant-design/SKILL.md",
|
||||||
"computedHash": "4d0447d48fced080b2825ecc0fb4d7ca836c8015882899c643acca0b864d5179"
|
"computedHash": "096d4ac9513e43030f960aab49b50168a3d5eb35be86926ac6e96e5998ea9466"
|
||||||
},
|
},
|
||||||
"antd": {
|
"antd": {
|
||||||
"source": "ant-design/antd-skill",
|
"source": "ant-design/antd-skill",
|
||||||
"sourceType": "github",
|
"sourceType": "github",
|
||||||
"skillPath": "skills/antd/SKILL.md",
|
"skillPath": "skills/antd/SKILL.md",
|
||||||
"computedHash": "4295010f09f85855cab9e9de9ec7f96c14541474b4f3f9d6ef89006430931b94"
|
"computedHash": "5e26c8042060bb811118927b5daf637af7929a00fa973dd8f5f804f3ba6e2bf2"
|
||||||
|
},
|
||||||
|
"migrate-oxfmt": {
|
||||||
|
"source": "oxc-project/oxc",
|
||||||
|
"sourceType": "github",
|
||||||
|
"skillPath": ".agents/skills/migrate-oxfmt/SKILL.md",
|
||||||
|
"computedHash": "b313a891bc4173aac0bd948d5e8c40f5dd921fe67eb5f51184c364e2b869f0e7"
|
||||||
|
},
|
||||||
|
"migrate-oxlint": {
|
||||||
|
"source": "oxc-project/oxc",
|
||||||
|
"sourceType": "github",
|
||||||
|
"skillPath": ".agents/skills/migrate-oxlint/SKILL.md",
|
||||||
|
"computedHash": "7bc6dccdcc557cdb7d887fc16c6632e685b13860d202b9c9ce5da7504f0dbe18"
|
||||||
|
},
|
||||||
|
"performance-lint-rules": {
|
||||||
|
"source": "oxc-project/oxc",
|
||||||
|
"sourceType": "github",
|
||||||
|
"skillPath": ".agents/skills/performance-lint-rules/SKILL.md",
|
||||||
|
"computedHash": "82a2d3a72bc381c3552834008435635d7157f22ee4efc4b441a33ad6e838c828"
|
||||||
},
|
},
|
||||||
"react-router-data-mode": {
|
"react-router-data-mode": {
|
||||||
"source": "remix-run/agent-skills",
|
"source": "remix-run/agent-skills",
|
||||||
"sourceType": "github",
|
"sourceType": "github",
|
||||||
"skillPath": "skills/react-router-data-mode/SKILL.md",
|
"skillPath": "skills/react-router-data-mode/SKILL.md",
|
||||||
"computedHash": "cbbe1b1cfa8f6ceae1ab26d26b38c612279c9c272cf956471838796d85659860"
|
"computedHash": "76e3e0f70ff47b743bd90999e676515221e25fd7ee89cd9e5b340417b1a601e2"
|
||||||
},
|
},
|
||||||
"react-router-declarative-mode": {
|
"react-router-declarative-mode": {
|
||||||
"source": "remix-run/agent-skills",
|
"source": "remix-run/agent-skills",
|
||||||
"sourceType": "github",
|
"sourceType": "github",
|
||||||
"skillPath": "skills/react-router-declarative-mode/SKILL.md",
|
"skillPath": "skills/react-router-declarative-mode/SKILL.md",
|
||||||
"computedHash": "b399ee32fa82efdbdad1121421702b7725fcffac36424529a0ea452796f3bc92"
|
"computedHash": "d7ebbf1ede90809618f02cb3b3d37b9871cdd6c88a81cf338e63de50a0df6a42"
|
||||||
},
|
},
|
||||||
"react-router-framework-mode": {
|
"react-router-framework-mode": {
|
||||||
"source": "remix-run/agent-skills",
|
"source": "remix-run/agent-skills",
|
||||||
"sourceType": "github",
|
"sourceType": "github",
|
||||||
"skillPath": "skills/react-router-framework-mode/SKILL.md",
|
"skillPath": "skills/react-router-framework-mode/SKILL.md",
|
||||||
"computedHash": "a3294459f3a5065c837929d9700fe7d35730d5051f2979090e0f715e8fea693f"
|
"computedHash": "26c5bdac2f686c47eb4c4b48b6cb52401cde1dc833e6d26408ddfb22ea83c5ca"
|
||||||
},
|
},
|
||||||
"vercel-react-best-practices": {
|
"vercel-react-best-practices": {
|
||||||
"source": "vercel-labs/agent-skills",
|
"source": "vercel-labs/agent-skills",
|
||||||
@@ -48,14 +66,14 @@
|
|||||||
"ref": "main",
|
"ref": "main",
|
||||||
"sourceType": "github",
|
"sourceType": "github",
|
||||||
"skillPath": "packages/x-skill/skills/x-components/SKILL.md",
|
"skillPath": "packages/x-skill/skills/x-components/SKILL.md",
|
||||||
"computedHash": "ebc195a3a5020b6d4f4533adf2e0af33253919f0c704947e727f877aba23a4c2"
|
"computedHash": "efb7661cadf8a35fae32ce9a6b261b82ee8c8a2bb76303b333ff166163c0a729"
|
||||||
},
|
},
|
||||||
"x-markdown": {
|
"x-markdown": {
|
||||||
"source": "ant-design/x",
|
"source": "ant-design/x",
|
||||||
"ref": "main",
|
"ref": "main",
|
||||||
"sourceType": "github",
|
"sourceType": "github",
|
||||||
"skillPath": "packages/x-skill/skills/x-markdown/SKILL.md",
|
"skillPath": "packages/x-skill/skills/x-markdown/SKILL.md",
|
||||||
"computedHash": "2d26b8eda1692929e99a8b6163ef8b206f1f096a4a84507b50dbe836a7ec041e"
|
"computedHash": "441c281e8537e4aebbc6db5dce0b12c170df916f81782f33f3c8f66dd3f17b17"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ import type { RuntimeMode } from "../shared/api";
|
|||||||
import type { ResolvedConfig, ResolvedLoggingConfig } from "./config/types";
|
import type { ResolvedConfig, ResolvedLoggingConfig } from "./config/types";
|
||||||
import type { MigrationRecord } from "./db/load-migrations";
|
import type { MigrationRecord } from "./db/load-migrations";
|
||||||
import type { Logger } from "./logger";
|
import type { Logger } from "./logger";
|
||||||
|
import type { MaterialProcessor } from "./processing";
|
||||||
import type { StartServerOptions } from "./server";
|
import type { StartServerOptions } from "./server";
|
||||||
|
|
||||||
import { loadServerConfig } from "./config";
|
import { loadServerConfig } from "./config";
|
||||||
import { createDatabase, loadMigrationsFromDir, runMigrations } from "./db";
|
import { createDatabase, loadMigrationsFromDir, runMigrations } from "./db";
|
||||||
import { createConsoleFallback, createRuntimeLogger } from "./logger";
|
import { createConsoleFallback, createRuntimeLogger } from "./logger";
|
||||||
|
import { startMaterialProcessor } from "./processing";
|
||||||
import { startServer } from "./server";
|
import { startServer } from "./server";
|
||||||
|
|
||||||
export interface BootstrapDependencies {
|
export interface BootstrapDependencies {
|
||||||
@@ -66,8 +68,11 @@ export async function bootstrap(options: BootstrapOptions, dependencies: Bootstr
|
|||||||
const db = createDatabase(config.dataDir, logger!);
|
const db = createDatabase(config.dataDir, logger!);
|
||||||
runMigrations(db, migrations, config.dataDir, logger!);
|
runMigrations(db, migrations, config.dataDir, logger!);
|
||||||
|
|
||||||
|
const processor: MaterialProcessor = startMaterialProcessor(db, logger!.child({ component: "processor" }));
|
||||||
|
|
||||||
const shutdown = () => {
|
const shutdown = () => {
|
||||||
logger?.info("收到退出信号,开始优雅关闭");
|
logger?.info("收到退出信号,开始优雅关闭");
|
||||||
|
processor.stop();
|
||||||
db.close();
|
db.close();
|
||||||
logger?.flush();
|
logger?.flush();
|
||||||
exit(0);
|
exit(0);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { isNumber, isString } from "es-toolkit";
|
|
||||||
import { dirname, isAbsolute, resolve } from "node:path";
|
import { dirname, isAbsolute, resolve } from "node:path";
|
||||||
|
import { isNumber, isString } from "es-toolkit";
|
||||||
|
|
||||||
import type { ConfigValidationIssue } from "./config/issues";
|
import type { ConfigValidationIssue } from "./config/issues";
|
||||||
import type { LoggingConfig, LogLevel, ResolvedConfig, ResolvedLoggingConfig, RotationFrequency } from "./config/types";
|
import type { LoggingConfig, LogLevel, ResolvedConfig, ResolvedLoggingConfig, RotationFrequency } from "./config/types";
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import type { Column, SQL } from "drizzle-orm";
|
|||||||
import type { SQLiteTable } from "drizzle-orm/sqlite-core";
|
import type { SQLiteTable } from "drizzle-orm/sqlite-core";
|
||||||
|
|
||||||
import Database from "bun:sqlite";
|
import Database from "bun:sqlite";
|
||||||
|
import { join } from "node:path";
|
||||||
import { and, eq, isNull, sql } from "drizzle-orm";
|
import { and, eq, isNull, sql } from "drizzle-orm";
|
||||||
import { drizzle } from "drizzle-orm/bun-sqlite";
|
import { drizzle } from "drizzle-orm/bun-sqlite";
|
||||||
import { join } from "node:path";
|
|
||||||
|
|
||||||
import type { Logger } from "../logger";
|
import type { Logger } from "../logger";
|
||||||
|
|
||||||
|
|||||||
196
src/server/db/entities.ts
Normal file
196
src/server/db/entities.ts
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
import type Database from "bun:sqlite";
|
||||||
|
|
||||||
|
import { and, desc, eq } from "drizzle-orm";
|
||||||
|
|
||||||
|
import type { CreateEntityRequest, Entity, EntityType, UpdateEntityRequest } from "../../shared/api";
|
||||||
|
import type { Logger } from "../logger";
|
||||||
|
|
||||||
|
import { notDeleted, paginateQuery, softDeleteRecord, timestamp, wrap } from "./connection";
|
||||||
|
import { entities, projects } from "./schema";
|
||||||
|
|
||||||
|
export function createEntity(
|
||||||
|
raw: Database,
|
||||||
|
projectId: string,
|
||||||
|
request: CreateEntityRequest,
|
||||||
|
_logger: Logger,
|
||||||
|
): { entity: Entity } | { error: string; status: number } {
|
||||||
|
const db = wrap(raw);
|
||||||
|
const project = db
|
||||||
|
.select()
|
||||||
|
.from(projects)
|
||||||
|
.where(and(eq(projects.id, projectId), notDeleted(projects)))
|
||||||
|
.get();
|
||||||
|
if (!project) return { error: "项目不存在", status: 404 };
|
||||||
|
if (project.status === "archived") return { error: "已归档项目不可操作", status: 409 };
|
||||||
|
|
||||||
|
const name = request.name.trim();
|
||||||
|
if (!name) return { error: "实体名称不能为空", status: 400 };
|
||||||
|
|
||||||
|
const duplicate = db
|
||||||
|
.select({ id: entities.id })
|
||||||
|
.from(entities)
|
||||||
|
.where(and(eq(entities.projectId, projectId), eq(entities.name, name), notDeleted(entities)))
|
||||||
|
.get();
|
||||||
|
if (duplicate) return { error: "实体名称已存在", status: 409 };
|
||||||
|
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
const now = timestamp();
|
||||||
|
|
||||||
|
db.insert(entities)
|
||||||
|
.values({
|
||||||
|
aliases: JSON.stringify(request.aliases ?? []),
|
||||||
|
createdAt: now,
|
||||||
|
description: request.description?.trim() ?? "",
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
projectId,
|
||||||
|
type: request.type,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
|
||||||
|
const row = db.select().from(entities).where(eq(entities.id, id)).get();
|
||||||
|
return { entity: toEntity(row!) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteEntity(
|
||||||
|
raw: Database,
|
||||||
|
projectId: string,
|
||||||
|
entityId: string,
|
||||||
|
_logger: Logger,
|
||||||
|
): { error: string; status: number } | { success: true } {
|
||||||
|
const db = wrap(raw);
|
||||||
|
const row = db
|
||||||
|
.select()
|
||||||
|
.from(entities)
|
||||||
|
.where(and(eq(entities.id, entityId), notDeleted(entities)))
|
||||||
|
.get();
|
||||||
|
if (!row) return { error: "实体不存在", status: 404 };
|
||||||
|
if (row.projectId !== projectId) return { error: "实体不属于该项目", status: 403 };
|
||||||
|
|
||||||
|
softDeleteRecord(db, entities, entityId);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEntity(
|
||||||
|
raw: Database,
|
||||||
|
projectId: string,
|
||||||
|
entityId: string,
|
||||||
|
): { entity: Entity } | { error: string; status: number } {
|
||||||
|
const db = wrap(raw);
|
||||||
|
const row = db
|
||||||
|
.select()
|
||||||
|
.from(entities)
|
||||||
|
.where(and(eq(entities.id, entityId), notDeleted(entities)))
|
||||||
|
.get();
|
||||||
|
if (!row) return { error: "实体不存在", status: 404 };
|
||||||
|
if (row.projectId !== projectId) return { error: "实体不属于该项目", status: 403 };
|
||||||
|
|
||||||
|
return { entity: toEntity(row) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listEntities(
|
||||||
|
raw: Database,
|
||||||
|
projectId: string,
|
||||||
|
options: { page: number; pageSize: number; type?: EntityType },
|
||||||
|
): { items: Entity[]; page: number; pageSize: number; total: number } {
|
||||||
|
const conditions = [eq(entities.projectId, projectId)];
|
||||||
|
|
||||||
|
if (options.type) {
|
||||||
|
conditions.push(eq(entities.type, options.type));
|
||||||
|
}
|
||||||
|
|
||||||
|
return paginateQuery(raw, entities, {
|
||||||
|
conditions,
|
||||||
|
mapRow: toEntity,
|
||||||
|
orderBy: () => desc(entities.createdAt),
|
||||||
|
page: options.page,
|
||||||
|
pageSize: options.pageSize,
|
||||||
|
softDelete: entities.deletedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listEntityNames(
|
||||||
|
raw: Database,
|
||||||
|
projectId: string,
|
||||||
|
): Array<{ aliases: string[]; id: string; name: string }> {
|
||||||
|
const db = wrap(raw);
|
||||||
|
const rows = db
|
||||||
|
.select({ aliases: entities.aliases, id: entities.id, name: entities.name })
|
||||||
|
.from(entities)
|
||||||
|
.where(and(eq(entities.projectId, projectId), notDeleted(entities)))
|
||||||
|
.all();
|
||||||
|
|
||||||
|
return rows.map((row) => ({
|
||||||
|
aliases: JSON.parse(row.aliases) as string[],
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateEntity(
|
||||||
|
raw: Database,
|
||||||
|
projectId: string,
|
||||||
|
entityId: string,
|
||||||
|
request: UpdateEntityRequest,
|
||||||
|
_logger: Logger,
|
||||||
|
): { entity: Entity } | { error: string; status: number } {
|
||||||
|
const db = wrap(raw);
|
||||||
|
const existing = db
|
||||||
|
.select()
|
||||||
|
.from(entities)
|
||||||
|
.where(and(eq(entities.id, entityId), notDeleted(entities)))
|
||||||
|
.get();
|
||||||
|
if (!existing) return { error: "实体不存在", status: 404 };
|
||||||
|
if (existing.projectId !== projectId) return { error: "实体不属于该项目", status: 403 };
|
||||||
|
|
||||||
|
const updates: Partial<typeof entities.$inferInsert> = {
|
||||||
|
updatedAt: timestamp(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const name = request.name?.trim();
|
||||||
|
if (name === "") return { error: "实体名称不能为空", status: 400 };
|
||||||
|
if (name !== undefined && name !== existing.name) {
|
||||||
|
const duplicate = db
|
||||||
|
.select({ id: entities.id })
|
||||||
|
.from(entities)
|
||||||
|
.where(and(eq(entities.projectId, projectId), eq(entities.name, name), notDeleted(entities)))
|
||||||
|
.get();
|
||||||
|
if (duplicate) return { error: "实体名称已存在", status: 409 };
|
||||||
|
updates.name = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.type !== undefined) {
|
||||||
|
updates.type = request.type;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.description !== undefined) {
|
||||||
|
updates.description = request.description.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.aliases !== undefined) {
|
||||||
|
updates.aliases = JSON.stringify(request.aliases);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(updates).length === 1 && updates.updatedAt) {
|
||||||
|
return { entity: toEntity(existing) };
|
||||||
|
}
|
||||||
|
|
||||||
|
db.update(entities).set(updates).where(eq(entities.id, entityId)).run();
|
||||||
|
|
||||||
|
const updated = db.select().from(entities).where(eq(entities.id, entityId)).get();
|
||||||
|
return { entity: toEntity(updated!) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function toEntity(row: typeof entities.$inferSelect): Entity {
|
||||||
|
return {
|
||||||
|
aliases: JSON.parse(row.aliases) as string[],
|
||||||
|
createdAt: row.createdAt,
|
||||||
|
description: row.description,
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
projectId: row.projectId,
|
||||||
|
type: row.type as EntityType,
|
||||||
|
updatedAt: row.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -20,4 +20,5 @@ export {
|
|||||||
listModels,
|
listModels,
|
||||||
updateModel,
|
updateModel,
|
||||||
} from "./models";
|
} from "./models";
|
||||||
export { conversations, messages, projects, schemaMigrations } from "./schema";
|
export { conversations, messages, projects, schemaMigrations, settings } from "./schema";
|
||||||
|
export { getSettings, updateSettings } from "./settings";
|
||||||
|
|||||||
@@ -2,11 +2,86 @@ import type Database from "bun:sqlite";
|
|||||||
|
|
||||||
import { and, desc, eq } from "drizzle-orm";
|
import { and, desc, eq } from "drizzle-orm";
|
||||||
|
|
||||||
import type { CreateMaterialRequest, Material, MaterialStatus } from "../../shared/api";
|
import type {
|
||||||
|
CreateMaterialRequest,
|
||||||
|
EntityConfirmation,
|
||||||
|
Material,
|
||||||
|
MaterialStatus,
|
||||||
|
MaterialType,
|
||||||
|
ProcessingResult,
|
||||||
|
} from "../../shared/api";
|
||||||
import type { Logger } from "../logger";
|
import type { Logger } from "../logger";
|
||||||
|
|
||||||
import { notDeleted, paginateQuery, softDeleteRecord, timestamp, wrap } from "./connection";
|
import { notDeleted, paginateQuery, softDeleteRecord, timestamp, wrap } from "./connection";
|
||||||
import { materials, projects } from "./schema";
|
import { createEntity, updateEntity } from "./entities";
|
||||||
|
import { entities as schema_entities, materials, projects } from "./schema";
|
||||||
|
|
||||||
|
const ALLOWED_MATERIAL_TYPES: MaterialType[] = ["general", "meeting"];
|
||||||
|
|
||||||
|
export function approveMaterial(
|
||||||
|
raw: Database,
|
||||||
|
projectId: string,
|
||||||
|
materialId: string,
|
||||||
|
entityConfirmations: EntityConfirmation[],
|
||||||
|
_logger: Logger,
|
||||||
|
): { error: string; status: number } | { material: Material } {
|
||||||
|
const db = wrap(raw);
|
||||||
|
const row = db
|
||||||
|
.select()
|
||||||
|
.from(materials)
|
||||||
|
.where(and(eq(materials.id, materialId), notDeleted(materials)))
|
||||||
|
.get();
|
||||||
|
if (!row) return { error: "素材不存在", status: 404 };
|
||||||
|
if (row.projectId !== projectId) return { error: "素材不属于该项目", status: 403 };
|
||||||
|
if (row.status !== "review") return { error: "仅待审核素材可通过", status: 409 };
|
||||||
|
|
||||||
|
if (row.processedContent && entityConfirmations.length > 0) {
|
||||||
|
try {
|
||||||
|
const processingResult = JSON.parse(row.processedContent) as ProcessingResult;
|
||||||
|
for (const confirmation of entityConfirmations) {
|
||||||
|
const candidate = processingResult.candidateEntities[confirmation.candidateIndex];
|
||||||
|
if (!candidate) continue;
|
||||||
|
|
||||||
|
if (confirmation.action === "create") {
|
||||||
|
createEntity(
|
||||||
|
raw,
|
||||||
|
projectId,
|
||||||
|
{ description: candidate.context, name: candidate.name, type: candidate.type },
|
||||||
|
_logger,
|
||||||
|
);
|
||||||
|
} else if (confirmation.action === "merge" && confirmation.targetEntityId) {
|
||||||
|
const entityResult = getEntityForMerge(raw, confirmation.targetEntityId);
|
||||||
|
if (entityResult) {
|
||||||
|
const newAliases = [...entityResult.aliases];
|
||||||
|
if (!newAliases.includes(candidate.name)) {
|
||||||
|
newAliases.push(candidate.name);
|
||||||
|
}
|
||||||
|
updateEntity(raw, projectId, confirmation.targetEntityId, { aliases: newAliases }, _logger);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// processedContent 解析失败时不阻塞审核通过
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = timestamp();
|
||||||
|
db.update(materials).set({ status: "approved", updatedAt: now }).where(eq(materials.id, materialId)).run();
|
||||||
|
|
||||||
|
const updated = db.select().from(materials).where(eq(materials.id, materialId)).get();
|
||||||
|
return { material: toMaterial(updated!) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEntityForMerge(raw: Database, entityId: string): { aliases: string[] } | null {
|
||||||
|
const db = wrap(raw);
|
||||||
|
const row = db
|
||||||
|
.select({ aliases: schema_entities.aliases })
|
||||||
|
.from(schema_entities)
|
||||||
|
.where(and(eq(schema_entities.id, entityId), notDeleted(schema_entities)))
|
||||||
|
.get();
|
||||||
|
if (!row) return null;
|
||||||
|
return { aliases: JSON.parse(row.aliases) as string[] };
|
||||||
|
}
|
||||||
|
|
||||||
export function createMaterial(
|
export function createMaterial(
|
||||||
raw: Database,
|
raw: Database,
|
||||||
@@ -31,6 +106,11 @@ export function createMaterial(
|
|||||||
return { error: "associatedDate 格式错误,要求 YYYY-MM-DD", status: 400 };
|
return { error: "associatedDate 格式错误,要求 YYYY-MM-DD", status: 400 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const materialType: MaterialType = request.materialType ?? "general";
|
||||||
|
if (!ALLOWED_MATERIAL_TYPES.includes(materialType)) {
|
||||||
|
return { error: "materialType 无效,仅支持 general/meeting", status: 400 };
|
||||||
|
}
|
||||||
|
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
const now = timestamp();
|
const now = timestamp();
|
||||||
|
|
||||||
@@ -40,6 +120,7 @@ export function createMaterial(
|
|||||||
createdAt: now,
|
createdAt: now,
|
||||||
description,
|
description,
|
||||||
id,
|
id,
|
||||||
|
materialType,
|
||||||
projectId,
|
projectId,
|
||||||
status: "pending",
|
status: "pending",
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
@@ -64,11 +145,37 @@ export function deleteMaterial(
|
|||||||
.get();
|
.get();
|
||||||
if (!row) return { error: "素材不存在", status: 404 };
|
if (!row) return { error: "素材不存在", status: 404 };
|
||||||
if (row.projectId !== projectId) return { error: "素材不属于该项目", status: 403 };
|
if (row.projectId !== projectId) return { error: "素材不属于该项目", status: 403 };
|
||||||
|
if (row.status === "processing") {
|
||||||
|
return { error: "处理中的素材不可删除", status: 409 };
|
||||||
|
}
|
||||||
|
|
||||||
softDeleteRecord(db, materials, materialId);
|
softDeleteRecord(db, materials, materialId);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function discardMaterial(
|
||||||
|
raw: Database,
|
||||||
|
projectId: string,
|
||||||
|
materialId: string,
|
||||||
|
_logger: Logger,
|
||||||
|
): { error: string; status: number } | { material: Material } {
|
||||||
|
const db = wrap(raw);
|
||||||
|
const row = db
|
||||||
|
.select()
|
||||||
|
.from(materials)
|
||||||
|
.where(and(eq(materials.id, materialId), notDeleted(materials)))
|
||||||
|
.get();
|
||||||
|
if (!row) return { error: "素材不存在", status: 404 };
|
||||||
|
if (row.projectId !== projectId) return { error: "素材不属于该项目", status: 403 };
|
||||||
|
if (row.status !== "review") return { error: "仅待审核素材可放弃", status: 409 };
|
||||||
|
|
||||||
|
const now = timestamp();
|
||||||
|
db.update(materials).set({ status: "discarded", updatedAt: now }).where(eq(materials.id, materialId)).run();
|
||||||
|
|
||||||
|
const updated = db.select().from(materials).where(eq(materials.id, materialId)).get();
|
||||||
|
return { material: toMaterial(updated!) };
|
||||||
|
}
|
||||||
|
|
||||||
export function getMaterial(
|
export function getMaterial(
|
||||||
raw: Database,
|
raw: Database,
|
||||||
projectId: string,
|
projectId: string,
|
||||||
@@ -107,12 +214,40 @@ export function listMaterials(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function retryMaterial(
|
||||||
|
raw: Database,
|
||||||
|
projectId: string,
|
||||||
|
materialId: string,
|
||||||
|
_logger: Logger,
|
||||||
|
): { error: string; status: number } | { material: Material } {
|
||||||
|
const db = wrap(raw);
|
||||||
|
const row = db
|
||||||
|
.select()
|
||||||
|
.from(materials)
|
||||||
|
.where(and(eq(materials.id, materialId), notDeleted(materials)))
|
||||||
|
.get();
|
||||||
|
if (!row) return { error: "素材不存在", status: 404 };
|
||||||
|
if (row.projectId !== projectId) return { error: "素材不属于该项目", status: 403 };
|
||||||
|
if (row.status !== "failed") return { error: "仅失败素材可重试", status: 409 };
|
||||||
|
|
||||||
|
const now = timestamp();
|
||||||
|
db.update(materials)
|
||||||
|
.set({ processedContent: null, status: "pending", updatedAt: now })
|
||||||
|
.where(eq(materials.id, materialId))
|
||||||
|
.run();
|
||||||
|
|
||||||
|
const updated = db.select().from(materials).where(eq(materials.id, materialId)).get();
|
||||||
|
return { material: toMaterial(updated!) };
|
||||||
|
}
|
||||||
|
|
||||||
function toMaterial(row: typeof materials.$inferSelect): Material {
|
function toMaterial(row: typeof materials.$inferSelect): Material {
|
||||||
return {
|
return {
|
||||||
associatedDate: row.associatedDate,
|
associatedDate: row.associatedDate,
|
||||||
createdAt: row.createdAt,
|
createdAt: row.createdAt,
|
||||||
description: row.description,
|
description: row.description,
|
||||||
id: row.id,
|
id: row.id,
|
||||||
|
materialType: row.materialType,
|
||||||
|
processedContent: row.processedContent,
|
||||||
projectId: row.projectId,
|
projectId: row.projectId,
|
||||||
status: row.status,
|
status: row.status,
|
||||||
updatedAt: row.updatedAt,
|
updatedAt: row.updatedAt,
|
||||||
|
|||||||
@@ -57,10 +57,16 @@ export const materials = sqliteTable(
|
|||||||
...baseColumns,
|
...baseColumns,
|
||||||
associatedDate: text("associated_date").notNull(),
|
associatedDate: text("associated_date").notNull(),
|
||||||
description: text("description").notNull(),
|
description: text("description").notNull(),
|
||||||
|
materialType: text("material_type", { enum: ["general", "meeting"] })
|
||||||
|
.notNull()
|
||||||
|
.default("general"),
|
||||||
|
processedContent: text("processed_content"),
|
||||||
projectId: text("project_id")
|
projectId: text("project_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => projects.id),
|
.references(() => projects.id),
|
||||||
status: text("status", { enum: ["pending", "approved", "discarded"] })
|
status: text("status", {
|
||||||
|
enum: ["pending", "processing", "review", "approved", "discarded", "failed"],
|
||||||
|
})
|
||||||
.notNull()
|
.notNull()
|
||||||
.default("pending"),
|
.default("pending"),
|
||||||
},
|
},
|
||||||
@@ -81,8 +87,28 @@ export const messages = sqliteTable(
|
|||||||
(table) => [index("messages_conversation_id_idx").on(table.conversationId)],
|
(table) => [index("messages_conversation_id_idx").on(table.conversationId)],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const entities = sqliteTable(
|
||||||
|
"entities",
|
||||||
|
{
|
||||||
|
...baseColumns,
|
||||||
|
aliases: text("aliases").notNull().default("[]"),
|
||||||
|
description: text("description").notNull().default(""),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
projectId: text("project_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => projects.id),
|
||||||
|
type: text("type").notNull().default("other"),
|
||||||
|
},
|
||||||
|
(table) => [index("entities_project_id_idx").on(table.projectId), index("entities_name_idx").on(table.name)],
|
||||||
|
);
|
||||||
|
|
||||||
export const schemaMigrations = sqliteTable("schema_migrations", {
|
export const schemaMigrations = sqliteTable("schema_migrations", {
|
||||||
appliedAt: text("applied_at").notNull(),
|
appliedAt: text("applied_at").notNull(),
|
||||||
checksum: text("checksum").notNull(),
|
checksum: text("checksum").notNull(),
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const settings = sqliteTable("settings", {
|
||||||
|
...baseColumns,
|
||||||
|
data: text("data").notNull().default("{}"),
|
||||||
|
});
|
||||||
|
|||||||
74
src/server/db/settings.ts
Normal file
74
src/server/db/settings.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import type Database from "bun:sqlite";
|
||||||
|
|
||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
|
||||||
|
import type { SettingsData } from "../../shared/api";
|
||||||
|
import type { Logger } from "../logger";
|
||||||
|
|
||||||
|
import { notDeleted, timestamp, wrap } from "./connection";
|
||||||
|
import { settings } from "./schema";
|
||||||
|
|
||||||
|
const SETTINGS_ID = "default";
|
||||||
|
|
||||||
|
export function getSettings(raw: Database): SettingsData {
|
||||||
|
const db = wrap(raw);
|
||||||
|
const row = db
|
||||||
|
.select()
|
||||||
|
.from(settings)
|
||||||
|
.where(and(eq(settings.id, SETTINGS_ID), notDeleted(settings)))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
return { compact: false, theme: "system" };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(row.data) as Partial<SettingsData>;
|
||||||
|
return {
|
||||||
|
compact: typeof parsed.compact === "boolean" ? parsed.compact : false,
|
||||||
|
defaultModels: parsed.defaultModels,
|
||||||
|
theme: parsed.theme === "dark" || parsed.theme === "light" || parsed.theme === "system" ? parsed.theme : "system",
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { compact: false, theme: "system" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateSettings(raw: Database, data: Partial<SettingsData>, _logger: Logger): SettingsData {
|
||||||
|
const db = wrap(raw);
|
||||||
|
const existing = db
|
||||||
|
.select()
|
||||||
|
.from(settings)
|
||||||
|
.where(and(eq(settings.id, SETTINGS_ID), notDeleted(settings)))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
let currentData: SettingsData = { compact: false, theme: "system" };
|
||||||
|
if (existing) {
|
||||||
|
try {
|
||||||
|
currentData = JSON.parse(existing.data) as SettingsData;
|
||||||
|
} catch {
|
||||||
|
// 解析失败时使用默认值
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const merged: SettingsData = { ...currentData, ...data };
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
db.update(settings)
|
||||||
|
.set({ data: JSON.stringify(merged), updatedAt: timestamp() })
|
||||||
|
.where(eq(settings.id, SETTINGS_ID))
|
||||||
|
.run();
|
||||||
|
} else {
|
||||||
|
const now = timestamp();
|
||||||
|
db.insert(settings)
|
||||||
|
.values({
|
||||||
|
createdAt: now,
|
||||||
|
data: JSON.stringify(merged),
|
||||||
|
id: SETTINGS_ID,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
@@ -38,7 +38,8 @@ export function parseListParams(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const keyword = url.searchParams.get("keyword") ?? undefined;
|
const keywordRaw = url.searchParams.get("keyword");
|
||||||
|
const keyword = keywordRaw === "" ? undefined : (keywordRaw ?? undefined);
|
||||||
|
|
||||||
const sortBy = url.searchParams.get("sortBy") ?? undefined;
|
const sortBy = url.searchParams.get("sortBy") ?? undefined;
|
||||||
const sortOrderParam = url.searchParams.get("sortOrder") ?? undefined;
|
const sortOrderParam = url.searchParams.get("sortOrder") ?? undefined;
|
||||||
|
|||||||
14
src/server/processing/index.ts
Normal file
14
src/server/processing/index.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import type Database from "bun:sqlite";
|
||||||
|
|
||||||
|
import type { Logger } from "../logger";
|
||||||
|
|
||||||
|
import { MaterialProcessor } from "./processor";
|
||||||
|
|
||||||
|
export { MaterialProcessor, type ProcessableMaterial } from "./processor";
|
||||||
|
export { getTemplate, type ProcessingTemplate } from "./templates";
|
||||||
|
|
||||||
|
export function startMaterialProcessor(db: Database, logger: Logger): MaterialProcessor {
|
||||||
|
const processor = new MaterialProcessor(db, logger);
|
||||||
|
processor.start();
|
||||||
|
return processor;
|
||||||
|
}
|
||||||
198
src/server/processing/processor.ts
Normal file
198
src/server/processing/processor.ts
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
import type Database from "bun:sqlite";
|
||||||
|
|
||||||
|
import { generateText } from "ai";
|
||||||
|
|
||||||
|
import type { MaterialType } from "../../shared/api";
|
||||||
|
import type { Logger } from "../logger";
|
||||||
|
|
||||||
|
import { and, asc, eq } from "drizzle-orm";
|
||||||
|
|
||||||
|
import { buildProviderRegistry } from "../ai/registry";
|
||||||
|
import { notDeleted, timestamp, wrap } from "../db/connection";
|
||||||
|
import { listEntityNames } from "../db/entities";
|
||||||
|
import { getModelWithProvider, listModels } from "../db/models";
|
||||||
|
import { materials } from "../db/schema";
|
||||||
|
import { getSettings } from "../db/settings";
|
||||||
|
|
||||||
|
import { getTemplate } from "./templates";
|
||||||
|
|
||||||
|
const MAX_RETRIES = 3;
|
||||||
|
const DEFAULT_INTERVAL_MS = 5000;
|
||||||
|
|
||||||
|
export interface ProcessableMaterial {
|
||||||
|
description: string;
|
||||||
|
id: string;
|
||||||
|
materialType: MaterialType;
|
||||||
|
projectId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MaterialProcessor {
|
||||||
|
private readonly db: Database;
|
||||||
|
private readonly logger: Logger;
|
||||||
|
private timer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
private running = false;
|
||||||
|
|
||||||
|
constructor(db: Database, logger: Logger) {
|
||||||
|
this.db = db;
|
||||||
|
this.logger = logger.child({ component: "material-processor" });
|
||||||
|
}
|
||||||
|
|
||||||
|
recoverStuckMaterials(): number {
|
||||||
|
const db = wrap(this.db);
|
||||||
|
const now = timestamp();
|
||||||
|
const restored = db
|
||||||
|
.update(materials)
|
||||||
|
.set({ status: "pending", updatedAt: now })
|
||||||
|
.where(and(eq(materials.status, "processing"), notDeleted(materials)))
|
||||||
|
.returning({ id: materials.id })
|
||||||
|
.all();
|
||||||
|
|
||||||
|
const count = restored.length;
|
||||||
|
if (count > 0) {
|
||||||
|
this.logger.info({ count }, "恢复卡住的素材到 pending 状态");
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
start(intervalMs: number = DEFAULT_INTERVAL_MS): void {
|
||||||
|
const recovered = this.recoverStuckMaterials();
|
||||||
|
this.logger.info({ intervalMs, recovered }, "素材处理器启动");
|
||||||
|
this.timer = setInterval(() => {
|
||||||
|
void this.tick();
|
||||||
|
}, intervalMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
if (this.timer !== null) {
|
||||||
|
clearInterval(this.timer);
|
||||||
|
this.timer = null;
|
||||||
|
}
|
||||||
|
this.running = false;
|
||||||
|
this.logger.info("素材处理器停止");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async tick(): Promise<void> {
|
||||||
|
if (this.running) {
|
||||||
|
this.logger.debug("上一轮处理尚未完成,跳过本次扫描");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.running = true;
|
||||||
|
try {
|
||||||
|
await this.processNext();
|
||||||
|
} catch (error: unknown) {
|
||||||
|
this.logger.error({ error: error instanceof Error ? error.message : String(error) }, "处理过程中发生未捕获错误");
|
||||||
|
} finally {
|
||||||
|
this.running = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async processNext(): Promise<void> {
|
||||||
|
const db = wrap(this.db);
|
||||||
|
const row = db
|
||||||
|
.select()
|
||||||
|
.from(materials)
|
||||||
|
.where(and(eq(materials.status, "pending"), notDeleted(materials)))
|
||||||
|
.orderBy(asc(materials.createdAt))
|
||||||
|
.limit(1)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
this.logger.debug("无待处理素材");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const processingAt = timestamp();
|
||||||
|
db.update(materials).set({ status: "processing", updatedAt: processingAt }).where(eq(materials.id, row.id)).run();
|
||||||
|
|
||||||
|
const material: ProcessableMaterial = {
|
||||||
|
description: row.description,
|
||||||
|
id: row.id,
|
||||||
|
materialType: row.materialType as MaterialType,
|
||||||
|
projectId: row.projectId,
|
||||||
|
};
|
||||||
|
|
||||||
|
let lastError: unknown;
|
||||||
|
let success = false;
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
||||||
|
try {
|
||||||
|
const result = await this.processOne(material);
|
||||||
|
const finishedAt = timestamp();
|
||||||
|
db.update(materials)
|
||||||
|
.set({ processedContent: result, status: "review", updatedAt: finishedAt })
|
||||||
|
.where(eq(materials.id, row.id))
|
||||||
|
.run();
|
||||||
|
this.logger.info({ attempt, materialId: row.id }, "素材处理成功");
|
||||||
|
success = true;
|
||||||
|
break;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
lastError = error;
|
||||||
|
this.logger.warn(
|
||||||
|
{
|
||||||
|
attempt,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
materialId: row.id,
|
||||||
|
},
|
||||||
|
`素材处理第 ${attempt} 次失败`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
const failedAt = timestamp();
|
||||||
|
db.update(materials).set({ status: "failed", updatedAt: failedAt }).where(eq(materials.id, row.id)).run();
|
||||||
|
this.logger.warn(
|
||||||
|
{
|
||||||
|
error: lastError instanceof Error ? lastError.message : String(lastError),
|
||||||
|
materialId: row.id,
|
||||||
|
},
|
||||||
|
`素材处理 ${MAX_RETRIES} 次均失败,标记为 failed`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async processOne(material: ProcessableMaterial): Promise<string> {
|
||||||
|
const modelInfo = getDefaultTextModel(this.db);
|
||||||
|
if (!modelInfo) {
|
||||||
|
throw new Error("没有可用的文本模型,请在设置中配置默认模型或添加至少一个模型");
|
||||||
|
}
|
||||||
|
|
||||||
|
const registry = buildProviderRegistry(this.db);
|
||||||
|
const model = registry.languageModel(`${modelInfo.providerId}:${modelInfo.externalId}`);
|
||||||
|
const existingEntities = listEntityNames(this.db, material.projectId);
|
||||||
|
const template = getTemplate(material.materialType);
|
||||||
|
const userPrompt = template.buildUserPrompt(material.description, existingEntities);
|
||||||
|
|
||||||
|
const result = await generateText({
|
||||||
|
model,
|
||||||
|
prompt: userPrompt,
|
||||||
|
system: template.systemPrompt,
|
||||||
|
});
|
||||||
|
|
||||||
|
const processingResult = template.parseOutput(result.text);
|
||||||
|
return JSON.stringify(processingResult);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDefaultTextModel(db: Database): { externalId: string; providerId: string } | null {
|
||||||
|
try {
|
||||||
|
const settings = getSettings(db);
|
||||||
|
if (settings.defaultModels?.text) {
|
||||||
|
const result = getModelWithProvider(db, settings.defaultModels.text);
|
||||||
|
if (!("error" in result)) {
|
||||||
|
return { externalId: result.model.externalId, providerId: result.provider.id };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// settings 不存在或解析失败,使用 fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallback = listModels(db, { page: 1, pageSize: 1 });
|
||||||
|
const firstModel = fallback.items[0];
|
||||||
|
if (!firstModel) return null;
|
||||||
|
|
||||||
|
const result = getModelWithProvider(db, firstModel.id);
|
||||||
|
if ("error" in result) return null;
|
||||||
|
|
||||||
|
return { externalId: result.model.externalId, providerId: result.provider.id };
|
||||||
|
}
|
||||||
79
src/server/processing/templates/general.ts
Normal file
79
src/server/processing/templates/general.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import type { ProcessingResult } from "../../../shared/api";
|
||||||
|
|
||||||
|
import type { ProcessingTemplate } from "./index";
|
||||||
|
|
||||||
|
const ENTITY_TYPES_DESC = `
|
||||||
|
实体类型说明:
|
||||||
|
- person: 人
|
||||||
|
- organization: 组织(公司/部门/客户/供应商等)
|
||||||
|
- system: 系统/软件
|
||||||
|
- feature: 功能/模块
|
||||||
|
- requirement: 需求
|
||||||
|
- issue: 问题/风险
|
||||||
|
- term: 术语/概念
|
||||||
|
- other: 其他`;
|
||||||
|
|
||||||
|
export const GENERAL_TEMPLATE: ProcessingTemplate = {
|
||||||
|
buildUserPrompt: (description: string, existingEntities: Array<{ aliases: string[]; id: string; name: string }>) => {
|
||||||
|
let entityList = "";
|
||||||
|
if (existingEntities.length > 0) {
|
||||||
|
entityList =
|
||||||
|
"当前项目下已有以下实体,请对照匹配(注意别名也是同一实体):\n" +
|
||||||
|
existingEntities
|
||||||
|
.map((e) => `- [${e.id}] ${e.name}${e.aliases.length > 0 ? `(别名:${e.aliases.join("、")})` : ""}`)
|
||||||
|
.join("\n") +
|
||||||
|
"\n如果识别到的实体与已有实体匹配,请将 matchedEntityId 设置为对应的 ID。";
|
||||||
|
}
|
||||||
|
|
||||||
|
return `请处理以下文本素材:\n\n${description}${entityList ? `\n\n${entityList}` : ""}`;
|
||||||
|
},
|
||||||
|
parseOutput: (text: string): ProcessingResult => {
|
||||||
|
const cleaned = text
|
||||||
|
.replace(/```json\s*/g, "")
|
||||||
|
.replace(/```\s*/g, "")
|
||||||
|
.trim();
|
||||||
|
const parsed = JSON.parse(cleaned) as {
|
||||||
|
candidateEntities?: Array<{ context?: string; matchedEntityId?: null | string; name?: string; type?: string }>;
|
||||||
|
normalizedContent?: string;
|
||||||
|
summary?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
candidateEntities: (parsed.candidateEntities ?? []).map((e) => ({
|
||||||
|
context: e.context ?? "",
|
||||||
|
matchedEntityId: e.matchedEntityId ?? null,
|
||||||
|
name: e.name ?? "",
|
||||||
|
type: (e.type ?? "other") as ProcessingResult["candidateEntities"][number]["type"],
|
||||||
|
})),
|
||||||
|
normalizedContent: parsed.normalizedContent ?? "",
|
||||||
|
summary: parsed.summary ?? "",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
systemPrompt: `你是 Alfred 预处理助手,负责整理用户输入的文本素材。
|
||||||
|
|
||||||
|
## 任务
|
||||||
|
分析输入的文本,生成以下结构化输出(纯 JSON 格式,不要包含其他文字):
|
||||||
|
|
||||||
|
{
|
||||||
|
"summary": "内容概要,1-2 句概括文本核心信息",
|
||||||
|
"normalizedContent": "规范化后的完整内容。保持原意,但修正口语化表达、去除冗余、统一格式。",
|
||||||
|
"candidateEntities": [
|
||||||
|
{
|
||||||
|
"name": "识别到的实体名称",
|
||||||
|
"type": "实体类型",
|
||||||
|
"context": "原文中相关的引用片段",
|
||||||
|
"matchedEntityId": "匹配到的已有实体 ID,无匹配则为 null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
${ENTITY_TYPES_DESC}
|
||||||
|
|
||||||
|
## 规则
|
||||||
|
- 只输出 JSON 对象,不要有任何其他文字、注释或 markdown 标记
|
||||||
|
- 识别文本中提到的人名、组织、系统、术语等重要实体
|
||||||
|
- 仔细对照已有实体列表进行匹配(包括别名),如果名称或别名相似则设置 matchedEntityId
|
||||||
|
- 如果实体的别名中包含了某个说法,也应该匹配到该实体
|
||||||
|
- 不要编造文本中未提到的实体
|
||||||
|
- normalizedContent 应保持客观,不要添加原文中没有的信息`,
|
||||||
|
} as const;
|
||||||
22
src/server/processing/templates/index.ts
Normal file
22
src/server/processing/templates/index.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import type { MaterialType, ProcessingResult } from "../../../shared/api";
|
||||||
|
|
||||||
|
import { GENERAL_TEMPLATE } from "./general";
|
||||||
|
import { MEETING_TEMPLATE } from "./meeting";
|
||||||
|
|
||||||
|
export interface ProcessingTemplate {
|
||||||
|
buildUserPrompt: (
|
||||||
|
description: string,
|
||||||
|
existingEntities: Array<{ aliases: string[]; id: string; name: string }>,
|
||||||
|
) => string;
|
||||||
|
parseOutput: (text: string) => ProcessingResult;
|
||||||
|
systemPrompt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TEMPLATES: Record<MaterialType, ProcessingTemplate> = {
|
||||||
|
general: GENERAL_TEMPLATE,
|
||||||
|
meeting: MEETING_TEMPLATE,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getTemplate(type: MaterialType): ProcessingTemplate {
|
||||||
|
return TEMPLATES[type];
|
||||||
|
}
|
||||||
79
src/server/processing/templates/meeting.ts
Normal file
79
src/server/processing/templates/meeting.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import type { ProcessingResult } from "../../../shared/api";
|
||||||
|
|
||||||
|
import type { ProcessingTemplate } from "./index";
|
||||||
|
|
||||||
|
const ENTITY_TYPES_DESC = `
|
||||||
|
实体类型说明:
|
||||||
|
- person: 人
|
||||||
|
- organization: 组织(公司/部门/客户/供应商等)
|
||||||
|
- system: 系统/软件
|
||||||
|
- feature: 功能/模块
|
||||||
|
- requirement: 需求
|
||||||
|
- issue: 问题/风险
|
||||||
|
- term: 术语/概念
|
||||||
|
- other: 其他`;
|
||||||
|
|
||||||
|
export const MEETING_TEMPLATE: ProcessingTemplate = {
|
||||||
|
buildUserPrompt: (description: string, existingEntities: Array<{ aliases: string[]; id: string; name: string }>) => {
|
||||||
|
let entityList = "";
|
||||||
|
if (existingEntities.length > 0) {
|
||||||
|
entityList =
|
||||||
|
"当前项目下已有以下实体,请对照匹配(注意别名也是同一实体):\n" +
|
||||||
|
existingEntities
|
||||||
|
.map((e) => `- [${e.id}] ${e.name}${e.aliases.length > 0 ? `(别名:${e.aliases.join("、")})` : ""}`)
|
||||||
|
.join("\n") +
|
||||||
|
"\n如果识别到的实体与已有实体匹配,请将 matchedEntityId 设置为对应的 ID。";
|
||||||
|
}
|
||||||
|
|
||||||
|
return `请处理以下会议相关文本素材:\n\n${description}${entityList ? `\n\n${entityList}` : ""}`;
|
||||||
|
},
|
||||||
|
parseOutput: (text: string): ProcessingResult => {
|
||||||
|
const cleaned = text
|
||||||
|
.replace(/```json\s*/g, "")
|
||||||
|
.replace(/```\s*/g, "")
|
||||||
|
.trim();
|
||||||
|
const parsed = JSON.parse(cleaned) as {
|
||||||
|
candidateEntities?: Array<{ context?: string; matchedEntityId?: null | string; name?: string; type?: string }>;
|
||||||
|
normalizedContent?: string;
|
||||||
|
summary?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
candidateEntities: (parsed.candidateEntities ?? []).map((e) => ({
|
||||||
|
context: e.context ?? "",
|
||||||
|
matchedEntityId: e.matchedEntityId ?? null,
|
||||||
|
name: e.name ?? "",
|
||||||
|
type: (e.type ?? "other") as ProcessingResult["candidateEntities"][number]["type"],
|
||||||
|
})),
|
||||||
|
normalizedContent: parsed.normalizedContent ?? "",
|
||||||
|
summary: parsed.summary ?? "",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
systemPrompt: `你是 Alfred 预处理助手,负责整理用户输入的会议相关文本素材。
|
||||||
|
|
||||||
|
## 任务
|
||||||
|
分析输入的文本,生成以下结构化输出(纯 JSON 格式,不要包含其他文字):
|
||||||
|
|
||||||
|
{
|
||||||
|
"summary": "会议内容概要,1-2 句概括核心内容",
|
||||||
|
"normalizedContent": "规范化后的会议完整内容。保持原意,但修正口语化表达、去除冗余、结构化呈现。如包含参会者、讨论要点、决议等内容,保持这些结构。",
|
||||||
|
"candidateEntities": [
|
||||||
|
{
|
||||||
|
"name": "识别到的实体名称(包括会议参与者、讨论中提到的组织/系统/术语等)",
|
||||||
|
"type": "实体类型",
|
||||||
|
"context": "原文中相关的引用片段",
|
||||||
|
"matchedEntityId": "匹配到的已有实体 ID,无匹配则为 null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
${ENTITY_TYPES_DESC}
|
||||||
|
|
||||||
|
## 规则
|
||||||
|
- 只输出 JSON 对象,不要有任何其他文字、注释或 markdown 标记
|
||||||
|
- 重点识别:参会人员、讨论中提到的组织/系统/术语/需求/问题
|
||||||
|
- 仔细对照已有实体列表进行匹配(包括别名)
|
||||||
|
- 如果实体的别名中包含了某个说法,也应该匹配到该实体
|
||||||
|
- 不要编造文本中未提到的信息
|
||||||
|
- normalizedContent 应保持客观,不要添加原文中没有的信息`,
|
||||||
|
} as const;
|
||||||
41
src/server/routes/entities/create.ts
Normal file
41
src/server/routes/entities/create.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import type Database from "bun:sqlite";
|
||||||
|
|
||||||
|
import type { CreateEntityRequest, RuntimeMode } from "../../../shared/api";
|
||||||
|
import type { Logger } from "../../logger";
|
||||||
|
|
||||||
|
import { createEntity } from "../../db/entities";
|
||||||
|
import { createApiError, jsonResponse, parseIdFromUrl } from "../../helpers";
|
||||||
|
import { validateIdParam } from "../../middleware";
|
||||||
|
|
||||||
|
export async function handleCreateEntity(
|
||||||
|
req: Request,
|
||||||
|
db: Database,
|
||||||
|
mode: RuntimeMode,
|
||||||
|
logger: Logger,
|
||||||
|
): Promise<Response> {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const projectIdStr = parseIdFromUrl(url);
|
||||||
|
|
||||||
|
const validated = validateIdParam(projectIdStr ?? "", mode);
|
||||||
|
if (validated instanceof Response) return validated;
|
||||||
|
|
||||||
|
let body: CreateEntityRequest;
|
||||||
|
try {
|
||||||
|
body = (await req.json()) as CreateEntityRequest;
|
||||||
|
} catch (e: unknown) {
|
||||||
|
logger.warn({ error: e instanceof Error ? e.message : String(e) }, "请求 JSON 解析失败");
|
||||||
|
return jsonResponse(createApiError("Invalid JSON body", 400), { mode, status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!body.name || typeof body.name !== "string") {
|
||||||
|
return jsonResponse(createApiError("name is required", 400), { mode, status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = createEntity(db, validated.id, body, logger);
|
||||||
|
if ("error" in result) {
|
||||||
|
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info({ entityId: result.entity.id, projectId: validated.id }, "实体创建成功");
|
||||||
|
return jsonResponse(result, { mode, status: 201 });
|
||||||
|
}
|
||||||
29
src/server/routes/entities/delete.ts
Normal file
29
src/server/routes/entities/delete.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import type Database from "bun:sqlite";
|
||||||
|
|
||||||
|
import type { RuntimeMode } from "../../../shared/api";
|
||||||
|
import type { Logger } from "../../logger";
|
||||||
|
|
||||||
|
import { deleteEntity } from "../../db/entities";
|
||||||
|
import { createApiError, jsonResponse } from "../../helpers";
|
||||||
|
import { validateIdParam } from "../../middleware";
|
||||||
|
|
||||||
|
export function handleDeleteEntity(req: Request, db: Database, mode: RuntimeMode, logger: Logger): Response {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const parts = url.pathname.split("/");
|
||||||
|
const projectIdStr = parts[3];
|
||||||
|
const entityIdStr = parts[5];
|
||||||
|
|
||||||
|
const validatedProject = validateIdParam(projectIdStr ?? "", mode);
|
||||||
|
if (validatedProject instanceof Response) return validatedProject;
|
||||||
|
|
||||||
|
const validatedEntity = validateIdParam(entityIdStr ?? "", mode);
|
||||||
|
if (validatedEntity instanceof Response) return validatedEntity;
|
||||||
|
|
||||||
|
const result = deleteEntity(db, validatedProject.id, validatedEntity.id, logger);
|
||||||
|
if ("error" in result) {
|
||||||
|
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info({ entityId: validatedEntity.id, projectId: validatedProject.id }, "实体删除成功");
|
||||||
|
return jsonResponse(result, { mode });
|
||||||
|
}
|
||||||
29
src/server/routes/entities/get.ts
Normal file
29
src/server/routes/entities/get.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import type Database from "bun:sqlite";
|
||||||
|
|
||||||
|
import type { RuntimeMode } from "../../../shared/api";
|
||||||
|
import type { Logger } from "../../logger";
|
||||||
|
|
||||||
|
import { getEntity } from "../../db/entities";
|
||||||
|
import { createApiError, jsonResponse } from "../../helpers";
|
||||||
|
import { validateIdParam } from "../../middleware";
|
||||||
|
|
||||||
|
export function handleGetEntity(req: Request, db: Database, mode: RuntimeMode, logger: Logger): Response {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const parts = url.pathname.split("/");
|
||||||
|
const projectIdStr = parts[3];
|
||||||
|
const entityIdStr = parts[5];
|
||||||
|
|
||||||
|
const validatedProject = validateIdParam(projectIdStr ?? "", mode);
|
||||||
|
if (validatedProject instanceof Response) return validatedProject;
|
||||||
|
|
||||||
|
const validatedEntity = validateIdParam(entityIdStr ?? "", mode);
|
||||||
|
if (validatedEntity instanceof Response) return validatedEntity;
|
||||||
|
|
||||||
|
const result = getEntity(db, validatedProject.id, validatedEntity.id);
|
||||||
|
if ("error" in result) {
|
||||||
|
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info({ entityId: validatedEntity.id, projectId: validatedProject.id }, "获取实体详情");
|
||||||
|
return jsonResponse(result, { mode });
|
||||||
|
}
|
||||||
45
src/server/routes/entities/list.ts
Normal file
45
src/server/routes/entities/list.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import type Database from "bun:sqlite";
|
||||||
|
|
||||||
|
import type { EntityType, RuntimeMode } from "../../../shared/api";
|
||||||
|
import type { Logger } from "../../logger";
|
||||||
|
|
||||||
|
import { listEntities } from "../../db/entities";
|
||||||
|
import { createApiError, jsonResponse, parseIdFromUrl } from "../../helpers";
|
||||||
|
import { validateIdParam, validatePagination } from "../../middleware";
|
||||||
|
|
||||||
|
export function handleListEntities(req: Request, db: Database, mode: RuntimeMode, _logger: Logger): Response {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const projectIdStr = parseIdFromUrl(url);
|
||||||
|
|
||||||
|
const validated = validateIdParam(projectIdStr ?? "", mode);
|
||||||
|
if (validated instanceof Response) return validated;
|
||||||
|
|
||||||
|
const pageParam = url.searchParams.get("page");
|
||||||
|
const pageSizeParam = url.searchParams.get("pageSize");
|
||||||
|
const typeParam = url.searchParams.get("type");
|
||||||
|
|
||||||
|
const pagination = validatePagination(pageParam, pageSizeParam, mode);
|
||||||
|
if (pagination instanceof Response) return pagination;
|
||||||
|
|
||||||
|
const ALLOWED_TYPES = [
|
||||||
|
"person",
|
||||||
|
"organization",
|
||||||
|
"system",
|
||||||
|
"feature",
|
||||||
|
"requirement",
|
||||||
|
"issue",
|
||||||
|
"term",
|
||||||
|
"other",
|
||||||
|
] as const;
|
||||||
|
if (typeParam && !(ALLOWED_TYPES as readonly string[]).includes(typeParam)) {
|
||||||
|
return jsonResponse(createApiError("Invalid type parameter", 400), { mode, status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = listEntities(db, validated.id, {
|
||||||
|
page: pagination.page,
|
||||||
|
pageSize: pagination.pageSize,
|
||||||
|
type: (typeParam as EntityType) ?? undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
return jsonResponse(result, { mode });
|
||||||
|
}
|
||||||
42
src/server/routes/entities/update.ts
Normal file
42
src/server/routes/entities/update.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import type Database from "bun:sqlite";
|
||||||
|
|
||||||
|
import type { RuntimeMode, UpdateEntityRequest } from "../../../shared/api";
|
||||||
|
import type { Logger } from "../../logger";
|
||||||
|
|
||||||
|
import { updateEntity } from "../../db/entities";
|
||||||
|
import { createApiError, jsonResponse } from "../../helpers";
|
||||||
|
import { validateIdParam } from "../../middleware";
|
||||||
|
|
||||||
|
export async function handleUpdateEntity(
|
||||||
|
req: Request,
|
||||||
|
db: Database,
|
||||||
|
mode: RuntimeMode,
|
||||||
|
logger: Logger,
|
||||||
|
): Promise<Response> {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const parts = url.pathname.split("/");
|
||||||
|
const projectIdStr = parts[3];
|
||||||
|
const entityIdStr = parts[5];
|
||||||
|
|
||||||
|
const validatedProject = validateIdParam(projectIdStr ?? "", mode);
|
||||||
|
if (validatedProject instanceof Response) return validatedProject;
|
||||||
|
|
||||||
|
const validatedEntity = validateIdParam(entityIdStr ?? "", mode);
|
||||||
|
if (validatedEntity instanceof Response) return validatedEntity;
|
||||||
|
|
||||||
|
let body: UpdateEntityRequest;
|
||||||
|
try {
|
||||||
|
body = (await req.json()) as UpdateEntityRequest;
|
||||||
|
} catch (e: unknown) {
|
||||||
|
logger.warn({ error: e instanceof Error ? e.message : String(e) }, "请求 JSON 解析失败");
|
||||||
|
return jsonResponse(createApiError("Invalid JSON body", 400), { mode, status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = updateEntity(db, validatedProject.id, validatedEntity.id, body, logger);
|
||||||
|
if ("error" in result) {
|
||||||
|
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info({ entityId: validatedEntity.id, projectId: validatedProject.id }, "实体更新成功");
|
||||||
|
return jsonResponse(result, { mode });
|
||||||
|
}
|
||||||
42
src/server/routes/materials/approve.ts
Normal file
42
src/server/routes/materials/approve.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import type Database from "bun:sqlite";
|
||||||
|
|
||||||
|
import type { EntityConfirmation, RuntimeMode } from "../../../shared/api";
|
||||||
|
import type { Logger } from "../../logger";
|
||||||
|
|
||||||
|
import { approveMaterial } from "../../db/materials";
|
||||||
|
import { createApiError, jsonResponse } from "../../helpers";
|
||||||
|
import { validateIdParam } from "../../middleware";
|
||||||
|
|
||||||
|
export async function handleApproveMaterial(
|
||||||
|
req: Request,
|
||||||
|
db: Database,
|
||||||
|
mode: RuntimeMode,
|
||||||
|
logger: Logger,
|
||||||
|
): Promise<Response> {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const parts = url.pathname.split("/");
|
||||||
|
const projectIdStr = parts[3];
|
||||||
|
const materialIdStr = parts[5];
|
||||||
|
|
||||||
|
const validatedProject = validateIdParam(projectIdStr ?? "", mode);
|
||||||
|
if (validatedProject instanceof Response) return validatedProject;
|
||||||
|
|
||||||
|
const validatedMaterial = validateIdParam(materialIdStr ?? "", mode);
|
||||||
|
if (validatedMaterial instanceof Response) return validatedMaterial;
|
||||||
|
|
||||||
|
let entityConfirmations: EntityConfirmation[] = [];
|
||||||
|
try {
|
||||||
|
const body = (await req.json()) as { entityConfirmations?: EntityConfirmation[] };
|
||||||
|
entityConfirmations = body.entityConfirmations ?? [];
|
||||||
|
} catch {
|
||||||
|
// body 为空时使用默认空数组
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = approveMaterial(db, validatedProject.id, validatedMaterial.id, entityConfirmations, logger);
|
||||||
|
if ("error" in result) {
|
||||||
|
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info({ materialId: validatedMaterial.id, projectId: validatedProject.id }, "素材审核通过");
|
||||||
|
return jsonResponse(result, { mode, status: 201 });
|
||||||
|
}
|
||||||
29
src/server/routes/materials/discard.ts
Normal file
29
src/server/routes/materials/discard.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import type Database from "bun:sqlite";
|
||||||
|
|
||||||
|
import type { RuntimeMode } from "../../../shared/api";
|
||||||
|
import type { Logger } from "../../logger";
|
||||||
|
|
||||||
|
import { discardMaterial } from "../../db/materials";
|
||||||
|
import { createApiError, jsonResponse } from "../../helpers";
|
||||||
|
import { validateIdParam } from "../../middleware";
|
||||||
|
|
||||||
|
export function handleDiscardMaterial(req: Request, db: Database, mode: RuntimeMode, logger: Logger): Response {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const parts = url.pathname.split("/");
|
||||||
|
const projectIdStr = parts[3];
|
||||||
|
const materialIdStr = parts[5];
|
||||||
|
|
||||||
|
const validatedProject = validateIdParam(projectIdStr ?? "", mode);
|
||||||
|
if (validatedProject instanceof Response) return validatedProject;
|
||||||
|
|
||||||
|
const validatedMaterial = validateIdParam(materialIdStr ?? "", mode);
|
||||||
|
if (validatedMaterial instanceof Response) return validatedMaterial;
|
||||||
|
|
||||||
|
const result = discardMaterial(db, validatedProject.id, validatedMaterial.id, logger);
|
||||||
|
if ("error" in result) {
|
||||||
|
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info({ materialId: validatedMaterial.id, projectId: validatedProject.id }, "素材已放弃");
|
||||||
|
return jsonResponse(result, { mode, status: 201 });
|
||||||
|
}
|
||||||
@@ -21,14 +21,15 @@ export function handleListMaterials(req: Request, db: Database, mode: RuntimeMod
|
|||||||
const pagination = validatePagination(pageParam, pageSizeParam, mode);
|
const pagination = validatePagination(pageParam, pageSizeParam, mode);
|
||||||
if (pagination instanceof Response) return pagination;
|
if (pagination instanceof Response) return pagination;
|
||||||
|
|
||||||
if (statusParam && statusParam !== "pending" && statusParam !== "approved" && statusParam !== "discarded") {
|
const ALLOWED_STATUSES = ["pending", "processing", "review", "approved", "discarded", "failed"] as const;
|
||||||
|
if (statusParam && !(ALLOWED_STATUSES as readonly string[]).includes(statusParam)) {
|
||||||
return jsonResponse(createApiError("Invalid status parameter", 400), { mode, status: 400 });
|
return jsonResponse(createApiError("Invalid status parameter", 400), { mode, status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = listMaterials(db, validated.id, {
|
const result = listMaterials(db, validated.id, {
|
||||||
page: pagination.page,
|
page: pagination.page,
|
||||||
pageSize: pagination.pageSize,
|
pageSize: pagination.pageSize,
|
||||||
status: (statusParam as "approved" | "discarded" | "pending") ?? undefined,
|
status: (statusParam as (typeof ALLOWED_STATUSES)[number]) ?? undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
return jsonResponse(result, { mode });
|
return jsonResponse(result, { mode });
|
||||||
|
|||||||
29
src/server/routes/materials/retry.ts
Normal file
29
src/server/routes/materials/retry.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import type Database from "bun:sqlite";
|
||||||
|
|
||||||
|
import type { RuntimeMode } from "../../../shared/api";
|
||||||
|
import type { Logger } from "../../logger";
|
||||||
|
|
||||||
|
import { retryMaterial } from "../../db/materials";
|
||||||
|
import { createApiError, jsonResponse } from "../../helpers";
|
||||||
|
import { validateIdParam } from "../../middleware";
|
||||||
|
|
||||||
|
export function handleRetryMaterial(req: Request, db: Database, mode: RuntimeMode, logger: Logger): Response {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const parts = url.pathname.split("/");
|
||||||
|
const projectIdStr = parts[3];
|
||||||
|
const materialIdStr = parts[5];
|
||||||
|
|
||||||
|
const validatedProject = validateIdParam(projectIdStr ?? "", mode);
|
||||||
|
if (validatedProject instanceof Response) return validatedProject;
|
||||||
|
|
||||||
|
const validatedMaterial = validateIdParam(materialIdStr ?? "", mode);
|
||||||
|
if (validatedMaterial instanceof Response) return validatedMaterial;
|
||||||
|
|
||||||
|
const result = retryMaterial(db, validatedProject.id, validatedMaterial.id, logger);
|
||||||
|
if ("error" in result) {
|
||||||
|
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info({ materialId: validatedMaterial.id, projectId: validatedProject.id }, "素材重试已触发");
|
||||||
|
return jsonResponse(result, { mode, status: 201 });
|
||||||
|
}
|
||||||
42
src/server/routes/settings.ts
Normal file
42
src/server/routes/settings.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import type Database from "bun:sqlite";
|
||||||
|
|
||||||
|
import type { RuntimeMode, SettingsData } from "../../shared/api";
|
||||||
|
import type { Logger } from "../logger";
|
||||||
|
|
||||||
|
import { getSettings, updateSettings } from "../db/settings";
|
||||||
|
import { createApiError, jsonResponse } from "../helpers";
|
||||||
|
|
||||||
|
export function handleGetSettings(_req: Request, db: Database, mode: RuntimeMode, _logger: Logger): Response {
|
||||||
|
const data = getSettings(db);
|
||||||
|
return jsonResponse(data, { mode });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleUpdateSettings(
|
||||||
|
req: Request,
|
||||||
|
db: Database,
|
||||||
|
mode: RuntimeMode,
|
||||||
|
logger: Logger,
|
||||||
|
): Promise<Response> {
|
||||||
|
let body: Partial<SettingsData>;
|
||||||
|
try {
|
||||||
|
body = (await req.json()) as Partial<SettingsData>;
|
||||||
|
} catch {
|
||||||
|
return jsonResponse(createApiError("Invalid JSON body", 400), { mode, status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.theme !== undefined && typeof body.theme !== "string") {
|
||||||
|
return jsonResponse(createApiError("theme must be a string", 400), { mode, status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.theme !== undefined && body.theme !== "dark" && body.theme !== "light" && body.theme !== "system") {
|
||||||
|
return jsonResponse(createApiError("theme 仅支持 dark、light、system", 400), { mode, status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.compact !== undefined && typeof body.compact !== "boolean") {
|
||||||
|
return jsonResponse(createApiError("compact 必须为布尔值", 400), { mode, status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = updateSettings(db, body, logger);
|
||||||
|
logger.info({ data: result }, "设置已更新");
|
||||||
|
return jsonResponse(result, { mode });
|
||||||
|
}
|
||||||
@@ -256,6 +256,80 @@ export function startServer(options: StartServerOptions) {
|
|||||||
logger,
|
logger,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
"/api/projects/:id/materials/:mid/approve": {
|
||||||
|
POST: withErrorHandler(
|
||||||
|
async (req) => {
|
||||||
|
const { handleApproveMaterial } = await import("./routes/materials/approve");
|
||||||
|
return handleApproveMaterial(req, db, mode, logger);
|
||||||
|
},
|
||||||
|
mode,
|
||||||
|
logger,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"/api/projects/:id/materials/:mid/discard": {
|
||||||
|
POST: withErrorHandler(
|
||||||
|
async (req) => {
|
||||||
|
const { handleDiscardMaterial } = await import("./routes/materials/discard");
|
||||||
|
return handleDiscardMaterial(req, db, mode, logger);
|
||||||
|
},
|
||||||
|
mode,
|
||||||
|
logger,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"/api/projects/:id/materials/:mid/retry": {
|
||||||
|
POST: withErrorHandler(
|
||||||
|
async (req) => {
|
||||||
|
const { handleRetryMaterial } = await import("./routes/materials/retry");
|
||||||
|
return handleRetryMaterial(req, db, mode, logger);
|
||||||
|
},
|
||||||
|
mode,
|
||||||
|
logger,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"/api/projects/:id/entities": {
|
||||||
|
GET: withErrorHandler(
|
||||||
|
async (req) => {
|
||||||
|
const { handleListEntities } = await import("./routes/entities/list");
|
||||||
|
return handleListEntities(req, db, mode, logger);
|
||||||
|
},
|
||||||
|
mode,
|
||||||
|
logger,
|
||||||
|
),
|
||||||
|
POST: withErrorHandler(
|
||||||
|
async (req) => {
|
||||||
|
const { handleCreateEntity } = await import("./routes/entities/create");
|
||||||
|
return handleCreateEntity(req, db, mode, logger);
|
||||||
|
},
|
||||||
|
mode,
|
||||||
|
logger,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"/api/projects/:id/entities/:eid": {
|
||||||
|
DELETE: withErrorHandler(
|
||||||
|
async (req) => {
|
||||||
|
const { handleDeleteEntity } = await import("./routes/entities/delete");
|
||||||
|
return handleDeleteEntity(req, db, mode, logger);
|
||||||
|
},
|
||||||
|
mode,
|
||||||
|
logger,
|
||||||
|
),
|
||||||
|
GET: withErrorHandler(
|
||||||
|
async (req) => {
|
||||||
|
const { handleGetEntity } = await import("./routes/entities/get");
|
||||||
|
return handleGetEntity(req, db, mode, logger);
|
||||||
|
},
|
||||||
|
mode,
|
||||||
|
logger,
|
||||||
|
),
|
||||||
|
PATCH: withErrorHandler(
|
||||||
|
async (req) => {
|
||||||
|
const { handleUpdateEntity } = await import("./routes/entities/update");
|
||||||
|
return handleUpdateEntity(req, db, mode, logger);
|
||||||
|
},
|
||||||
|
mode,
|
||||||
|
logger,
|
||||||
|
),
|
||||||
|
},
|
||||||
"/api/projects/:id/restore": {
|
"/api/projects/:id/restore": {
|
||||||
POST: withErrorHandler(
|
POST: withErrorHandler(
|
||||||
async (req) => {
|
async (req) => {
|
||||||
@@ -330,6 +404,24 @@ export function startServer(options: StartServerOptions) {
|
|||||||
logger,
|
logger,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
"/api/settings": {
|
||||||
|
GET: withErrorHandler(
|
||||||
|
async (req) => {
|
||||||
|
const { handleGetSettings } = await import("./routes/settings");
|
||||||
|
return handleGetSettings(req, db, mode, logger);
|
||||||
|
},
|
||||||
|
mode,
|
||||||
|
logger,
|
||||||
|
),
|
||||||
|
PUT: withErrorHandler(
|
||||||
|
async (req) => {
|
||||||
|
const { handleUpdateSettings } = await import("./routes/settings");
|
||||||
|
return handleUpdateSettings(req, db, mode, logger);
|
||||||
|
},
|
||||||
|
mode,
|
||||||
|
logger,
|
||||||
|
),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export interface CreateConversationRequest {
|
|||||||
export interface CreateMaterialRequest {
|
export interface CreateMaterialRequest {
|
||||||
associatedDate: string;
|
associatedDate: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
materialType?: MaterialType;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateModelRequest {
|
export interface CreateModelRequest {
|
||||||
@@ -54,10 +55,77 @@ export interface CreateProviderRequest {
|
|||||||
type: ProviderType;
|
type: ProviderType;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==========================================
|
export interface ApproveMaterialRequest {
|
||||||
// 在此定义你的业务类型
|
entityConfirmations?: EntityConfirmation[];
|
||||||
// 前后端共享的类型都放在这个文件中
|
}
|
||||||
// ==========================================
|
|
||||||
|
export interface CandidateEntity {
|
||||||
|
context: string;
|
||||||
|
matchedEntityId: null | string;
|
||||||
|
name: string;
|
||||||
|
type: EntityType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateEntityRequest {
|
||||||
|
aliases?: string[];
|
||||||
|
description?: string;
|
||||||
|
name: string;
|
||||||
|
type: EntityType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Entity {
|
||||||
|
aliases: string[];
|
||||||
|
createdAt: string;
|
||||||
|
description: string;
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
projectId: string;
|
||||||
|
type: EntityType;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EntityConfirmation {
|
||||||
|
action: "create" | "discard" | "merge" | "select";
|
||||||
|
candidateIndex: number;
|
||||||
|
targetEntityId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EntityListResponse {
|
||||||
|
items: Entity[];
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EntityResponse {
|
||||||
|
entity: Entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ENTITY_TYPES = [
|
||||||
|
"person",
|
||||||
|
"organization",
|
||||||
|
"system",
|
||||||
|
"feature",
|
||||||
|
"requirement",
|
||||||
|
"issue",
|
||||||
|
"term",
|
||||||
|
"other",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type EntityType = (typeof ENTITY_TYPES)[number];
|
||||||
|
|
||||||
|
export interface ProcessingResult {
|
||||||
|
candidateEntities: CandidateEntity[];
|
||||||
|
normalizedContent: string;
|
||||||
|
summary: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateEntityRequest {
|
||||||
|
aliases?: string[];
|
||||||
|
description?: string;
|
||||||
|
name?: string;
|
||||||
|
type?: EntityType;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ListSortParams {
|
export interface ListSortParams {
|
||||||
sortBy?: string;
|
sortBy?: string;
|
||||||
@@ -69,6 +137,8 @@ export interface Material {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
description: string;
|
description: string;
|
||||||
id: string;
|
id: string;
|
||||||
|
materialType: MaterialType;
|
||||||
|
processedContent: null | string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
status: MaterialStatus;
|
status: MaterialStatus;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
@@ -85,7 +155,9 @@ export interface MaterialResponse {
|
|||||||
material: Material;
|
material: Material;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MaterialStatus = "approved" | "discarded" | "pending";
|
export type MaterialStatus = "approved" | "discarded" | "failed" | "pending" | "processing" | "review";
|
||||||
|
|
||||||
|
export type MaterialType = "general" | "meeting";
|
||||||
|
|
||||||
export interface Message {
|
export interface Message {
|
||||||
content: string;
|
content: string;
|
||||||
@@ -237,11 +309,31 @@ export type ProviderType = "anthropic" | "openai" | "openai-compatible";
|
|||||||
|
|
||||||
export type RuntimeMode = "development" | "production" | "test";
|
export type RuntimeMode = "development" | "production" | "test";
|
||||||
|
|
||||||
|
/** 模型能力到默认模型 ID 的映射,用于后台自动流程 */
|
||||||
|
export interface DefaultModelSettings {
|
||||||
|
/** 文本能力,覆盖 text + reasoning */
|
||||||
|
text?: string | null;
|
||||||
|
imageRecognition?: string | null;
|
||||||
|
audioRecognition?: string | null;
|
||||||
|
videoRecognition?: string | null;
|
||||||
|
imageGeneration?: string | null;
|
||||||
|
audioGeneration?: string | null;
|
||||||
|
videoGeneration?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SettingsData {
|
||||||
|
compact?: boolean;
|
||||||
|
defaultModels?: DefaultModelSettings;
|
||||||
|
theme: ThemePreference;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TestModelRequest {
|
export interface TestModelRequest {
|
||||||
externalId: string;
|
externalId: string;
|
||||||
providerId: string;
|
providerId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ThemePreference = "dark" | "light" | "system";
|
||||||
|
|
||||||
export interface UpdateModelRequest {
|
export interface UpdateModelRequest {
|
||||||
capabilities?: ModelCapability[];
|
capabilities?: ModelCapability[];
|
||||||
contextLength?: null | number;
|
contextLength?: null | number;
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import "overlayscrollbars/styles/overlayscrollbars.css";
|
|||||||
import { OverlayScrollbarsComponent, type OverlayScrollbarsComponentRef } from "overlayscrollbars-react";
|
import { OverlayScrollbarsComponent, type OverlayScrollbarsComponentRef } from "overlayscrollbars-react";
|
||||||
import { useCallback, useRef, useState } from "react";
|
import { useCallback, useRef, useState } from "react";
|
||||||
|
|
||||||
import { useIsDark } from "../../shared/hooks/use-is-dark";
|
|
||||||
import { useChatScroll } from "./use-chat-scroll";
|
import { useChatScroll } from "./use-chat-scroll";
|
||||||
|
|
||||||
interface ChatScrollAreaProps {
|
interface ChatScrollAreaProps {
|
||||||
@@ -19,7 +18,6 @@ export function ChatScrollArea({ children, messages, status }: ChatScrollAreaPro
|
|||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const osRef = useRef<OverlayScrollbarsComponentRef>(null);
|
const osRef = useRef<OverlayScrollbarsComponentRef>(null);
|
||||||
const [viewportElement, setViewportElement] = useState<HTMLDivElement | null>(null);
|
const [viewportElement, setViewportElement] = useState<HTMLDivElement | null>(null);
|
||||||
const isDark = useIsDark();
|
|
||||||
|
|
||||||
const handleOsInitialized = useCallback(() => {
|
const handleOsInitialized = useCallback(() => {
|
||||||
const os = osRef.current;
|
const os = osRef.current;
|
||||||
@@ -42,7 +40,7 @@ export function ChatScrollArea({ children, messages, status }: ChatScrollAreaPro
|
|||||||
overflow: { x: "hidden", y: "scroll" },
|
overflow: { x: "hidden", y: "scroll" },
|
||||||
scrollbars: {
|
scrollbars: {
|
||||||
autoHide: "move",
|
autoHide: "move",
|
||||||
theme: isDark ? "os-theme-custom-dark" : "os-theme-custom",
|
theme: "os-theme-custom",
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
ref={osRef}
|
ref={osRef}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { useMemo, useState } from "react";
|
|||||||
import type { Conversation } from "../../../../shared/api";
|
import type { Conversation } from "../../../../shared/api";
|
||||||
|
|
||||||
import { SidebarGroup } from "../../../shared/components/SidebarGroup";
|
import { SidebarGroup } from "../../../shared/components/SidebarGroup";
|
||||||
import { useIsDark } from "../../../shared/hooks/use-is-dark";
|
|
||||||
import { GROUP_LABELS, groupByDate } from "../../../shared/utils/date-group";
|
import { GROUP_LABELS, groupByDate } from "../../../shared/utils/date-group";
|
||||||
import { ConversationCard } from "./ConversationCard";
|
import { ConversationCard } from "./ConversationCard";
|
||||||
|
|
||||||
@@ -30,7 +29,6 @@ export function ConversationList({
|
|||||||
}: ConversationListProps) {
|
}: ConversationListProps) {
|
||||||
const [inputText, setInputText] = useState("");
|
const [inputText, setInputText] = useState("");
|
||||||
const [appliedSearch, setAppliedSearch] = useState("");
|
const [appliedSearch, setAppliedSearch] = useState("");
|
||||||
const isDark = useIsDark();
|
|
||||||
|
|
||||||
const filteredConversations = useMemo(() => {
|
const filteredConversations = useMemo(() => {
|
||||||
if (!appliedSearch) return conversations;
|
if (!appliedSearch) return conversations;
|
||||||
@@ -60,7 +58,7 @@ export function ConversationList({
|
|||||||
overflow: { x: "hidden", y: "scroll" },
|
overflow: { x: "hidden", y: "scroll" },
|
||||||
scrollbars: {
|
scrollbars: {
|
||||||
autoHide: "move",
|
autoHide: "move",
|
||||||
theme: isDark ? "os-theme-custom-dark" : "os-theme-custom",
|
theme: "os-theme-custom",
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
import { CopyOutlined } from "@ant-design/icons";
|
import { HighlightBlock } from "./HighlightBlock";
|
||||||
import { Button, message } from "antd";
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
|
||||||
import { codeToHtml } from "shiki";
|
|
||||||
|
|
||||||
import { useIsDark } from "../../../shared/hooks/use-is-dark";
|
|
||||||
|
|
||||||
interface CodeBlockProps {
|
interface CodeBlockProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -13,61 +8,9 @@ interface CodeBlockProps {
|
|||||||
isStreaming: boolean;
|
isStreaming: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CodeBlock({ children, className: _className, isStreaming }: CodeBlockProps) {
|
export function CodeBlock({ children, isStreaming }: CodeBlockProps) {
|
||||||
const isDark = useIsDark();
|
|
||||||
const [highlighted, setHighlighted] = useState<null | string>(null);
|
|
||||||
const { codeText, lang } = extractCode(children);
|
const { codeText, lang } = extractCode(children);
|
||||||
|
return <HighlightBlock code={codeText} isStreaming={isStreaming} lang={lang} />;
|
||||||
const handleCopy = useCallback(() => {
|
|
||||||
navigator.clipboard.writeText(codeText).then(
|
|
||||||
() => message.success("已复制"),
|
|
||||||
() => message.error("复制失败"),
|
|
||||||
);
|
|
||||||
}, [codeText]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isStreaming || !codeText) return;
|
|
||||||
|
|
||||||
let cancelled = false;
|
|
||||||
codeToHtml(codeText, {
|
|
||||||
lang,
|
|
||||||
theme: isDark ? "github-dark" : "github-light",
|
|
||||||
})
|
|
||||||
.then((html) => {
|
|
||||||
if (!cancelled) setHighlighted(html);
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
if (!cancelled) setHighlighted(null);
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [codeText, lang, isDark, isStreaming]);
|
|
||||||
|
|
||||||
if (isStreaming) {
|
|
||||||
return (
|
|
||||||
<pre className="code-block">
|
|
||||||
<code>{codeText}</code>
|
|
||||||
</pre>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="code-block">
|
|
||||||
<div className="code-block-header">
|
|
||||||
<span className="code-block-lang">{lang}</span>
|
|
||||||
<Button icon={<CopyOutlined />} onClick={handleCopy} size="small" type="text" />
|
|
||||||
</div>
|
|
||||||
{highlighted ? (
|
|
||||||
<div className="code-block-body" dangerouslySetInnerHTML={{ __html: highlighted }} />
|
|
||||||
) : (
|
|
||||||
<pre className="code-block-body">
|
|
||||||
<code>{codeText}</code>
|
|
||||||
</pre>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractCode(children: ReactNode): { codeText: string; lang: string } {
|
function extractCode(children: ReactNode): { codeText: string; lang: string } {
|
||||||
|
|||||||
71
src/web/features/chat/parts/HighlightBlock.tsx
Normal file
71
src/web/features/chat/parts/HighlightBlock.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { CopyOutlined } from "@ant-design/icons";
|
||||||
|
import { App, Button } from "antd";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { codeToHtml } from "shiki";
|
||||||
|
|
||||||
|
import { useIsDark } from "../../../shared/hooks/use-is-dark";
|
||||||
|
|
||||||
|
interface HighlightBlockProps {
|
||||||
|
code: string;
|
||||||
|
isStreaming: boolean;
|
||||||
|
lang: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HighlightBlock({ code, isStreaming, lang }: HighlightBlockProps) {
|
||||||
|
const { message } = App.useApp();
|
||||||
|
const isDark = useIsDark();
|
||||||
|
const [highlighted, setHighlighted] = useState<null | string>(null);
|
||||||
|
|
||||||
|
const handleCopy = useCallback(() => {
|
||||||
|
navigator.clipboard.writeText(code).then(
|
||||||
|
() => message.success("已复制"),
|
||||||
|
() => message.error("复制失败"),
|
||||||
|
);
|
||||||
|
}, [code, message]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isStreaming || !code) return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
codeToHtml(code, {
|
||||||
|
lang,
|
||||||
|
theme: isDark ? "github-dark" : "github-light",
|
||||||
|
})
|
||||||
|
.then((html) => {
|
||||||
|
if (!cancelled) setHighlighted(html);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setHighlighted(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [code, lang, isDark, isStreaming]);
|
||||||
|
|
||||||
|
if (isStreaming) {
|
||||||
|
return (
|
||||||
|
<pre className="code-block">
|
||||||
|
<code>{code}</code>
|
||||||
|
</pre>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="code-block">
|
||||||
|
<div className="code-block-header">
|
||||||
|
<span className="code-block-lang">{lang}</span>
|
||||||
|
<Button className="btn-dimmed" icon={<CopyOutlined />} onClick={handleCopy} size="small" type="text" />
|
||||||
|
</div>
|
||||||
|
{highlighted ? (
|
||||||
|
<div className="code-block-body" dangerouslySetInnerHTML={{ __html: highlighted }} />
|
||||||
|
) : (
|
||||||
|
<div className="code-block-body">
|
||||||
|
<pre>
|
||||||
|
<code>{code}</code>
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
import { CheckCircleFilled, LoadingOutlined } from "@ant-design/icons";
|
import { CheckCircleFilled, LoadingOutlined } from "@ant-design/icons";
|
||||||
import { Collapse, Flex, Typography } from "antd";
|
import { Collapse, Flex, Typography } from "antd";
|
||||||
|
import { Markdown } from "markdown-to-jsx/react";
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
import { CodeBlock } from "./CodeBlock";
|
||||||
|
import { MarkdownTable } from "./MarkdownTable";
|
||||||
import type { PartProps } from "./types";
|
import type { PartProps } from "./types";
|
||||||
|
|
||||||
const REASONING_KEY = "reasoning";
|
const REASONING_KEY = "reasoning";
|
||||||
@@ -33,7 +36,21 @@ export function ReasoningPart({ part }: PartProps) {
|
|||||||
ghost
|
ghost
|
||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
children: <Typography.Text type="secondary">{text}</Typography.Text>,
|
children: (
|
||||||
|
<div className="reasoning-content">
|
||||||
|
<Markdown
|
||||||
|
options={{
|
||||||
|
optimizeForStreaming: isStreaming,
|
||||||
|
overrides: {
|
||||||
|
pre: { component: CodeBlock, props: { isStreaming } },
|
||||||
|
table: MarkdownTable,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</Markdown>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
key: REASONING_KEY,
|
key: REASONING_KEY,
|
||||||
label: isStreaming ? (
|
label: isStreaming ? (
|
||||||
<Flex align="center" component="span" gap={4}>
|
<Flex align="center" component="span" gap={4}>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { CheckCircleFilled, CloseCircleFilled, LoadingOutlined } from "@ant-design/icons";
|
import { CheckCircleFilled, CloseCircleFilled, LoadingOutlined } from "@ant-design/icons";
|
||||||
import { Collapse, Flex, Typography } from "antd";
|
import { Collapse, Flex, Typography } from "antd";
|
||||||
|
|
||||||
|
import { HighlightBlock } from "./HighlightBlock";
|
||||||
import type { PartProps } from "./types";
|
import type { PartProps } from "./types";
|
||||||
|
|
||||||
interface ToolPartData {
|
interface ToolPartData {
|
||||||
@@ -20,7 +21,19 @@ function getToolState(part: ToolPartData) {
|
|||||||
return "input-streaming" as const;
|
return "input-streaming" as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FORMAT_JSON = (v: unknown) => JSON.stringify(v, null, 2);
|
function getInputLang(value: unknown): string {
|
||||||
|
return typeof value === "object" && value !== null ? "json" : "text";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOutputLang(value: unknown): string {
|
||||||
|
return typeof value === "object" && value !== null ? "json" : "text";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatContent(value: unknown): string {
|
||||||
|
if (typeof value === "object" && value !== null) return JSON.stringify(value, null, 2);
|
||||||
|
if (typeof value === "string") return value;
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
export function ToolPart({ part }: PartProps) {
|
export function ToolPart({ part }: PartProps) {
|
||||||
const toolPart = part as unknown as ToolPartData;
|
const toolPart = part as unknown as ToolPartData;
|
||||||
@@ -31,13 +44,25 @@ export function ToolPart({ part }: PartProps) {
|
|||||||
|
|
||||||
const isStreaming = state === "input-streaming" || state === "input-available";
|
const isStreaming = state === "input-streaming" || state === "input-available";
|
||||||
|
|
||||||
|
const formattedInput = toolPart.input != null ? formatContent(toolPart.input) : "";
|
||||||
|
const inputLang = toolPart.input != null ? getInputLang(toolPart.input) : "text";
|
||||||
|
|
||||||
|
const hasOutput = "output" in toolPart && toolPart.output != null;
|
||||||
|
const formattedOutput = hasOutput ? formatContent(toolPart.output) : "";
|
||||||
|
const outputLang = hasOutput ? getOutputLang(toolPart.output) : "text";
|
||||||
|
|
||||||
if (state === "output-error") {
|
if (state === "output-error") {
|
||||||
return (
|
return (
|
||||||
<Collapse
|
<Collapse
|
||||||
ghost
|
ghost
|
||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
children: <Typography.Text type="danger">{toolPart.errorText}</Typography.Text>,
|
children: (
|
||||||
|
<div className="tool-call-section">
|
||||||
|
<Typography.Text type="danger">错误</Typography.Text>
|
||||||
|
<HighlightBlock code={toolPart.errorText!} isStreaming={false} lang="text" />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
key: toolPart.toolCallId ?? toolName,
|
key: toolPart.toolCallId ?? toolName,
|
||||||
label: (
|
label: (
|
||||||
<Flex align="center" component="span" gap={4}>
|
<Flex align="center" component="span" gap={4}>
|
||||||
@@ -59,18 +84,18 @@ export function ToolPart({ part }: PartProps) {
|
|||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
children: (
|
children: (
|
||||||
<Flex gap={4} vertical>
|
<Flex gap={8} vertical>
|
||||||
{toolPart.input != null && (
|
{toolPart.input != null && (
|
||||||
<>
|
<div className="tool-call-section">
|
||||||
<Typography.Text type="secondary">参数:</Typography.Text>
|
<Typography.Text type="secondary">入参</Typography.Text>
|
||||||
<pre className="tool-result-pre">{FORMAT_JSON(toolPart.input)}</pre>
|
<HighlightBlock code={formattedInput} isStreaming={isStreaming} lang={inputLang} />
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
{"output" in toolPart && toolPart.output != null && (
|
{hasOutput && (
|
||||||
<>
|
<div className="tool-call-section">
|
||||||
<Typography.Text type="secondary">结果:</Typography.Text>
|
<Typography.Text type="secondary">出参</Typography.Text>
|
||||||
<pre className="tool-result-pre">{FORMAT_JSON(toolPart.output)}</pre>
|
<HighlightBlock code={formattedOutput} isStreaming={isStreaming} lang={outputLang} />
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!toolPart.input && !("output" in toolPart) && (
|
{!toolPart.input && !("output" in toolPart) && (
|
||||||
<Typography.Text type="secondary">生成中...</Typography.Text>
|
<Typography.Text type="secondary">生成中...</Typography.Text>
|
||||||
|
|||||||
24
src/web/features/entities/components/EntityCard.tsx
Normal file
24
src/web/features/entities/components/EntityCard.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { Tag, Typography } from "antd";
|
||||||
|
|
||||||
|
import type { Entity } from "../types";
|
||||||
|
|
||||||
|
import { ENTITY_TYPE_COLORS, ENTITY_TYPE_LABELS } from "./constants";
|
||||||
|
|
||||||
|
interface EntityCardProps {
|
||||||
|
entity: Entity;
|
||||||
|
onSelect: () => void;
|
||||||
|
selected: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EntityCard({ entity, onSelect, selected }: EntityCardProps) {
|
||||||
|
const className = selected ? "app-sidebar-list-item app-sidebar-list-item--selected" : "app-sidebar-list-item";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className} onClick={onSelect} style={{ padding: "8px 12px" }}>
|
||||||
|
<Typography.Text ellipsis strong style={{ display: "block", marginBottom: 4 }}>
|
||||||
|
{entity.name}
|
||||||
|
</Typography.Text>
|
||||||
|
<Tag color={ENTITY_TYPE_COLORS[entity.type] ?? "default"}>{ENTITY_TYPE_LABELS[entity.type] ?? entity.type}</Tag>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
114
src/web/features/entities/components/EntityDetailPanel.tsx
Normal file
114
src/web/features/entities/components/EntityDetailPanel.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { DeleteOutlined, EditOutlined } from "@ant-design/icons";
|
||||||
|
import { Card, Descriptions, Empty, Popconfirm, Result, Space, Spin, Tag } from "antd";
|
||||||
|
import "overlayscrollbars/styles/overlayscrollbars.css";
|
||||||
|
import { OverlayScrollbarsComponent } from "overlayscrollbars-react";
|
||||||
|
|
||||||
|
import type { Entity } from "../types";
|
||||||
|
|
||||||
|
import { useDeleteEntity, useEntity } from "../../../shared/hooks/use-entities";
|
||||||
|
import { formatRelativeTime } from "../../../shared/utils/time";
|
||||||
|
import { ENTITY_TYPE_COLORS, ENTITY_TYPE_LABELS } from "./constants";
|
||||||
|
|
||||||
|
interface EntityDetailPanelProps {
|
||||||
|
entityId: null | string;
|
||||||
|
onDelete: (id: string) => void;
|
||||||
|
onEdit: (entity: Entity) => void;
|
||||||
|
projectId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EntityDetailPanel({ entityId, onDelete, onEdit, projectId }: EntityDetailPanelProps) {
|
||||||
|
const { data, error, isLoading } = useEntity({ entityId, projectId });
|
||||||
|
const deleteMutation = useDeleteEntity(projectId);
|
||||||
|
|
||||||
|
if (!entityId) {
|
||||||
|
return (
|
||||||
|
<div className="app-inbox-panel">
|
||||||
|
<OverlayScrollbarsComponent
|
||||||
|
className="app-inbox-content"
|
||||||
|
options={{
|
||||||
|
overflow: { x: "hidden", y: "scroll" },
|
||||||
|
scrollbars: { autoHide: "move", theme: "os-theme-custom" },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Empty description="请在左侧选择实体" />
|
||||||
|
</OverlayScrollbarsComponent>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="app-inbox-panel">
|
||||||
|
<OverlayScrollbarsComponent
|
||||||
|
className="app-inbox-content"
|
||||||
|
options={{
|
||||||
|
overflow: { x: "hidden", y: "scroll" },
|
||||||
|
scrollbars: { autoHide: "move", theme: "os-theme-custom" },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Spin />
|
||||||
|
</OverlayScrollbarsComponent>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !data) {
|
||||||
|
return (
|
||||||
|
<div className="app-inbox-panel">
|
||||||
|
<OverlayScrollbarsComponent
|
||||||
|
className="app-inbox-content"
|
||||||
|
options={{
|
||||||
|
overflow: { x: "hidden", y: "scroll" },
|
||||||
|
scrollbars: { autoHide: "move", theme: "os-theme-custom" },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Result subTitle="加载实体失败" />
|
||||||
|
</OverlayScrollbarsComponent>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
void deleteMutation.mutate({ entityId: data.id, projectId }, { onSuccess: () => onDelete(data.id) });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app-inbox-panel">
|
||||||
|
<OverlayScrollbarsComponent
|
||||||
|
className="app-inbox-content"
|
||||||
|
options={{ overflow: { x: "hidden", y: "scroll" }, scrollbars: { autoHide: "move", theme: "os-theme-custom" } }}
|
||||||
|
>
|
||||||
|
<Card
|
||||||
|
extra={
|
||||||
|
<Space>
|
||||||
|
<EditOutlined onClick={() => onEdit(data)} style={{ cursor: "pointer" }} />
|
||||||
|
<Popconfirm
|
||||||
|
description="删除后相关内容退化为普通文本"
|
||||||
|
okButtonProps={{ danger: true }}
|
||||||
|
okText="删除"
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
title="确认删除该实体?"
|
||||||
|
>
|
||||||
|
<DeleteOutlined style={{ color: "var(--ant-color-error)", cursor: "pointer" }} />
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
size="small"
|
||||||
|
title={data.name}
|
||||||
|
>
|
||||||
|
<Descriptions column={1} size="small">
|
||||||
|
<Descriptions.Item label="类型">
|
||||||
|
<Tag color={ENTITY_TYPE_COLORS[data.type] ?? "default"}>{ENTITY_TYPE_LABELS[data.type] ?? data.type}</Tag>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="描述">{data.description || "暂无描述"}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="别名">
|
||||||
|
{data.aliases.length > 0 ? data.aliases.join("、") : "无"}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="创建时间">{formatRelativeTime(data.createdAt)}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="更新时间">{formatRelativeTime(data.updatedAt)}</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
</Card>
|
||||||
|
</OverlayScrollbarsComponent>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
95
src/web/features/entities/components/EntityFormModal.tsx
Normal file
95
src/web/features/entities/components/EntityFormModal.tsx
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { App as AntApp, Form, Input, Modal, Select } from "antd";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import type { Entity, EntityType } from "../types";
|
||||||
|
import { ENTITY_TYPES } from "../types";
|
||||||
|
|
||||||
|
import { ENTITY_TYPE_LABELS } from "./constants";
|
||||||
|
|
||||||
|
interface EntityFormModalProps {
|
||||||
|
editingEntity: Entity | null;
|
||||||
|
onCancel: () => void;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onSubmit: (entity: Entity | null, values: FormValues) => Promise<void>;
|
||||||
|
open: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FormValues {
|
||||||
|
aliases: string[];
|
||||||
|
description: string;
|
||||||
|
name: string;
|
||||||
|
type: EntityType;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TYPE_OPTIONS = ENTITY_TYPES.map((t) => ({
|
||||||
|
label: ENTITY_TYPE_LABELS[t],
|
||||||
|
value: t,
|
||||||
|
}));
|
||||||
|
|
||||||
|
export function EntityFormModal({ editingEntity, onCancel, onOpenChange, onSubmit, open }: EntityFormModalProps) {
|
||||||
|
const { message } = AntApp.useApp();
|
||||||
|
const [form] = Form.useForm<FormValues>();
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
if (editingEntity) {
|
||||||
|
form.setFieldsValue({
|
||||||
|
aliases: editingEntity.aliases,
|
||||||
|
description: editingEntity.description,
|
||||||
|
name: editingEntity.name,
|
||||||
|
type: editingEntity.type,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
form.resetFields();
|
||||||
|
}
|
||||||
|
}, [form, open, editingEntity]);
|
||||||
|
|
||||||
|
const handleFinish = async (values: FormValues) => {
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await onSubmit(editingEntity, values);
|
||||||
|
message.success(editingEntity ? "实体已更新" : "实体已创建");
|
||||||
|
onOpenChange(false);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
message.error(`操作失败:${e instanceof Error ? e.message : "未知错误"}`);
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
confirmLoading={submitting}
|
||||||
|
destroyOnHidden
|
||||||
|
okText="确定"
|
||||||
|
onCancel={() => {
|
||||||
|
onCancel();
|
||||||
|
onOpenChange(false);
|
||||||
|
}}
|
||||||
|
onOk={() => void form.submit()}
|
||||||
|
open={open}
|
||||||
|
title={editingEntity ? "编辑实体" : "新增实体"}
|
||||||
|
>
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
initialValues={{ aliases: [], description: "", type: "other" as EntityType }}
|
||||||
|
layout="vertical"
|
||||||
|
onFinish={(values) => void handleFinish(values)}
|
||||||
|
>
|
||||||
|
<Form.Item label="名称" name="name" rules={[{ message: "请输入实体名称", required: true, whitespace: true }]}>
|
||||||
|
<Input maxLength={100} placeholder="实体名称" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="类型" name="type" rules={[{ message: "请选择类型", required: true }]}>
|
||||||
|
<Select options={TYPE_OPTIONS} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="描述" name="description">
|
||||||
|
<Input.TextArea maxLength={500} placeholder="实体描述" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="别名" name="aliases">
|
||||||
|
<Select mode="tags" placeholder="输入别名后按回车" />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
85
src/web/features/entities/components/EntityList.tsx
Normal file
85
src/web/features/entities/components/EntityList.tsx
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { PlusOutlined } from "@ant-design/icons";
|
||||||
|
import { Button, Empty, Segmented, Skeleton } from "antd";
|
||||||
|
import "overlayscrollbars/styles/overlayscrollbars.css";
|
||||||
|
import { OverlayScrollbarsComponent } from "overlayscrollbars-react";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
|
||||||
|
import type { Entity } from "../types";
|
||||||
|
import { ENTITY_TYPES } from "../types";
|
||||||
|
|
||||||
|
import { SidebarGroup } from "../../../shared/components/SidebarGroup";
|
||||||
|
import { GROUP_LABELS, groupByDate } from "../../../shared/utils/date-group";
|
||||||
|
import { ENTITY_TYPE_LABELS } from "./constants";
|
||||||
|
import { EntityCard } from "./EntityCard";
|
||||||
|
|
||||||
|
interface EntityListProps {
|
||||||
|
entities: readonly Entity[];
|
||||||
|
loading: boolean;
|
||||||
|
onAddClick: () => void;
|
||||||
|
onSelect: (id: string) => void;
|
||||||
|
selectedId: null | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EntityList({ entities, loading, onAddClick, onSelect, selectedId }: EntityListProps) {
|
||||||
|
const [filterType, setFilterType] = useState<string>("all");
|
||||||
|
|
||||||
|
const filteredEntities = useMemo(() => {
|
||||||
|
if (filterType === "all") return entities;
|
||||||
|
return entities.filter((e) => e.type === filterType);
|
||||||
|
}, [entities, filterType]);
|
||||||
|
|
||||||
|
const groupedEntities = useMemo(() => groupByDate(filteredEntities, "createdAt"), [filteredEntities]);
|
||||||
|
|
||||||
|
const segmentedOptions = useMemo(
|
||||||
|
() => [
|
||||||
|
{ label: "全部", value: "all" },
|
||||||
|
...ENTITY_TYPES.map((t) => ({
|
||||||
|
label: ENTITY_TYPE_LABELS[t],
|
||||||
|
value: t,
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app-sidebar-list" style={{ width: 260 }}>
|
||||||
|
<div className="app-sidebar-list-header">
|
||||||
|
<Button block icon={<PlusOutlined />} onClick={onAddClick} type="primary">
|
||||||
|
新增实体
|
||||||
|
</Button>
|
||||||
|
<Segmented block onChange={(value) => setFilterType(value)} options={segmentedOptions} value={filterType} />
|
||||||
|
</div>
|
||||||
|
<OverlayScrollbarsComponent
|
||||||
|
className="app-sidebar-list-body"
|
||||||
|
options={{
|
||||||
|
overflow: { x: "hidden", y: "scroll" },
|
||||||
|
scrollbars: { autoHide: "move", theme: "os-theme-custom" },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<Skeleton active paragraph={{ rows: 6 }} title={false} />
|
||||||
|
) : entities.length === 0 ? (
|
||||||
|
<Empty description="暂无实体" image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||||
|
) : filteredEntities.length === 0 ? (
|
||||||
|
<Empty description="当前筛选条件下无实体" image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||||
|
) : (
|
||||||
|
groupedEntities.map((group) => {
|
||||||
|
if (group.items.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<SidebarGroup count={group.items.length} key={group.key} label={GROUP_LABELS[group.key]}>
|
||||||
|
{group.items.map((entity) => (
|
||||||
|
<EntityCard
|
||||||
|
entity={entity}
|
||||||
|
key={entity.id}
|
||||||
|
onSelect={() => onSelect(entity.id)}
|
||||||
|
selected={entity.id === selectedId}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SidebarGroup>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</OverlayScrollbarsComponent>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
src/web/features/entities/components/constants.ts
Normal file
23
src/web/features/entities/components/constants.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import type { EntityType } from "../types";
|
||||||
|
|
||||||
|
export const ENTITY_TYPE_LABELS: Record<EntityType, string> = {
|
||||||
|
feature: "功能/模块",
|
||||||
|
issue: "问题/风险",
|
||||||
|
organization: "组织",
|
||||||
|
other: "其他",
|
||||||
|
person: "人",
|
||||||
|
requirement: "需求",
|
||||||
|
system: "系统/软件",
|
||||||
|
term: "术语/概念",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ENTITY_TYPE_COLORS: Record<EntityType, string> = {
|
||||||
|
feature: "blue",
|
||||||
|
issue: "red",
|
||||||
|
organization: "purple",
|
||||||
|
other: "default",
|
||||||
|
person: "green",
|
||||||
|
requirement: "orange",
|
||||||
|
system: "cyan",
|
||||||
|
term: "geekblue",
|
||||||
|
};
|
||||||
82
src/web/features/entities/index.tsx
Normal file
82
src/web/features/entities/index.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import type { Entity } from "./types";
|
||||||
|
|
||||||
|
import { useCurrentProject } from "../../shared/hooks/use-current-project";
|
||||||
|
import { useCreateEntity, useDeleteEntity, useEntityList, useUpdateEntity } from "../../shared/hooks/use-entities";
|
||||||
|
import { EntityDetailPanel } from "./components/EntityDetailPanel";
|
||||||
|
import { type FormValues, EntityFormModal } from "./components/EntityFormModal";
|
||||||
|
import { EntityList } from "./components/EntityList";
|
||||||
|
|
||||||
|
export function EntitiesPage() {
|
||||||
|
const project = useCurrentProject();
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [selectedId, setSelectedId] = useState<null | string>(null);
|
||||||
|
const [editingEntity, setEditingEntity] = useState<Entity | null>(null);
|
||||||
|
|
||||||
|
const { data, isLoading } = useEntityList(project.id, { pageSize: 200 });
|
||||||
|
const createMutation = useCreateEntity(project.id);
|
||||||
|
const updateMutation = useUpdateEntity(project.id);
|
||||||
|
const deleteMutation = useDeleteEntity(project.id);
|
||||||
|
|
||||||
|
const handleEdit = (entity: Entity) => {
|
||||||
|
setEditingEntity(entity);
|
||||||
|
setModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (existing: Entity | null, values: FormValues) => {
|
||||||
|
if (existing) {
|
||||||
|
await updateMutation.mutateAsync({
|
||||||
|
body: {
|
||||||
|
aliases: values.aliases,
|
||||||
|
description: values.description,
|
||||||
|
name: values.name,
|
||||||
|
type: values.type,
|
||||||
|
},
|
||||||
|
entityId: existing.id,
|
||||||
|
projectId: project.id,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const entity = await createMutation.mutateAsync({
|
||||||
|
body: {
|
||||||
|
aliases: values.aliases,
|
||||||
|
description: values.description,
|
||||||
|
name: values.name,
|
||||||
|
type: values.type,
|
||||||
|
},
|
||||||
|
projectId: project.id,
|
||||||
|
});
|
||||||
|
setSelectedId(entity.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (id: string) => {
|
||||||
|
void deleteMutation.mutate({ entityId: id, projectId: project.id });
|
||||||
|
if (selectedId === id) {
|
||||||
|
setSelectedId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app-inbox-page">
|
||||||
|
<EntityList
|
||||||
|
entities={data?.items ?? []}
|
||||||
|
loading={isLoading}
|
||||||
|
onAddClick={() => {
|
||||||
|
setEditingEntity(null);
|
||||||
|
setModalOpen(true);
|
||||||
|
}}
|
||||||
|
onSelect={setSelectedId}
|
||||||
|
selectedId={selectedId}
|
||||||
|
/>
|
||||||
|
<EntityDetailPanel entityId={selectedId} onDelete={handleDelete} onEdit={handleEdit} projectId={project.id} />
|
||||||
|
<EntityFormModal
|
||||||
|
editingEntity={editingEntity}
|
||||||
|
onCancel={() => setEditingEntity(null)}
|
||||||
|
onOpenChange={setModalOpen}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
open={modalOpen}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
2
src/web/features/entities/types.ts
Normal file
2
src/web/features/entities/types.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export type { Entity, EntityType } from "../../../shared/api";
|
||||||
|
export { ENTITY_TYPES } from "../../../shared/api";
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { App as AntApp, DatePicker, Form, Input, Modal } from "antd";
|
import { App as AntApp, DatePicker, Form, Input, Modal, Select } from "antd";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
import type { CreateMaterialRequest, Material } from "../types";
|
import type { CreateMaterialRequest, Material, MaterialType } from "../types";
|
||||||
|
|
||||||
interface AddMaterialModalProps {
|
interface AddMaterialModalProps {
|
||||||
onAdd: (body: CreateMaterialRequest) => Promise<Material>;
|
onAdd: (body: CreateMaterialRequest) => Promise<Material>;
|
||||||
@@ -13,8 +13,14 @@ interface AddMaterialModalProps {
|
|||||||
interface FormValues {
|
interface FormValues {
|
||||||
associatedDate: dayjs.Dayjs;
|
associatedDate: dayjs.Dayjs;
|
||||||
description: string;
|
description: string;
|
||||||
|
materialType: MaterialType;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MATERIAL_TYPE_OPTIONS = [
|
||||||
|
{ label: "通用", value: "general" },
|
||||||
|
{ label: "会议", value: "meeting" },
|
||||||
|
];
|
||||||
|
|
||||||
export function AddMaterialModal({ onAdd, onOpenChange, open }: AddMaterialModalProps) {
|
export function AddMaterialModal({ onAdd, onOpenChange, open }: AddMaterialModalProps) {
|
||||||
const { message } = AntApp.useApp();
|
const { message } = AntApp.useApp();
|
||||||
const [form] = Form.useForm<FormValues>();
|
const [form] = Form.useForm<FormValues>();
|
||||||
@@ -29,6 +35,7 @@ export function AddMaterialModal({ onAdd, onOpenChange, open }: AddMaterialModal
|
|||||||
const body: CreateMaterialRequest = {
|
const body: CreateMaterialRequest = {
|
||||||
associatedDate: values.associatedDate.format("YYYY-MM-DD"),
|
associatedDate: values.associatedDate.format("YYYY-MM-DD"),
|
||||||
description: values.description,
|
description: values.description,
|
||||||
|
materialType: values.materialType,
|
||||||
};
|
};
|
||||||
|
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
@@ -55,7 +62,7 @@ export function AddMaterialModal({ onAdd, onOpenChange, open }: AddMaterialModal
|
|||||||
>
|
>
|
||||||
<Form
|
<Form
|
||||||
form={form}
|
form={form}
|
||||||
initialValues={{ associatedDate: dayjs() }}
|
initialValues={{ associatedDate: dayjs(), materialType: "general" as MaterialType }}
|
||||||
layout="vertical"
|
layout="vertical"
|
||||||
onFinish={(values) => void handleFinish(values)}
|
onFinish={(values) => void handleFinish(values)}
|
||||||
>
|
>
|
||||||
@@ -69,6 +76,9 @@ export function AddMaterialModal({ onAdd, onOpenChange, open }: AddMaterialModal
|
|||||||
<Form.Item label="关联时间" name="associatedDate" rules={[{ message: "请选择关联时间", required: true }]}>
|
<Form.Item label="关联时间" name="associatedDate" rules={[{ message: "请选择关联时间", required: true }]}>
|
||||||
<DatePicker className="app-inbox-datepicker" />
|
<DatePicker className="app-inbox-datepicker" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item label="素材类型" name="materialType" rules={[{ message: "请选择素材类型", required: true }]}>
|
||||||
|
<Select options={MATERIAL_TYPE_OPTIONS} />
|
||||||
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
|
|||||||
138
src/web/features/inbox/components/EntityCandidatePanel.tsx
Normal file
138
src/web/features/inbox/components/EntityCandidatePanel.tsx
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import type { CandidateEntity, EntityConfirmation } from "../../../../shared/api";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import { Badge, Button, Card, Flex, Modal, Select, Space, Tag, Typography } from "antd";
|
||||||
|
|
||||||
|
import { useEntityList } from "../../../shared/hooks/use-entities";
|
||||||
|
|
||||||
|
interface EntityCandidatePanelProps {
|
||||||
|
candidates: CandidateEntity[];
|
||||||
|
projectId: string;
|
||||||
|
onConfirmationsChange: (confirmations: EntityConfirmation[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EntityCandidatePanel({ candidates, projectId, onConfirmationsChange }: EntityCandidatePanelProps) {
|
||||||
|
const [confirmations, setConfirmations] = useState<Map<number, EntityConfirmation>>(new Map());
|
||||||
|
const [selectingIndex, setSelectingIndex] = useState<number | null>(null);
|
||||||
|
const [selectValue, setSelectValue] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { data: entityList } = useEntityList(projectId, { pageSize: 200 });
|
||||||
|
|
||||||
|
const handleAction = (index: number, action: EntityConfirmation["action"], targetEntityId?: string) => {
|
||||||
|
const next = new Map(confirmations);
|
||||||
|
if (action === "discard") {
|
||||||
|
next.set(index, { action: "discard", candidateIndex: index });
|
||||||
|
} else if (action === "merge" && targetEntityId) {
|
||||||
|
next.set(index, { action: "merge", candidateIndex: index, targetEntityId });
|
||||||
|
} else if (action === "create") {
|
||||||
|
next.set(index, { action: "create", candidateIndex: index });
|
||||||
|
} else if (action === "select" && targetEntityId) {
|
||||||
|
next.set(index, { action: "select", candidateIndex: index, targetEntityId });
|
||||||
|
}
|
||||||
|
setConfirmations(next);
|
||||||
|
onConfirmationsChange(Array.from(next.values()));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectConfirm = () => {
|
||||||
|
if (selectingIndex !== null && selectValue) {
|
||||||
|
handleAction(selectingIndex, "select", selectValue);
|
||||||
|
}
|
||||||
|
setSelectingIndex(null);
|
||||||
|
setSelectValue(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!candidates || candidates.length === 0) return null;
|
||||||
|
|
||||||
|
const entityOptions = (entityList?.items ?? []).map((e) => ({
|
||||||
|
label: `${e.name}${e.aliases.length > 0 ? ` (${e.aliases.join("、")})` : ""}`,
|
||||||
|
value: e.id,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card size="small" title="候选实体">
|
||||||
|
<Flex gap={8} vertical>
|
||||||
|
{candidates.map((candidate, index) => {
|
||||||
|
const conf = confirmations.get(index);
|
||||||
|
return (
|
||||||
|
<Card key={index} size="small" type="inner">
|
||||||
|
<Flex align="center" gap={8} justify="space-between" wrap="wrap">
|
||||||
|
<Flex align="center" gap={8}>
|
||||||
|
<Typography.Text strong>{candidate.name}</Typography.Text>
|
||||||
|
<Tag>{candidate.type}</Tag>
|
||||||
|
{candidate.matchedEntityId && <Badge color="blue" text="有匹配" />}
|
||||||
|
</Flex>
|
||||||
|
<Space size="small">
|
||||||
|
{candidate.matchedEntityId && (
|
||||||
|
<Button
|
||||||
|
onClick={() => handleAction(index, "merge", candidate.matchedEntityId ?? undefined)}
|
||||||
|
size="small"
|
||||||
|
type={conf?.action === "merge" ? "primary" : "default"}
|
||||||
|
>
|
||||||
|
合并
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
onClick={() => handleAction(index, "create")}
|
||||||
|
size="small"
|
||||||
|
type={conf?.action === "create" ? "primary" : "default"}
|
||||||
|
>
|
||||||
|
新建
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setSelectingIndex(index);
|
||||||
|
setSelectValue(candidate.matchedEntityId);
|
||||||
|
}}
|
||||||
|
size="small"
|
||||||
|
type={conf?.action === "select" ? "primary" : "default"}
|
||||||
|
>
|
||||||
|
选择
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
danger={conf?.action === "discard"}
|
||||||
|
onClick={() => handleAction(index, "discard")}
|
||||||
|
size="small"
|
||||||
|
type={conf?.action === "discard" ? "primary" : "default"}
|
||||||
|
>
|
||||||
|
放弃
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Flex>
|
||||||
|
{candidate.context && (
|
||||||
|
<Typography.Paragraph
|
||||||
|
ellipsis={{ rows: 2 }}
|
||||||
|
style={{ color: "var(--ant-color-text-secondary)", fontSize: 12, margin: "4px 0 0 0" }}
|
||||||
|
>
|
||||||
|
原文:{candidate.context}
|
||||||
|
</Typography.Paragraph>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Flex>
|
||||||
|
</Card>
|
||||||
|
<Modal
|
||||||
|
onCancel={() => {
|
||||||
|
setSelectingIndex(null);
|
||||||
|
setSelectValue(null);
|
||||||
|
}}
|
||||||
|
onOk={handleSelectConfirm}
|
||||||
|
open={selectingIndex !== null}
|
||||||
|
title="选择已有实体"
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
filterOption={(input, option) => String(option?.label ?? "").includes(input)}
|
||||||
|
onChange={setSelectValue}
|
||||||
|
options={entityOptions}
|
||||||
|
placeholder="搜索实体名称"
|
||||||
|
showSearch
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
value={selectValue}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,12 +13,16 @@ interface MaterialCardProps {
|
|||||||
const STATUS_MAP: Record<MaterialStatus, { color: string; label: string }> = {
|
const STATUS_MAP: Record<MaterialStatus, { color: string; label: string }> = {
|
||||||
approved: { color: "green", label: "已通过" },
|
approved: { color: "green", label: "已通过" },
|
||||||
discarded: { color: "red", label: "已放弃" },
|
discarded: { color: "red", label: "已放弃" },
|
||||||
pending: { color: "gold", label: "待审核" },
|
failed: { color: "magenta", label: "失败" },
|
||||||
|
pending: { color: "gold", label: "待处理" },
|
||||||
|
processing: { color: "blue", label: "处理中" },
|
||||||
|
review: { color: "orange", label: "待审核" },
|
||||||
};
|
};
|
||||||
|
|
||||||
export function MaterialCard({ material, onDelete, onSelect, selected }: MaterialCardProps) {
|
export function MaterialCard({ material, onDelete, onSelect, selected }: MaterialCardProps) {
|
||||||
const statusInfo = STATUS_MAP[material.status];
|
const statusInfo = STATUS_MAP[material.status];
|
||||||
const className = selected ? "material-list-item material-list-item--selected" : "material-list-item";
|
const className = selected ? "app-sidebar-list-item app-sidebar-list-item--selected" : "app-sidebar-list-item";
|
||||||
|
const canDelete = material.status !== "processing";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex align="center" className={className} gap="small" justify="space-between" onClick={onSelect}>
|
<Flex align="center" className={className} gap="small" justify="space-between" onClick={onSelect}>
|
||||||
@@ -34,26 +38,28 @@ export function MaterialCard({ material, onDelete, onSelect, selected }: Materia
|
|||||||
{statusInfo && <Tag color={statusInfo.color}>{statusInfo.label}</Tag>}
|
{statusInfo && <Tag color={statusInfo.color}>{statusInfo.label}</Tag>}
|
||||||
</span>
|
</span>
|
||||||
<span className="material-item-actions">
|
<span className="material-item-actions">
|
||||||
<Popconfirm
|
{canDelete && (
|
||||||
description="删除后不可恢复"
|
<Popconfirm
|
||||||
okButtonProps={{ danger: true }}
|
description="删除后不可恢复"
|
||||||
okText="删除"
|
okButtonProps={{ danger: true }}
|
||||||
onCancel={(e) => e?.stopPropagation()}
|
okText="删除"
|
||||||
onConfirm={(e) => {
|
onCancel={(e) => e?.stopPropagation()}
|
||||||
e?.stopPropagation();
|
onConfirm={(e) => {
|
||||||
onDelete();
|
e?.stopPropagation();
|
||||||
}}
|
onDelete();
|
||||||
title="确认删除该素材?"
|
}}
|
||||||
>
|
title="确认删除该素材?"
|
||||||
<Button
|
>
|
||||||
aria-label="删除"
|
<Button
|
||||||
danger
|
aria-label="删除"
|
||||||
icon={<DeleteOutlined />}
|
danger
|
||||||
onClick={(e) => e.stopPropagation()}
|
icon={<DeleteOutlined />}
|
||||||
size="small"
|
onClick={(e) => e.stopPropagation()}
|
||||||
type="text"
|
size="small"
|
||||||
/>
|
type="text"
|
||||||
</Popconfirm>
|
/>
|
||||||
|
</Popconfirm>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|||||||
@@ -1,35 +1,115 @@
|
|||||||
import { Card, Descriptions, Tag, Typography } from "antd";
|
import { Card, Descriptions, Flex, Tag, Typography } from "antd";
|
||||||
|
|
||||||
import type { Material } from "../types";
|
import type { EntityConfirmation, ProcessingResult } from "../../../../shared/api";
|
||||||
|
import type { Material, MaterialType } from "../types";
|
||||||
|
|
||||||
import { formatRelativeTime } from "../../../shared/utils/time";
|
import { formatRelativeTime } from "../../../shared/utils/time";
|
||||||
|
import { STATUS_MAP } from "./constants";
|
||||||
|
import { EntityCandidatePanel } from "./EntityCandidatePanel";
|
||||||
|
|
||||||
interface MaterialContentProps {
|
interface MaterialContentProps {
|
||||||
material: Material;
|
material: Material;
|
||||||
|
projectId?: string;
|
||||||
|
onConfirmationsChange?: (confirmations: EntityConfirmation[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_MAP: Record<string, { color: string; label: string }> = {
|
const MATERIAL_TYPE_LABELS: Record<MaterialType, string> = {
|
||||||
approved: { color: "green", label: "已通过" },
|
general: "通用",
|
||||||
discarded: { color: "red", label: "已放弃" },
|
meeting: "会议",
|
||||||
pending: { color: "gold", label: "待审核" },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function MaterialContent({ material }: MaterialContentProps) {
|
function parseProcessingResult(raw: null | string): ProcessingResult | null {
|
||||||
|
if (!raw) return null;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as Partial<ProcessingResult>;
|
||||||
|
if (parsed && typeof parsed === "object") {
|
||||||
|
return {
|
||||||
|
candidateEntities: Array.isArray(parsed.candidateEntities) ? parsed.candidateEntities : [],
|
||||||
|
normalizedContent: typeof parsed.normalizedContent === "string" ? parsed.normalizedContent : "",
|
||||||
|
summary: typeof parsed.summary === "string" ? parsed.summary : "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MaterialContent({ material, projectId, onConfirmationsChange }: MaterialContentProps) {
|
||||||
const statusInfo = STATUS_MAP[material.status] ?? { color: "default", label: material.status };
|
const statusInfo = STATUS_MAP[material.status] ?? { color: "default", label: material.status };
|
||||||
|
const typeLabel = MATERIAL_TYPE_LABELS[material.materialType] ?? material.materialType;
|
||||||
|
const processingResult = parseProcessingResult(material.processedContent);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-inbox-content">
|
<Flex gap={12} vertical>
|
||||||
<Typography.Title level={4}>素材详情</Typography.Title>
|
<Card size="small" title="原始文本">
|
||||||
<Card>
|
|
||||||
<Typography.Paragraph>{material.description}</Typography.Paragraph>
|
<Typography.Paragraph>{material.description}</Typography.Paragraph>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{processingResult && (
|
||||||
|
<>
|
||||||
|
<Card size="small" title="AI 摘要">
|
||||||
|
<Typography.Paragraph>{processingResult.summary}</Typography.Paragraph>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card size="small" title="规范化内容">
|
||||||
|
<Typography.Paragraph
|
||||||
|
style={{
|
||||||
|
background: "var(--ant-color-fill-quaternary)",
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: 12,
|
||||||
|
whiteSpace: "pre-wrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{processingResult.normalizedContent}
|
||||||
|
</Typography.Paragraph>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{material.status === "review" && projectId && onConfirmationsChange && (
|
||||||
|
<EntityCandidatePanel
|
||||||
|
candidates={processingResult.candidateEntities}
|
||||||
|
projectId={projectId}
|
||||||
|
onConfirmationsChange={onConfirmationsChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{material.status !== "review" && processingResult.candidateEntities.length > 0 && (
|
||||||
|
<Card size="small" title="候选实体(已确认)">
|
||||||
|
<Flex gap={4} wrap="wrap">
|
||||||
|
{processingResult.candidateEntities.map((ce: { name: string }, i: number) => (
|
||||||
|
<Tag key={i}>{ce.name}</Tag>
|
||||||
|
))}
|
||||||
|
</Flex>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!processingResult && material.processedContent && (
|
||||||
|
<Card size="small" title="处理结果">
|
||||||
|
<Typography.Paragraph
|
||||||
|
style={{
|
||||||
|
background: "var(--ant-color-fill-quaternary)",
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: 12,
|
||||||
|
whiteSpace: "pre-wrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{material.processedContent}
|
||||||
|
</Typography.Paragraph>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card size="small" title="基本信息">
|
||||||
<Descriptions column={1} size="small">
|
<Descriptions column={1} size="small">
|
||||||
<Descriptions.Item label="状态">
|
<Descriptions.Item label="状态">
|
||||||
<Tag color={statusInfo.color}>{statusInfo.label}</Tag>
|
<Tag color={statusInfo.color}>{statusInfo.label}</Tag>
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="素材类型">{typeLabel}</Descriptions.Item>
|
||||||
<Descriptions.Item label="关联时间">{material.associatedDate}</Descriptions.Item>
|
<Descriptions.Item label="关联时间">{material.associatedDate}</Descriptions.Item>
|
||||||
<Descriptions.Item label="创建时间">{formatRelativeTime(material.createdAt)}</Descriptions.Item>
|
<Descriptions.Item label="创建时间">{formatRelativeTime(material.createdAt)}</Descriptions.Item>
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</Flex>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,51 +1,160 @@
|
|||||||
import { Empty, Result, Spin } from "antd";
|
import { CheckOutlined, CloseOutlined, RedoOutlined } from "@ant-design/icons";
|
||||||
|
import "overlayscrollbars/styles/overlayscrollbars.css";
|
||||||
|
import { App as AntApp, Button, Empty, Result, Space, Spin, Typography } from "antd";
|
||||||
|
import { OverlayScrollbarsComponent } from "overlayscrollbars-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import type { EntityConfirmation } from "../../../../shared/api";
|
||||||
|
|
||||||
import { useMaterial } from "../../../shared/hooks/use-materials";
|
import { useMaterial } from "../../../shared/hooks/use-materials";
|
||||||
import { MaterialContent } from "./MaterialContent";
|
import { MaterialContent } from "./MaterialContent";
|
||||||
|
|
||||||
|
const OS_OPTIONS = {
|
||||||
|
overflow: { x: "hidden", y: "scroll" },
|
||||||
|
scrollbars: { autoHide: "move", theme: "os-theme-custom" },
|
||||||
|
} as const;
|
||||||
|
|
||||||
interface MaterialDetailPanelProps {
|
interface MaterialDetailPanelProps {
|
||||||
materialId: null | string;
|
materialId: null | string;
|
||||||
|
onApprove: (materialId: string, entityConfirmations: EntityConfirmation[]) => Promise<void>;
|
||||||
|
onDiscard: (materialId: string) => Promise<void>;
|
||||||
|
onRetry: (materialId: string) => Promise<void>;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MaterialDetailPanel({ materialId, projectId }: MaterialDetailPanelProps) {
|
export function MaterialDetailPanel({
|
||||||
|
materialId,
|
||||||
|
onApprove,
|
||||||
|
onDiscard,
|
||||||
|
onRetry,
|
||||||
|
projectId,
|
||||||
|
}: MaterialDetailPanelProps) {
|
||||||
if (!materialId) {
|
if (!materialId) {
|
||||||
return (
|
return (
|
||||||
<div className="app-inbox-content">
|
<div className="app-inbox-panel">
|
||||||
<Empty description="请在左侧选择素材" />
|
<OverlayScrollbarsComponent className="app-inbox-content" options={OS_OPTIONS}>
|
||||||
|
<Empty description="请在左侧选择素材" />
|
||||||
|
</OverlayScrollbarsComponent>
|
||||||
|
<div className="app-inbox-action-bar">
|
||||||
|
<Typography.Text style={{ flex: 1, textAlign: "center", color: "var(--ant-color-text-tertiary)" }}>
|
||||||
|
请先选择素材
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <MaterialDetailPanelInner materialId={materialId} projectId={projectId} />;
|
return (
|
||||||
|
<MaterialDetailPanelInner
|
||||||
|
materialId={materialId}
|
||||||
|
onApprove={onApprove}
|
||||||
|
onDiscard={onDiscard}
|
||||||
|
onRetry={onRetry}
|
||||||
|
projectId={projectId}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MaterialDetailPanelInner({ materialId, projectId }: MaterialDetailPanelProps) {
|
function MaterialDetailPanelInner({ materialId, onApprove, onDiscard, onRetry, projectId }: MaterialDetailPanelProps) {
|
||||||
const { data, error, isLoading } = useMaterial({ materialId, projectId });
|
const { data, error, isLoading } = useMaterial({ materialId, projectId });
|
||||||
|
const { message } = AntApp.useApp();
|
||||||
|
const [entityConfirmations, setEntityConfirmations] = useState<EntityConfirmation[]>([]);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="app-inbox-content">
|
<div className="app-inbox-panel">
|
||||||
<Spin />
|
<OverlayScrollbarsComponent className="app-inbox-content" options={OS_OPTIONS}>
|
||||||
|
<Spin />
|
||||||
|
</OverlayScrollbarsComponent>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="app-inbox-content">
|
<div className="app-inbox-panel">
|
||||||
<Result subTitle="加载素材详情失败" />
|
<OverlayScrollbarsComponent className="app-inbox-content" options={OS_OPTIONS}>
|
||||||
|
<Result subTitle="加载素材详情失败" />
|
||||||
|
</OverlayScrollbarsComponent>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data) {
|
if (!data || !materialId) {
|
||||||
return (
|
return (
|
||||||
<div className="app-inbox-content">
|
<div className="app-inbox-panel">
|
||||||
<Empty description="请在左侧选择素材" />
|
<OverlayScrollbarsComponent className="app-inbox-content" options={OS_OPTIONS}>
|
||||||
|
<Empty description="请在左侧选择素材" />
|
||||||
|
</OverlayScrollbarsComponent>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <MaterialContent material={data} />;
|
const id = materialId;
|
||||||
|
|
||||||
|
const handleApprove = async () => {
|
||||||
|
try {
|
||||||
|
await onApprove(id, entityConfirmations);
|
||||||
|
message.success("已通过");
|
||||||
|
setEntityConfirmations([]);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
message.error(`操作失败:${e instanceof Error ? e.message : "未知错误"}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDiscard = async () => {
|
||||||
|
try {
|
||||||
|
await onDiscard(id);
|
||||||
|
message.success("已放弃");
|
||||||
|
setEntityConfirmations([]);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
message.error(`操作失败:${e instanceof Error ? e.message : "未知错误"}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRetry = async () => {
|
||||||
|
try {
|
||||||
|
await onRetry(id);
|
||||||
|
message.success("已重新提交处理");
|
||||||
|
} catch (e: unknown) {
|
||||||
|
message.error(`操作失败:${e instanceof Error ? e.message : "未知错误"}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app-inbox-panel">
|
||||||
|
<OverlayScrollbarsComponent className="app-inbox-content" options={OS_OPTIONS}>
|
||||||
|
<MaterialContent material={data} projectId={projectId} onConfirmationsChange={setEntityConfirmations} />
|
||||||
|
</OverlayScrollbarsComponent>
|
||||||
|
<div className="app-inbox-action-bar">
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
disabled={data.status !== "review"}
|
||||||
|
icon={<CheckOutlined />}
|
||||||
|
onClick={() => void handleApprove()}
|
||||||
|
>
|
||||||
|
通过
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
danger
|
||||||
|
disabled={data.status !== "review"}
|
||||||
|
icon={<CloseOutlined />}
|
||||||
|
onClick={() => void handleDiscard()}
|
||||||
|
>
|
||||||
|
放弃
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
disabled={data.status !== "failed"}
|
||||||
|
icon={<RedoOutlined />}
|
||||||
|
onClick={() => void handleRetry()}
|
||||||
|
>
|
||||||
|
重试
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
AppstoreOutlined,
|
AppstoreOutlined,
|
||||||
CheckCircleOutlined,
|
|
||||||
ClockCircleOutlined,
|
ClockCircleOutlined,
|
||||||
CloseCircleOutlined,
|
ExclamationCircleOutlined,
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
|
SyncOutlined,
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
import { Button, Empty, Segmented, Skeleton } from "antd";
|
import { Button, Empty, Segmented, Skeleton } from "antd";
|
||||||
import "overlayscrollbars/styles/overlayscrollbars.css";
|
import "overlayscrollbars/styles/overlayscrollbars.css";
|
||||||
@@ -13,7 +13,6 @@ import { useMemo, useState } from "react";
|
|||||||
import type { Material } from "../types";
|
import type { Material } from "../types";
|
||||||
|
|
||||||
import { SidebarGroup } from "../../../shared/components/SidebarGroup";
|
import { SidebarGroup } from "../../../shared/components/SidebarGroup";
|
||||||
import { useIsDark } from "../../../shared/hooks/use-is-dark";
|
|
||||||
import { GROUP_LABELS, groupByDate } from "../../../shared/utils/date-group";
|
import { GROUP_LABELS, groupByDate } from "../../../shared/utils/date-group";
|
||||||
import { MaterialCard } from "./MaterialCard";
|
import { MaterialCard } from "./MaterialCard";
|
||||||
|
|
||||||
@@ -28,16 +27,15 @@ interface MaterialListProps {
|
|||||||
|
|
||||||
const STATUS_FILTER_OPTIONS = [
|
const STATUS_FILTER_OPTIONS = [
|
||||||
{ icon: <AppstoreOutlined />, label: "全部", value: "all" },
|
{ icon: <AppstoreOutlined />, label: "全部", value: "all" },
|
||||||
{ color: "#faad14", icon: <ClockCircleOutlined />, label: "待审核", value: "pending" },
|
{ color: "#1677ff", icon: <SyncOutlined />, label: "运行中", value: "processing" },
|
||||||
{ color: "#52c41a", icon: <CheckCircleOutlined />, label: "已通过", value: "approved" },
|
{ color: "#fa8c16", icon: <ClockCircleOutlined />, label: "待审核", value: "review" },
|
||||||
{ color: "#ff4d4f", icon: <CloseCircleOutlined />, label: "已放弃", value: "discarded" },
|
{ color: "#ff4d4f", icon: <ExclamationCircleOutlined />, label: "失败", value: "failed" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
type FilterValue = (typeof STATUS_FILTER_OPTIONS)[number]["value"];
|
type FilterValue = (typeof STATUS_FILTER_OPTIONS)[number]["value"];
|
||||||
|
|
||||||
export function MaterialList({ loading, materials, onAddClick, onDelete, onSelect, selectedId }: MaterialListProps) {
|
export function MaterialList({ loading, materials, onAddClick, onDelete, onSelect, selectedId }: MaterialListProps) {
|
||||||
const [filterStatus, setFilterStatus] = useState<FilterValue>("all");
|
const [filterStatus, setFilterStatus] = useState<FilterValue>("all");
|
||||||
const isDark = useIsDark();
|
|
||||||
|
|
||||||
const filteredMaterials = useMemo(() => {
|
const filteredMaterials = useMemo(() => {
|
||||||
if (filterStatus === "all") return materials;
|
if (filterStatus === "all") return materials;
|
||||||
@@ -74,7 +72,7 @@ export function MaterialList({ loading, materials, onAddClick, onDelete, onSelec
|
|||||||
overflow: { x: "hidden", y: "scroll" },
|
overflow: { x: "hidden", y: "scroll" },
|
||||||
scrollbars: {
|
scrollbars: {
|
||||||
autoHide: "move",
|
autoHide: "move",
|
||||||
theme: isDark ? "os-theme-custom-dark" : "os-theme-custom",
|
theme: "os-theme-custom",
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
10
src/web/features/inbox/components/constants.ts
Normal file
10
src/web/features/inbox/components/constants.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import type { MaterialStatus } from "../types";
|
||||||
|
|
||||||
|
export const STATUS_MAP: Record<MaterialStatus, { color: string; label: string }> = {
|
||||||
|
approved: { color: "green", label: "已通过" },
|
||||||
|
discarded: { color: "red", label: "已放弃" },
|
||||||
|
failed: { color: "magenta", label: "失败" },
|
||||||
|
pending: { color: "gold", label: "待处理" },
|
||||||
|
processing: { color: "blue", label: "处理中" },
|
||||||
|
review: { color: "orange", label: "待审核" },
|
||||||
|
};
|
||||||
@@ -1,9 +1,14 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import type { CreateMaterialRequest, Material } from "./types";
|
import type { CreateMaterialRequest, EntityConfirmation, Material } from "./types";
|
||||||
|
|
||||||
import { useCurrentProject } from "../../shared/hooks/use-current-project";
|
import { useCurrentProject } from "../../shared/hooks/use-current-project";
|
||||||
import { useCreateMaterial } from "../../shared/hooks/use-materials";
|
import {
|
||||||
|
useApproveMaterial,
|
||||||
|
useCreateMaterial,
|
||||||
|
useDiscardMaterial,
|
||||||
|
useRetryMaterial,
|
||||||
|
} from "../../shared/hooks/use-materials";
|
||||||
import { AddMaterialModal } from "./components/AddMaterialModal";
|
import { AddMaterialModal } from "./components/AddMaterialModal";
|
||||||
import { MaterialDetailPanel } from "./components/MaterialDetailPanel";
|
import { MaterialDetailPanel } from "./components/MaterialDetailPanel";
|
||||||
import { MaterialSidebar } from "./components/MaterialSidebar";
|
import { MaterialSidebar } from "./components/MaterialSidebar";
|
||||||
@@ -14,6 +19,9 @@ export function InboxPage() {
|
|||||||
const [selectedId, setSelectedId] = useState<null | string>(null);
|
const [selectedId, setSelectedId] = useState<null | string>(null);
|
||||||
|
|
||||||
const createMutation = useCreateMaterial(project.id);
|
const createMutation = useCreateMaterial(project.id);
|
||||||
|
const approveMutation = useApproveMaterial(project.id);
|
||||||
|
const discardMutation = useDiscardMaterial(project.id);
|
||||||
|
const retryMutation = useRetryMaterial(project.id);
|
||||||
|
|
||||||
const handleCreate = async (body: CreateMaterialRequest): Promise<Material> => {
|
const handleCreate = async (body: CreateMaterialRequest): Promise<Material> => {
|
||||||
const material = await createMutation.mutateAsync({ body, projectId: project.id });
|
const material = await createMutation.mutateAsync({ body, projectId: project.id });
|
||||||
@@ -27,6 +35,18 @@ export function InboxPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleApprove = async (materialId: string, entityConfirmations: EntityConfirmation[]) => {
|
||||||
|
await approveMutation.mutateAsync({ entityConfirmations, materialId, projectId: project.id });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDiscard = async (materialId: string) => {
|
||||||
|
await discardMutation.mutateAsync({ materialId, projectId: project.id });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRetry = async (materialId: string) => {
|
||||||
|
await retryMutation.mutateAsync({ materialId, projectId: project.id });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-inbox-page">
|
<div className="app-inbox-page">
|
||||||
<MaterialSidebar
|
<MaterialSidebar
|
||||||
@@ -36,7 +56,13 @@ export function InboxPage() {
|
|||||||
projectId={project.id}
|
projectId={project.id}
|
||||||
selectedId={selectedId}
|
selectedId={selectedId}
|
||||||
/>
|
/>
|
||||||
<MaterialDetailPanel materialId={selectedId} projectId={project.id} />
|
<MaterialDetailPanel
|
||||||
|
materialId={selectedId}
|
||||||
|
onApprove={handleApprove}
|
||||||
|
onDiscard={handleDiscard}
|
||||||
|
onRetry={handleRetry}
|
||||||
|
projectId={project.id}
|
||||||
|
/>
|
||||||
<AddMaterialModal onAdd={handleCreate} onOpenChange={setModalOpen} open={modalOpen} />
|
<AddMaterialModal onAdd={handleCreate} onOpenChange={setModalOpen} open={modalOpen} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1 +1,7 @@
|
|||||||
export type { CreateMaterialRequest, Material, MaterialStatus } from "../../../shared/api";
|
export type {
|
||||||
|
CreateMaterialRequest,
|
||||||
|
EntityConfirmation,
|
||||||
|
Material,
|
||||||
|
MaterialStatus,
|
||||||
|
MaterialType,
|
||||||
|
} from "../../../shared/api";
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { TableColumnsType, TableProps } from "antd";
|
import type { TableColumnsType, TableProps } from "antd";
|
||||||
|
|
||||||
import { DeleteOutlined, EditOutlined, InboxOutlined, LoginOutlined, RedoOutlined } from "@ant-design/icons";
|
import { DeleteOutlined, EditOutlined, InboxOutlined, LoginOutlined, RedoOutlined } from "@ant-design/icons";
|
||||||
import { Button, Popconfirm, Space, Table, Tag } from "antd";
|
import { Button, Popconfirm, Space, Table, Tag, theme } from "antd";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
|
|
||||||
@@ -40,6 +40,7 @@ export function ProjectTable({
|
|||||||
sortOrder,
|
sortOrder,
|
||||||
}: ProjectTableProps) {
|
}: ProjectTableProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { token: themeToken } = theme.useToken();
|
||||||
|
|
||||||
const columns = useMemo<TableColumnsType<Project>>(
|
const columns = useMemo<TableColumnsType<Project>>(
|
||||||
() => [
|
() => [
|
||||||
@@ -60,7 +61,7 @@ export function ProjectTable({
|
|||||||
if (record.status === "archived") {
|
if (record.status === "archived") {
|
||||||
return <Tag>已归档</Tag>;
|
return <Tag>已归档</Tag>;
|
||||||
}
|
}
|
||||||
return <Tag color="blue">进行中</Tag>;
|
return <Tag color={themeToken.colorPrimary}>进行中</Tag>;
|
||||||
},
|
},
|
||||||
title: "状态",
|
title: "状态",
|
||||||
width: 90,
|
width: 90,
|
||||||
@@ -137,7 +138,7 @@ export function ProjectTable({
|
|||||||
width: 260,
|
width: 260,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[navigate, onEdit, onArchive, onRestore, onDelete, sortBy, sortOrder],
|
[navigate, onEdit, onArchive, onRestore, onDelete, sortBy, sortOrder, themeToken.colorPrimary],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleTableChange: TableProps<Project>["onChange"] = (pagination, _filters, sorter) => {
|
const handleTableChange: TableProps<Project>["onChange"] = (pagination, _filters, sorter) => {
|
||||||
|
|||||||
119
src/web/features/settings/components/ModelSettingsCard.tsx
Normal file
119
src/web/features/settings/components/ModelSettingsCard.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { useQueries } from "@tanstack/react-query";
|
||||||
|
import { App as AntApp, Card, Form, Select } from "antd";
|
||||||
|
import { useCallback, useMemo } from "react";
|
||||||
|
|
||||||
|
import type { DefaultModelSettings, ModelCapability } from "../../../../shared/api";
|
||||||
|
import { fetchModelList } from "../../../shared/hooks/use-models";
|
||||||
|
import { useSettings } from "../../../shared/hooks/use-settings";
|
||||||
|
|
||||||
|
interface CapabilityItem {
|
||||||
|
field: keyof DefaultModelSettings;
|
||||||
|
filterCapabilities: ModelCapability[];
|
||||||
|
help?: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CAPABILITY_ITEMS: CapabilityItem[] = [
|
||||||
|
{ field: "text", filterCapabilities: ["text", "reasoning"], help: "同时用于推理场景", label: "文本" },
|
||||||
|
{ field: "imageRecognition", filterCapabilities: ["image-recognition"], label: "图片识别" },
|
||||||
|
{ field: "audioRecognition", filterCapabilities: ["audio-recognition"], label: "音频识别" },
|
||||||
|
{ field: "videoRecognition", filterCapabilities: ["video-recognition"], label: "视频识别" },
|
||||||
|
{ field: "imageGeneration", filterCapabilities: ["image-generation"], label: "图片生成" },
|
||||||
|
{ field: "audioGeneration", filterCapabilities: ["audio-generation"], label: "音频生成" },
|
||||||
|
{ field: "videoGeneration", filterCapabilities: ["video-generation"], label: "视频生成" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SAVE_MESSAGE_KEY = "model-save";
|
||||||
|
|
||||||
|
interface CapabilityModelSelectProps {
|
||||||
|
disabled: boolean;
|
||||||
|
item: CapabilityItem;
|
||||||
|
onChange: (field: keyof DefaultModelSettings, modelId: string | undefined) => void;
|
||||||
|
value: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CapabilityModelSelect({ disabled, item, onChange, value }: CapabilityModelSelectProps) {
|
||||||
|
const queries = useQueries({
|
||||||
|
queries: item.filterCapabilities.map((cap) => ({
|
||||||
|
queryFn: () => fetchModelList({ capabilities: cap, pageSize: 200 }),
|
||||||
|
queryKey: ["models", "list-cap", cap],
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
const isLoading = queries.some((q) => q.isLoading);
|
||||||
|
|
||||||
|
const options = useMemo(() => {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const result: Array<{ label: string; value: string }> = [];
|
||||||
|
for (const q of queries) {
|
||||||
|
for (const model of q.data?.items ?? []) {
|
||||||
|
if (!seen.has(model.id)) {
|
||||||
|
seen.add(model.id);
|
||||||
|
result.push({ label: model.name, value: model.id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, [queries]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form.Item colon={false} help={item.help} label={item.label}>
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
disabled={disabled}
|
||||||
|
loading={isLoading}
|
||||||
|
onChange={(val) => onChange(item.field, val)}
|
||||||
|
options={options}
|
||||||
|
placeholder={isLoading ? "加载中..." : "请选择"}
|
||||||
|
value={value}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModelSettingsCard() {
|
||||||
|
const { message } = AntApp.useApp();
|
||||||
|
const { data: settings, isUpdating, updateSettings } = useSettings();
|
||||||
|
|
||||||
|
const handleChange = useCallback(
|
||||||
|
(field: keyof DefaultModelSettings, modelId: string | undefined) => {
|
||||||
|
const next: DefaultModelSettings = { ...settings?.defaultModels, [field]: modelId ?? null };
|
||||||
|
message.open({ content: "保存中...", duration: 0, key: SAVE_MESSAGE_KEY, type: "loading" });
|
||||||
|
updateSettings(
|
||||||
|
{ defaultModels: next },
|
||||||
|
{
|
||||||
|
onError: () => {
|
||||||
|
void message.open({ content: "保存失败", duration: 2, key: SAVE_MESSAGE_KEY, type: "error" });
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
void message.open({ content: "已保存", duration: 1.5, key: SAVE_MESSAGE_KEY, type: "success" });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[settings?.defaultModels, updateSettings, message],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card title="模型" type="inner">
|
||||||
|
<Form
|
||||||
|
className="settings-form"
|
||||||
|
colon={false}
|
||||||
|
disabled={isUpdating}
|
||||||
|
labelAlign="left"
|
||||||
|
labelCol={{ flex: "120px" }}
|
||||||
|
layout="horizontal"
|
||||||
|
>
|
||||||
|
{CAPABILITY_ITEMS.map((item) => (
|
||||||
|
<CapabilityModelSelect
|
||||||
|
key={item.field}
|
||||||
|
disabled={isUpdating}
|
||||||
|
item={item}
|
||||||
|
onChange={handleChange}
|
||||||
|
value={settings?.defaultModels?.[item.field] ?? undefined}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
83
src/web/features/settings/index.tsx
Normal file
83
src/web/features/settings/index.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { App as AntApp, Card, Form, Radio, Space, Switch } from "antd";
|
||||||
|
|
||||||
|
import type { ThemePreference } from "../../../shared/api";
|
||||||
|
import { useSettings } from "../../shared/hooks/use-settings";
|
||||||
|
import { parseThemePreference, useThemePreference } from "../../shared/hooks/use-theme-preference";
|
||||||
|
import { ModelSettingsCard } from "./components/ModelSettingsCard";
|
||||||
|
|
||||||
|
const THEME_OPTIONS = [
|
||||||
|
{ label: "系统", value: "system" },
|
||||||
|
{ label: "明亮", value: "light" },
|
||||||
|
{ label: "黑暗", value: "dark" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const SAVE_MESSAGE_KEY = "settings-save";
|
||||||
|
|
||||||
|
export function SettingsPage() {
|
||||||
|
const { message } = AntApp.useApp();
|
||||||
|
const { compact, preference, setCompact, setPreference } = useThemePreference();
|
||||||
|
const { isUpdating, updateSettings } = useSettings();
|
||||||
|
|
||||||
|
const handleThemeChange = (value: ThemePreference) => {
|
||||||
|
message.open({ content: "保存中...", duration: 0, key: SAVE_MESSAGE_KEY, type: "loading" });
|
||||||
|
updateSettings(
|
||||||
|
{ theme: value },
|
||||||
|
{
|
||||||
|
onError: () => {
|
||||||
|
void message.open({ content: "保存失败", duration: 2, key: SAVE_MESSAGE_KEY, type: "error" });
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
setPreference(value);
|
||||||
|
void message.open({ content: "已保存", duration: 1.5, key: SAVE_MESSAGE_KEY, type: "success" });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCompactChange = (checked: boolean) => {
|
||||||
|
message.open({ content: "保存中...", duration: 0, key: SAVE_MESSAGE_KEY, type: "loading" });
|
||||||
|
updateSettings(
|
||||||
|
{ compact: checked },
|
||||||
|
{
|
||||||
|
onError: () => {
|
||||||
|
void message.open({ content: "保存失败", duration: 2, key: SAVE_MESSAGE_KEY, type: "error" });
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
setCompact(checked);
|
||||||
|
void message.open({ content: "已保存", duration: 1.5, key: SAVE_MESSAGE_KEY, type: "success" });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Space orientation="vertical" size="middle" style={{ width: "100%" }}>
|
||||||
|
<Card title="主题" type="inner">
|
||||||
|
<Form
|
||||||
|
className="settings-form"
|
||||||
|
colon={false}
|
||||||
|
disabled={isUpdating}
|
||||||
|
labelAlign="left"
|
||||||
|
labelCol={{ flex: "120px" }}
|
||||||
|
layout="horizontal"
|
||||||
|
>
|
||||||
|
<Form.Item colon={false} help="选择跟随系统将自动适配操作系统的深浅色偏好" label="主题模式">
|
||||||
|
<Radio.Group
|
||||||
|
buttonStyle="solid"
|
||||||
|
onChange={(e) => handleThemeChange(parseThemePreference(e.target.value))}
|
||||||
|
optionType="button"
|
||||||
|
options={THEME_OPTIONS.map((o) => ({ label: o.label, value: o.value }))}
|
||||||
|
value={preference}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item colon={false} help="开启后控件间距和高度变小,显示更多内容" label="紧凑模式">
|
||||||
|
<Switch checked={compact} onChange={handleCompactChange} />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
<ModelSettingsCard />
|
||||||
|
</Space>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,11 @@
|
|||||||
import { ApiOutlined, CloudServerOutlined, DashboardOutlined, FolderOutlined, RobotOutlined } from "@ant-design/icons";
|
import {
|
||||||
|
ApiOutlined,
|
||||||
|
CloudServerOutlined,
|
||||||
|
DashboardOutlined,
|
||||||
|
FolderOutlined,
|
||||||
|
RobotOutlined,
|
||||||
|
SettingOutlined,
|
||||||
|
} from "@ant-design/icons";
|
||||||
import { createElement } from "react";
|
import { createElement } from "react";
|
||||||
|
|
||||||
import type { MenuItemConfig } from "../../menu";
|
import type { MenuItemConfig } from "../../menu";
|
||||||
@@ -16,4 +23,5 @@ export const ADMIN_MENU_ITEMS: readonly MenuItemConfig[] = [
|
|||||||
path: "",
|
path: "",
|
||||||
value: "model-management",
|
value: "model-management",
|
||||||
},
|
},
|
||||||
|
{ icon: createElement(SettingOutlined), label: "设置", path: "/settings", value: "settings" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { InboxOutlined, MessageOutlined } from "@ant-design/icons";
|
import { InboxOutlined, MessageOutlined, TeamOutlined } from "@ant-design/icons";
|
||||||
import { createElement } from "react";
|
import { createElement } from "react";
|
||||||
|
|
||||||
import type { MenuItemConfig } from "../../menu";
|
import type { MenuItemConfig } from "../../menu";
|
||||||
@@ -6,6 +6,7 @@ import type { MenuItemConfig } from "../../menu";
|
|||||||
export const WORKBENCH_MENU_ITEMS: readonly MenuItemConfig[] = [
|
export const WORKBENCH_MENU_ITEMS: readonly MenuItemConfig[] = [
|
||||||
{ icon: createElement(MessageOutlined), label: "聊天室", path: "", value: "chat" },
|
{ icon: createElement(MessageOutlined), label: "聊天室", path: "", value: "chat" },
|
||||||
{ icon: createElement(InboxOutlined), label: "收集箱", path: "inbox", value: "inbox" },
|
{ icon: createElement(InboxOutlined), label: "收集箱", path: "inbox", value: "inbox" },
|
||||||
|
{ icon: createElement(TeamOutlined), label: "实体", path: "entities", value: "entities" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export function buildWorkbenchPath(projectId: string, relativePath = ""): string {
|
export function buildWorkbenchPath(projectId: string, relativePath = ""): string {
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ import { Route, Routes } from "react-router";
|
|||||||
|
|
||||||
import { ChatPage } from "./features/chat/ChatPage";
|
import { ChatPage } from "./features/chat/ChatPage";
|
||||||
import { DashboardPage } from "./features/dashboard";
|
import { DashboardPage } from "./features/dashboard";
|
||||||
|
import { EntitiesPage } from "./features/entities";
|
||||||
import { InboxPage } from "./features/inbox";
|
import { InboxPage } from "./features/inbox";
|
||||||
import { ModelListPage } from "./features/models/ModelListPage";
|
import { ModelListPage } from "./features/models/ModelListPage";
|
||||||
import { ProviderListPage } from "./features/models/ProviderListPage";
|
import { ProviderListPage } from "./features/models/ProviderListPage";
|
||||||
import { NotFoundPage } from "./features/not-found";
|
import { NotFoundPage } from "./features/not-found";
|
||||||
import { ProjectsPage } from "./features/projects";
|
import { ProjectsPage } from "./features/projects";
|
||||||
|
import { SettingsPage } from "./features/settings";
|
||||||
import { AdminConsoleLayout } from "./layouts/admin-layout/AdminConsoleLayout";
|
import { AdminConsoleLayout } from "./layouts/admin-layout/AdminConsoleLayout";
|
||||||
import { WorkbenchProjectGate } from "./layouts/workbench-layout/WorkbenchProjectGate";
|
import { WorkbenchProjectGate } from "./layouts/workbench-layout/WorkbenchProjectGate";
|
||||||
import { RouteError } from "./shared/components/RouteError";
|
import { RouteError } from "./shared/components/RouteError";
|
||||||
@@ -19,11 +21,13 @@ export function AppRoutes() {
|
|||||||
<Route element={<ProjectsPage />} path="/projects" />
|
<Route element={<ProjectsPage />} path="/projects" />
|
||||||
<Route element={<ModelListPage />} path="/models" />
|
<Route element={<ModelListPage />} path="/models" />
|
||||||
<Route element={<ProviderListPage />} path="/models/providers" />
|
<Route element={<ProviderListPage />} path="/models/providers" />
|
||||||
|
<Route element={<SettingsPage />} path="/settings" />
|
||||||
</Route>
|
</Route>
|
||||||
<Route element={<WorkbenchProjectGate />} errorElement={<RouteError />} path="/workbench/:projectId">
|
<Route element={<WorkbenchProjectGate />} errorElement={<RouteError />} path="/workbench/:projectId">
|
||||||
<Route element={<ChatPage />} path="" />
|
<Route element={<ChatPage />} path="" />
|
||||||
<Route element={<ChatPage />} path="chat" />
|
<Route element={<ChatPage />} path="chat" />
|
||||||
<Route element={<InboxPage />} path="inbox" />
|
<Route element={<InboxPage />} path="inbox" />
|
||||||
|
<Route element={<EntitiesPage />} path="entities" />
|
||||||
</Route>
|
</Route>
|
||||||
<Route element={<NotFoundPage />} path="*" />
|
<Route element={<NotFoundPage />} path="*" />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { MenuFoldOutlined, MenuUnfoldOutlined } from "@ant-design/icons";
|
import { MenuFoldOutlined, MenuUnfoldOutlined } from "@ant-design/icons";
|
||||||
import { XProvider } from "@ant-design/x";
|
import { XProvider } from "@ant-design/x";
|
||||||
import zhCN_X from "@ant-design/x/locale/zh_CN";
|
import zhCN_X from "@ant-design/x/locale/zh_CN";
|
||||||
import { App as AntApp, Layout, Segmented, theme } from "antd";
|
import { App as AntApp, Layout } from "antd";
|
||||||
import zhCN from "antd/locale/zh_CN";
|
import zhCN from "antd/locale/zh_CN";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
|
||||||
@@ -11,28 +11,22 @@ import { APP } from "../../../../shared/app";
|
|||||||
import { useMeta } from "../../hooks/use-meta";
|
import { useMeta } from "../../hooks/use-meta";
|
||||||
import { useSidebarCollapsed } from "../../hooks/use-sidebar-collapsed";
|
import { useSidebarCollapsed } from "../../hooks/use-sidebar-collapsed";
|
||||||
import { useThemePreference } from "../../hooks/use-theme-preference";
|
import { useThemePreference } from "../../hooks/use-theme-preference";
|
||||||
|
import { buildThemeConfig } from "../../theme/theme-config";
|
||||||
import { Sidebar } from "../Sidebar";
|
import { Sidebar } from "../Sidebar";
|
||||||
import { ConsoleOutlet } from "./ConsoleOutlet";
|
import { ConsoleOutlet } from "./ConsoleOutlet";
|
||||||
|
|
||||||
const { Content, Header, Sider } = Layout;
|
const { Content, Header, Sider } = Layout;
|
||||||
|
|
||||||
const THEME_OPTIONS = [
|
|
||||||
{ label: "系统", value: "system" },
|
|
||||||
{ label: "明亮", value: "light" },
|
|
||||||
{ label: "黑暗", value: "dark" },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export function ConsoleShell({ headerExtra, menuItems, title }: ConsoleShellProps) {
|
export function ConsoleShell({ headerExtra, menuItems, title }: ConsoleShellProps) {
|
||||||
const { effectiveTheme, preference: themePreference, setPreference: setThemePreference } = useThemePreference();
|
const { compact, effectiveTheme } = useThemePreference();
|
||||||
const { collapsed, setCollapsed } = useSidebarCollapsed();
|
const { collapsed, setCollapsed } = useSidebarCollapsed();
|
||||||
const { data: meta } = useMeta();
|
const { data: meta } = useMeta();
|
||||||
|
|
||||||
const versionDisplay = meta?.version ? `v${meta.version}` : null;
|
const versionDisplay = meta?.version ? `v${meta.version}` : null;
|
||||||
const themeAlgorithm = effectiveTheme === "dark" ? theme.darkAlgorithm : theme.defaultAlgorithm;
|
|
||||||
const locale = useMemo(() => ({ ...zhCN, ...zhCN_X }), []);
|
const locale = useMemo(() => ({ ...zhCN, ...zhCN_X }), []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<XProvider locale={locale} theme={{ algorithm: themeAlgorithm }}>
|
<XProvider locale={locale} theme={buildThemeConfig({ compact, effectiveTheme })}>
|
||||||
<AntApp>
|
<AntApp>
|
||||||
<Layout className="app-layout">
|
<Layout className="app-layout">
|
||||||
<Header className="app-header">
|
<Header className="app-header">
|
||||||
@@ -43,14 +37,7 @@ export function ConsoleShell({ headerExtra, menuItems, title }: ConsoleShellProp
|
|||||||
<span className="app-console-title">{title}</span>
|
<span className="app-console-title">{title}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="app-header-right">
|
<div className="app-header-right">{headerExtra}</div>
|
||||||
{headerExtra}
|
|
||||||
<Segmented
|
|
||||||
onChange={(value) => setThemePreference(value)}
|
|
||||||
options={THEME_OPTIONS.map((option) => ({ label: option.label, value: option.value }))}
|
|
||||||
value={themePreference}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Header>
|
</Header>
|
||||||
<Layout>
|
<Layout>
|
||||||
<Sider
|
<Sider
|
||||||
@@ -58,7 +45,7 @@ export function ConsoleShell({ headerExtra, menuItems, title }: ConsoleShellProp
|
|||||||
collapsedWidth={64}
|
collapsedWidth={64}
|
||||||
collapsible
|
collapsible
|
||||||
onCollapse={(c) => setCollapsed(c)}
|
onCollapse={(c) => setCollapsed(c)}
|
||||||
theme="light"
|
theme={effectiveTheme === "dark" ? "dark" : "light"}
|
||||||
trigger={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
trigger={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
||||||
width={232}
|
width={232}
|
||||||
>
|
>
|
||||||
|
|||||||
118
src/web/shared/hooks/use-entities.ts
Normal file
118
src/web/shared/hooks/use-entities.ts
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
CreateEntityRequest,
|
||||||
|
Entity,
|
||||||
|
EntityListResponse,
|
||||||
|
EntityResponse,
|
||||||
|
EntityType,
|
||||||
|
UpdateEntityRequest,
|
||||||
|
} from "../../../shared/api";
|
||||||
|
|
||||||
|
import { handleResponse, handleVoidResponse } from "../utils/api";
|
||||||
|
import { createConsoleLogger } from "../utils/logger";
|
||||||
|
|
||||||
|
const ENTITIES_KEY = ["entities"] as const;
|
||||||
|
const logger = createConsoleLogger();
|
||||||
|
|
||||||
|
export function createEntity(args: { body: CreateEntityRequest; projectId: string }): Promise<Entity> {
|
||||||
|
const response = fetch(`/api/projects/${args.projectId}/entities`, {
|
||||||
|
body: JSON.stringify(args.body),
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
return response.then((r) => handleResponse(r, (data) => (data as EntityResponse).entity));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteEntity(args: { entityId: string; projectId: string }): Promise<void> {
|
||||||
|
const response = fetch(`/api/projects/${args.projectId}/entities/${args.entityId}`, { method: "DELETE" });
|
||||||
|
return response.then(handleVoidResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchEntity(args: { entityId: string; projectId: string }): Promise<Entity> {
|
||||||
|
const response = await fetch(`/api/projects/${args.projectId}/entities/${args.entityId}`);
|
||||||
|
return handleResponse(response, (data) => (data as EntityResponse).entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchEntities(
|
||||||
|
projectId: string,
|
||||||
|
params?: { page?: number; pageSize?: number; type?: EntityType },
|
||||||
|
): Promise<EntityListResponse> {
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
if (params?.page) searchParams.set("page", String(params.page));
|
||||||
|
if (params?.pageSize) searchParams.set("pageSize", String(params.pageSize));
|
||||||
|
if (params?.type) searchParams.set("type", params.type);
|
||||||
|
const qs = searchParams.toString();
|
||||||
|
const url = `/api/projects/${projectId}/entities${qs ? `?${qs}` : ""}`;
|
||||||
|
const response = fetch(url);
|
||||||
|
return response.then((r) => {
|
||||||
|
if (!r.ok) {
|
||||||
|
return r.json().then((body: null | { error?: string }) => {
|
||||||
|
throw new Error(body?.error ?? `HTTP ${r.status}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return r.json() as Promise<EntityListResponse>;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateEntity(args: {
|
||||||
|
body: UpdateEntityRequest;
|
||||||
|
entityId: string;
|
||||||
|
projectId: string;
|
||||||
|
}): Promise<Entity> {
|
||||||
|
const response = fetch(`/api/projects/${args.projectId}/entities/${args.entityId}`, {
|
||||||
|
body: JSON.stringify(args.body),
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
method: "PATCH",
|
||||||
|
});
|
||||||
|
return response.then((r) => handleResponse(r, (data) => (data as EntityResponse).entity));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateEntity(projectId: string) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: createEntity,
|
||||||
|
onSuccess: (data) => {
|
||||||
|
logger.info("实体创建成功", { entityId: data.id, projectId });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: [...ENTITIES_KEY, "list", projectId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteEntity(projectId: string) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: deleteEntity,
|
||||||
|
onSuccess: (_data, variables) => {
|
||||||
|
logger.info("实体删除成功", { entityId: variables.entityId, projectId });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: [...ENTITIES_KEY, "list", projectId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useEntity(args: { entityId: null | string; projectId: string }) {
|
||||||
|
return useQuery({
|
||||||
|
enabled: !!args.entityId,
|
||||||
|
queryFn: () => fetchEntity({ entityId: args.entityId!, projectId: args.projectId }),
|
||||||
|
queryKey: [...ENTITIES_KEY, "detail", args.projectId, args.entityId],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useEntityList(projectId: string, params?: { page?: number; pageSize?: number; type?: EntityType }) {
|
||||||
|
return useQuery({
|
||||||
|
queryFn: () => fetchEntities(projectId, params),
|
||||||
|
queryKey: [...ENTITIES_KEY, "list", projectId, params],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateEntity(projectId: string) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: updateEntity,
|
||||||
|
onSuccess: (data) => {
|
||||||
|
logger.info("实体更新成功", { entityId: data.id, projectId });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: [...ENTITIES_KEY, "list", projectId] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: [...ENTITIES_KEY, "detail", projectId, data.id] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|||||||
|
|
||||||
import type {
|
import type {
|
||||||
CreateMaterialRequest,
|
CreateMaterialRequest,
|
||||||
|
EntityConfirmation,
|
||||||
Material,
|
Material,
|
||||||
MaterialListResponse,
|
MaterialListResponse,
|
||||||
MaterialResponse,
|
MaterialResponse,
|
||||||
@@ -23,11 +24,34 @@ export function createMaterial(args: { body: CreateMaterialRequest; projectId: s
|
|||||||
return response.then((r) => handleResponse(r, (data) => (data as MaterialResponse).material));
|
return response.then((r) => handleResponse(r, (data) => (data as MaterialResponse).material));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function approveMaterial(args: {
|
||||||
|
entityConfirmations?: EntityConfirmation[];
|
||||||
|
materialId: string;
|
||||||
|
projectId: string;
|
||||||
|
}): Promise<Material> {
|
||||||
|
const response = fetch(`/api/projects/${args.projectId}/materials/${args.materialId}/approve`, {
|
||||||
|
body: JSON.stringify({ entityConfirmations: args.entityConfirmations ?? [] }),
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
return response.then((r) => handleResponse(r, (data) => (data as MaterialResponse).material));
|
||||||
|
}
|
||||||
|
|
||||||
export function deleteMaterial(args: { materialId: string; projectId: string }): Promise<void> {
|
export function deleteMaterial(args: { materialId: string; projectId: string }): Promise<void> {
|
||||||
const response = fetch(`/api/projects/${args.projectId}/materials/${args.materialId}`, { method: "DELETE" });
|
const response = fetch(`/api/projects/${args.projectId}/materials/${args.materialId}`, { method: "DELETE" });
|
||||||
return response.then(handleVoidResponse);
|
return response.then(handleVoidResponse);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function discardMaterial(args: { materialId: string; projectId: string }): Promise<Material> {
|
||||||
|
const response = fetch(`/api/projects/${args.projectId}/materials/${args.materialId}/discard`, { method: "POST" });
|
||||||
|
return response.then((r) => handleResponse(r, (data) => (data as MaterialResponse).material));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function retryMaterial(args: { materialId: string; projectId: string }): Promise<Material> {
|
||||||
|
const response = fetch(`/api/projects/${args.projectId}/materials/${args.materialId}/retry`, { method: "POST" });
|
||||||
|
return response.then((r) => handleResponse(r, (data) => (data as MaterialResponse).material));
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchMaterial(args: { materialId: string; projectId: string }): Promise<Material> {
|
export async function fetchMaterial(args: { materialId: string; projectId: string }): Promise<Material> {
|
||||||
const response = await fetch(`/api/projects/${args.projectId}/materials/${args.materialId}`);
|
const response = await fetch(`/api/projects/${args.projectId}/materials/${args.materialId}`);
|
||||||
return handleResponse(response, (data) => (data as MaterialResponse).material);
|
return handleResponse(response, (data) => (data as MaterialResponse).material);
|
||||||
@@ -65,6 +89,18 @@ export function useCreateMaterial(projectId: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useApproveMaterial(projectId: string) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: approveMaterial,
|
||||||
|
onSuccess: (data) => {
|
||||||
|
logger.info("素材通过成功", { materialId: data.id, projectId });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: [...MATERIALS_KEY, "list", projectId] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: [...MATERIALS_KEY, "detail", projectId, data.id] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useDeleteMaterial(projectId: string) {
|
export function useDeleteMaterial(projectId: string) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
@@ -76,6 +112,30 @@ export function useDeleteMaterial(projectId: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useDiscardMaterial(projectId: string) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: discardMaterial,
|
||||||
|
onSuccess: (data) => {
|
||||||
|
logger.info("素材放弃成功", { materialId: data.id, projectId });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: [...MATERIALS_KEY, "list", projectId] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: [...MATERIALS_KEY, "detail", projectId, data.id] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRetryMaterial(projectId: string) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: retryMaterial,
|
||||||
|
onSuccess: (data) => {
|
||||||
|
logger.info("素材重试成功", { materialId: data.id, projectId });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: [...MATERIALS_KEY, "list", projectId] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: [...MATERIALS_KEY, "detail", projectId, data.id] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useMaterial(args: { materialId: null | string; projectId: string }) {
|
export function useMaterial(args: { materialId: null | string; projectId: string }) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
enabled: !!args.materialId,
|
enabled: !!args.materialId,
|
||||||
|
|||||||
44
src/web/shared/hooks/use-settings.ts
Normal file
44
src/web/shared/hooks/use-settings.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import type { SettingsData } from "../../../shared/api";
|
||||||
|
|
||||||
|
export function useSettings() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const query = useQuery({
|
||||||
|
queryFn: fetchSettings,
|
||||||
|
queryKey: ["settings"],
|
||||||
|
staleTime: 60000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: updateSettings,
|
||||||
|
onSuccess: (data) => {
|
||||||
|
queryClient.setQueryData(["settings"], data);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: query.data,
|
||||||
|
error: query.error,
|
||||||
|
isLoading: query.isLoading,
|
||||||
|
isUpdating: mutation.isPending,
|
||||||
|
updateSettings: mutation.mutate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchSettings(): Promise<SettingsData> {
|
||||||
|
const response = await fetch("/api/settings");
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
return response.json() as Promise<SettingsData>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateSettings(data: Partial<SettingsData>): Promise<SettingsData> {
|
||||||
|
const response = await fetch("/api/settings", {
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
method: "PUT",
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
return response.json() as Promise<SettingsData>;
|
||||||
|
}
|
||||||
@@ -1,13 +1,18 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import type { SettingsData } from "../../../shared/api";
|
||||||
|
|
||||||
export type EffectiveTheme = "dark" | "light";
|
export type EffectiveTheme = "dark" | "light";
|
||||||
export type ThemePreference = "dark" | "light" | "system";
|
export type ThemePreference = "dark" | "light" | "system";
|
||||||
|
|
||||||
const PREFERENCE_CHANGE_EVENT = "theme-preference-change";
|
const PREFERENCE_CHANGE_EVENT = "theme-preference-change";
|
||||||
|
const COMPACT_CHANGE_EVENT = "theme-compact-change";
|
||||||
|
|
||||||
export const THEME_PREFERENCE_STORAGE_KEY = "theme.preference";
|
export const THEME_PREFERENCE_STORAGE_KEY = "theme.preference";
|
||||||
export const THEME_MEDIA_QUERY = "(prefers-color-scheme: dark)";
|
export const THEME_MEDIA_QUERY = "(prefers-color-scheme: dark)";
|
||||||
|
|
||||||
|
export const COMPACT_STORAGE_KEY = "theme.compact";
|
||||||
|
|
||||||
export function getSystemPrefersDark(matchMedia: Window["matchMedia"] = window.matchMedia): boolean {
|
export function getSystemPrefersDark(matchMedia: Window["matchMedia"] = window.matchMedia): boolean {
|
||||||
try {
|
try {
|
||||||
return matchMedia(THEME_MEDIA_QUERY).matches;
|
return matchMedia(THEME_MEDIA_QUERY).matches;
|
||||||
@@ -28,6 +33,14 @@ export function readThemePreference(storage: Storage = window.localStorage): The
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function readCompactPreference(storage: Storage = window.localStorage): boolean {
|
||||||
|
try {
|
||||||
|
return storage.getItem(COMPACT_STORAGE_KEY) === "true";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveEffectiveTheme(preference: ThemePreference, systemPrefersDark: boolean): EffectiveTheme {
|
export function resolveEffectiveTheme(preference: ThemePreference, systemPrefersDark: boolean): EffectiveTheme {
|
||||||
if (preference === "dark" || preference === "light") return preference;
|
if (preference === "dark" || preference === "light") return preference;
|
||||||
return systemPrefersDark ? "dark" : "light";
|
return systemPrefersDark ? "dark" : "light";
|
||||||
@@ -35,9 +48,34 @@ export function resolveEffectiveTheme(preference: ThemePreference, systemPrefers
|
|||||||
|
|
||||||
export function useThemePreference() {
|
export function useThemePreference() {
|
||||||
const [preference, setPreferenceState] = useState<ThemePreference>(() => readThemePreference());
|
const [preference, setPreferenceState] = useState<ThemePreference>(() => readThemePreference());
|
||||||
|
const [compact, setCompactState] = useState<boolean>(() => readCompactPreference());
|
||||||
const [systemPrefersDark, setSystemPrefersDark] = useState(() => getSystemPrefersDark());
|
const [systemPrefersDark, setSystemPrefersDark] = useState(() => getSystemPrefersDark());
|
||||||
const effectiveTheme = resolveEffectiveTheme(preference, systemPrefersDark);
|
const effectiveTheme = resolveEffectiveTheme(preference, systemPrefersDark);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
fetch("/api/settings")
|
||||||
|
.then((res) => {
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
return res.json() as Promise<SettingsData>;
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const apiTheme = parseThemePreference(data.theme);
|
||||||
|
setPreferenceState((prev) => (prev !== apiTheme ? apiTheme : prev));
|
||||||
|
writeThemePreference(apiTheme);
|
||||||
|
const apiCompact = typeof data.compact === "boolean" ? data.compact : false;
|
||||||
|
setCompactState((prev) => (prev !== apiCompact ? apiCompact : prev));
|
||||||
|
writeCompactPreference(apiCompact);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// API 不可用时维持 localStorage 缓存值
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const mediaQueryList = window.matchMedia(THEME_MEDIA_QUERY);
|
const mediaQueryList = window.matchMedia(THEME_MEDIA_QUERY);
|
||||||
|
|
||||||
@@ -55,12 +93,26 @@ export function useThemePreference() {
|
|||||||
return () => window.removeEventListener(PREFERENCE_CHANGE_EVENT, handleStorageEvent as EventListener);
|
return () => window.removeEventListener(PREFERENCE_CHANGE_EVENT, handleStorageEvent as EventListener);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleCompactEvent = (event: CustomEvent) => {
|
||||||
|
const next = typeof event.detail === "boolean" ? event.detail : false;
|
||||||
|
setCompactState((prev) => (prev !== next ? next : prev));
|
||||||
|
};
|
||||||
|
window.addEventListener(COMPACT_CHANGE_EVENT, handleCompactEvent as EventListener);
|
||||||
|
return () => window.removeEventListener(COMPACT_CHANGE_EVENT, handleCompactEvent as EventListener);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const setPreference = useCallback((nextPreference: ThemePreference) => {
|
const setPreference = useCallback((nextPreference: ThemePreference) => {
|
||||||
setPreferenceState(nextPreference);
|
setPreferenceState(nextPreference);
|
||||||
writeThemePreference(nextPreference);
|
writeThemePreference(nextPreference);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return { effectiveTheme, preference, setPreference };
|
const setCompact = useCallback((nextCompact: boolean) => {
|
||||||
|
setCompactState(nextCompact);
|
||||||
|
writeCompactPreference(nextCompact);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { compact, effectiveTheme, preference, setCompact, setPreference };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function writeThemePreference(preference: ThemePreference, storage: Storage = window.localStorage) {
|
export function writeThemePreference(preference: ThemePreference, storage: Storage = window.localStorage) {
|
||||||
@@ -75,3 +127,16 @@ export function writeThemePreference(preference: ThemePreference, storage: Stora
|
|||||||
// jsdom 等环境可能不支持 CustomEvent
|
// jsdom 等环境可能不支持 CustomEvent
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function writeCompactPreference(value: boolean, storage: Storage = window.localStorage): void {
|
||||||
|
try {
|
||||||
|
storage.setItem(COMPACT_STORAGE_KEY, String(value));
|
||||||
|
} catch {
|
||||||
|
// 存储不可用时不阻断
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
window.dispatchEvent(new CustomEvent(COMPACT_CHANGE_EVENT, { detail: value }));
|
||||||
|
} catch {
|
||||||
|
// jsdom 等环境兼容
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
39
src/web/shared/theme/theme-config.ts
Normal file
39
src/web/shared/theme/theme-config.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { theme } from "antd";
|
||||||
|
|
||||||
|
import type { EffectiveTheme } from "../hooks/use-theme-preference";
|
||||||
|
|
||||||
|
interface BuildThemeConfigOptions {
|
||||||
|
compact?: boolean;
|
||||||
|
effectiveTheme: EffectiveTheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildThemeConfig({ compact = false, effectiveTheme }: BuildThemeConfigOptions) {
|
||||||
|
const isDark = effectiveTheme === "dark";
|
||||||
|
const baseAlgorithm = isDark ? theme.darkAlgorithm : theme.defaultAlgorithm;
|
||||||
|
const algorithm = compact ? [baseAlgorithm, theme.compactAlgorithm] : [baseAlgorithm];
|
||||||
|
|
||||||
|
return {
|
||||||
|
algorithm,
|
||||||
|
components: {
|
||||||
|
Layout: {
|
||||||
|
bodyBg: isDark ? "#0a0a0a" : "transparent",
|
||||||
|
headerBg: "transparent",
|
||||||
|
siderBg: isDark ? "#141414" : "#ffffff",
|
||||||
|
triggerBg: isDark ? "#141414" : "#ffffff",
|
||||||
|
},
|
||||||
|
Menu: {
|
||||||
|
itemActiveBg: isDark ? "#1f1f1f" : "#e5e5e5",
|
||||||
|
itemHoverBg: isDark ? "#1a1a1a" : "#f0f0f0",
|
||||||
|
itemSelectedBg: isDark ? "#2a2a2a" : "#0a0a0a",
|
||||||
|
itemSelectedColor: "#ffffff",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cssVar: {},
|
||||||
|
token: {
|
||||||
|
borderRadius: 10,
|
||||||
|
colorLink: isDark ? "#a3a3a3" : "#0a0a0a",
|
||||||
|
colorPrimary: isDark ? "#525252" : "#0a0a0a",
|
||||||
|
controlHeight: compact ? 28 : 36,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -65,6 +65,7 @@ body {
|
|||||||
color: var(--ant-color-text-secondary);
|
color: var(--ant-color-text-secondary);
|
||||||
font-size: var(--ant-font-size);
|
font-size: var(--ant-font-size);
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-unavailable {
|
.app-unavailable {
|
||||||
@@ -81,10 +82,22 @@ body {
|
|||||||
min-height: 60vh;
|
min-height: 60vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ant-layout-sider {
|
||||||
|
border-right: 1px solid var(--ant-color-border-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-layout-sider-trigger {
|
||||||
|
border-right: 1px solid var(--ant-color-border-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-layout-sider .ant-menu.ant-menu-root {
|
||||||
|
border-inline-end: none;
|
||||||
|
}
|
||||||
|
|
||||||
.app-sidebar-list {
|
.app-sidebar-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
border-right: 1px solid var(--ant-color-border-secondary);
|
border: 1px solid var(--ant-color-border-secondary);
|
||||||
border-radius: var(--ant-border-radius-lg);
|
border-radius: var(--ant-border-radius-lg);
|
||||||
background: var(--ant-color-bg-container);
|
background: var(--ant-color-bg-container);
|
||||||
}
|
}
|
||||||
@@ -118,11 +131,16 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.app-sidebar-list-item--selected {
|
.app-sidebar-list-item--selected {
|
||||||
background: var(--ant-color-primary-bg);
|
background: var(--ant-color-primary);
|
||||||
|
color: var(--ant-color-text-light-solid);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-sidebar-list-item--selected .ant-typography {
|
||||||
|
color: var(--ant-color-text-light-solid);
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-sidebar-list-item--selected:hover {
|
.app-sidebar-list-item--selected:hover {
|
||||||
background: var(--ant-color-primary-bg);
|
background: var(--ant-color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-sidebar-item-actions {
|
.app-sidebar-item-actions {
|
||||||
@@ -225,9 +243,11 @@ body {
|
|||||||
margin: 4px 0;
|
margin: 4px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-result-pre {
|
/* 工具调用参数 section */
|
||||||
font-size: 12px;
|
.tool-call-section {
|
||||||
margin: 0;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--ant-margin-xxs);
|
||||||
}
|
}
|
||||||
|
|
||||||
.msg-title-ai {
|
.msg-title-ai {
|
||||||
@@ -238,6 +258,11 @@ body {
|
|||||||
padding: 0 var(--ant-padding-sm);
|
padding: 0 var(--ant-padding-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.reasoning-content {
|
||||||
|
font-size: var(--ant-font-size-sm);
|
||||||
|
color: var(--ant-color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
.icon-primary {
|
.icon-primary {
|
||||||
color: var(--ant-color-primary);
|
color: var(--ant-color-primary);
|
||||||
}
|
}
|
||||||
@@ -267,17 +292,19 @@ body {
|
|||||||
background: var(--ant-color-bg-container);
|
background: var(--ant-color-bg-container);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-extra-actions .btn-dimmed {
|
.card-extra-actions .btn-dimmed,
|
||||||
|
.code-block-header .ant-btn.btn-dimmed {
|
||||||
color: var(--ant-color-text-quaternary);
|
color: var(--ant-color-text-quaternary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-extra-actions .btn-dimmed:hover {
|
.card-extra-actions .btn-dimmed:hover,
|
||||||
|
.code-block-header .ant-btn.btn-dimmed:hover {
|
||||||
color: var(--ant-color-text-secondary);
|
color: var(--ant-color-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-scroll-bottom-btn {
|
.chat-scroll-bottom-btn {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 115px;
|
bottom: 140px;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -297,23 +324,9 @@ body {
|
|||||||
--os-padding-axis: 2px;
|
--os-padding-axis: 2px;
|
||||||
--os-track-border-radius: 10px;
|
--os-track-border-radius: 10px;
|
||||||
--os-handle-border-radius: 10px;
|
--os-handle-border-radius: 10px;
|
||||||
--os-handle-bg: rgba(0, 0, 0, 0.15);
|
--os-handle-bg: var(--ant-color-border-secondary);
|
||||||
--os-handle-bg-hover: rgba(0, 0, 0, 0.25);
|
--os-handle-bg-hover: var(--ant-color-text-quaternary);
|
||||||
--os-handle-bg-active: rgba(0, 0, 0, 0.35);
|
--os-handle-bg-active: var(--ant-color-text-tertiary);
|
||||||
--os-handle-min-size: 33px;
|
|
||||||
--os-handle-max-size: none;
|
|
||||||
--os-handle-interactive-area-offset: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.os-theme-custom-dark {
|
|
||||||
--os-size: 8px;
|
|
||||||
--os-padding-perpendicular: 2px;
|
|
||||||
--os-padding-axis: 2px;
|
|
||||||
--os-track-border-radius: 10px;
|
|
||||||
--os-handle-border-radius: 10px;
|
|
||||||
--os-handle-bg: rgba(255, 255, 255, 0.15);
|
|
||||||
--os-handle-bg-hover: rgba(255, 255, 255, 0.25);
|
|
||||||
--os-handle-bg-active: rgba(255, 255, 255, 0.35);
|
|
||||||
--os-handle-min-size: 33px;
|
--os-handle-min-size: 33px;
|
||||||
--os-handle-max-size: none;
|
--os-handle-max-size: none;
|
||||||
--os-handle-interactive-area-offset: 4px;
|
--os-handle-interactive-area-offset: 4px;
|
||||||
@@ -321,46 +334,41 @@ body {
|
|||||||
|
|
||||||
.app-inbox-page {
|
.app-inbox-page {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
gap: var(--ant-margin-sm);
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-inbox-content {
|
.app-inbox-panel {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
gap: var(--ant-margin-sm);
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: var(--ant-padding-xl);
|
}
|
||||||
overflow-y: auto;
|
|
||||||
|
.app-inbox-content {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-inbox-action-bar {
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding: var(--ant-padding-sm);
|
||||||
|
border: 1px solid var(--ant-color-border-secondary);
|
||||||
|
border-radius: var(--ant-border-radius-lg);
|
||||||
|
background: var(--ant-color-bg-container);
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-inbox-datepicker {
|
.app-inbox-datepicker {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Inbox material list items */
|
|
||||||
.material-list-item {
|
|
||||||
border: none;
|
|
||||||
margin: var(--ant-margin-xxs) var(--ant-margin-xxs);
|
|
||||||
padding: var(--ant-padding-xs) var(--ant-padding-sm);
|
|
||||||
border-radius: var(--ant-border-radius-lg);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.material-list-item:hover {
|
|
||||||
background: var(--ant-color-bg-text-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.material-list-item--selected {
|
|
||||||
background: var(--ant-color-primary-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.material-list-item--selected:hover {
|
|
||||||
background: var(--ant-color-primary-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.material-item-right {
|
.material-item-right {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -385,11 +393,11 @@ body {
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.material-list-item:hover .material-item-tag {
|
.app-sidebar-list-item:hover .material-item-tag {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.material-list-item:hover .material-item-actions {
|
.app-sidebar-list-item:hover .material-item-actions {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -425,9 +433,12 @@ body {
|
|||||||
|
|
||||||
.code-block-body {
|
.code-block-body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-block-body > pre {
|
||||||
padding: var(--ant-padding-sm);
|
padding: var(--ant-padding-sm);
|
||||||
|
margin: 0;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
background: var(--ant-color-bg-container);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.code-block-body code {
|
.code-block-body code {
|
||||||
|
|||||||
18
tests/happydom.ts
Normal file
18
tests/happydom.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { GlobalRegistrator } from "@happy-dom/global-registrator";
|
||||||
|
|
||||||
|
// 保存 Node/Bun 原生实现,避免被 happy-dom 覆盖导致服务端测试失败
|
||||||
|
const nativeFetch = globalThis.fetch;
|
||||||
|
const nativeResponse = globalThis.Response;
|
||||||
|
const nativeRequest = globalThis.Request;
|
||||||
|
const nativeHeaders = globalThis.Headers;
|
||||||
|
const nativeFormData = globalThis.FormData;
|
||||||
|
const nativeBlob = globalThis.Blob;
|
||||||
|
|
||||||
|
GlobalRegistrator.register();
|
||||||
|
|
||||||
|
globalThis.fetch = nativeFetch;
|
||||||
|
globalThis.Response = nativeResponse;
|
||||||
|
globalThis.Request = nativeRequest;
|
||||||
|
globalThis.Headers = nativeHeaders;
|
||||||
|
globalThis.FormData = nativeFormData;
|
||||||
|
globalThis.Blob = nativeBlob;
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
/* eslint-disable @typescript-eslint/require-await */
|
/* oxlint-disable typescript/require-await */
|
||||||
import { afterEach, describe, expect, test } from "bun:test";
|
import { afterEach, describe, expect, test } from "bun:test";
|
||||||
import { mkdirSync } from "node:fs";
|
import { mkdirSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
|
|||||||
264
tests/server/db/entities.test.ts
Normal file
264
tests/server/db/entities.test.ts
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
import type Database from "bun:sqlite";
|
||||||
|
|
||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createEntity,
|
||||||
|
deleteEntity,
|
||||||
|
getEntity,
|
||||||
|
listEntities,
|
||||||
|
listEntityNames,
|
||||||
|
updateEntity,
|
||||||
|
} from "../../../src/server/db/entities";
|
||||||
|
import { createProject } from "../../../src/server/db/projects";
|
||||||
|
import { createNoopLogger } from "../../../src/server/logger";
|
||||||
|
import { createMigratedTestDatabase } from "../../helpers";
|
||||||
|
|
||||||
|
const LOG = createNoopLogger();
|
||||||
|
|
||||||
|
function withEntitiesDb(callback: (db: Database) => void): void {
|
||||||
|
const handle = createMigratedTestDatabase("entities-dao-test");
|
||||||
|
try {
|
||||||
|
callback(handle.db);
|
||||||
|
handle.close();
|
||||||
|
} finally {
|
||||||
|
handle.cleanup();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupProject(db: Database, name = "测试项目"): string {
|
||||||
|
const result = createProject(db, { name }, LOG);
|
||||||
|
if ("error" in result) throw new Error(result.error);
|
||||||
|
return result.project.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupEntity(
|
||||||
|
db: Database,
|
||||||
|
projectId: string,
|
||||||
|
overrides: Partial<{ aliases: string[]; description: string; name: string; type: string }> = {},
|
||||||
|
): string {
|
||||||
|
const result = createEntity(
|
||||||
|
db,
|
||||||
|
projectId,
|
||||||
|
{
|
||||||
|
aliases: overrides.aliases,
|
||||||
|
description: overrides.description ?? "测试实体描述",
|
||||||
|
name: overrides.name ?? "测试实体",
|
||||||
|
type: (overrides.type ?? "person") as "person",
|
||||||
|
},
|
||||||
|
LOG,
|
||||||
|
);
|
||||||
|
if ("error" in result) throw new Error(result.error);
|
||||||
|
return result.entity.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("实体数据访问层", () => {
|
||||||
|
describe("createEntity", () => {
|
||||||
|
test("创建实体成功,aliases 为数组", () => {
|
||||||
|
withEntitiesDb((db) => {
|
||||||
|
const projectId = setupProject(db);
|
||||||
|
const result = createEntity(
|
||||||
|
db,
|
||||||
|
projectId,
|
||||||
|
{
|
||||||
|
description: "描述",
|
||||||
|
name: "张三",
|
||||||
|
type: "person",
|
||||||
|
},
|
||||||
|
LOG,
|
||||||
|
);
|
||||||
|
expect("error" in result).toBe(false);
|
||||||
|
const entity = (result as { entity: { aliases: string[]; description: string; name: string; type: string } })
|
||||||
|
.entity;
|
||||||
|
expect(entity.name).toBe("张三");
|
||||||
|
expect(entity.description).toBe("描述");
|
||||||
|
expect(entity.type).toBe("person");
|
||||||
|
expect(entity.aliases).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("创建实体时指定别名", () => {
|
||||||
|
withEntitiesDb((db) => {
|
||||||
|
const projectId = setupProject(db);
|
||||||
|
const result = createEntity(
|
||||||
|
db,
|
||||||
|
projectId,
|
||||||
|
{
|
||||||
|
aliases: ["小张", "张工"],
|
||||||
|
name: "张三",
|
||||||
|
type: "person",
|
||||||
|
},
|
||||||
|
LOG,
|
||||||
|
);
|
||||||
|
expect("error" in result).toBe(false);
|
||||||
|
const entity = (result as { entity: { aliases: string[] } }).entity;
|
||||||
|
expect(entity.aliases).toEqual(["小张", "张工"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("空名称返回 400", () => {
|
||||||
|
withEntitiesDb((db) => {
|
||||||
|
const projectId = setupProject(db);
|
||||||
|
const result = createEntity(db, projectId, { name: " ", type: "other" }, LOG);
|
||||||
|
expect("error" in result).toBe(true);
|
||||||
|
expect((result as { status: number }).status).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("同项目下重名返回 409", () => {
|
||||||
|
withEntitiesDb((db) => {
|
||||||
|
const projectId = setupProject(db);
|
||||||
|
createEntity(db, projectId, { name: "张三", type: "person" }, LOG);
|
||||||
|
const result = createEntity(db, projectId, { name: "张三", type: "person" }, LOG);
|
||||||
|
expect("error" in result).toBe(true);
|
||||||
|
expect((result as { status: number }).status).toBe(409);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("不同项目可重名", () => {
|
||||||
|
withEntitiesDb((db) => {
|
||||||
|
const p1 = setupProject(db, "项目一");
|
||||||
|
const p2 = setupProject(db, "项目二");
|
||||||
|
expect("error" in createEntity(db, p1, { name: "张三", type: "person" }, LOG)).toBe(false);
|
||||||
|
expect("error" in createEntity(db, p2, { name: "张三", type: "person" }, LOG)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getEntity", () => {
|
||||||
|
test("获取实体成功", () => {
|
||||||
|
withEntitiesDb((db) => {
|
||||||
|
const projectId = setupProject(db);
|
||||||
|
const entityId = setupEntity(db, projectId);
|
||||||
|
const result = getEntity(db, projectId, entityId);
|
||||||
|
expect("error" in result).toBe(false);
|
||||||
|
expect((result as { entity: { id: string } }).entity.id).toBe(entityId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("不存在返回 404", () => {
|
||||||
|
withEntitiesDb((db) => {
|
||||||
|
const projectId = setupProject(db);
|
||||||
|
const result = getEntity(db, projectId, "nonexistent");
|
||||||
|
expect("error" in result).toBe(true);
|
||||||
|
expect((result as { status: number }).status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("listEntities", () => {
|
||||||
|
test("分页列出实体", () => {
|
||||||
|
withEntitiesDb((db) => {
|
||||||
|
const projectId = setupProject(db);
|
||||||
|
setupEntity(db, projectId, { name: "实体1", type: "person" });
|
||||||
|
setupEntity(db, projectId, { name: "实体2", type: "organization" });
|
||||||
|
|
||||||
|
const result = listEntities(db, projectId, { page: 1, pageSize: 10 });
|
||||||
|
expect(result.total).toBe(2);
|
||||||
|
const types = result.items.map((i: { type: string }) => i.type).sort();
|
||||||
|
expect(types).toContain("person");
|
||||||
|
expect(types).toContain("organization");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("按类型筛选", () => {
|
||||||
|
withEntitiesDb((db) => {
|
||||||
|
const projectId = setupProject(db);
|
||||||
|
setupEntity(db, projectId, { name: "人", type: "person" });
|
||||||
|
setupEntity(db, projectId, { name: "公司", type: "organization" });
|
||||||
|
|
||||||
|
const result = listEntities(db, projectId, { page: 1, pageSize: 10, type: "person" });
|
||||||
|
expect(result.total).toBe(1);
|
||||||
|
const firstItem = result.items[0]!;
|
||||||
|
expect(firstItem.name).toBe("人");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("软删除实体不出现在列表中", () => {
|
||||||
|
withEntitiesDb((db) => {
|
||||||
|
const projectId = setupProject(db);
|
||||||
|
const entityId = setupEntity(db, projectId);
|
||||||
|
deleteEntity(db, projectId, entityId, LOG);
|
||||||
|
const result = listEntities(db, projectId, { page: 1, pageSize: 10 });
|
||||||
|
expect(result.total).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("listEntityNames", () => {
|
||||||
|
test("返回所有实体名称和别名", () => {
|
||||||
|
withEntitiesDb((db) => {
|
||||||
|
const projectId = setupProject(db);
|
||||||
|
setupEntity(db, projectId, { aliases: ["小张"], name: "张三", type: "person" });
|
||||||
|
setupEntity(db, projectId, { name: "李四", type: "person" });
|
||||||
|
|
||||||
|
const result = listEntityNames(db, projectId);
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
const names = result.map((r) => r.name).sort();
|
||||||
|
expect(names).toEqual(["张三", "李四"]);
|
||||||
|
const p3 = result.find((r) => r.name === "张三")!;
|
||||||
|
expect(p3.aliases).toEqual(["小张"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateEntity", () => {
|
||||||
|
test("更新实体名称和别名", () => {
|
||||||
|
withEntitiesDb((db) => {
|
||||||
|
const projectId = setupProject(db);
|
||||||
|
const entityId = setupEntity(db, projectId, { name: "张三" });
|
||||||
|
const result = updateEntity(db, projectId, entityId, { aliases: ["张总", "老张"], name: "张三丰" }, LOG);
|
||||||
|
expect("error" in result).toBe(false);
|
||||||
|
const entity = (result as { entity: { aliases: string[]; name: string } }).entity;
|
||||||
|
expect(entity.name).toBe("张三丰");
|
||||||
|
expect(entity.aliases).toEqual(["张总", "老张"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("更新时名称去重", () => {
|
||||||
|
withEntitiesDb((db) => {
|
||||||
|
const projectId = setupProject(db);
|
||||||
|
setupEntity(db, projectId, { name: "已有实体" });
|
||||||
|
const entityId = setupEntity(db, projectId, { name: "张三" });
|
||||||
|
const result = updateEntity(db, projectId, entityId, { name: "已有实体" }, LOG);
|
||||||
|
expect("error" in result).toBe(true);
|
||||||
|
expect((result as { status: number }).status).toBe(409);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("空参数返回原实体", () => {
|
||||||
|
withEntitiesDb((db) => {
|
||||||
|
const projectId = setupProject(db);
|
||||||
|
const entityId = setupEntity(db, projectId);
|
||||||
|
const result = updateEntity(db, projectId, entityId, {}, LOG);
|
||||||
|
expect("error" in result).toBe(false);
|
||||||
|
const entity = (result as { entity: { id: string } }).entity;
|
||||||
|
expect(entity.id).toBe(entityId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deleteEntity", () => {
|
||||||
|
test("软删除实体成功", () => {
|
||||||
|
withEntitiesDb((db) => {
|
||||||
|
const projectId = setupProject(db);
|
||||||
|
const entityId = setupEntity(db, projectId);
|
||||||
|
const result = deleteEntity(db, projectId, entityId, LOG);
|
||||||
|
expect("error" in result).toBe(false);
|
||||||
|
|
||||||
|
const getResult = getEntity(db, projectId, entityId);
|
||||||
|
expect("error" in getResult).toBe(true);
|
||||||
|
expect((getResult as { status: number }).status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("不存在返回 404", () => {
|
||||||
|
withEntitiesDb((db) => {
|
||||||
|
const projectId = setupProject(db);
|
||||||
|
const result = deleteEntity(db, projectId, "nonexistent", LOG);
|
||||||
|
expect("error" in result).toBe(true);
|
||||||
|
expect((result as { status: number }).status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
277
tests/server/db/materials.test.ts
Normal file
277
tests/server/db/materials.test.ts
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
import type Database from "bun:sqlite";
|
||||||
|
|
||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
|
||||||
|
import {
|
||||||
|
approveMaterial,
|
||||||
|
createMaterial,
|
||||||
|
deleteMaterial,
|
||||||
|
discardMaterial,
|
||||||
|
getMaterial,
|
||||||
|
retryMaterial,
|
||||||
|
} from "../../../src/server/db/materials";
|
||||||
|
import { createProject } from "../../../src/server/db/projects";
|
||||||
|
import { createNoopLogger } from "../../../src/server/logger";
|
||||||
|
import { createMigratedTestDatabase } from "../../helpers";
|
||||||
|
|
||||||
|
const LOG = createNoopLogger();
|
||||||
|
|
||||||
|
function withMaterialsDb(callback: (db: Database) => void): void {
|
||||||
|
const handle = createMigratedTestDatabase("materials-dao-test");
|
||||||
|
try {
|
||||||
|
callback(handle.db);
|
||||||
|
handle.close();
|
||||||
|
} finally {
|
||||||
|
handle.cleanup();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupProject(db: Database, name = "测试项目"): string {
|
||||||
|
const result = createProject(db, { name }, LOG);
|
||||||
|
if ("error" in result) throw new Error(result.error);
|
||||||
|
return result.project.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupMaterial(
|
||||||
|
db: Database,
|
||||||
|
projectId: string,
|
||||||
|
overrides: Partial<{
|
||||||
|
associatedDate: string;
|
||||||
|
description: string;
|
||||||
|
materialType: "general" | "meeting";
|
||||||
|
}> = {},
|
||||||
|
): string {
|
||||||
|
const result = createMaterial(
|
||||||
|
db,
|
||||||
|
projectId,
|
||||||
|
{
|
||||||
|
associatedDate: overrides.associatedDate ?? "2024-01-15",
|
||||||
|
description: overrides.description ?? "测试素材",
|
||||||
|
materialType: overrides.materialType,
|
||||||
|
},
|
||||||
|
LOG,
|
||||||
|
);
|
||||||
|
if ("error" in result) throw new Error(result.error);
|
||||||
|
return result.material.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setMaterialStatus(
|
||||||
|
db: Database,
|
||||||
|
materialId: string,
|
||||||
|
status: "approved" | "discarded" | "failed" | "pending" | "processing" | "review",
|
||||||
|
processedContent: string | null = null,
|
||||||
|
): void {
|
||||||
|
db.prepare("UPDATE materials SET status = ?, processed_content = ?, updated_at = ? WHERE id = ?").run(
|
||||||
|
status,
|
||||||
|
processedContent,
|
||||||
|
new Date().toISOString(),
|
||||||
|
materialId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("素材数据访问层", () => {
|
||||||
|
describe("createMaterial", () => {
|
||||||
|
test("默认 materialType 为 general", () => {
|
||||||
|
withMaterialsDb((db) => {
|
||||||
|
const projectId = setupProject(db);
|
||||||
|
const result = createMaterial(db, projectId, { associatedDate: "2024-01-15", description: "测试" }, LOG);
|
||||||
|
expect("error" in result).toBe(false);
|
||||||
|
const material = (result as { material: { materialType: string } }).material;
|
||||||
|
expect(material.materialType).toBe("general");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("显式指定 materialType 为 meeting", () => {
|
||||||
|
withMaterialsDb((db) => {
|
||||||
|
const projectId = setupProject(db);
|
||||||
|
const result = createMaterial(
|
||||||
|
db,
|
||||||
|
projectId,
|
||||||
|
{ associatedDate: "2024-01-15", description: "会议素材", materialType: "meeting" },
|
||||||
|
LOG,
|
||||||
|
);
|
||||||
|
expect("error" in result).toBe(false);
|
||||||
|
const material = (result as { material: { materialType: string } }).material;
|
||||||
|
expect(material.materialType).toBe("meeting");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("非法 materialType 返回 400", () => {
|
||||||
|
withMaterialsDb((db) => {
|
||||||
|
const projectId = setupProject(db);
|
||||||
|
const result = createMaterial(
|
||||||
|
db,
|
||||||
|
projectId,
|
||||||
|
{
|
||||||
|
associatedDate: "2024-01-15",
|
||||||
|
description: "测试",
|
||||||
|
materialType: "invalid" as "general",
|
||||||
|
},
|
||||||
|
LOG,
|
||||||
|
);
|
||||||
|
expect("error" in result).toBe(true);
|
||||||
|
expect((result as { status: number }).status).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getMaterial", () => {
|
||||||
|
test("返回包含 materialType 和 processedContent 字段", () => {
|
||||||
|
withMaterialsDb((db) => {
|
||||||
|
const projectId = setupProject(db);
|
||||||
|
const materialId = setupMaterial(db, projectId);
|
||||||
|
const result = getMaterial(db, projectId, materialId);
|
||||||
|
expect("error" in result).toBe(false);
|
||||||
|
const material = (result as { material: { materialType: string; processedContent: null | string } }).material;
|
||||||
|
expect(material.materialType).toBe("general");
|
||||||
|
expect(material.processedContent).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("approveMaterial", () => {
|
||||||
|
test("review 状态通过成功", () => {
|
||||||
|
withMaterialsDb((db) => {
|
||||||
|
const projectId = setupProject(db);
|
||||||
|
const materialId = setupMaterial(db, projectId);
|
||||||
|
setMaterialStatus(db, materialId, "review");
|
||||||
|
|
||||||
|
const result = approveMaterial(db, projectId, materialId, [], LOG);
|
||||||
|
expect("error" in result).toBe(false);
|
||||||
|
const material = (result as { material: { status: string } }).material;
|
||||||
|
expect(material.status).toBe("approved");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("非 review 状态拒绝(pending)", () => {
|
||||||
|
withMaterialsDb((db) => {
|
||||||
|
const projectId = setupProject(db);
|
||||||
|
const materialId = setupMaterial(db, projectId);
|
||||||
|
|
||||||
|
const result = approveMaterial(db, projectId, materialId, [], LOG);
|
||||||
|
expect("error" in result).toBe(true);
|
||||||
|
expect((result as { status: number }).status).toBe(409);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("素材不存在返回 404", () => {
|
||||||
|
withMaterialsDb((db) => {
|
||||||
|
const projectId = setupProject(db);
|
||||||
|
const result = approveMaterial(db, projectId, "nonexistent", [], LOG);
|
||||||
|
expect("error" in result).toBe(true);
|
||||||
|
expect((result as { status: number }).status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("discardMaterial", () => {
|
||||||
|
test("review 状态放弃成功", () => {
|
||||||
|
withMaterialsDb((db) => {
|
||||||
|
const projectId = setupProject(db);
|
||||||
|
const materialId = setupMaterial(db, projectId);
|
||||||
|
setMaterialStatus(db, materialId, "review");
|
||||||
|
|
||||||
|
const result = discardMaterial(db, projectId, materialId, LOG);
|
||||||
|
expect("error" in result).toBe(false);
|
||||||
|
const material = (result as { material: { status: string } }).material;
|
||||||
|
expect(material.status).toBe("discarded");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("非 review 状态拒绝", () => {
|
||||||
|
withMaterialsDb((db) => {
|
||||||
|
const projectId = setupProject(db);
|
||||||
|
const materialId = setupMaterial(db, projectId);
|
||||||
|
|
||||||
|
const result = discardMaterial(db, projectId, materialId, LOG);
|
||||||
|
expect("error" in result).toBe(true);
|
||||||
|
expect((result as { status: number }).status).toBe(409);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("retryMaterial", () => {
|
||||||
|
test("failed 状态重试成功并清空 processedContent", () => {
|
||||||
|
withMaterialsDb((db) => {
|
||||||
|
const projectId = setupProject(db);
|
||||||
|
const materialId = setupMaterial(db, projectId);
|
||||||
|
setMaterialStatus(db, materialId, "failed", "之前的内容");
|
||||||
|
|
||||||
|
const result = retryMaterial(db, projectId, materialId, LOG);
|
||||||
|
expect("error" in result).toBe(false);
|
||||||
|
const material = (result as { material: { processedContent: null | string; status: string } }).material;
|
||||||
|
expect(material.status).toBe("pending");
|
||||||
|
expect(material.processedContent).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("非 failed 状态拒绝", () => {
|
||||||
|
withMaterialsDb((db) => {
|
||||||
|
const projectId = setupProject(db);
|
||||||
|
const materialId = setupMaterial(db, projectId);
|
||||||
|
setMaterialStatus(db, materialId, "review");
|
||||||
|
|
||||||
|
const result = retryMaterial(db, projectId, materialId, LOG);
|
||||||
|
expect("error" in result).toBe(true);
|
||||||
|
expect((result as { status: number }).status).toBe(409);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deleteMaterial", () => {
|
||||||
|
test("processing 状态禁止删除返回 409", () => {
|
||||||
|
withMaterialsDb((db) => {
|
||||||
|
const projectId = setupProject(db);
|
||||||
|
const materialId = setupMaterial(db, projectId);
|
||||||
|
setMaterialStatus(db, materialId, "processing");
|
||||||
|
|
||||||
|
const result = deleteMaterial(db, projectId, materialId, LOG);
|
||||||
|
expect("error" in result).toBe(true);
|
||||||
|
expect((result as { status: number }).status).toBe(409);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("pending 状态可正常删除", () => {
|
||||||
|
withMaterialsDb((db) => {
|
||||||
|
const projectId = setupProject(db);
|
||||||
|
const materialId = setupMaterial(db, projectId);
|
||||||
|
|
||||||
|
const result = deleteMaterial(db, projectId, materialId, LOG);
|
||||||
|
expect("error" in result).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("review 状态可正常删除", () => {
|
||||||
|
withMaterialsDb((db) => {
|
||||||
|
const projectId = setupProject(db);
|
||||||
|
const materialId = setupMaterial(db, projectId);
|
||||||
|
setMaterialStatus(db, materialId, "review");
|
||||||
|
|
||||||
|
const result = deleteMaterial(db, projectId, materialId, LOG);
|
||||||
|
expect("error" in result).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("failed 状态可正常删除", () => {
|
||||||
|
withMaterialsDb((db) => {
|
||||||
|
const projectId = setupProject(db);
|
||||||
|
const materialId = setupMaterial(db, projectId);
|
||||||
|
setMaterialStatus(db, materialId, "failed");
|
||||||
|
|
||||||
|
const result = deleteMaterial(db, projectId, materialId, LOG);
|
||||||
|
expect("error" in result).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("approved 状态可正常删除", () => {
|
||||||
|
withMaterialsDb((db) => {
|
||||||
|
const projectId = setupProject(db);
|
||||||
|
const materialId = setupMaterial(db, projectId);
|
||||||
|
setMaterialStatus(db, materialId, "approved");
|
||||||
|
|
||||||
|
const result = deleteMaterial(db, projectId, materialId, LOG);
|
||||||
|
expect("error" in result).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
157
tests/server/db/settings.test.ts
Normal file
157
tests/server/db/settings.test.ts
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import type Database from "bun:sqlite";
|
||||||
|
|
||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
|
||||||
|
import { getSettings, updateSettings } from "../../../src/server/db/settings";
|
||||||
|
import { createNoopLogger } from "../../../src/server/logger";
|
||||||
|
import { createMigratedTestDatabase } from "../../helpers";
|
||||||
|
|
||||||
|
function withSettingsDb(callback: (db: Database) => void): void {
|
||||||
|
const handle = createMigratedTestDatabase("settings-test");
|
||||||
|
try {
|
||||||
|
callback(handle.db);
|
||||||
|
handle.close();
|
||||||
|
} finally {
|
||||||
|
handle.cleanup();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("设置数据访问层", () => {
|
||||||
|
test("getSettings 无数据时返回默认值", () => {
|
||||||
|
withSettingsDb((db) => {
|
||||||
|
const result = getSettings(db);
|
||||||
|
expect(result).toEqual({ compact: false, theme: "system" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("updateSettings 写入并读取", () => {
|
||||||
|
withSettingsDb((db) => {
|
||||||
|
const updated = updateSettings(db, { theme: "dark" }, createNoopLogger());
|
||||||
|
expect(updated).toEqual({ compact: false, theme: "dark" });
|
||||||
|
|
||||||
|
const read = getSettings(db);
|
||||||
|
expect(read).toEqual({ compact: false, theme: "dark" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("updateSettings 部分更新合并", () => {
|
||||||
|
withSettingsDb((db) => {
|
||||||
|
updateSettings(db, { theme: "dark" }, createNoopLogger());
|
||||||
|
const result = updateSettings(db, { theme: "light" }, createNoopLogger());
|
||||||
|
expect(result).toEqual({ compact: false, theme: "light" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getSettings 解析非法 JSON 返回默认值", () => {
|
||||||
|
withSettingsDb((db) => {
|
||||||
|
db.run(
|
||||||
|
"INSERT INTO settings (id, created_at, updated_at, data) VALUES ('default', '2024-01-01T00:00:00.000Z', '2024-01-01T00:00:00.000Z', 'not-json')",
|
||||||
|
);
|
||||||
|
const result = getSettings(db);
|
||||||
|
expect(result).toEqual({ compact: false, theme: "system" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getSettings 未知 theme 值返回默认值", () => {
|
||||||
|
withSettingsDb((db) => {
|
||||||
|
db.run(
|
||||||
|
"INSERT INTO settings (id, created_at, updated_at, data) VALUES ('default', '2024-01-01T00:00:00.000Z', '2024-01-01T00:00:00.000Z', '{\"theme\":\"unknown\"}')",
|
||||||
|
);
|
||||||
|
const result = getSettings(db);
|
||||||
|
expect(result).toEqual({ compact: false, theme: "system" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("updateSettings 幂等覆盖", () => {
|
||||||
|
withSettingsDb((db) => {
|
||||||
|
const a = updateSettings(db, { theme: "dark" }, createNoopLogger());
|
||||||
|
const b = updateSettings(db, { theme: "dark" }, createNoopLogger());
|
||||||
|
expect(a).toEqual({ compact: false, theme: "dark" });
|
||||||
|
expect(b).toEqual({ compact: false, theme: "dark" });
|
||||||
|
|
||||||
|
const row = db
|
||||||
|
.query("SELECT COUNT(*) as cnt FROM settings WHERE id = 'default' AND deleted_at IS NULL")
|
||||||
|
.get() as { cnt: number };
|
||||||
|
expect(row.cnt).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getSettings 无 compact 字段时默认 false", () => {
|
||||||
|
withSettingsDb((db) => {
|
||||||
|
db.run(
|
||||||
|
"INSERT INTO settings (id, created_at, updated_at, data) VALUES ('default', '2024-01-01T00:00:00.000Z', '2024-01-01T00:00:00.000Z', '{\"theme\":\"dark\"}')",
|
||||||
|
);
|
||||||
|
const result = getSettings(db);
|
||||||
|
expect(result).toEqual({ compact: false, theme: "dark" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("updateSettings 写入 compact 并读取", () => {
|
||||||
|
withSettingsDb((db) => {
|
||||||
|
const updated = updateSettings(db, { compact: true }, createNoopLogger());
|
||||||
|
expect(updated).toEqual({ compact: true, theme: "system" });
|
||||||
|
|
||||||
|
const read = getSettings(db);
|
||||||
|
expect(read).toEqual({ compact: true, theme: "system" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("updateSettings compact 与 theme 合并", () => {
|
||||||
|
withSettingsDb((db) => {
|
||||||
|
updateSettings(db, { theme: "dark" }, createNoopLogger());
|
||||||
|
const result = updateSettings(db, { compact: true }, createNoopLogger());
|
||||||
|
expect(result).toEqual({ compact: true, theme: "dark" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getSettings compact 为非布尔值时回退 false", () => {
|
||||||
|
withSettingsDb((db) => {
|
||||||
|
db.run(
|
||||||
|
"INSERT INTO settings (id, created_at, updated_at, data) VALUES ('default', '2024-01-01T00:00:00.000Z', '2024-01-01T00:00:00.000Z', '{\"theme\":\"dark\",\"compact\":\"yes\"}')",
|
||||||
|
);
|
||||||
|
const result = getSettings(db);
|
||||||
|
expect(result).toEqual({ compact: false, theme: "dark" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("updateSettings 写入 defaultModels 并读取", () => {
|
||||||
|
withSettingsDb((db) => {
|
||||||
|
const updated = updateSettings(
|
||||||
|
db,
|
||||||
|
{ defaultModels: { text: "model-1", imageRecognition: null } },
|
||||||
|
createNoopLogger(),
|
||||||
|
);
|
||||||
|
expect(updated.defaultModels).toEqual({ text: "model-1", imageRecognition: null });
|
||||||
|
expect(updated.compact).toBe(false);
|
||||||
|
expect(updated.theme).toBe("system");
|
||||||
|
|
||||||
|
const read = getSettings(db);
|
||||||
|
expect(read.defaultModels).toEqual({ text: "model-1", imageRecognition: null });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("defaultModels 与 theme/compact 合并持久化", () => {
|
||||||
|
withSettingsDb((db) => {
|
||||||
|
updateSettings(db, { theme: "dark", compact: true }, createNoopLogger());
|
||||||
|
const result = updateSettings(db, { defaultModels: { imageGeneration: "model-2" } }, createNoopLogger());
|
||||||
|
expect(result).toEqual({
|
||||||
|
compact: true,
|
||||||
|
defaultModels: { imageGeneration: "model-2" },
|
||||||
|
theme: "dark",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("defaultModels 全量替换——前端负责深度合并", () => {
|
||||||
|
withSettingsDb((db) => {
|
||||||
|
updateSettings(db, { defaultModels: { text: "model-1", imageRecognition: "model-2" } }, createNoopLogger());
|
||||||
|
// 前端在 onChange 中负责合并 old + newField,提交完整对象
|
||||||
|
const result = updateSettings(
|
||||||
|
db,
|
||||||
|
{ defaultModels: { text: "model-3", imageRecognition: "model-2" } },
|
||||||
|
createNoopLogger(),
|
||||||
|
);
|
||||||
|
expect(result.defaultModels).toEqual({ text: "model-3", imageRecognition: "model-2" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user