Compare commits

..

8 Commits

Author SHA1 Message Date
ad10134c20 chore: merge dev-chat into master
# Conflicts:
#	src/web/styles.css
2026-06-03 17:56:02 +08:00
ea9bc41e4c refactor(prompts): 移除 apply-review 中所有文档回写步骤,适配 fast-drive 单向工作流 2026-06-03 17:43:07 +08:00
a896091d27 feat: 增强 Markdown 代码块高亮和表格样式 2026-06-03 17:23:43 +08:00
1a7fd58553 feat(inbox): 侧边栏状态筛选与日期分组 — Segmented 图标筛选 + Skeleton 加载态 + 五级日期分组可折叠 + 卡片显示关联日期 2026-06-03 17:22:14 +08:00
abe30ead6a refactor(inbox): 侧边栏素材列表改为轻量 Flex 布局 — Card→Flex, 新增状态 Tag, hover 切换删除按钮, 左侧竖线选中态 2026-06-03 16:21:56 +08:00
714da2d633 feat: 聊天侧边栏新对话按钮统一为 antd Button 样式 2026-06-03 15:17:05 +08:00
21b557c255 feat(inbox): 素材持久化 CRUD — 数据库表 + API + 前端接入
- 新增 materials 表(id/projectId/description/associatedDate/status/createdAt/updatedAt)
- 新增 4 个后端 API 路由(list/create/get/delete)+ 13 个测试
- 新增 use-materials hooks(TanStack Query)
- 收集箱页面重构为三层架构(InboxPage + MaterialSidebar + MaterialDetailPanel)
- MaterialCard: Popconfirm 删除确认 + 粗粒度时间格式
- MaterialContent: 展示状态标签 + createdAt
- 更新开发文档 backend.md / frontend.md
2026-06-03 14:53:23 +08:00
02a202290f refactor: 替换 Markdown 渲染组件为 markdown-to-jsx 2026-06-03 13:13:04 +08:00
44 changed files with 2505 additions and 464 deletions

111
bun.lock
View File

@@ -11,7 +11,6 @@
"@ai-sdk/react": "^3.0.195",
"@ant-design/icons": "^6.2.3",
"@ant-design/x": "^2.7.0",
"@ant-design/x-markdown": "^2.7.0",
"@sinclair/typebox": "^0.34.49",
"@tanstack/react-query": "^5.100.14",
"ai": "^6.0.193",
@@ -19,6 +18,7 @@
"antd": "^6.4.3",
"drizzle-orm": "^0.45.2",
"es-toolkit": "^1.47.0",
"markdown-to-jsx": "^9.8.1",
"overlayscrollbars": "^2.16.0",
"overlayscrollbars-react": "^0.5.6",
"pino": "^10.3.1",
@@ -28,6 +28,7 @@
"react-dom": "^19.2.6",
"react-router": "^7.15.1",
"recharts": "^3.8.1",
"shiki": "^4.2.0",
"zod": "^4.4.3",
},
"devDependencies": {
@@ -91,8 +92,6 @@
"@ant-design/x": ["@ant-design/x@2.7.0", "", { "dependencies": { "@ant-design/colors": "^8.0.0", "@ant-design/cssinjs": "^2.0.1", "@ant-design/cssinjs-utils": "^2.0.2", "@ant-design/fast-color": "^3.0.0", "@ant-design/icons": "^6.0.0", "@babel/runtime": "^7.25.6", "@rc-component/motion": "^1.1.6", "@rc-component/resize-observer": "^1.0.1", "@rc-component/util": "^1.4.0", "clsx": "^2.1.1", "lodash.throttle": "^4.1.1", "mermaid": "^11.12.1", "react-syntax-highlighter": "^16.1.0" }, "peerDependencies": { "antd": "^6.1.1", "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-p5OtxQ9elbmeFRllGt1yj5wi6VHe41PIAmwrBU/OlaYydru5qIYsJzCS3DPRhkWkVdErU5oZwU74Z2oce2F5Uw=="],
"@ant-design/x-markdown": ["@ant-design/x-markdown@2.7.0", "", { "dependencies": { "clsx": "^2.1.1", "dompurify": "^3.2.6", "html-react-parser": "^5.2.13", "katex": "^0.16.22", "marked": "^15.0.12" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-tmuwbeulTD5nfO15VCb3mN13iCTT106626dVFxGjhj1tWnmLL+fIngyv3U8SOTq94+Baor6QdSWtPZluVKCIbw=="],
"@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="],
"@asamuzakjp/css-color": ["@asamuzakjp/css-color@5.1.11", "https://registry.npmmirror.com/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", { "dependencies": { "@asamuzakjp/generational-cache": "^1.0.1", "@csstools/css-calc": "^3.2.0", "@csstools/css-color-parser": "^4.1.0", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg=="],
@@ -433,6 +432,22 @@
"@rtsao/scc": ["@rtsao/scc@1.1.0", "https://registry.npmmirror.com/@rtsao/scc/-/scc-1.1.0.tgz", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="],
"@shikijs/core": ["@shikijs/core@4.2.0", "", { "dependencies": { "@shikijs/primitive": "4.2.0", "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-Hc87Ab1Ld/vEbZRCbwx344I5v+4RU8CVToUTRkqXL1+TjbuOp9U5Xa0M23V4GEWHxVn+yO5otb+HkQVm3ptWQQ=="],
"@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.6" } }, "sha512-fjETeq1k5ffyXqRgS6+3hpvqseLalp1kjNfRbXpUgWR8FpZ1CmQfiNHovc5lncYjt/Vg5JK/WJEmLahjwMa0og=="],
"@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-hTorK1dffPkpbMUk6Z+828PgRo7d07HbnizoP0hNPFjhxMHctj0Px/qoHeGMYafc6ju+u9iMldN4JbVzNQM++g=="],
"@shikijs/langs": ["@shikijs/langs@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0" } }, "sha512-bwrVRlJ0wUhZxAbVdvBbv2TTC9yLsh4C/IO5Ofz0T8MQntgDvyVnkbjw9vi50r1kx7RCIJdnJnjZAwmAsXFLZQ=="],
"@shikijs/primitive": ["@shikijs/primitive@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-NOq+DtUkVBJtZMVXL5A0vI0Xk8nvDYaXetFHSJFlOqjDZIVhIPRYFdGkSoElDqNuegikcc3A76SNUa8dTqtAYA=="],
"@shikijs/themes": ["@shikijs/themes@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0" } }, "sha512-RX8IHYeLv8Cu2W6ruc3RxUqWn0IYCqSrMBzi/uRGAmfyDNOnNO5BF/Px7o97n4XTpmFTo5GbRaazuOWj+2ak2w=="],
"@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="],
"@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],
"@simple-libs/child-process-utils": ["@simple-libs/child-process-utils@1.0.2", "https://registry.npmmirror.com/@simple-libs/child-process-utils/-/child-process-utils-1.0.2.tgz", { "dependencies": { "@simple-libs/stream-utils": "^1.2.0" } }, "sha512-/4R8QKnd/8agJynkNdJmNw2MBxuFTRcNFnE5Sg/G+jkSsV8/UBgULMzhizWWW42p8L5H7flImV2ATi79Ove2Tw=="],
"@simple-libs/stream-utils": ["@simple-libs/stream-utils@1.2.0", "https://registry.npmmirror.com/@simple-libs/stream-utils/-/stream-utils-1.2.0.tgz", {}, "sha512-KxXvfapcixpz6rVEB6HPjOUZT22yN6v0vI0urQSk1L8MlEWPDFCZkhw2xmkyoTGYeFw7tWTZd7e3lVzRZRN/EA=="],
@@ -537,6 +552,8 @@
"@types/json5": ["@types/json5@0.0.29", "https://registry.npmmirror.com/@types/json5/-/json5-0.0.29.tgz", {}, "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ=="],
"@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
"@types/node": ["@types/node@25.6.2", "https://registry.npmmirror.com/@types/node/-/node-25.6.2.tgz", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw=="],
"@types/prismjs": ["@types/prismjs@1.26.6", "", {}, "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw=="],
@@ -573,6 +590,8 @@
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.60.0", "https://registry.npmmirror.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.60.0.tgz", { "dependencies": { "@typescript-eslint/types": "8.60.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-9WI52t8ZGLVGrPMBet25yAftqY/n95+zmoUUtJBBQTKDSKUu7OsPTroT2op7U9JatkoRccL0YkWDNMFfC4Sjxg=="],
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.1", "", {}, "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ=="],
"@unrs/resolver-binding-android-arm-eabi": ["@unrs/resolver-binding-android-arm-eabi@1.11.1", "https://registry.npmmirror.com/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", { "os": "android", "cpu": "arm" }, "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw=="],
"@unrs/resolver-binding-android-arm64": ["@unrs/resolver-binding-android-arm64@1.11.1", "https://registry.npmmirror.com/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", { "os": "android", "cpu": "arm64" }, "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g=="],
@@ -681,8 +700,12 @@
"caniuse-lite": ["caniuse-lite@1.0.30001792", "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz", {}, "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw=="],
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
"character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="],
"character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="],
"character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="],
"character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="],
@@ -835,20 +858,14 @@
"detect-libc": ["detect-libc@2.1.2", "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
"doctrine": ["doctrine@2.1.0", "https://registry.npmmirror.com/doctrine/-/doctrine-2.1.0.tgz", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="],
"dom-accessibility-api": ["dom-accessibility-api@0.5.16", "https://registry.npmmirror.com/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
"dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
"domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="],
"domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="],
"dompurify": ["dompurify@3.4.7", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-2jBxDJY4RR06tQNy4w5FlFH7kfxsQZlufd0sbv+chfHCxeJwrFw2baUDsSwvBISD4K4RDbd0PTfy3uNXsR6siA=="],
"domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="],
"dot-prop": ["dot-prop@5.3.0", "https://registry.npmmirror.com/dot-prop/-/dot-prop-5.3.0.tgz", { "dependencies": { "is-obj": "^2.0.0" } }, "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q=="],
"drizzle-kit": ["drizzle-kit@0.31.10", "https://registry.npmmirror.com/drizzle-kit/-/drizzle-kit-0.31.10.tgz", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "tsx": "^4.21.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw=="],
@@ -1013,6 +1030,10 @@
"hast-util-parse-selector": ["hast-util-parse-selector@4.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A=="],
"hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="],
"hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="],
"hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="],
"help-me": ["help-me@5.0.0", "https://registry.npmmirror.com/help-me/-/help-me-5.0.0.tgz", {}, "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg=="],
@@ -1025,13 +1046,9 @@
"highlightjs-vue": ["highlightjs-vue@1.0.0", "", {}, "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA=="],
"html-dom-parser": ["html-dom-parser@5.1.8", "", { "dependencies": { "domhandler": "5.0.3", "htmlparser2": "10.1.0" } }, "sha512-MCIUng//mF2qTtGHXJWr6OLfHWmg3Pm8ezpfiltF83tizPWY17JxT4dRLE8lykJ5bChJELoY3onQKPbufJHxYA=="],
"html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "https://registry.npmmirror.com/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="],
"html-react-parser": ["html-react-parser@5.2.17", "", { "dependencies": { "domhandler": "5.0.3", "html-dom-parser": "5.1.8", "react-property": "2.0.2", "style-to-js": "1.1.21" }, "peerDependencies": { "@types/react": "0.14 || 15 || 16 || 17 || 18 || 19", "react": "0.14 || 15 || 16 || 17 || 18 || 19" }, "optionalPeers": ["@types/react"] }, "sha512-m+K/7Moq1jodAB4VL0RXSOmtwLUYoAsikZhwd+hGQe5Vtw2dbWfpFd60poxojMU0Tsh9w59mN1QLEcoHz0Dx9w=="],
"htmlparser2": ["htmlparser2@10.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "entities": "^7.0.1" } }, "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ=="],
"html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="],
"husky": ["husky@9.1.7", "https://registry.npmmirror.com/husky/-/husky-9.1.7.tgz", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="],
@@ -1049,8 +1066,6 @@
"ini": ["ini@6.0.0", "https://registry.npmmirror.com/ini/-/ini-6.0.0.tgz", {}, "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ=="],
"inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="],
"internal-slot": ["internal-slot@1.1.0", "https://registry.npmmirror.com/internal-slot/-/internal-slot-1.1.0.tgz", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="],
"internmap": ["internmap@2.0.3", "https://registry.npmmirror.com/internmap/-/internmap-2.0.3.tgz", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
@@ -1209,16 +1224,30 @@
"lz-string": ["lz-string@1.5.0", "https://registry.npmmirror.com/lz-string/-/lz-string-1.5.0.tgz", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="],
"marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="],
"markdown-to-jsx": ["markdown-to-jsx@9.8.1", "", { "peerDependencies": { "react": ">= 16.0.0", "react-native": "*", "solid-js": ">=1.0.0", "vue": ">=3.0.0" }, "optionalPeers": ["react", "react-native", "solid-js", "vue"] }, "sha512-yq70dLPkBnE2LYFtGTLfRes4qyBDS+a4wDttAA/b/BzVGrbs2e0TfCeSFrMkapCg1lsxYi+42BowuBDxLP9k4Q=="],
"marked": ["marked@16.4.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="],
"mdn-data": ["mdn-data@2.27.1", "https://registry.npmmirror.com/mdn-data/-/mdn-data-2.27.1.tgz", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="],
"meow": ["meow@13.2.0", "https://registry.npmmirror.com/meow/-/meow-13.2.0.tgz", {}, "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA=="],
"mermaid": ["mermaid@11.15.0", "", { "dependencies": { "@braintree/sanitize-url": "^7.1.1", "@iconify/utils": "^3.0.2", "@mermaid-js/parser": "^1.1.1", "@types/d3": "^7.4.3", "@upsetjs/venn.js": "^2.0.0", "cytoscape": "^3.33.1", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.14", "dayjs": "^1.11.19", "dompurify": "^3.3.1", "es-toolkit": "^1.45.1", "katex": "^0.16.25", "khroma": "^2.1.0", "marked": "^16.3.0", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", "uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0" } }, "sha512-pTMbcf3rWdtLiYGpmoTjHEpeY8seiy6sR+9nD7LOs8KfUbHE4lOUAprTRqRAcWSQ6MQpdX+YEsxShtGsINtPtw=="],
"micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="],
"micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="],
"micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="],
"micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="],
"micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],
"mimic-function": ["mimic-function@5.0.1", "https://registry.npmmirror.com/mimic-function/-/mimic-function-5.0.1.tgz", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="],
"minimatch": ["minimatch@10.2.5", "https://registry.npmmirror.com/minimatch/-/minimatch-10.2.5.tgz", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
@@ -1259,6 +1288,10 @@
"onetime": ["onetime@7.0.0", "https://registry.npmmirror.com/onetime/-/onetime-7.0.0.tgz", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="],
"oniguruma-parser": ["oniguruma-parser@0.12.2", "", {}, "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw=="],
"oniguruma-to-es": ["oniguruma-to-es@4.3.6", "", { "dependencies": { "oniguruma-parser": "^0.12.2", "regex": "^6.1.0", "regex-recursion": "^6.0.2" } }, "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA=="],
"optionator": ["optionator@0.9.4", "https://registry.npmmirror.com/optionator/-/optionator-0.9.4.tgz", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
"overlayscrollbars": ["overlayscrollbars@2.16.0", "", {}, "sha512-N03oje/q7j93D0aLZtoCdsDSYLmhheSsv8H7oSLE7HhdV9P/bmCURtLV/KbPye7P/bpfyt/obSfDpGUYoJ0OWg=="],
@@ -1337,8 +1370,6 @@
"react-is": ["react-is@18.3.1", "https://registry.npmmirror.com/react-is/-/react-is-18.3.1.tgz", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
"react-property": ["react-property@2.0.2", "", {}, "sha512-+PbtI3VuDV0l6CleQMsx2gtK0JZbZKbpdu5ynr+lbsuvtmgbNcS3VM0tuY2QjFNOcWxvXeHjDpy42RO+4U2rug=="],
"react-redux": ["react-redux@9.2.0", "https://registry.npmmirror.com/react-redux/-/react-redux-9.2.0.tgz", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "@types/react": "^18.2.25 || ^19", "react": "^18.0 || ^19", "redux": "^5.0.0" }, "optionalPeers": ["@types/react", "redux"] }, "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g=="],
"react-router": ["react-router@7.15.1", "https://registry.npmmirror.com/react-router/-/react-router-7.15.1.tgz", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-R8rl9HhgikFYoPJymnUtPXWbnDb3oget6lQnfIoupbt61aT9aOhRkDsY2XRhZRyX1Z/8a5sL74fXmFNm3NRK5A=="],
@@ -1357,6 +1388,12 @@
"refractor": ["refractor@5.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/prismjs": "^1.0.0", "hastscript": "^9.0.0", "parse-entities": "^4.0.0" } }, "sha512-QXOrHQF5jOpjjLfiNk5GFnWhRXvxjUVnlFxkeDmewR5sXkr3iM46Zo+CnRR8B+MDVqkULW4EcLVcRBNOPXHosw=="],
"regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="],
"regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="],
"regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="],
"regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "https://registry.npmmirror.com/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="],
"require-from-string": ["require-from-string@2.0.2", "https://registry.npmmirror.com/require-from-string/-/require-from-string-2.0.2.tgz", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
@@ -1413,6 +1450,8 @@
"shebang-regex": ["shebang-regex@3.0.0", "https://registry.npmmirror.com/shebang-regex/-/shebang-regex-3.0.0.tgz", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
"shiki": ["shiki@4.2.0", "", { "dependencies": { "@shikijs/core": "4.2.0", "@shikijs/engine-javascript": "4.2.0", "@shikijs/engine-oniguruma": "4.2.0", "@shikijs/langs": "4.2.0", "@shikijs/themes": "4.2.0", "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-hjNax6o/ylDy9lefQEaSDtzaT3iVNtZ3WmpQnbuQNoG4xvnSKf2kSKbihZVO4JRG1TTMejs7CmNRYlWgAL66pQ=="],
"side-channel": ["side-channel@1.1.0", "https://registry.npmmirror.com/side-channel/-/side-channel-1.1.0.tgz", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
"side-channel-list": ["side-channel-list@1.0.1", "https://registry.npmmirror.com/side-channel-list/-/side-channel-list-1.0.1.tgz", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="],
@@ -1453,16 +1492,14 @@
"string.prototype.trimstart": ["string.prototype.trimstart@1.0.8", "https://registry.npmmirror.com/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg=="],
"stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
"strip-ansi": ["strip-ansi@7.2.0", "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-7.2.0.tgz", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
"strip-bom": ["strip-bom@3.0.0", "https://registry.npmmirror.com/strip-bom/-/strip-bom-3.0.0.tgz", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="],
"strip-json-comments": ["strip-json-comments@5.0.3", "https://registry.npmmirror.com/strip-json-comments/-/strip-json-comments-5.0.3.tgz", {}, "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw=="],
"style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="],
"style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="],
"stylis": ["stylis@4.4.0", "https://registry.npmmirror.com/stylis/-/stylis-4.4.0.tgz", {}, "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA=="],
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "https://registry.npmmirror.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
@@ -1493,6 +1530,8 @@
"tr46": ["tr46@6.0.0", "https://registry.npmmirror.com/tr46/-/tr46-6.0.0.tgz", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw=="],
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
"ts-api-utils": ["ts-api-utils@2.5.0", "https://registry.npmmirror.com/ts-api-utils/-/ts-api-utils-2.5.0.tgz", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="],
"ts-dedent": ["ts-dedent@2.2.0", "", {}, "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ=="],
@@ -1523,6 +1562,16 @@
"undici-types": ["undici-types@7.25.0", "https://registry.npmmirror.com/undici-types/-/undici-types-7.25.0.tgz", {}, "sha512-AXNgS1Byr27fTI+2bsPEkV9CxkT8H6xNyRI68b3TatlZo3RkzlqQBLL+w7SmGPVpokjHbcuNVQUWE7FRTg+LRA=="],
"unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="],
"unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="],
"unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="],
"unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="],
"unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="],
"unrs-resolver": ["unrs-resolver@1.11.1", "https://registry.npmmirror.com/unrs-resolver/-/unrs-resolver-1.11.1.tgz", { "dependencies": { "napi-postinstall": "^0.3.0" }, "optionalDependencies": { "@unrs/resolver-binding-android-arm-eabi": "1.11.1", "@unrs/resolver-binding-android-arm64": "1.11.1", "@unrs/resolver-binding-darwin-arm64": "1.11.1", "@unrs/resolver-binding-darwin-x64": "1.11.1", "@unrs/resolver-binding-freebsd-x64": "1.11.1", "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", "@unrs/resolver-binding-linux-x64-musl": "1.11.1", "@unrs/resolver-binding-wasm32-wasi": "1.11.1", "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" } }, "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg=="],
"update-browserslist-db": ["update-browserslist-db@1.2.3", "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
@@ -1533,6 +1582,10 @@
"uuid": ["uuid@14.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg=="],
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
"victory-vendor": ["victory-vendor@37.3.6", "https://registry.npmmirror.com/victory-vendor/-/victory-vendor-37.3.6.tgz", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ=="],
"vite": ["vite@8.0.14", "https://registry.npmmirror.com/vite/-/vite-8.0.14.tgz", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.15", "rolldown": "1.0.2", "tinyglobby": "^0.2.16" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw=="],
@@ -1581,6 +1634,8 @@
"zod-validation-error": ["zod-validation-error@4.0.2", "https://registry.npmmirror.com/zod-validation-error/-/zod-validation-error-4.0.2.tgz", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="],
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
"@babel/core/json5": ["json5@2.2.3", "https://registry.npmmirror.com/json5/-/json5-2.2.3.tgz", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "https://registry.npmmirror.com/lru-cache/-/lru-cache-5.1.1.tgz", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
@@ -1643,8 +1698,6 @@
"d3-sankey/d3-shape": ["d3-shape@1.3.7", "", { "dependencies": { "d3-path": "1" } }, "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw=="],
"dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"eslint/ajv": ["ajv@6.15.0", "https://registry.npmmirror.com/ajv/-/ajv-6.15.0.tgz", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="],
"eslint-import-resolver-node/debug": ["debug@3.2.7", "https://registry.npmmirror.com/debug/-/debug-3.2.7.tgz", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
@@ -1655,8 +1708,6 @@
"eslint-plugin-import/minimatch": ["minimatch@3.1.5", "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.5.tgz", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
"htmlparser2/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
"import-fresh/resolve-from": ["resolve-from@4.0.0", "https://registry.npmmirror.com/resolve-from/-/resolve-from-4.0.0.tgz", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
"is-bun-module/semver": ["semver@7.8.0", "https://registry.npmmirror.com/semver/-/semver-7.8.0.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="],
@@ -1665,8 +1716,6 @@
"log-update/wrap-ansi": ["wrap-ansi@9.0.2", "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-9.0.2.tgz", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
"mermaid/marked": ["marked@16.4.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="],
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
"pretty-format/react-is": ["react-is@17.0.2", "https://registry.npmmirror.com/react-is/-/react-is-17.0.2.tgz", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],

View File

@@ -30,6 +30,7 @@ SQLite + bun:sqlite + Drizzle ORM。
| `providers.ts` | createProvider、getProvider、listProviders、listProviderOptions、updateProvider、deleteProvider |
| `models.ts` | createModel、getModel、listModels、getModelWithProvider、getModelsByProviderId、updateModel、deleteModel |
| `conversations.ts` | createConversation、getConversation、listConversations、updateConversation、updateConversationTimestamp、deleteConversation、createMessage、createMessages、listMessages |
| `materials.ts` | createMaterial、getMaterial、listMaterials、deleteMaterial |
输入输出类型来自 `src/shared/api.ts`
@@ -56,6 +57,17 @@ SQLite + bun:sqlite + Drizzle ORM。
- `POST /api/providers/test` — 用未保存配置测试,不写入 DB不阻止保存。Base URL 不可达或 API Key 无效返回 `ok: false``/models` 不支持返回 `ok: true` + 提示。
- `POST /api/models/test` — 用模型关联供应商 + modelId 测试。
## 素材 API
| 方法 | 路径 | 说明 |
| ------ | ---------------------------------- | ---------------------- |
| GET | `/api/projects/:id/materials` | 列出项目下素材(分页) |
| POST | `/api/projects/:id/materials` | 创建素材 |
| GET | `/api/projects/:id/materials/:mid` | 获取素材详情 |
| DELETE | `/api/projects/:id/materials/:mid` | 删除素材(硬删除) |
校验description 必填非空associatedDate 必填 YYYY-MM-DD项目须存在且 active素材归属校验不匹配返回 403。
## 聊天 API
| 方法 | 路径 | 说明 |

View File

@@ -21,23 +21,25 @@ ConsoleShell 包含:`XProvider(zhCN + zhCN_X)` + `AntApp` + `Layout`(Header/Si
| 项目管理 | `features/projects/` | 项目 CRUD、归档、搜索 |
| 模型管理 | `features/models/` | 供应商/模型管理、连通性测试 |
| 聊天 | `features/chat/` | 会话管理、消息渲染、AI 对话 |
| 收集箱 | `features/inbox/` | 素材 CRUD、持久化、列表管理 |
## 页面
| 页面 | 路径 | 入口 |
| -------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 总览 | `/` | `features/dashboard/index.tsx` |
| 项目管理 | `/projects` | `features/projects/index.tsx` — ProjectToolbar(Tab 切换 active/archived + 搜索 + 新建) + ProjectTable + ProjectFormModal。支持创建/编辑/归档/恢复/删除,仅 active 项目可跳转工作台。 |
| 模型管理 | `/models` | `features/models/index.tsx` — antd Tabs 切换供应商/模型视图。模型表单和表格使用 `GET /api/providers/options`。供应商表单支持预保存连通性测试(`POST /api/providers/test`),新建时 type 默认 `openai-compatible`,测试 `ok: false` 展示失败但不阻止保存。 |
| 聊天室 | `/workbench/:id` | `features/chat/index.tsx` |
| 404 | `*` | `features/not-found/index.tsx` |
| 页面 | 路径 | 入口 |
| -------- | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 总览 | `/` | `features/dashboard/index.tsx` |
| 项目管理 | `/projects` | `features/projects/index.tsx` — ProjectToolbar(Tab 切换 active/archived + 搜索 + 新建) + ProjectTable + ProjectFormModal。支持创建/编辑/归档/恢复/删除,仅 active 项目可跳转工作台。 |
| 模型管理 | `/models` | `features/models/index.tsx` — antd Tabs 切换供应商/模型视图。模型表单和表格使用 `GET /api/providers/options`。供应商表单支持预保存连通性测试(`POST /api/providers/test`),新建时 type 默认 `openai-compatible`,测试 `ok: false` 展示失败但不阻止保存。 |
| 聊天室 | `/workbench/:id` | `features/chat/index.tsx` |
| 收集箱 | `/workbench/:id/inbox` | `features/inbox/index.tsx` — 协调层selectedId + modalOpen+ MaterialSidebar列表容器+ MaterialDetailPanel详情容器+ AddMaterialModal。素材 CRUD 通过 TanStack Query hooks 接入后端 API。 |
| 404 | `*` | `features/not-found/index.tsx` |
### 聊天页面
`ChatPage` = `Conversations`@ant-design/x+ `ChatPanel`
- **Conversations**会话侧边栏TanStack Query 管理会话列表,支持创建/选中/删除menu dropdown
- **ChatPanel**`useChat`@ai-sdk/react+ `DefaultChatTransport`ai 包)与后端 SSE 通信。按 `part.type` 分派渲染TextPartXMarkdown、ReasoningPart、ToolPart四态。支持编辑重发、重新生成、复制。
- **ChatPanel**`useChat`@ai-sdk/react+ `DefaultChatTransport`ai 包)与后端 SSE 通信。按 `part.type` 分派渲染TextPartmarkdown-to-jsx 含自定义 overridesCodeBlock 提供 Shiki 语法高亮和复制按钮、MarkdownTable 提供类 antd 表格样式、ReasoningPart、ToolPart四态。支持编辑重发、重新生成、复制。
- **Sender**@ant-design/x输入框 + 发送/停止按钮 + 模型 Selectfooter slot
## 共享代码
@@ -64,6 +66,7 @@ ConsoleShell 包含:`XProvider(zhCN + zhCN_X)` + `AntApp` + `Layout`(Header/Si
| `use-sidebar-collapsed` | `shared/hooks/use-sidebar-collapsed.ts` | 侧边栏折叠 localStorage 持久化 |
| `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` | 素材 CRUDcreate/delete/fetch/list + Query hooks |
### 共享工具函数

View File

@@ -1,17 +1,17 @@
审查 OpenSpec apply 完成后以及后续手动修补后的实际变更,判断实际产物验证结果和变更文档是否与 `design.md` 事实来源一致,识别偏离、漏记和可优化点,并将确认后的实际变更同步回变更文档,按以下流程执行。
审查 OpenSpec apply 完成后以及后续手动修补后的实际变更,判断实际产物验证结果是否与 `design.md` 事实来源一致,识别偏离、漏记和可优化点,按以下流程执行。
## 约束
- 先审查再修复;未经用户确认,不修改实际产物或变更文档
- 先审查再修复;未经用户确认,不修改实际产物
- 默认按 `fast-drive` workflow 审查;识别 change 后先确认 `schemaName`;若实际 schema 不同,说明差异,仅对实际存在的 artifacts 做审查
-`fast-drive` workflow 下,核心 artifacts 是 `design.md``tasks.md`;不要要求存在 `proposal.md``specs/*.md`
-`fast-drive` workflow 下,`design.md` 是范围、需求、决策、执行约束、执行方向和验证预期的事实来源,`tasks.md` 是 apply 进度和验证闭环的跟踪文件
- 禁止同步或修改 `openspec/specs/` 下的主规范;若实际 schema 包含 `specs/*.md`,也只允许修改本次 change 目录下实际存在的 spec artifacts主规范同步属于 archive 阶段,不在此提示词范围内
- 禁止同步或修改 `openspec/specs/` 下的主规范,该操作属于 archive 阶段,不在此提示词范围内
- 优先使用当前会话中的执行说明、验证结论、手动修补记录和已生成的变更文档;仅在无法明确 change、`schemaName`、改动范围或修补来源时,再用提问工具或 OpenSpec 命令补充定位
- 不要因为实际产物已经存在就自动以实际产物为准先判断差异属于“design 要求未完成”、“验证后新增修补”、“合理落地细化”还是“意外偏离/回归”
- 每批实际产物或文档修改执行前用提问工具获得用户确认
- 每批实际产物修改执行前用提问工具获得用户确认
- 删除/重写前用提问工具获得用户确认;若存在 git 仓库,不创建 `.bak` 备份文件,改用当前 `git status` / `git diff` 作为回退依据;仅在无版本控制或用户明确要求时,才将备份放到代码目录外的用户确认路径
- 若修改实际产物涉及新行为、流程、接口、内容、数据、配置、责任边界或用户可见结果,同步更新验证材料、相关变更文档和必要的文档/沟通材料
- 若修改实际产物涉及新行为、流程、接口、内容、数据、配置、责任边界或用户可见结果,同步更新验证材料
## 1. 收集
@@ -66,21 +66,20 @@ g) 若实际 schema 不是 `fast-drive`,只读取实际存在的 artifacts
按以下优先级检查:
| 优先级 | 维度 | 检查点 |
| ------ | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| P0 | 实际变更与验证结论 | 当前实际产物的真实状态是什么apply 后是否有手动改动或验证后修补;验证是否证明这些变更有效;若缺少验证结果,标记相关结论为“未验证”;检查是否存在回归、未覆盖场景或被掩盖的问题 |
| P1 | `design.md` 一致性 | 实际变更是否符合“需求”“目标 / 非目标”“执行约束”“决策”“执行计划”和“验证计划”;“待解决问题”是否已明确区分 blocking / non-blocking 或写出“无”;是否违反被明确否决的方案、用户偏好或约束 |
| P2 | `tasks.md` 真实性 | 已完成任务是否真的完成并完成必要验证未完成任务是否仍然必要apply 或手动修补是否引入了需要补充的新任务验证任务或文档/沟通任务 |
| P3 | 文档回写完整性 | 已落地的实际变更、验证后新增修补、边界处理、异常路径、验证结论、实际触达产物是否已同步回 `design.md``tasks.md`;若影响行为、流程、接口、内容、数据、配置、责任边界或用户可见结果,再检查必要的文档/沟通材料是否同步 |
| P4 | 质量与可维护性 | 实际产物的结构、清晰度、一致性、可维护性、风险处理、移交质量、验证质量、与现有模式的一致性是否存在明显问题或可优化点 |
| P5 | Schema 兼容性 | 对实际存在的 artifacts 检查是否符合其 schema若不是 `fast-drive`,仅按实际 artifacts 检查,不凭空要求 `fast-drive` 专属结构;最终 artifacts 是否仍保留模板注释、空表格行或占位任务文本 |
| 优先级 | 维度 | 检查点 |
| ------ | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| P0 | 实际变更与验证结论 | 当前实际产物的真实状态是什么apply 后是否有手动改动或验证后修补;验证是否证明这些变更有效;若缺少验证结果,标记相关结论为“未验证”;检查是否存在回归、未覆盖场景或被掩盖的问题 |
| P1 | `design.md` 一致性 | 实际变更是否符合“需求”“目标 / 非目标”“执行约束”“决策”“执行计划”和“验证计划”;“待解决问题”是否已明确区分 blocking / non-blocking 或写出“无”;是否违反被明确否决的方案、用户偏好或约束 |
| P2 | `tasks.md` 真实性 | 已完成任务是否真的完成并完成必要验证未完成任务是否仍然必要apply 或手动修补是否引入了需要补充的新任务验证任务 |
| P3 | 质量与可维护性 | 实际产物的结构、清晰度、一致性、可维护性、风险处理、移交质量、验证质量、与现有模式的一致性是否存在明显问题或可优化点 |
| P4 | Schema 兼容性 | 实际存在的 artifacts 检查是否符合其 schema若不是 `fast-drive`,仅按实际 artifacts 检查,不凭空要求 `fast-drive` 专属结构;最终 artifacts 是否仍保留模板注释、空表格行或占位任务文本 |
分析时区分四类差异:
- `design.md` 要求已明确,但实际变更未完成或完成不充分 → 需补充实际工作或验证
- 实际变更因验证暴露问题、手动修补或合理落地细化而新增/变更 → 需回写 `design.md` 和/或 `tasks.md`
- 实际变更因验证暴露问题、手动修补或合理落地细化而新增/变更 → 需确认是否需要补充验证
- 实际变更与 `design.md` 不一致,且无法判断应以哪边为准 → 列入待确认清单
- `tasks.md` 状态与实际完成情况或验证结果不一致 → 修正任务状态或补充任务
- `tasks.md` 状态与实际完成情况或验证结果不一致 → 列入任务状态问题清单
不要把以下情况直接视为合理修补:
@@ -91,14 +90,13 @@ g) 若实际 schema 不是 `fast-drive`,只读取实际存在的 artifacts
重点识别:
- `design.md` 要求但未落地的结果、流程、内容、场景、异常处理、文档/沟通更新或验证步骤
- 实际变更偏离目标 / 非目标”“执行约束”“决策”或“执行计划的地方
- apply 完成后新增的修补、边界处理、接口调整、行为变化、流程变化或内容变化未同步到 `design.md`
- 影响范围与实际改动范围不一致,导致新会话无法复盘真实影响面
- 验证计划中要求的验证、质量检查、审阅、批准、沟通检查或人工检查未执行或未记录
- `tasks.md` 标记完成,但实际产物验证、文档或沟通未闭环
- `design.md` 要求但未落地的结果、流程、内容、场景、异常处理或验证步骤
- 实际变更偏离"目标 / 非目标""执行约束""决策"或"执行计划"的地方
- apply 完成后新增的修补、边界处理、接口调整、行为变化、流程变化或内容变化
- "影响范围"与实际改动范围不一致,导致新会话无法复盘真实影响面
- "验证计划"中要求的验证、质量检查、审阅、批准、沟通检查或人工检查未执行或未记录
- `tasks.md` 标记完成,但实际产物验证未闭环
- `design.md``tasks.md` 仍保留 `<!-- ... -->` 模板注释、空表格行、`Replace with...``TBD``TODO` 等未解决占位内容
- 必要的文档/沟通材料未同步影响行为、流程、接口、内容、数据、配置、责任边界或用户可见结果的变更
- 实际产物存在明显的重复、复杂度过高、表达不清、责任不明、风险处理薄弱、验证质量不足等问题
- `fast-drive` change 中仍错误依赖 `proposal.md``specs/*.md``Capabilities``Modified Capabilities` 的内容
@@ -107,13 +105,12 @@ g) 若实际 schema 不是 `fast-drive`,只读取实际存在的 artifacts
1. **问题总览表**:问题类型 × 涉及文件数
2. **实际变更与修补清单**:本次已落地的主要变更、后续修补和验证结论;若缺少验证结果,对未验证部分单独标记
3. **Design 偏离清单**:实际变更未完成、完成不充分或偏离 `design.md` 的内容
4. **需回写文档清单**:实际产物和验证中已确认、但 `design.md``tasks.md` 或相关文档/沟通材料未体现的变更、修补或约束变化
5. **方向待确认清单**实际变更与 `design.md` 不一致,且无法判断应以哪边为准的事项
6. **任务状态问题清单**未真正完成、状态错误或需补充的新任务
7. **验证问题清单**缺失覆盖、掩盖错误、验证不足或修补后未回归验证的问题
8. **质量/优化清单**:可优化的实际产物问题和建议
9. **Schema 差异清单**:实际 schema 与默认 `fast-drive` 不同时,列出因此跳过或改按实际 artifacts 审查的内容
10. **逐项分析**:每个问题说明位置、问题、影响、建议和建议修复方向
4. **方向待确认清单**:实际变更与 `design.md` 不一致,且无法判断应以哪边为准的事项
5. **任务状态问题清单**未真正完成、状态错误或需补充的新任务
6. **验证问题清单**缺失覆盖、掩盖错误、验证不足或修补后未回归验证的问题
7. **质量/优化清单**可优化的实际产物问题和建议
8. **Schema 差异清单**:实际 schema 与默认 `fast-drive` 不同时,列出因此跳过或改按实际 artifacts 审查的内容
9. **逐项分析**:每个问题说明位置、问题、影响、建议和建议修复方向
若所有清单均为空,输出“审查通过,未发现问题”,跳至步骤 5。
@@ -124,11 +121,9 @@ g) 若实际 schema 不是 `fast-drive`,只读取实际存在的 artifacts
再整理完整修复方案,按类别列出:
- 实际工作或验证补充:补完成、补异常处理、补回归验证、修复被弱化或绕过的验证
- Design 回写:同步 `design.md` 中遗漏或过时的需求、执行约束、影响范围、决策、执行计划、验证计划、风险或待解决问题
- 任务状态修正:修正已完成/未完成状态,补充 apply 后新增但已完成的修补任务或验证任务
- 文档/沟通同步:同步行为、流程、接口、内容、数据、配置、责任边界或用户可见结果变化
- 质量优化:在不改变目标结果的前提下优化结构、表达、一致性、可维护性或移交质量
- Schema 兼容处理:若实际 schema 不是 `fast-drive`,按实际存在的 artifacts 说明额外文档同步
- Schema 兼容处理:若实际 schema 不是 `fast-drive`,按实际存在的 artifacts 说明需关注的差异
对每个拟修改的文件说明:
@@ -142,38 +137,26 @@ g) 若实际 schema 不是 `fast-drive`,只读取实际存在的 artifacts
## 4. 执行
逐项执行已确认的实际产物验证和文档修复。
逐项执行已确认的实际产物验证修复。
若涉及删除或重写:
- 存在 git 仓库时,先记录当前 `git status` / `git diff`,不要在实际产物、文档或代码目录创建 `.bak` 文件
- 存在 git 仓库时,先记录当前 `git status` / `git diff`,不要在实际产物或代码目录创建 `.bak` 文件
- 不存在版本控制,或用户明确要求备份时,先用提问工具确认代码目录外的备份路径,再执行修改
若修改了实际产物或验证材料:
- 同步更新相关变更文档;若影响行为、流程、接口、内容、数据、配置、责任边界或用户可见结果,再同步必要的文档/沟通材料
- 运行或执行相关验证;若修补影响范围较大,再补充执行受影响的回归验证
若修改了文档
执行后重新读取所有被修改的实际产物和验证材料,并复核
- `fast-drive` workflow 下,确认 `design.md` 仍是事实来源,`tasks.md` 仍从 `design.md` 派生
- 确认 `design.md` 的需求、执行约束、影响范围、决策、执行计划、验证计划、风险和待解决问题与实际变更一致
- 确认 `tasks.md` 每个完成任务都有对应实际产物和必要验证,新增修补已补充任务或记录在合适任务中
- 禁止将本次 change 内容同步到 `openspec/specs/`,该操作属于 archive 阶段
-`fast-drive` workflow 下不创建 `proposal.md``specs/*.md`;若实际 schema 不是 `fast-drive`,则按实际 schema 的 required artifacts 创建或更新本次 change 目录下的 artifacts
执行后重新读取所有被修改的实际产物、验证材料和文档,并复核:
- “Design 偏离清单” 是否已清空或已标注保留原因
- “需回写文档清单” 是否已清空
- “方向待确认清单” 是否已清空或已记录用户决策
- “任务状态问题清单” 和 “验证问题清单” 是否已清空或已标注残留原因
- “质量/优化清单” 中哪些已处理,哪些有意延期
- 必要的文档/沟通材料是否已按影响范围同步
- 所有模板注释、空表格行和占位文本是否已清空或替换为有效内容
- "Design 偏离清单" 是否已清空或已标注保留原因
- "方向待确认清单" 是否已清空或已记录用户决策
- "任务状态问题清单" 和 "验证问题清单" 是否已清空或已标注残留原因
- "质量/优化清单" 中哪些已处理,哪些有意延期
## 5. 收尾
列出所有修改的文件、回退依据、验证命令或检查结果、文档同步摘要和剩余风险;若实际创建了备份,再列出备份文件。
列出所有修改的文件、回退依据、验证命令或检查结果和剩余风险;若实际创建了备份,再列出备份文件。
若本次因缺少验证结果、修补记录或上下文而降级执行,或有问题因信息不足暂未处理,单独说明。

View File

@@ -0,0 +1,12 @@
CREATE TABLE `materials` (
`associated_date` text NOT NULL,
`created_at` text NOT NULL,
`description` text NOT NULL,
`id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`status` text DEFAULT 'pending' NOT NULL,
`updated_at` text NOT NULL,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE INDEX `materials_project_id_idx` ON `materials` (`project_id`);

View File

@@ -0,0 +1,499 @@
{
"version": "6",
"dialect": "sqlite",
"id": "340f6d1a-081b-413d-a289-f39592ece0a2",
"prevId": "da8963db-526e-46a1-a453-4027d5541db9",
"tables": {
"conversations": {
"name": "conversations",
"columns": {
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"model_id": {
"name": "model_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"project_id": {
"name": "project_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'新会话'"
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"conversations_project_id_idx": {
"name": "conversations_project_id_idx",
"columns": ["project_id"],
"isUnique": false
}
},
"foreignKeys": {
"conversations_model_id_models_id_fk": {
"name": "conversations_model_id_models_id_fk",
"tableFrom": "conversations",
"tableTo": "models",
"columnsFrom": ["model_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
},
"conversations_project_id_projects_id_fk": {
"name": "conversations_project_id_projects_id_fk",
"tableFrom": "conversations",
"tableTo": "projects",
"columnsFrom": ["project_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"materials": {
"name": "materials",
"columns": {
"associated_date": {
"name": "associated_date",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"project_id": {
"name": "project_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'pending'"
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"materials_project_id_idx": {
"name": "materials_project_id_idx",
"columns": ["project_id"],
"isUnique": false
}
},
"foreignKeys": {
"materials_project_id_projects_id_fk": {
"name": "materials_project_id_projects_id_fk",
"tableFrom": "materials",
"tableTo": "projects",
"columnsFrom": ["project_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"messages": {
"name": "messages",
"columns": {
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"conversation_id": {
"name": "conversation_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"parts": {
"name": "parts",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"messages_conversation_id_idx": {
"name": "messages_conversation_id_idx",
"columns": ["conversation_id"],
"isUnique": false
}
},
"foreignKeys": {
"messages_conversation_id_conversations_id_fk": {
"name": "messages_conversation_id_conversations_id_fk",
"tableFrom": "messages",
"tableTo": "conversations",
"columnsFrom": ["conversation_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"models": {
"name": "models",
"columns": {
"capabilities": {
"name": "capabilities",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"context_length": {
"name": "context_length",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"max_output_tokens": {
"name": "max_output_tokens",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"model_id": {
"name": "model_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"models_provider_id_model_id_unique": {
"name": "models_provider_id_model_id_unique",
"columns": ["provider_id", "model_id"],
"isUnique": true
},
"models_provider_id_idx": {
"name": "models_provider_id_idx",
"columns": ["provider_id"],
"isUnique": false
}
},
"foreignKeys": {
"models_provider_id_providers_id_fk": {
"name": "models_provider_id_providers_id_fk",
"tableFrom": "models",
"tableTo": "providers",
"columnsFrom": ["provider_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"projects": {
"name": "projects",
"columns": {
"archived_at": {
"name": "archived_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'active'"
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"projects_name_unique": {
"name": "projects_name_unique",
"columns": ["name"],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"providers": {
"name": "providers",
"columns": {
"api_key": {
"name": "api_key",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"base_url": {
"name": "base_url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'openai-compatible'"
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"providers_name_unique": {
"name": "providers_name_unique",
"columns": ["name"],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"schema_migrations": {
"name": "schema_migrations",
"columns": {
"applied_at": {
"name": "applied_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"checksum": {
"name": "checksum",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -22,6 +22,13 @@
"when": 1780162361636,
"tag": "0002_orange_black_knight",
"breakpoints": true
},
{
"idx": 3,
"version": "6",
"when": 1780463734721,
"tag": "0003_lying_cassandra_nova",
"breakpoints": true
}
]
}

View File

@@ -59,7 +59,6 @@
"@ai-sdk/react": "^3.0.195",
"@ant-design/icons": "^6.2.3",
"@ant-design/x": "^2.7.0",
"@ant-design/x-markdown": "^2.7.0",
"@sinclair/typebox": "^0.34.49",
"@tanstack/react-query": "^5.100.14",
"ai": "^6.0.193",
@@ -67,6 +66,7 @@
"antd": "^6.4.3",
"drizzle-orm": "^0.45.2",
"es-toolkit": "^1.47.0",
"markdown-to-jsx": "^9.8.1",
"overlayscrollbars": "^2.16.0",
"overlayscrollbars-react": "^0.5.6",
"pino": "^10.3.1",
@@ -76,6 +76,7 @@
"react-dom": "^19.2.6",
"react-router": "^7.15.1",
"recharts": "^3.8.1",
"shiki": "^4.2.0",
"zod": "^4.4.3"
}
}

107
src/server/db/materials.ts Normal file
View File

@@ -0,0 +1,107 @@
import type Database from "bun:sqlite";
import { desc, eq } from "drizzle-orm";
import type { CreateMaterialRequest, Material, MaterialStatus } from "../../shared/api";
import type { Logger } from "../logger";
import { paginateQuery, wrap } from "./connection";
import { materials, projects } from "./schema";
export function createMaterial(
raw: Database,
projectId: string,
request: CreateMaterialRequest,
_logger: Logger,
): { error: string; status: number } | { material: Material } {
const db = wrap(raw);
const project = db.select().from(projects).where(eq(projects.id, projectId)).get();
if (!project) return { error: "项目不存在", status: 404 };
if (project.status === "archived") return { error: "已归档项目不可操作", status: 409 };
const description = request.description.trim();
if (!description) return { error: "描述不能为空", status: 400 };
const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
if (!dateRegex.test(request.associatedDate)) {
return { error: "associatedDate 格式错误,要求 YYYY-MM-DD", status: 400 };
}
const id = crypto.randomUUID();
const now = new Date().toISOString();
db.insert(materials)
.values({
associatedDate: request.associatedDate,
createdAt: now,
description,
id,
projectId,
status: "pending",
updatedAt: now,
})
.run();
const row = db.select().from(materials).where(eq(materials.id, id)).get();
return { material: toMaterial(row!) };
}
export function deleteMaterial(
raw: Database,
projectId: string,
materialId: string,
_logger: Logger,
): { error: string; status: number } | { success: true } {
const db = wrap(raw);
const row = db.select().from(materials).where(eq(materials.id, materialId)).get();
if (!row) return { error: "素材不存在", status: 404 };
if (row.projectId !== projectId) return { error: "素材不属于该项目", status: 403 };
db.delete(materials).where(eq(materials.id, materialId)).run();
return { success: true };
}
export function getMaterial(
raw: Database,
projectId: string,
materialId: string,
): { error: string; status: number } | { material: Material } {
const db = wrap(raw);
const row = db.select().from(materials).where(eq(materials.id, materialId)).get();
if (!row) return { error: "素材不存在", status: 404 };
if (row.projectId !== projectId) return { error: "素材不属于该项目", status: 403 };
return { material: toMaterial(row) };
}
export function listMaterials(
raw: Database,
projectId: string,
options: { page: number; pageSize: number; status?: MaterialStatus },
): { items: Material[]; page: number; pageSize: number; total: number } {
const conditions = [eq(materials.projectId, projectId)];
if (options.status) {
conditions.push(eq(materials.status, options.status));
}
return paginateQuery(raw, materials, {
conditions,
mapRow: toMaterial,
orderBy: () => desc(materials.createdAt),
page: options.page,
pageSize: options.pageSize,
});
}
function toMaterial(row: typeof materials.$inferSelect): Material {
return {
associatedDate: row.associatedDate,
createdAt: row.createdAt,
description: row.description,
id: row.id,
projectId: row.projectId,
status: row.status,
updatedAt: row.updatedAt,
};
}

View File

@@ -62,6 +62,24 @@ export const conversations = sqliteTable(
(table) => [index("conversations_project_id_idx").on(table.projectId)],
);
export const materials = sqliteTable(
"materials",
{
associatedDate: text("associated_date").notNull(),
createdAt: text("created_at").notNull(),
description: text("description").notNull(),
id: text("id").primaryKey(),
projectId: text("project_id")
.notNull()
.references(() => projects.id),
status: text("status", { enum: ["pending", "approved", "discarded"] })
.notNull()
.default("pending"),
updatedAt: text("updated_at").notNull(),
},
(table) => [index("materials_project_id_idx").on(table.projectId)],
);
export const messages = sqliteTable(
"messages",
{

View File

@@ -0,0 +1,45 @@
import type Database from "bun:sqlite";
import type { CreateMaterialRequest, RuntimeMode } from "../../../shared/api";
import type { Logger } from "../../logger";
import { createMaterial } from "../../db/materials";
import { createApiError, jsonResponse, parseIdFromUrl } from "../../helpers";
import { validateIdParam } from "../../middleware";
export async function handleCreateMaterial(
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: CreateMaterialRequest;
try {
body = (await req.json()) as CreateMaterialRequest;
} 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.description || typeof body.description !== "string") {
return jsonResponse(createApiError("description is required", 400), { mode, status: 400 });
}
if (!body.associatedDate || typeof body.associatedDate !== "string") {
return jsonResponse(createApiError("associatedDate is required", 400), { mode, status: 400 });
}
const result = createMaterial(db, validated.id, body, logger);
if ("error" in result) {
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
}
logger.info({ materialId: result.material.id, projectId: validated.id }, "素材创建成功");
return jsonResponse(result, { mode, status: 201 });
}

View File

@@ -0,0 +1,29 @@
import type Database from "bun:sqlite";
import type { RuntimeMode } from "../../../shared/api";
import type { Logger } from "../../logger";
import { deleteMaterial } from "../../db/materials";
import { createApiError, jsonResponse } from "../../helpers";
import { validateIdParam } from "../../middleware";
export function handleDeleteMaterial(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 = deleteMaterial(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 new Response(null, { status: 204 });
}

View File

@@ -0,0 +1,28 @@
import type Database from "bun:sqlite";
import type { RuntimeMode } from "../../../shared/api";
import type { Logger } from "../../logger";
import { getMaterial } from "../../db/materials";
import { createApiError, jsonResponse } from "../../helpers";
import { validateIdParam } from "../../middleware";
export function handleGetMaterial(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 = getMaterial(db, validatedProject.id, validatedMaterial.id);
if ("error" in result) {
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
}
return jsonResponse(result, { mode });
}

View File

@@ -0,0 +1,35 @@
import type Database from "bun:sqlite";
import type { RuntimeMode } from "../../../shared/api";
import type { Logger } from "../../logger";
import { listMaterials } from "../../db/materials";
import { createApiError, jsonResponse, parseIdFromUrl } from "../../helpers";
import { validateIdParam, validatePagination } from "../../middleware";
export function handleListMaterials(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 statusParam = url.searchParams.get("status");
const pagination = validatePagination(pageParam, pageSizeParam, mode);
if (pagination instanceof Response) return pagination;
if (statusParam && statusParam !== "pending" && statusParam !== "approved" && statusParam !== "discarded") {
return jsonResponse(createApiError("Invalid status parameter", 400), { mode, status: 400 });
}
const result = listMaterials(db, validated.id, {
page: pagination.page,
pageSize: pagination.pageSize,
status: (statusParam as "approved" | "discarded" | "pending") ?? undefined,
});
return jsonResponse(result, { mode });
}

View File

@@ -220,6 +220,42 @@ export function startServer(options: StartServerOptions) {
logger,
),
},
"/api/projects/:id/materials": {
GET: withErrorHandler(
async (req) => {
const { handleListMaterials } = await import("./routes/materials/list");
return handleListMaterials(req, db, mode, logger);
},
mode,
logger,
),
POST: withErrorHandler(
async (req) => {
const { handleCreateMaterial } = await import("./routes/materials/create");
return handleCreateMaterial(req, db, mode, logger);
},
mode,
logger,
),
},
"/api/projects/:id/materials/:mid": {
DELETE: withErrorHandler(
async (req) => {
const { handleDeleteMaterial } = await import("./routes/materials/delete");
return handleDeleteMaterial(req, db, mode, logger);
},
mode,
logger,
),
GET: withErrorHandler(
async (req) => {
const { handleGetMaterial } = await import("./routes/materials/get");
return handleGetMaterial(req, db, mode, logger);
},
mode,
logger,
),
},
"/api/projects/:id/restore": {
POST: withErrorHandler(
async (req) => {

View File

@@ -28,6 +28,11 @@ export interface CreateConversationRequest {
title?: string;
}
export interface CreateMaterialRequest {
associatedDate: string;
description: string;
}
export interface CreateModelRequest {
capabilities: ModelCapability[];
contextLength?: null | number;
@@ -54,6 +59,29 @@ export interface CreateProviderRequest {
// 前后端共享的类型都放在这个文件中
// ==========================================
export interface Material {
associatedDate: string;
createdAt: string;
description: string;
id: string;
projectId: string;
status: MaterialStatus;
updatedAt: string;
}
export interface MaterialListResponse {
items: Material[];
page: number;
pageSize: number;
total: number;
}
export interface MaterialResponse {
material: Material;
}
export type MaterialStatus = "approved" | "discarded" | "pending";
export interface Message {
content: string;
conversationId: string;

3
src/web/css.d.ts vendored
View File

@@ -1,4 +1 @@
declare module "*.css";
declare module "react-syntax-highlighter/dist/esm/styles/prism" {
export { oneDark, oneLight } from "react-syntax-highlighter";
}

View File

@@ -1,7 +1,7 @@
import { DeleteOutlined, MoreOutlined } from "@ant-design/icons";
import { DeleteOutlined, MoreOutlined, PlusOutlined } from "@ant-design/icons";
import { Conversations } from "@ant-design/x";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { App, Spin } from "antd";
import { App, Button, Spin } from "antd";
import { useMemo, useState } from "react";
import type { Conversation } from "../../../shared/api";
@@ -52,23 +52,30 @@ export function ChatPage() {
return (
<div className="app-chat-page">
<div className="app-chat-conversations">
<div className="app-chat-conversations-header">
<Button
block
icon={<PlusOutlined />}
onClick={() => {
void createConversation(project.id, defaultModelId ?? undefined)
.then((conv) => {
void queryClient.invalidateQueries({ queryKey: CONVERSATIONS_KEY });
setActiveConversationId(conv.id);
})
.catch((err: Error) => {
void message.error(`创建会话失败:${err.message}`);
});
}}
type="primary"
>
</Button>
</div>
{isLoading ? (
<Spin />
) : (
<Conversations
activeKey={activeConversationId ?? ""}
creation={{
onClick: () => {
void createConversation(project.id, defaultModelId ?? undefined)
.then((conv) => {
void queryClient.invalidateQueries({ queryKey: CONVERSATIONS_KEY });
setActiveConversationId(conv.id);
})
.catch((err: Error) => {
void message.error(`创建会话失败:${err.message}`);
});
},
}}
items={conversations}
menu={(conv) => ({
items: [
@@ -85,6 +92,7 @@ export function ChatPage() {
trigger: <MoreOutlined />,
})}
onActiveChange={(key) => setActiveConversationId(key)}
rootClassName="app-chat-conversations-list"
/>
)}
</div>

View File

@@ -0,0 +1,84 @@
import type { ReactNode } from "react";
import { CopyOutlined } from "@ant-design/icons";
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 {
children: ReactNode;
className?: string;
isStreaming: boolean;
}
export function CodeBlock({ children, className: _className, isStreaming }: CodeBlockProps) {
const isDark = useIsDark();
const [highlighted, setHighlighted] = useState<null | string>(null);
const { codeText, lang } = extractCode(children);
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 } {
if (children && typeof children === "object" && "props" in children && children.props) {
const props = children.props as Record<string, unknown>;
const codeText = typeof props["children"] === "string" ? props["children"] : "";
const codeClassName = typeof props["className"] === "string" ? props["className"] : "";
const classes = codeClassName.split(/\s+/);
const langClass = classes.find((c) => c.startsWith("lang-") || c.startsWith("language-")) ?? "";
const lang = langClass.replace(/^(language|lang)-/, "") || "text";
return { codeText, lang };
}
return { codeText: typeof children === "string" ? children : "", lang: "text" };
}

View File

@@ -1,82 +0,0 @@
import { CopyOutlined } from "@ant-design/icons";
import { CodeHighlighter } from "@ant-design/x";
import { App, Button, Flex, Typography } from "antd";
import React from "react";
import { oneDark, oneLight } from "react-syntax-highlighter/dist/esm/styles/prism";
import { useIsDark } from "../../../shared/hooks/use-is-dark";
type SyntaxTheme = Record<string, Record<string, null | number | string>>;
const customOneDark: SyntaxTheme = {
...(oneDark as SyntaxTheme),
'pre[class*="language-"]': {
...(oneDark as SyntaxTheme)['pre[class*="language-"]'],
margin: 0,
},
};
const customOneLight: SyntaxTheme = {
...(oneLight as SyntaxTheme),
'pre[class*="language-"]': {
...(oneLight as SyntaxTheme)['pre[class*="language-"]'],
margin: 0,
},
};
interface CodeBlockWithCopyProps {
block?: boolean;
children?: React.ReactNode;
className?: string;
lang?: string;
streamStatus?: "done" | "loading";
}
export function CodeBlockWithCopy({ block, children, className, lang }: CodeBlockWithCopyProps) {
const { message } = App.useApp();
const isDark = useIsDark();
if (!block) {
return <code className={className}>{children}</code>;
}
const codeText = extractText(children);
const displayLang = lang ?? "plaintext";
const handleCopy = () => {
void navigator.clipboard.writeText(codeText).then(() => {
void message.success("已复制");
});
};
const header = (
<Flex align="center" justify="space-between" style={{ padding: "0 4px" }}>
<Typography.Text style={{ color: "var(--ant-color-text-quaternary)", fontSize: 12 }}>
{displayLang}
</Typography.Text>
<Button
icon={<CopyOutlined />}
onClick={handleCopy}
size="small"
style={{ color: "var(--ant-color-text-quaternary)" }}
type="text"
/>
</Flex>
);
return (
<CodeHighlighter
header={header}
highlightProps={{ style: (isDark ? customOneDark : customOneLight) as React.CSSProperties }}
lang={displayLang}
>
{codeText}
</CodeHighlighter>
);
}
function extractText(children: React.ReactNode): string {
return React.Children.toArray(children)
.map((child) => (typeof child === "string" ? child : ""))
.join("");
}

View File

@@ -0,0 +1,9 @@
import type { ReactNode } from "react";
interface MarkdownTableProps {
children: ReactNode;
}
export function MarkdownTable({ children }: MarkdownTableProps) {
return <table className="markdown-table">{children}</table>;
}

View File

@@ -1,38 +1,35 @@
import { XMarkdown } from "@ant-design/x-markdown";
import "@ant-design/x-markdown/themes/dark.css";
import "@ant-design/x-markdown/themes/light.css";
import { Typography } from "antd";
import Markdown from "markdown-to-jsx/react";
import type { PartProps } from "./types";
import { useIsDark } from "../../../shared/hooks/use-is-dark";
import { CodeBlockWithCopy } from "./CodeBlockWithCopy";
import { CodeBlock } from "./CodeBlock";
import { MarkdownTable } from "./MarkdownTable";
interface TextPartProps extends PartProps {
isStreaming: boolean;
role: string;
}
const xmarkdownComponents = {
code: CodeBlockWithCopy,
pre: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
};
export function TextPart({ isStreaming, part, role }: TextPartProps) {
const text = typeof part["text"] === "string" ? part["text"] : "";
const isDark = useIsDark();
return (
<div className="part-body">
{role === "user" ? (
<Typography.Paragraph className="message-body-text">{text}</Typography.Paragraph>
) : (
<XMarkdown
className={isDark ? "x-markdown-dark" : "x-markdown-light"}
components={xmarkdownComponents}
content={text}
streaming={{ hasNextChunk: isStreaming }}
/>
<Markdown
options={{
optimizeForStreaming: isStreaming,
overrides: {
pre: { component: CodeBlock, props: { isStreaming } },
table: MarkdownTable,
},
}}
>
{text}
</Markdown>
)}
</div>
);

View File

@@ -1,11 +1,11 @@
import { App as AntApp, DatePicker, Form, Input, Modal } from "antd";
import dayjs from "dayjs";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import type { Material } from "../types";
import type { CreateMaterialRequest, Material } from "../types";
interface AddMaterialModalProps {
onAdd: (material: Material) => void;
onAdd: (body: CreateMaterialRequest) => Promise<Material>;
onOpenChange: (open: boolean) => void;
open: boolean;
}
@@ -18,26 +18,34 @@ interface FormValues {
export function AddMaterialModal({ onAdd, onOpenChange, open }: AddMaterialModalProps) {
const { message } = AntApp.useApp();
const [form] = Form.useForm<FormValues>();
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (!open) return;
form.resetFields();
}, [form, open]);
const handleFinish = (values: FormValues) => {
const material: Material = {
const handleFinish = async (values: FormValues) => {
const body: CreateMaterialRequest = {
associatedDate: values.associatedDate.format("YYYY-MM-DD"),
createdAt: new Date().toISOString(),
description: values.description,
id: crypto.randomUUID(),
};
onAdd(material);
message.success("素材已添加");
onOpenChange(false);
setSubmitting(true);
try {
await onAdd(body);
message.success("素材已添加");
onOpenChange(false);
} catch (e: unknown) {
message.error(`添加失败:${e instanceof Error ? e.message : "未知错误"}`);
} finally {
setSubmitting(false);
}
};
return (
<Modal
confirmLoading={submitting}
destroyOnHidden
okText="确定"
onCancel={() => onOpenChange(false)}

View File

@@ -1,9 +1,7 @@
import { DeleteOutlined } from "@ant-design/icons";
import { Button, Card, Flex, Typography } from "antd";
import { Button, Flex, Popconfirm, Tag, Typography } from "antd";
import type { Material } from "../types";
import { formatRelativeTime } from "../../../shared/utils/time";
import type { Material, MaterialStatus } from "../types";
interface MaterialCardProps {
material: Material;
@@ -12,26 +10,59 @@ interface MaterialCardProps {
selected: boolean;
}
function formatAssociatedDate(date: string): string {
if (!date) return "—";
return date;
}
const STATUS_MAP: Record<MaterialStatus, { color: string; label: string }> = {
approved: { color: "green", label: "已通过" },
discarded: { color: "red", label: "已放弃" },
pending: { color: "gold", label: "待审核" },
};
export function MaterialCard({ material, onDelete, onSelect, selected }: MaterialCardProps) {
const statusInfo = STATUS_MAP[material.status];
const className = selected ? "material-list-item material-list-item--selected" : "material-list-item";
return (
<Card className={selected ? "app-inbox-card-selected" : undefined} hoverable onClick={onSelect} size="small">
<Typography.Paragraph ellipsis={{ rows: 3 }}>{material.description}</Typography.Paragraph>
<Flex align="center" justify="space-between">
<Typography.Text type="secondary">
{material.associatedDate} · {formatRelativeTime(material.createdAt)}
<Flex align="center" className={className} gap="small" justify="space-between" onClick={onSelect}>
<div style={{ flex: 1, minWidth: 0 }}>
<Typography.Text ellipsis strong={selected}>
{material.description}
</Typography.Text>
<Button
aria-label="删除"
danger
icon={<DeleteOutlined />}
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
size="small"
type="text"
/>
</Flex>
</Card>
<br />
<Typography.Text className="material-item-time" type="secondary">
{formatAssociatedDate(material.associatedDate)}
</Typography.Text>
</div>
<div className="material-item-right">
<span className="material-item-tag">
{statusInfo && <Tag color={statusInfo.color}>{statusInfo.label}</Tag>}
</span>
<span className="material-item-actions">
<Popconfirm
description="删除后不可恢复"
okButtonProps={{ danger: true }}
okText="删除"
onCancel={(e) => e?.stopPropagation()}
onConfirm={(e) => {
e?.stopPropagation();
onDelete();
}}
title="确认删除该素材?"
>
<Button
aria-label="删除"
danger
icon={<DeleteOutlined />}
onClick={(e) => e.stopPropagation()}
size="small"
type="text"
/>
</Popconfirm>
</span>
</div>
</Flex>
);
}

View File

@@ -1,21 +1,21 @@
import { Card, Descriptions, Empty, Typography } from "antd";
import { Card, Descriptions, Tag, Typography } from "antd";
import type { Material } from "../types";
import { formatRelativeTime } from "../../../shared/utils/time";
interface MaterialContentProps {
material: Material | null;
material: Material;
}
const STATUS_MAP: Record<string, { color: string; label: string }> = {
approved: { color: "green", label: "已通过" },
discarded: { color: "red", label: "已放弃" },
pending: { color: "gold", label: "待审核" },
};
export function MaterialContent({ material }: MaterialContentProps) {
if (!material) {
return (
<div className="app-inbox-content">
<Empty description="请在左侧选择素材" />
</div>
);
}
const statusInfo = STATUS_MAP[material.status] ?? { color: "default", label: material.status };
return (
<div className="app-inbox-content">
@@ -23,6 +23,9 @@ export function MaterialContent({ material }: MaterialContentProps) {
<Card>
<Typography.Paragraph>{material.description}</Typography.Paragraph>
<Descriptions column={1} size="small">
<Descriptions.Item label="状态">
<Tag color={statusInfo.color}>{statusInfo.label}</Tag>
</Descriptions.Item>
<Descriptions.Item label="关联时间">{material.associatedDate}</Descriptions.Item>
<Descriptions.Item label="创建时间">{formatRelativeTime(material.createdAt)}</Descriptions.Item>
</Descriptions>

View File

@@ -0,0 +1,51 @@
import { Empty, Result, Spin } from "antd";
import { useMaterial } from "../../../shared/hooks/use-materials";
import { MaterialContent } from "./MaterialContent";
interface MaterialDetailPanelProps {
materialId: null | string;
projectId: string;
}
export function MaterialDetailPanel({ materialId, projectId }: MaterialDetailPanelProps) {
if (!materialId) {
return (
<div className="app-inbox-content">
<Empty description="请在左侧选择素材" />
</div>
);
}
return <MaterialDetailPanelInner materialId={materialId} projectId={projectId} />;
}
function MaterialDetailPanelInner({ materialId, projectId }: MaterialDetailPanelProps) {
const { data, error, isLoading } = useMaterial({ materialId, projectId });
if (isLoading) {
return (
<div className="app-inbox-content">
<Spin />
</div>
);
}
if (error) {
return (
<div className="app-inbox-content">
<Result subTitle="加载素材详情失败" />
</div>
);
}
if (!data) {
return (
<div className="app-inbox-content">
<Empty description="请在左侧选择素材" />
</div>
);
}
return <MaterialContent material={data} />;
}

View File

@@ -0,0 +1,39 @@
import { CaretDownOutlined, CaretRightOutlined } from "@ant-design/icons";
import { Typography } from "antd";
import { type ReactNode, useState } from "react";
interface MaterialGroupProps {
children: ReactNode;
count: number;
emptyText?: string;
label: string;
}
export function MaterialGroup({ children, count, emptyText, label }: MaterialGroupProps) {
const [collapsed, setCollapsed] = useState(false);
return (
<div className="app-inbox-group">
<div className="app-inbox-group-header" onClick={() => setCollapsed(!collapsed)}>
<span className="app-inbox-group-arrow">{collapsed ? <CaretRightOutlined /> : <CaretDownOutlined />}</span>
<Typography.Text className="app-inbox-group-label" type="secondary">
{label}
</Typography.Text>
<Typography.Text className="app-inbox-group-count" type="secondary">
({count})
</Typography.Text>
</div>
{!collapsed && (
<div className="app-inbox-group-content">
{count === 0 && emptyText ? (
<Typography.Text className="app-inbox-group-empty" type="secondary">
{emptyText}
</Typography.Text>
) : (
children
)}
</div>
)}
</div>
);
}

View File

@@ -1,11 +1,22 @@
import { PlusOutlined } from "@ant-design/icons";
import { Button, Empty } from "antd";
import {
AppstoreOutlined,
CheckCircleOutlined,
ClockCircleOutlined,
CloseCircleOutlined,
PlusOutlined,
} from "@ant-design/icons";
import { Button, Empty, Segmented, Skeleton } from "antd";
import { useMemo, useState } from "react";
import type { Material } from "../types";
import { MaterialCard } from "./MaterialCard";
import { MaterialGroup } from "./MaterialGroup";
type DateGroup = "earlier" | "thisMonth" | "thisWeek" | "today" | "yesterday";
interface MaterialListProps {
loading: boolean;
materials: readonly Material[];
onAddClick: () => void;
onDelete: (id: string) => void;
@@ -13,27 +24,132 @@ interface MaterialListProps {
selectedId: null | string;
}
export function MaterialList({ materials, onAddClick, onDelete, onSelect, selectedId }: MaterialListProps) {
const GROUP_LABELS: Record<DateGroup, string> = {
earlier: "更早",
thisMonth: "本月",
thisWeek: "本周",
today: "今天",
yesterday: "昨天",
};
const GROUP_ORDER: readonly DateGroup[] = ["today", "yesterday", "thisWeek", "thisMonth", "earlier"];
interface MaterialGroupData {
items: Material[];
key: DateGroup;
}
function getDateGroup(dateStr: string, now: Date): DateGroup {
const date = new Date(dateStr);
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const yesterday = new Date(today.getTime() - 86_400_000);
const dateDay = new Date(date.getFullYear(), date.getMonth(), date.getDate());
if (dateDay.getTime() >= today.getTime()) return "today";
if (dateDay.getTime() >= yesterday.getTime()) return "yesterday";
const dayOfWeek = today.getDay();
const mondayOffset = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
const monday = new Date(today.getTime() - mondayOffset * 86_400_000);
if (dateDay.getTime() >= monday.getTime()) return "thisWeek";
if (dateDay.getFullYear() === today.getFullYear() && dateDay.getMonth() === today.getMonth()) {
return "thisMonth";
}
return "earlier";
}
function groupMaterialsByDate(materials: readonly Material[]): MaterialGroupData[] {
const now = new Date();
const groups = new Map<DateGroup, Material[]>();
for (const m of materials) {
const group = getDateGroup(m.createdAt, now);
if (!groups.has(group)) groups.set(group, []);
groups.get(group)!.push(m);
}
return GROUP_ORDER.map((key) => ({ items: groups.get(key) ?? [], key }));
}
const STATUS_FILTER_OPTIONS = [
{ icon: <AppstoreOutlined />, label: "全部", value: "all" },
{ color: "#faad14", icon: <ClockCircleOutlined />, label: "待审核", value: "pending" },
{ color: "#52c41a", icon: <CheckCircleOutlined />, label: "已通过", value: "approved" },
{ color: "#ff4d4f", icon: <CloseCircleOutlined />, label: "已放弃", value: "discarded" },
] as const;
type FilterValue = (typeof STATUS_FILTER_OPTIONS)[number]["value"];
export function MaterialList({ loading, materials, onAddClick, onDelete, onSelect, selectedId }: MaterialListProps) {
const [filterStatus, setFilterStatus] = useState<FilterValue>("all");
const filteredMaterials = useMemo(() => {
if (filterStatus === "all") return materials;
return materials.filter((m) => m.status === filterStatus);
}, [materials, filterStatus]);
const groupedMaterials = useMemo(() => groupMaterialsByDate(filteredMaterials), [filteredMaterials]);
const segmentedOptions = useMemo(
() =>
STATUS_FILTER_OPTIONS.map((opt) => ({
label: (
<span>
{"color" in opt && opt.color ? <span style={{ color: opt.color }}>{opt.icon}</span> : opt.icon}
<span className="app-inbox-filter-count">{getStatusCount(materials, opt.value)}</span>
</span>
),
value: opt.value,
})),
[materials],
);
const showAllGroups = filterStatus === "all";
return (
<div className="app-inbox-sidebar">
<Button block icon={<PlusOutlined />} onClick={onAddClick} type="primary">
</Button>
<Segmented block onChange={(value) => setFilterStatus(value)} options={segmentedOptions} value={filterStatus} />
<div className="app-inbox-list">
{materials.length === 0 ? (
{loading ? (
<Skeleton active paragraph={{ rows: 6 }} title={false} />
) : materials.length === 0 ? (
<Empty description="暂无素材" image={Empty.PRESENTED_IMAGE_SIMPLE} />
) : filteredMaterials.length === 0 ? (
<Empty description="当前筛选条件下无素材" image={Empty.PRESENTED_IMAGE_SIMPLE} />
) : (
materials.map((material) => (
<MaterialCard
key={material.id}
material={material}
onDelete={() => onDelete(material.id)}
onSelect={() => onSelect(material.id)}
selected={material.id === selectedId}
/>
))
groupedMaterials.map((group) => {
if (!showAllGroups && group.items.length === 0) return null;
return (
<MaterialGroup
count={group.items.length}
emptyText="暂无"
key={group.key}
label={GROUP_LABELS[group.key]}
>
{group.items.map((material) => (
<MaterialCard
key={material.id}
material={material}
onDelete={() => onDelete(material.id)}
onSelect={() => onSelect(material.id)}
selected={material.id === selectedId}
/>
))}
</MaterialGroup>
);
})
)}
</div>
</div>
);
}
function getStatusCount(materials: readonly Material[], status: string): number {
if (status === "all") return materials.length;
return materials.filter((m) => m.status === status).length;
}

View File

@@ -0,0 +1,44 @@
import { Result } from "antd";
import { useDeleteMaterial, useMaterialList } from "../../../shared/hooks/use-materials";
import { MaterialList } from "./MaterialList";
interface MaterialSidebarProps {
onAddClick: () => void;
onDelete: (id: string) => void;
onSelect: (id: string) => void;
projectId: string;
selectedId: null | string;
}
export function MaterialSidebar({ onAddClick, onDelete, onSelect, projectId, selectedId }: MaterialSidebarProps) {
const { data, error, isLoading, refetch } = useMaterialList(projectId, { pageSize: 200 });
const deleteMutation = useDeleteMaterial(projectId);
const handleDelete = (id: string) => {
void deleteMutation.mutate({ materialId: id, projectId }, { onSuccess: () => onDelete(id) });
};
if (error) {
return (
<div className="app-inbox-sidebar">
<Result
extra={<button onClick={() => void refetch()}></button>}
status="error"
subTitle="加载素材列表失败"
/>
</div>
);
}
return (
<MaterialList
loading={isLoading}
materials={data?.items ?? []}
onAddClick={onAddClick}
onDelete={handleDelete}
onSelect={onSelect}
selectedId={selectedId}
/>
);
}

View File

@@ -1,39 +1,43 @@
import { useState } from "react";
import type { Material } from "./types";
import type { CreateMaterialRequest, Material } from "./types";
import { useCurrentProject } from "../../shared/hooks/use-current-project";
import { useCreateMaterial } from "../../shared/hooks/use-materials";
import { AddMaterialModal } from "./components/AddMaterialModal";
import { MaterialContent } from "./components/MaterialContent";
import { MaterialList } from "./components/MaterialList";
import { MaterialDetailPanel } from "./components/MaterialDetailPanel";
import { MaterialSidebar } from "./components/MaterialSidebar";
export function InboxPage() {
const [materials, setMaterials] = useState<Material[]>([]);
const project = useCurrentProject();
const [modalOpen, setModalOpen] = useState(false);
const [selectedId, setSelectedId] = useState<null | string>(null);
const selectedMaterial = materials.find((m) => m.id === selectedId) ?? null;
const createMutation = useCreateMaterial(project.id);
const handleAdd = (material: Material) => {
setMaterials((prev) => [...prev, material].sort((a, b) => b.associatedDate.localeCompare(a.associatedDate)));
const handleCreate = async (body: CreateMaterialRequest): Promise<Material> => {
const material = await createMutation.mutateAsync({ body, projectId: project.id });
setSelectedId(material.id);
return material;
};
const handleDelete = (id: string) => {
setMaterials((prev) => prev.filter((m) => m.id !== id));
if (selectedId === id) setSelectedId(null);
const handleDelete = (_id: string) => {
if (selectedId === _id) {
setSelectedId(null);
}
};
return (
<div className="app-inbox-page">
<MaterialList
materials={materials}
<MaterialSidebar
onAddClick={() => setModalOpen(true)}
onDelete={handleDelete}
onSelect={setSelectedId}
projectId={project.id}
selectedId={selectedId}
/>
<MaterialContent material={selectedMaterial} />
<AddMaterialModal onAdd={handleAdd} onOpenChange={setModalOpen} open={modalOpen} />
<MaterialDetailPanel materialId={selectedId} projectId={project.id} />
<AddMaterialModal onAdd={handleCreate} onOpenChange={setModalOpen} open={modalOpen} />
</div>
);
}

View File

@@ -1,6 +1 @@
export interface Material {
associatedDate: string;
createdAt: string;
description: string;
id: string;
}
export type { CreateMaterialRequest, Material, MaterialStatus } from "../../../shared/api";

View File

@@ -0,0 +1,95 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type {
CreateMaterialRequest,
Material,
MaterialListResponse,
MaterialResponse,
MaterialStatus,
} from "../../../shared/api";
import { handleResponse, handleVoidResponse } from "../utils/api";
import { createConsoleLogger } from "../utils/logger";
const MATERIALS_KEY = ["materials"] as const;
const logger = createConsoleLogger();
export function createMaterial(args: { body: CreateMaterialRequest; projectId: string }): Promise<Material> {
const response = fetch(`/api/projects/${args.projectId}/materials`, {
body: JSON.stringify(args.body),
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> {
const response = fetch(`/api/projects/${args.projectId}/materials/${args.materialId}`, { method: "DELETE" });
return response.then(handleVoidResponse);
}
export async function fetchMaterial(args: { materialId: string; projectId: string }): Promise<Material> {
const response = await fetch(`/api/projects/${args.projectId}/materials/${args.materialId}`);
return handleResponse(response, (data) => (data as MaterialResponse).material);
}
export function fetchMaterials(
projectId: string,
params?: { page?: number; pageSize?: number; status?: MaterialStatus },
): Promise<MaterialListResponse> {
const searchParams = new URLSearchParams();
if (params?.page) searchParams.set("page", String(params.page));
if (params?.pageSize) searchParams.set("pageSize", String(params.pageSize));
if (params?.status) searchParams.set("status", params.status);
const qs = searchParams.toString();
const url = `/api/projects/${projectId}/materials${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<MaterialListResponse>;
});
}
export function useCreateMaterial(projectId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: createMaterial,
onSuccess: (data) => {
logger.info("素材创建成功", { materialId: data.id, projectId });
void queryClient.invalidateQueries({ queryKey: [...MATERIALS_KEY, "list", projectId] });
},
});
}
export function useDeleteMaterial(projectId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: deleteMaterial,
onSuccess: (_data, variables) => {
logger.info("素材删除成功", { materialId: variables.materialId, projectId });
void queryClient.invalidateQueries({ queryKey: [...MATERIALS_KEY, "list", projectId] });
},
});
}
export function useMaterial(args: { materialId: null | string; projectId: string }) {
return useQuery({
enabled: !!args.materialId,
queryFn: () => fetchMaterial({ materialId: args.materialId!, projectId: args.projectId }),
queryKey: [...MATERIALS_KEY, "detail", args.projectId, args.materialId],
});
}
export function useMaterialList(
projectId: string,
params?: { page?: number; pageSize?: number; status?: MaterialStatus },
) {
return useQuery({
queryFn: () => fetchMaterials(projectId, params),
queryKey: [...MATERIALS_KEY, "list", projectId, params],
});
}

View File

@@ -1,8 +1,10 @@
import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
export type EffectiveTheme = "dark" | "light";
export type ThemePreference = "dark" | "light" | "system";
const PREFERENCE_CHANGE_EVENT = "theme-preference-change";
export const THEME_PREFERENCE_STORAGE_KEY = "theme.preference";
export const THEME_MEDIA_QUERY = "(prefers-color-scheme: dark)";
@@ -44,10 +46,19 @@ export function useThemePreference() {
return () => mediaQueryList.removeEventListener("change", handleChange);
}, []);
const setPreference = (nextPreference: ThemePreference) => {
useEffect(() => {
const handleStorageEvent = (event: CustomEvent) => {
const next = parseThemePreference(event.detail);
setPreferenceState((prev) => (prev !== next ? next : prev));
};
window.addEventListener(PREFERENCE_CHANGE_EVENT, handleStorageEvent as EventListener);
return () => window.removeEventListener(PREFERENCE_CHANGE_EVENT, handleStorageEvent as EventListener);
}, []);
const setPreference = useCallback((nextPreference: ThemePreference) => {
setPreferenceState(nextPreference);
writeThemePreference(nextPreference);
};
}, []);
return { effectiveTheme, preference, setPreference };
}
@@ -58,4 +69,9 @@ export function writeThemePreference(preference: ThemePreference, storage: Stora
} catch {
// 存储不可用时仅使用当前内存状态,避免阻断 Dashboard 渲染。
}
try {
window.dispatchEvent(new CustomEvent(PREFERENCE_CHANGE_EVENT, { detail: preference }));
} catch {
// jsdom 等环境可能不支持 CustomEvent
}
}

View File

@@ -90,6 +90,16 @@ body {
background: var(--ant-color-bg-container);
}
.app-chat-conversations-header {
padding: var(--ant-padding-sm);
border-bottom: 1px solid var(--ant-color-border-secondary);
}
.app-chat-conversations-list {
flex: 1;
min-height: 0;
}
.app-chat-panel {
display: flex;
flex: 1;
@@ -239,32 +249,6 @@ body {
--os-handle-interactive-area-offset: 4px;
}
.x-markdown-light table,
.x-markdown-dark table {
border-collapse: collapse;
width: 100%;
}
.x-markdown-light th,
.x-markdown-light td,
.x-markdown-dark th,
.x-markdown-dark td {
border: 1px solid var(--ant-color-border);
padding: 6px 12px;
text-align: left;
}
.x-markdown-light th,
.x-markdown-dark th {
background: var(--ant-color-fill-quaternary);
font-weight: 600;
}
.x-markdown-light .x-md-table-wrap,
.x-markdown-dark .x-md-table-wrap {
overflow-x: auto;
}
.app-inbox-page {
display: flex;
height: 100%;
@@ -278,6 +262,7 @@ body {
gap: var(--ant-margin-sm);
padding: var(--ant-padding-sm);
border-right: 1px solid var(--ant-color-border-secondary);
border-radius: var(--ant-border-radius-lg);
background: var(--ant-color-bg-container);
}
@@ -285,7 +270,6 @@ body {
display: flex;
flex: 1;
flex-direction: column;
gap: var(--ant-margin-xs);
min-height: 0;
overflow-y: auto;
}
@@ -300,10 +284,190 @@ body {
overflow-y: auto;
}
.app-inbox-card-selected {
box-shadow: 0 0 0 1px var(--ant-color-primary);
}
.app-inbox-datepicker {
width: 100%;
}
/* Inbox material list items */
.material-list-item {
border-left: 3px solid transparent;
border-bottom: 1px solid var(--ant-color-border-secondary);
padding: var(--ant-padding-xs) var(--ant-padding-sm);
padding-left: var(--ant-padding-sm);
cursor: pointer;
transition: border-color 0.15s ease, background 0.15s ease;
}
.material-list-item:last-child {
border-bottom: none;
}
.material-list-item:hover {
background: var(--ant-color-fill-tertiary);
}
.material-list-item--selected {
border-left-color: var(--ant-color-primary);
}
.material-list-item--selected:hover {
background: var(--ant-color-fill-tertiary);
}
.material-item-right {
position: relative;
display: inline-flex;
align-items: center;
flex-shrink: 0;
}
.material-item-tag,
.material-item-actions {
transition: opacity 0.15s ease;
}
.material-item-tag {
opacity: 1;
}
.material-item-actions {
position: absolute;
top: 50%;
right: 0;
transform: translateY(-50%);
opacity: 0;
}
.material-list-item:hover .material-item-tag {
opacity: 0;
}
.material-list-item:hover .material-item-actions {
opacity: 1;
}
.material-item-time {
font-size: var(--ant-font-size-sm);
}
.app-inbox-group {
margin-top: var(--ant-margin-xs);
}
.app-inbox-group-header {
display: flex;
align-items: center;
gap: var(--ant-margin-xxs);
padding: var(--ant-padding-xs) var(--ant-padding-xs);
cursor: pointer;
user-select: none;
border-radius: var(--ant-border-radius-sm);
transition: background 0.15s ease;
}
.app-inbox-group-header:hover {
background: var(--ant-color-fill-tertiary);
}
.app-inbox-group-arrow {
display: inline-flex;
align-items: center;
font-size: var(--ant-font-size-sm);
color: var(--ant-color-text-quaternary);
width: 14px;
}
.app-inbox-group-label {
font-size: var(--ant-font-size-sm);
font-weight: 500;
}
.app-inbox-group-count {
font-size: var(--ant-font-size-sm);
}
.app-inbox-group-content {
padding-bottom: var(--ant-padding-xs);
}
.app-inbox-group-empty {
display: block;
padding: var(--ant-padding-xs) var(--ant-padding);
font-size: var(--ant-font-size-sm);
}
.app-inbox-filter-count {
margin-left: 4px;
font-size: var(--ant-font-size-sm);
}
/* Markdown 代码块 */
.code-block {
margin: var(--ant-margin-sm) 0;
border: 1px solid var(--ant-color-border-secondary);
border-radius: var(--ant-border-radius-lg);
overflow: hidden;
font-family: var(--ant-font-family-code, monospace);
font-size: var(--ant-font-size-sm);
}
.code-block-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--ant-padding-xxs) var(--ant-padding-sm);
background: var(--ant-color-bg-container);
border-bottom: 1px solid var(--ant-color-border-secondary);
}
.code-block-lang {
color: var(--ant-color-text-quaternary);
font-size: var(--ant-font-size-xs);
text-transform: lowercase;
}
.code-block-body {
margin: 0;
padding: var(--ant-padding-sm);
overflow-x: auto;
background: var(--ant-color-bg-container);
}
.code-block-body code {
font-family: inherit;
font-size: inherit;
}
/* Markdown 表格 */
.markdown-table {
width: 100%;
margin: var(--ant-margin-sm) 0;
border-collapse: separate;
border-spacing: 0;
border: 1px solid var(--ant-color-border-secondary);
border-radius: var(--ant-border-radius-lg);
overflow: hidden;
font-size: var(--ant-font-size);
}
.markdown-table th,
.markdown-table td {
padding: var(--ant-padding-xs) var(--ant-padding-sm);
border-bottom: 1px solid var(--ant-color-border-secondary);
text-align: left;
}
.markdown-table thead th {
background: var(--ant-color-bg-container);
color: var(--ant-color-text);
font-weight: 600;
border-bottom: 1px solid var(--ant-color-border-secondary);
}
.markdown-table tbody tr:last-child td {
border-bottom: none;
}
.markdown-table tbody tr:hover td {
background: var(--ant-color-fill-quaternary);
}

View File

@@ -0,0 +1,283 @@
import type Database from "bun:sqlite";
import { describe, expect, test } from "bun:test";
import type { Material, RuntimeMode } from "../../../src/shared/api";
import { archiveProject, createProject } from "../../../src/server/db/projects";
import { createNoopLogger } from "../../../src/server/logger";
import { createMigratedMemoryTestDatabase } from "../../helpers";
const MODE: RuntimeMode = "test";
const LOG = createNoopLogger();
async function createMaterialViaHandler(req: Request, db: Database): Promise<Response> {
const { handleCreateMaterial: h } = await import("../../../src/server/routes/materials/create");
return h(req, db, MODE, LOG);
}
function createTestProject(db: Database, name = "测试项目") {
const result = createProject(db, { name }, LOG);
if ("error" in result) throw new Error(result.error);
return result.project;
}
async function deleteMaterialViaHandler(req: Request, db: Database): Promise<Response> {
const { handleDeleteMaterial: h } = await import("../../../src/server/routes/materials/delete");
return h(req, db, MODE, LOG);
}
async function getMaterialViaHandler(req: Request, db: Database): Promise<Response> {
const { handleGetMaterial: h } = await import("../../../src/server/routes/materials/get");
return h(req, db, MODE, LOG);
}
async function listMaterialsViaHandler(req: Request, db: Database): Promise<Response> {
const { handleListMaterials: h } = await import("../../../src/server/routes/materials/list");
return h(req, db, MODE, LOG);
}
async function withRouteDb(callback: (db: Database) => Promise<void>): Promise<void> {
const handle = createMigratedMemoryTestDatabase("material-route-test");
try {
await callback(handle.db);
handle.close();
} finally {
handle.cleanup();
}
}
describe("素材 API 路由", () => {
describe("POST /api/projects/:id/materials", () => {
test("正常创建素材", async () => {
await withRouteDb(async (db) => {
const project = createTestProject(db);
const req = new Request(`http://localhost/api/projects/${project.id}/materials`, {
body: JSON.stringify({ associatedDate: "2024-01-15", description: "测试素材" }),
headers: { "Content-Type": "application/json" },
method: "POST",
});
const res = await createMaterialViaHandler(req, db);
expect(res.status).toBe(201);
const body = (await res.json()) as { material: Material };
expect(body.material.description).toBe("测试素材");
expect(body.material.associatedDate).toBe("2024-01-15");
expect(body.material.projectId).toBe(project.id);
expect(body.material.status).toBe("pending");
});
});
test("缺少 description 返回 400", async () => {
await withRouteDb(async (db) => {
const project = createTestProject(db);
const req = new Request(`http://localhost/api/projects/${project.id}/materials`, {
body: JSON.stringify({ associatedDate: "2024-01-15" }),
headers: { "Content-Type": "application/json" },
method: "POST",
});
const res = await createMaterialViaHandler(req, db);
expect(res.status).toBe(400);
});
});
test("缺少 associatedDate 返回 400", async () => {
await withRouteDb(async (db) => {
const project = createTestProject(db);
const req = new Request(`http://localhost/api/projects/${project.id}/materials`, {
body: JSON.stringify({ description: "测试素材" }),
headers: { "Content-Type": "application/json" },
method: "POST",
});
const res = await createMaterialViaHandler(req, db);
expect(res.status).toBe(400);
});
});
test("associatedDate 格式错误返回 400", async () => {
await withRouteDb(async (db) => {
const project = createTestProject(db);
const req = new Request(`http://localhost/api/projects/${project.id}/materials`, {
body: JSON.stringify({ associatedDate: "2024/01/15", description: "测试素材" }),
headers: { "Content-Type": "application/json" },
method: "POST",
});
const res = await createMaterialViaHandler(req, db);
expect(res.status).toBe(400);
});
});
test("项目不存在返回 404", async () => {
await withRouteDb(async (db) => {
const req = new Request("http://localhost/api/projects/nonexistent/materials", {
body: JSON.stringify({ associatedDate: "2024-01-15", description: "测试素材" }),
headers: { "Content-Type": "application/json" },
method: "POST",
});
const res = await createMaterialViaHandler(req, db);
expect(res.status).toBe(404);
});
});
test("已归档项目返回 409", async () => {
await withRouteDb(async (db) => {
const project = createTestProject(db);
archiveProject(db, project.id, LOG);
const req = new Request(`http://localhost/api/projects/${project.id}/materials`, {
body: JSON.stringify({ associatedDate: "2024-01-15", description: "测试素材" }),
headers: { "Content-Type": "application/json" },
method: "POST",
});
const res = await createMaterialViaHandler(req, db);
expect(res.status).toBe(409);
});
});
});
describe("GET /api/projects/:id/materials", () => {
test("正常列表查询", async () => {
await withRouteDb(async (db) => {
const project = createTestProject(db);
const req1 = new Request(`http://localhost/api/projects/${project.id}/materials`, {
body: JSON.stringify({ associatedDate: "2024-01-15", description: "素材1" }),
headers: { "Content-Type": "application/json" },
method: "POST",
});
await createMaterialViaHandler(req1, db);
const req2 = new Request(`http://localhost/api/projects/${project.id}/materials`, {
body: JSON.stringify({ associatedDate: "2024-01-16", description: "素材2" }),
headers: { "Content-Type": "application/json" },
method: "POST",
});
await createMaterialViaHandler(req2, db);
const req = new Request(`http://localhost/api/projects/${project.id}/materials?page=1&pageSize=20`);
const res = await listMaterialsViaHandler(req, db);
expect(res.status).toBe(200);
const body = (await res.json()) as { items: Material[]; total: number };
expect(body.total).toBe(2);
expect(body.items.length).toBe(2);
});
});
test("空项目返回空列表", async () => {
await withRouteDb(async (db) => {
const project = createTestProject(db);
const req = new Request(`http://localhost/api/projects/${project.id}/materials?page=1&pageSize=20`);
const res = await listMaterialsViaHandler(req, db);
expect(res.status).toBe(200);
const body = (await res.json()) as { items: Material[]; total: number };
expect(body.total).toBe(0);
expect(body.items.length).toBe(0);
});
});
});
describe("GET /api/projects/:id/materials/:mid", () => {
test("正常获取详情", async () => {
await withRouteDb(async (db) => {
const project = createTestProject(db);
const createReq = new Request(`http://localhost/api/projects/${project.id}/materials`, {
body: JSON.stringify({ associatedDate: "2024-01-15", description: "详情测试" }),
headers: { "Content-Type": "application/json" },
method: "POST",
});
const createRes = await createMaterialViaHandler(createReq, db);
const createBody = (await createRes.json()) as { material: Material };
const req = new Request(`http://localhost/api/projects/${project.id}/materials/${createBody.material.id}`);
const res = await getMaterialViaHandler(req, db);
expect(res.status).toBe(200);
const body = (await res.json()) as { material: Material };
expect(body.material.description).toBe("详情测试");
});
});
test("归属错误返回 403", async () => {
await withRouteDb(async (db) => {
const projectA = createTestProject(db, "项目A");
const projectB = createProject(db, { name: "项目B" }, LOG);
if ("error" in projectB) throw new Error(projectB.error);
const createReq = new Request(`http://localhost/api/projects/${projectA.id}/materials`, {
body: JSON.stringify({ associatedDate: "2024-01-15", description: "测试" }),
headers: { "Content-Type": "application/json" },
method: "POST",
});
const createRes = await createMaterialViaHandler(createReq, db);
const createBody = (await createRes.json()) as { material: Material };
const req = new Request(
`http://localhost/api/projects/${projectB.project.id}/materials/${createBody.material.id}`,
);
const res = await getMaterialViaHandler(req, db);
expect(res.status).toBe(403);
});
});
});
describe("DELETE /api/projects/:id/materials/:mid", () => {
test("正常删除", async () => {
await withRouteDb(async (db) => {
const project = createTestProject(db);
const createReq = new Request(`http://localhost/api/projects/${project.id}/materials`, {
body: JSON.stringify({ associatedDate: "2024-01-15", description: "待删除" }),
headers: { "Content-Type": "application/json" },
method: "POST",
});
const createRes = await createMaterialViaHandler(createReq, db);
const createBody = (await createRes.json()) as { material: Material };
const req = new Request(`http://localhost/api/projects/${project.id}/materials/${createBody.material.id}`, {
method: "DELETE",
});
const res = await deleteMaterialViaHandler(req, db);
expect(res.status).toBe(204);
});
});
test("删除后再查返回 404", async () => {
await withRouteDb(async (db) => {
const project = createTestProject(db);
const createReq = new Request(`http://localhost/api/projects/${project.id}/materials`, {
body: JSON.stringify({ associatedDate: "2024-01-15", description: "待删除" }),
headers: { "Content-Type": "application/json" },
method: "POST",
});
const createRes = await createMaterialViaHandler(createReq, db);
const createBody = (await createRes.json()) as { material: Material };
const delReq = new Request(`http://localhost/api/projects/${project.id}/materials/${createBody.material.id}`, {
method: "DELETE",
});
await deleteMaterialViaHandler(delReq, db);
const getReq = new Request(`http://localhost/api/projects/${project.id}/materials/${createBody.material.id}`);
const res = await getMaterialViaHandler(getReq, db);
expect(res.status).toBe(404);
});
});
test("归属错误返回 403", async () => {
await withRouteDb(async (db) => {
const projectA = createTestProject(db, "项目A");
const projectB = createProject(db, { name: "项目B" }, LOG);
if ("error" in projectB) throw new Error(projectB.error);
const createReq = new Request(`http://localhost/api/projects/${projectA.id}/materials`, {
body: JSON.stringify({ associatedDate: "2024-01-15", description: "测试" }),
headers: { "Content-Type": "application/json" },
method: "POST",
});
const createRes = await createMaterialViaHandler(createReq, db);
const createBody = (await createRes.json()) as { material: Material };
const req = new Request(
`http://localhost/api/projects/${projectB.project.id}/materials/${createBody.material.id}`,
{
method: "DELETE",
},
);
const res = await deleteMaterialViaHandler(req, db);
expect(res.status).toBe(403);
});
});
});
});

View File

@@ -0,0 +1,87 @@
import { screen, waitFor } from "@testing-library/react";
import { describe, expect, mock, test } from "bun:test";
import { createElement } from "react";
import { renderWithProviders } from "../../test-utils";
void mock.module("../../../../src/web/shared/hooks/use-is-dark", () => ({
useIsDark: () => false,
}));
void mock.module("shiki", () => ({
codeToHtml: (code: string, options: { lang: string; theme: string }) =>
Promise.resolve(`<pre class="shiki ${options.theme}"><code>${code}</code></pre>`),
}));
import { CodeBlock } from "../../../../src/web/features/chat/parts/CodeBlock";
function createCodeChild(text: string, lang = "javascript"): React.ReactElement {
return createElement("code", { className: `language-${lang}` }, text);
}
describe("CodeBlock 流式期间纯文本渲染", () => {
test("流式时渲染为纯文本 pre", () => {
const child = createCodeChild("const x = 1;");
renderWithProviders(createElement(CodeBlock, { children: child, isStreaming: true }));
const pre = document.querySelector(".code-block");
expect(pre).toBeTruthy();
expect(pre!.tagName).toBe("PRE");
expect(screen.getByText("const x = 1;")).toBeTruthy();
expect(document.querySelector(".code-block-header")).toBeNull();
});
});
describe("CodeBlock 非流式高亮渲染", () => {
test("非流式时显示语言标签和复制按钮", () => {
const child = createCodeChild("const x = 1;");
renderWithProviders(createElement(CodeBlock, { children: child, isStreaming: false }));
expect(document.querySelector(".code-block-header")).toBeTruthy();
expect(document.querySelector(".code-block-lang")).toBeTruthy();
expect(screen.getByText("javascript")).toBeTruthy();
});
test("非流式时异步高亮代码", async () => {
const child = createCodeChild("const x = 1;");
renderWithProviders(createElement(CodeBlock, { children: child, isStreaming: false }));
await waitFor(() => {
const body = document.querySelector(".code-block-body");
expect(body).toBeTruthy();
expect(body!.innerHTML).toContain("const x = 1;");
});
});
});
describe("CodeBlock 复制按钮", () => {
test("复制按钮存在且可点击", () => {
const child = createCodeChild("test code");
renderWithProviders(createElement(CodeBlock, { children: child, isStreaming: false }));
const button = document.querySelector(".code-block-header button");
expect(button).toBeTruthy();
});
});
describe("CodeBlock 语言标签显示", () => {
test("常见语言显示正确", () => {
const child = createCodeChild("print('hello')", "python");
renderWithProviders(createElement(CodeBlock, { children: child, isStreaming: false }));
expect(screen.getByText("python")).toBeTruthy();
});
test("无语言时显示 text", () => {
const child = createElement("code", { className: "" }, "plain text");
renderWithProviders(createElement(CodeBlock, { children: child, isStreaming: false }));
expect(screen.getByText("text")).toBeTruthy();
});
});

View File

@@ -1,99 +0,0 @@
import { screen } from "@testing-library/react";
import { describe, expect, mock, test } from "bun:test";
import { createElement } from "react";
import { CodeBlockWithCopy } from "../../../../src/web/features/chat/parts/CodeBlockWithCopy";
import { renderWithProviders } from "../../test-utils";
const mockWriteText = mock(() => Promise.resolve());
Object.defineProperty(navigator, "clipboard", {
configurable: true,
get: () => ({ writeText: mockWriteText }),
});
describe("CodeBlockWithCopy", () => {
test("block 模式渲染 CodeHighlighter 和语言标签", () => {
renderWithProviders(
createElement(CodeBlockWithCopy, {
block: true,
children: "const x = 1;",
lang: "typescript",
streamStatus: "done",
}),
);
expect(screen.getByText("typescript")).toBeTruthy();
});
test("block 模式渲染复制按钮", () => {
renderWithProviders(
createElement(CodeBlockWithCopy, {
block: true,
children: "hello world",
lang: "python",
streamStatus: "done",
}),
);
const copyBtn = screen.getByRole("button");
expect(copyBtn).toBeTruthy();
});
test("block 模式语言为空时显示 plaintext", () => {
renderWithProviders(
createElement(CodeBlockWithCopy, {
block: true,
children: "some code",
lang: "",
streamStatus: "done",
}),
);
expect(screen.getByText("plaintext")).toBeTruthy();
});
test("block 模式语言为 undefined 时显示 plaintext", () => {
renderWithProviders(
createElement(CodeBlockWithCopy, {
block: true,
children: "some code",
streamStatus: "done",
}),
);
expect(screen.getByText("plaintext")).toBeTruthy();
});
test("inline 模式返回 code 元素", () => {
const { container } = renderWithProviders(
createElement(CodeBlockWithCopy, {
block: false,
children: "inline code",
className: "language-ts",
}),
);
const code = container.querySelector("code.language-ts");
expect(code).toBeTruthy();
expect(code?.textContent).toBe("inline code");
});
test("点击复制按钮调用 clipboard.writeText", () => {
mockWriteText.mockClear();
renderWithProviders(
createElement(CodeBlockWithCopy, {
block: true,
children: "copy me",
lang: "javascript",
streamStatus: "done",
}),
);
const copyBtn = screen.getByRole("button");
copyBtn.click();
expect(mockWriteText).toHaveBeenCalledWith("copy me");
});
});

View File

@@ -0,0 +1,43 @@
import { screen } from "@testing-library/react";
import { describe, expect, test } from "bun:test";
import { createElement } from "react";
import { MarkdownTable } from "../../../../src/web/features/chat/parts/MarkdownTable";
import { renderWithProviders } from "../../test-utils";
describe("MarkdownTable 渲染表格", () => {
test("渲染原生 table 元素并添加 class", () => {
const children = createElement(
"thead",
null,
createElement("tr", null, createElement("th", null, "列1"), createElement("th", null, "列2")),
);
renderWithProviders(createElement(MarkdownTable, { children }));
const table = document.querySelector(".markdown-table");
expect(table).toBeTruthy();
expect(table!.tagName).toBe("TABLE");
expect(screen.getByText("列1")).toBeTruthy();
expect(screen.getByText("列2")).toBeTruthy();
});
test("正确传递 children 内容", () => {
const children = createElement(
"tbody",
null,
createElement("tr", null, createElement("td", null, "值1"), createElement("td", null, "值2")),
);
renderWithProviders(createElement(MarkdownTable, { children }));
expect(screen.getByText("值1")).toBeTruthy();
expect(screen.getByText("值2")).toBeTruthy();
});
test("只有传入 table 元素时才有 class", () => {
const { container } = renderWithProviders(createElement(MarkdownTable, { children: null }));
expect(container.querySelector(".markdown-table")).toBeTruthy();
});
});

View File

@@ -0,0 +1,92 @@
import { screen } from "@testing-library/react";
import { describe, expect, test } from "bun:test";
import { createElement } from "react";
import { TextPart } from "../../../../src/web/features/chat/parts/TextPart";
import { renderWithProviders } from "../../test-utils";
function createTextPart(text: string, _isStreaming = false, _role = "assistant"): Record<string, unknown> {
return { text, type: "text" };
}
describe("TextPart AI 消息含 Markdown 渲染", () => {
test("代码块使用 pre override 渲染", () => {
const part = createTextPart("```javascript\nconst x = 1;\n```");
renderWithProviders(createElement(TextPart, { isStreaming: false, part, role: "assistant" }));
expect(screen.getByText("const x = 1;")).toBeTruthy();
expect(document.querySelector(".code-block")).toBeTruthy();
});
test("表格使用 markdown-table class 渲染", () => {
const part = createTextPart("| A | B |\n| --- | --- |\n| 1 | 2 |");
renderWithProviders(createElement(TextPart, { isStreaming: false, part, role: "assistant" }));
expect(screen.getByText("A")).toBeTruthy();
expect(document.querySelector(".markdown-table")).toBeTruthy();
});
test("表格单元格内富文本正常渲染", () => {
const part = createTextPart("| A | B |\n| --- | --- |\n| **粗体** | `code` |");
renderWithProviders(createElement(TextPart, { isStreaming: false, part, role: "assistant" }));
const bold = screen.getByText("粗体");
expect(bold).toBeTruthy();
expect(bold.tagName).toBe("STRONG");
expect(screen.getByText("code")).toBeTruthy();
});
});
describe("TextPart 用户消息不受 overrides 影响", () => {
test("用户消息不应用 Markdown 渲染", () => {
const part = createTextPart("```javascript\nconst x = 1;\n```");
const { container } = renderWithProviders(createElement(TextPart, { isStreaming: false, part, role: "user" }));
expect(container.textContent).toContain("const x = 1;");
expect(document.querySelector(".code-block")).toBeNull();
});
test("用户消息使用 Typography.Paragraph 渲染", () => {
const part = createTextPart("普通文本消息");
renderWithProviders(createElement(TextPart, { isStreaming: false, part, role: "user" }));
expect(document.querySelector(".message-body-text")).toBeTruthy();
});
});
describe("TextPart 流式状态传递", () => {
test("流式状态下 isStreaming 传递给 CodeBlock", () => {
const part = createTextPart("```javascript\nconst x = 1;\n```");
renderWithProviders(createElement(TextPart, { isStreaming: true, part, role: "assistant" }));
const codeBlock = document.querySelector(".code-block");
expect(codeBlock).toBeTruthy();
expect(codeBlock!.tagName).toBe("PRE");
});
test("非流式状态下渲染完整代码块结构", () => {
const part = createTextPart("```javascript\nconst x = 1;\n```");
renderWithProviders(createElement(TextPart, { isStreaming: false, part, role: "assistant" }));
expect(document.querySelector(".code-block-header")).toBeTruthy();
});
});
describe("TextPart 纯文本 AI 消息", () => {
test("无代码块无表格的消息正常渲染", () => {
const part = createTextPart("这是一条普通的 AI 回复。");
const { container } = renderWithProviders(createElement(TextPart, { isStreaming: false, part, role: "assistant" }));
expect(screen.getByText("这是一条普通的 AI 回复。")).toBeTruthy();
expect(container.querySelector(".code-block")).toBeNull();
expect(container.querySelector(".markdown-table")).toBeNull();
});
});

View File

@@ -2,11 +2,21 @@ import { fireEvent, screen, waitFor } from "@testing-library/react";
import { describe, expect, test, vi } from "bun:test";
import { createElement } from "react";
import type { Material } from "../../../../src/web/features/inbox/types";
import type { CreateMaterialRequest, Material } from "../../../../src/shared/api";
import { AddMaterialModal } from "../../../../src/web/features/inbox/components/AddMaterialModal";
import { renderWithProviders } from "../../test-utils";
const MOCK_CREATED: Material = {
associatedDate: "2026-06-03",
createdAt: "2026-06-03T00:00:00.000Z",
description: "测试描述",
id: "new-id",
projectId: "project-1",
status: "pending",
updatedAt: "2026-06-03T00:00:00.000Z",
};
describe("AddMaterialModal", () => {
test("打开时渲染表单字段", () => {
renderWithProviders(
@@ -49,7 +59,8 @@ describe("AddMaterialModal", () => {
});
test("点击确定触发表单提交", async () => {
const onAdd = vi.fn<(material: Material) => void>();
const onAdd = vi.fn<(body: CreateMaterialRequest) => Promise<Material>>();
onAdd.mockResolvedValue(MOCK_CREATED);
renderWithProviders(
createElement(AddMaterialModal, {
onAdd,
@@ -69,9 +80,29 @@ describe("AddMaterialModal", () => {
const callArgs = onAdd.mock.calls[0];
expect(callArgs).toBeDefined();
const calledMaterial = callArgs![0];
expect(calledMaterial.description).toBe("测试描述");
expect(calledMaterial.associatedDate).toMatch(/^\d{4}-\d{2}-\d{2}$/);
expect(calledMaterial.id).toBeTruthy();
const calledBody = callArgs![0];
expect(calledBody.description).toBe("测试描述");
expect(calledBody.associatedDate).toMatch(/^\d{4}-\d{2}-\d{2}$/);
});
test("提交失败显示错误提示", async () => {
const onAdd = vi.fn<(body: CreateMaterialRequest) => Promise<Material>>();
onAdd.mockRejectedValue(new Error("网络错误"));
renderWithProviders(
createElement(AddMaterialModal, {
onAdd,
onOpenChange: vi.fn(),
open: true,
}),
);
const textarea = screen.getByPlaceholderText("请输入素材描述");
fireEvent.change(textarea, { target: { value: "测试描述" } });
fireEvent.click(screen.getByText("确 定"));
await waitFor(() => {
expect(onAdd).toHaveBeenCalledTimes(1);
});
});
});

View File

@@ -2,18 +2,85 @@ import { fireEvent, screen, waitFor } from "@testing-library/react";
import { describe, expect, test } from "bun:test";
import { createElement } from "react";
import type { Project } from "../../../../src/shared/api";
import { InboxPage } from "../../../../src/web/features/inbox";
import { renderWithProviders } from "../../test-utils";
import { ProjectContext } from "../../../../src/web/shared/hooks/use-current-project";
import { installFetchMock, jsonResponse, renderWithProviders } from "../../test-utils";
const MOCK_PROJECT: Project = {
archivedAt: null,
createdAt: "2026-01-01T00:00:00.000Z",
description: "",
id: "project-1",
name: "测试项目",
status: "active",
updatedAt: "2026-01-01T00:00:00.000Z",
};
const EMPTY_LIST = { items: [], page: 1, pageSize: 20, total: 0 };
function makeMaterial(overrides: Partial<{ description: string; id: string }> = {}) {
return {
associatedDate: "2026-06-03",
createdAt: "2026-06-03T00:00:00.000Z",
description: overrides.description ?? "测试素材",
id: overrides.id ?? "mat-1",
projectId: "project-1",
status: "pending",
updatedAt: "2026-06-03T00:00:00.000Z",
};
}
function renderInboxPage() {
return renderWithProviders(
createElement(ProjectContext.Provider, {
children: createElement(InboxPage),
value: MOCK_PROJECT,
}),
);
}
describe("InboxPage", () => {
test("初始状态显示空状态提示", () => {
renderWithProviders(createElement(InboxPage));
expect(screen.getByText("暂无素材")).not.toBeNull();
test("初始状态显示空列表和空详情", async () => {
const calls = installFetchMock((call) => {
if (call.url.includes("/materials")) return jsonResponse(EMPTY_LIST);
return jsonResponse({});
});
renderInboxPage();
await waitFor(() => {
expect(screen.getByText("暂无素材")).not.toBeNull();
});
expect(screen.getByText("请在左侧选择素材")).not.toBeNull();
const listCall = calls.find((c) => c.url.includes("/materials") && c.method === "GET");
expect(listCall).toBeDefined();
});
test("新增素材后列表追加且自动选中", async () => {
renderWithProviders(createElement(InboxPage));
const createdId = "mat-new";
const created = makeMaterial({ description: "新增的素材", id: createdId });
installFetchMock((call) => {
if (call.method === "POST" && call.url.includes("/materials")) {
return jsonResponse({ material: created }, { status: 201 });
}
if (call.method === "GET" && call.url.includes("/materials/" + createdId)) {
return jsonResponse({ material: created });
}
if (call.method === "GET" && call.url.includes("/materials")) {
return jsonResponse({ ...EMPTY_LIST, items: [created], total: 1 });
}
return jsonResponse({});
});
renderInboxPage();
await waitFor(() => {
expect(screen.getByRole("button", { name: /新增素材/ })).not.toBeNull();
});
fireEvent.click(screen.getByRole("button", { name: /新增素材/ }));
@@ -27,27 +94,28 @@ describe("InboxPage", () => {
fireEvent.click(screen.getByText("确 定"));
await waitFor(() => {
expect(screen.getByText("新增的素材")).not.toBeNull();
const cards = screen.getAllByText("新增的素材");
expect(cards.length).toBeGreaterThanOrEqual(1);
});
expect(screen.getByText("素材详情")).not.toBeNull();
expect(screen.queryByText("暂无素材")).toBeNull();
expect(screen.queryByText("请在左侧选择素材")).toBeNull();
});
test("删除素材后列表更新", async () => {
renderWithProviders(createElement(InboxPage));
let deleted = false;
const material = makeMaterial({ description: "待删除的素材", id: "mat-del" });
fireEvent.click(screen.getByRole("button", { name: /新增素材/ }));
await waitFor(() => {
expect(screen.getByText("新增素材", { selector: ".ant-modal-title" })).not.toBeNull();
installFetchMock((call) => {
if (call.method === "DELETE" && call.url.includes("/materials/" + material.id)) {
deleted = true;
return new Response(null, { status: 204 });
}
if (call.method === "GET" && call.url.includes("/materials")) {
if (deleted) return jsonResponse(EMPTY_LIST);
return jsonResponse({ ...EMPTY_LIST, items: [material], total: 1 });
}
return jsonResponse({});
});
const textarea = screen.getByPlaceholderText("请输入素材描述");
fireEvent.change(textarea, { target: { value: "待删除的素材" } });
fireEvent.click(screen.getByText("确 定"));
renderInboxPage();
await waitFor(() => {
expect(screen.getByText("待删除的素材")).not.toBeNull();
@@ -55,9 +123,14 @@ describe("InboxPage", () => {
fireEvent.click(screen.getByLabelText("删除"));
await waitFor(() => {
expect(screen.getByText("确认删除该素材?")).not.toBeNull();
});
fireEvent.click(screen.getByText("删 除"));
await waitFor(() => {
expect(screen.getByText("暂无素材")).not.toBeNull();
expect(screen.getByText("请在左侧选择素材")).not.toBeNull();
});
});
});

View File

@@ -1,21 +1,24 @@
import { fireEvent, screen } from "@testing-library/react";
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { describe, expect, test, vi } from "bun:test";
import { createElement } from "react";
import type { Material } from "../../../../src/web/features/inbox/types";
import type { Material } from "../../../../src/shared/api";
import { MaterialCard } from "../../../../src/web/features/inbox/components/MaterialCard";
import { renderWithProviders } from "../../test-utils";
const MOCK_MATERIAL: Material = {
associatedDate: "2026-06-03",
createdAt: new Date().toISOString(),
createdAt: "2026-06-03T00:00:00.000Z",
description: "测试素材描述",
id: "test-id",
projectId: "project-1",
status: "pending",
updatedAt: "2026-06-03T00:00:00.000Z",
};
describe("MaterialCard", () => {
test("渲染素材描述和日期信息", () => {
test("渲染素材描述、时间和状态标签", () => {
renderWithProviders(
createElement(MaterialCard, {
material: MOCK_MATERIAL,
@@ -25,7 +28,8 @@ describe("MaterialCard", () => {
}),
);
expect(screen.getByText("测试素材描述")).not.toBeNull();
expect(screen.getByText(/2026-06-03/)).not.toBeNull();
expect(screen.getByText("今天")).not.toBeNull();
expect(screen.getByText("待审核")).not.toBeNull();
});
test("点击卡片触发 onSelect", () => {
@@ -38,12 +42,12 @@ describe("MaterialCard", () => {
selected: false,
}),
);
const card = screen.getByText("测试素材描述").closest(".ant-card")!;
fireEvent.click(card);
const item = screen.getByText("测试素材描述").closest(".material-list-item")!;
fireEvent.click(item);
expect(onSelect).toHaveBeenCalledTimes(1);
});
test("点击删除按钮触发 onDelete 且不触发 onSelect", () => {
test("点击删除按钮弹出确认框,确认后触发 onDelete", async () => {
const onDelete = vi.fn();
const onSelect = vi.fn();
renderWithProviders(
@@ -55,11 +59,20 @@ describe("MaterialCard", () => {
}),
);
fireEvent.click(screen.getByLabelText("删除"));
expect(onDelete).toHaveBeenCalledTimes(1);
await waitFor(() => {
expect(screen.getByText("确认删除该素材?")).not.toBeNull();
});
fireEvent.click(screen.getByText("删 除"));
await waitFor(() => {
expect(onDelete).toHaveBeenCalledTimes(1);
});
expect(onSelect).not.toHaveBeenCalled();
});
test("选中状态添加 app-inbox-card-selected 类", () => {
test("选中时包含 material-list-item--selected 类", () => {
renderWithProviders(
createElement(MaterialCard, {
material: MOCK_MATERIAL,
@@ -68,7 +81,20 @@ describe("MaterialCard", () => {
selected: true,
}),
);
const card = screen.getByText("测试素材描述").closest(".app-inbox-card-selected");
expect(card).not.toBeNull();
const item = screen.getByText("测试素材描述").closest(".material-list-item--selected");
expect(item).not.toBeNull();
});
test("未选中时不包含 material-list-item--selected 类名", () => {
renderWithProviders(
createElement(MaterialCard, {
material: MOCK_MATERIAL,
onDelete: vi.fn(),
onSelect: vi.fn(),
selected: false,
}),
);
const item = screen.getByText("测试素材描述").closest(".material-list-item--selected");
expect(item).toBeNull();
});
});

View File

@@ -2,28 +2,39 @@ import { screen } from "@testing-library/react";
import { describe, expect, test } from "bun:test";
import { createElement } from "react";
import type { Material } from "../../../../src/web/features/inbox/types";
import type { Material } from "../../../../src/shared/api";
import { MaterialContent } from "../../../../src/web/features/inbox/components/MaterialContent";
import { renderWithProviders } from "../../test-utils";
const MOCK_MATERIAL: Material = {
associatedDate: "2026-06-03",
createdAt: new Date().toISOString(),
createdAt: "2026-06-03T00:00:00.000Z",
description: "详细描述内容",
id: "test-id",
projectId: "project-1",
status: "pending",
updatedAt: "2026-06-03T00:00:00.000Z",
};
describe("MaterialContent", () => {
test("未选中时显示空状态提示", () => {
renderWithProviders(createElement(MaterialContent, { material: null }));
expect(screen.getByText("请在左侧选择素材")).not.toBeNull();
});
test("选中时展示素材详情", () => {
test("展示素材详情和状态", () => {
renderWithProviders(createElement(MaterialContent, { material: MOCK_MATERIAL }));
expect(screen.getByText("素材详情")).not.toBeNull();
expect(screen.getByText("详细描述内容")).not.toBeNull();
expect(screen.getByText("2026-06-03")).not.toBeNull();
expect(screen.getByText("待审核")).not.toBeNull();
});
test("展示已通过状态", () => {
const approved: Material = { ...MOCK_MATERIAL, status: "approved" };
renderWithProviders(createElement(MaterialContent, { material: approved }));
expect(screen.getByText("已通过")).not.toBeNull();
});
test("展示已放弃状态", () => {
const discarded: Material = { ...MOCK_MATERIAL, status: "discarded" };
renderWithProviders(createElement(MaterialContent, { material: discarded }));
expect(screen.getByText("已放弃")).not.toBeNull();
});
});

View File

@@ -2,7 +2,7 @@ import { screen } from "@testing-library/react";
import { describe, expect, test, vi } from "bun:test";
import { createElement } from "react";
import type { Material } from "../../../../src/web/features/inbox/types";
import type { Material } from "../../../../src/shared/api";
import { MaterialList } from "../../../../src/web/features/inbox/components/MaterialList";
import { renderWithProviders } from "../../test-utils";
@@ -10,15 +10,21 @@ import { renderWithProviders } from "../../test-utils";
const MOCK_MATERIALS: Material[] = [
{
associatedDate: "2026-06-03",
createdAt: new Date().toISOString(),
createdAt: "2026-06-03T00:00:00.000Z",
description: "素材一",
id: "id-1",
projectId: "project-1",
status: "pending",
updatedAt: "2026-06-03T00:00:00.000Z",
},
{
associatedDate: "2026-06-02",
createdAt: new Date().toISOString(),
createdAt: "2026-06-02T00:00:00.000Z",
description: "素材二",
id: "id-2",
projectId: "project-1",
status: "pending",
updatedAt: "2026-06-02T00:00:00.000Z",
},
];
@@ -26,6 +32,7 @@ describe("MaterialList", () => {
test("列表为空时显示暂无素材", () => {
renderWithProviders(
createElement(MaterialList, {
loading: false,
materials: [],
onAddClick: vi.fn(),
onDelete: vi.fn(),
@@ -39,6 +46,7 @@ describe("MaterialList", () => {
test("渲染素材卡片列表", () => {
renderWithProviders(
createElement(MaterialList, {
loading: false,
materials: MOCK_MATERIALS,
onAddClick: vi.fn(),
onDelete: vi.fn(),
@@ -54,6 +62,7 @@ describe("MaterialList", () => {
const onAddClick = vi.fn();
renderWithProviders(
createElement(MaterialList, {
loading: false,
materials: [],
onAddClick,
onDelete: vi.fn(),
@@ -64,4 +73,18 @@ describe("MaterialList", () => {
screen.getByText("新增素材").click();
expect(onAddClick).toHaveBeenCalledTimes(1);
});
test("加载中显示 Spin", () => {
renderWithProviders(
createElement(MaterialList, {
loading: true,
materials: [],
onAddClick: vi.fn(),
onDelete: vi.fn(),
onSelect: vi.fn(),
selectedId: null,
}),
);
expect(document.querySelector(".ant-spin")).not.toBeNull();
});
});