Compare commits
5 Commits
01257b8952
...
90a66437d7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90a66437d7 | ||
|
|
3bd464d400 | ||
|
|
a6f5d085e5 | ||
|
|
a02c6b6e4f | ||
|
|
e9477d84d8 |
8
.github/workflows/ci.yml
vendored
8
.github/workflows/ci.yml
vendored
@@ -34,8 +34,8 @@ jobs:
|
||||
run: |
|
||||
uv run alembic upgrade head
|
||||
uv run alembic check
|
||||
- name: pytest
|
||||
run: uv run pytest -q
|
||||
- name: pytest + coverage gate (>=80%)
|
||||
run: uv run pytest -q --cov --cov-report=term-missing --cov-fail-under=80
|
||||
|
||||
# 打包冒烟:构建 ww-agents wheel → 装进裸 venv → import ww_agents 并断言 SPECS。
|
||||
# 证明 prompts/*.md 随 wheel 分发(源码树 pytest 测不出此漏带——见
|
||||
@@ -85,6 +85,6 @@ jobs:
|
||||
- name: typecheck
|
||||
working-directory: apps/web
|
||||
run: pnpm typecheck
|
||||
- name: test
|
||||
- name: test + coverage gate (>=80%)
|
||||
working-directory: apps/web
|
||||
run: pnpm test
|
||||
run: pnpm test:coverage
|
||||
|
||||
36
CLAUDE.md
36
CLAUDE.md
@@ -89,6 +89,38 @@ Aligns `ARCHITECTURE.md §9.3`. This is how every failure gets diagnosed.
|
||||
- **Errors** logged with full context at the boundary, mapped to the error envelope (`ARCHITECTURE §7.1` is the source of truth for envelope shape + error codes). Frontend logs client errors with the same `request_id`.
|
||||
- **Redact**: never log API keys; truncate/hash full prompts and manuscript text (log lengths/hashes, not the novel).
|
||||
|
||||
### Definition of Done (每次编码完成的硬门禁) — 不通过不算完成
|
||||
|
||||
每一次代码改动(无论多小)在声明「完成」前,**必须**按顺序全部通过以下门禁。任一项失败就回去修,不得跳过、不得只跑部分、不得仅靠肉眼判断。
|
||||
|
||||
1. **TDD 已遵守** — 先写失败测试(RED)→ 实现到通过(GREEN)→ 重构(REFACTOR)。新增/修改的逻辑都有对应测试;改 bug 先写能复现的失败测试。见上文 §TDD。
|
||||
2. **测试覆盖率 ≥ 80%(已工具化 + CI 强制)** — 改动涉及的模块覆盖率不得低于 80%;新代码不许拉低整体覆盖率。跑命令核对真实数字,不要估:
|
||||
- 后端:`uv run pytest -q --cov --cov-report=term-missing --cov-fail-under=80`(低于 80% 直接非零退出。当前基线 ~88%)。配置在根 `pyproject.toml` 的 `[tool.coverage.*]`。
|
||||
- 前端:`cd apps/web && pnpm test:coverage`(`vitest.config.ts` 设 lines/functions/branches/statements 阈值 80。当前基线 ~95%)。
|
||||
- **前端覆盖率范围 = `lib/**`(纯逻辑 + React hooks)**:hooks(`use*.ts`)用 jsdom + `@testing-library/react` 的 `renderHook` 单测(测试文件首行 `// @vitest-environment jsdom`,mock `@/lib/api/client` + `@/components/Toast`,参见 `lib/generation/useWorldGen.test.ts`)。组件(.tsx)仍由 E2E/人工验收覆盖,不在 vitest 范围(写新 hook 时必须同步补 `renderHook` 测试)。
|
||||
3. **跑完三类自动化测试**(改动触及的那侧必跑,跨栈改动两侧都跑):
|
||||
- **后端 Unit + Integration**:`uv run pytest -q`(需要 pg 的集成测试先 `docker compose up -d pg`)。
|
||||
- **前端自动化测试**:`cd apps/web && pnpm test`(vitest)。
|
||||
- **E2E**(动到 write→review→accept 主链路时):跑相关 E2E 流程(mock gateway)。
|
||||
- LLM 一律 mock,**绝不在测试/CI 命中真实 LLAPI**(见上文 §TDD)。
|
||||
4. **全部门禁绿**(与 §Toolchain、CI 一致):
|
||||
- 后端:`uv run ruff check .` · `uv run ruff format .` · `uv run mypy packages apps` · `uv run pytest -q` · 动过模型时 `uv run alembic check` 无漂移。
|
||||
- 前端:`cd apps/web && pnpm lint` · `pnpm typecheck` · `pnpm test` · `pnpm build`;动过后端 schema 先 `pnpm gen:api` 再校验。
|
||||
5. **符合架构与编程规范** — 不违反上文 §Architectural invariants、§Locked tech stack、§Python/§Frontend/§LangGraph 实践,以及全局编码风格(不可变更新、KISS/DRY/YAGNI、文件 <800 行、函数 <50 行、显式错误处理、边界校验、无硬编码密钥)。安全敏感改动(auth/输入处理/DB 查询/外部 API/加密)按 §code-review 触发安全自查。
|
||||
6. **提交(每次完成都 commit)** — 见下文 §Commit discipline。
|
||||
7. **回写状态/文档** — 按 §Per-task workflow 更新 `PROGRESS.md`;若实现暴露 spec 缺口,回写对应 spec(见 §Conventions)。
|
||||
|
||||
> 报告要诚实:测试失败就如实说明并贴输出;跳过了哪一步要讲明;只有真正跑过且全绿,才说「完成」。
|
||||
|
||||
### Commit discipline (提交纪律) — 每次完成即提交
|
||||
|
||||
- **每次完成一个可工作的改动就 commit**,小步提交、一次一个聚焦改动;不要把多个无关改动堆进一个提交。
|
||||
- **提交前** Definition of Done 的门禁(1–5)必须已全绿——不提交跑不过测试或 lint/类型不干净的代码。
|
||||
- **提交信息格式**:遵循 Conventional Commits —— `<type>: <description>`(type ∈ feat/fix/refactor/docs/test/chore/perf/ci),中文描述与本仓库现有提交风格一致;需要时附 body 说明「为什么」。
|
||||
- **提交信息禁止任何署名/归属尾注** — **不得包含 `Co-Authored-By:` 或任何 `Generated with` / AI 署名行**(全局已在 `~/.claude/settings.json` 关闭 attribution;此处再次明确,覆盖任何默认加尾注的行为)。
|
||||
- **分支**:不在 `main` 上直接提交;按 §Git Workflow 在特性分支工作。`develop` 为当前集成分支。
|
||||
- **push / 开 PR 仅在用户明确要求时**进行;PR body 同样不加 AI 署名。
|
||||
|
||||
## Toolchain (Phase 0 已落地)
|
||||
|
||||
工具:**uv**(Python workspace,4 members: `apps/api` + `packages/{shared,config,db}`) + **pnpm**(前端,`apps/web`)。Python 3.12+,Node 22。
|
||||
@@ -97,8 +129,8 @@ Aligns `ARCHITECTURE.md §9.3`. This is how every failure gets diagnosed.
|
||||
- **依赖安装**:`uv sync`(后端,仓库根);`pnpm install`(前端,`cd apps/web`)。
|
||||
- **本地起服务**:`docker compose up`(pg + api + web)。仅起库:`docker compose up -d pg`。裸跑 API:`uv run uvicorn ww_api.main:app --reload`。
|
||||
- **迁移**:`uv run alembic upgrade head`;改模型后 `uv run alembic revision --autogenerate -m "..."`;漂移校验 `uv run alembic check`(需 pg 在跑)。
|
||||
- **后端门禁**:`uv run ruff check .` · `uv run ruff format .` · `uv run mypy packages apps` · `uv run pytest -q`。单测:`uv run pytest path::test -q`。
|
||||
- **前端门禁**(`cd apps/web`):`pnpm lint` · `pnpm typecheck` · `pnpm test`(vitest)· `pnpm build`。
|
||||
- **后端门禁**:`uv run ruff check .` · `uv run ruff format .` · `uv run mypy packages apps` · `uv run pytest -q`。覆盖率门禁:`uv run pytest -q --cov --cov-report=term-missing --cov-fail-under=80`。单测:`uv run pytest path::test -q`(单测不带 `--cov`,避免 fail-under 误报)。
|
||||
- **前端门禁**(`cd apps/web`):`pnpm lint` · `pnpm typecheck` · `pnpm test`(vitest)· `pnpm build`。覆盖率门禁:`pnpm test:coverage`(阈值 80%,范围 `lib/**`)。
|
||||
- **重生成 TS 客户端**:改后端 schema 后 `cd apps/web && pnpm gen:api`(离线:`uv run python -m ww_api.export_openapi` → `openapi-typescript` 生成 `lib/api/schema.d.ts`)。
|
||||
- **pnpm 配置**:在 `apps/web/pnpm-workspace.yaml`(pnpm 11 不再读 package.json/.npmrc)——含 `onlyBuiltDependencies` 白名单 + `verifyDepsBeforeRun: false`(见 `memory/gotchas.md`)。
|
||||
- **CI**:`.github/workflows/ci.yml`(backend job 带 pg service 跑 ruff/mypy/alembic/pytest;frontend job 跑 gen:api/lint/typecheck/test)。
|
||||
|
||||
39
PROGRESS.md
39
PROGRESS.md
@@ -8,7 +8,42 @@
|
||||
|
||||
---
|
||||
|
||||
## 当前阶段:T6 · 创作工具箱(通用生成器框架)— P1 框架地基
|
||||
## 当前阶段:QA-CR · 全项目 Code Review 整改批
|
||||
|
||||
> **背景**:[2026-06-26] 对全仓做了一次**七维度 Code Review**(后端 Python 规范 / LangGraph 最佳实践 / 后端 FastAPI 架构 / 前端编程规范 / 前端架构 / 应用安全 / 依赖时效),6 个专科 reviewer 并行 + 依赖自查。绿门禁下仍揪出 **2 CRITICAL + 13 HIGH + 一批 MEDIUM/LOW**——再次证伪「测试过=没问题」。架构本身成熟、不变量纪律强(provider 中立 / HITL accept 单写 / checkpoint 仅控制流 / SecretStr 全覆盖);**确认无虞**:SSRF(`base_url` 仅来自硬编码 `_PROVIDER_BASE_URLS`,无用户 URL 入网)/ SQL 注入(全 ORM 参数化)/ XSS(无 `dangerouslySetInnerHTML`/`eval`)/ 错误信封不泄栈密钥 / Kimi 用 RFC 8628 device flow 无 CSRF 面。
|
||||
> **整改 DoD**:CRITICAL + HIGH 全部 ✅ 且各自补回归测试;MEDIUM 择批清理;门禁全绿(后端 ruff/format/mypy/pytest + alembic 无漂移;前端 lint/tsc/vitest/build)。**守 §Definition of Done**:每项 TDD 先红后绿、完成即小步 commit。
|
||||
> **落地顺序**:① 本周 CR-C1/C2 + 两资源泄漏(H1/H2)+ Fernet key(H8);② 合并前 计费两项(H3)+ request_id(H4)+ max_length(H9)+ 前端三 HIGH(H10/H11/H12);③ MEDIUM/LOW 技术债批。
|
||||
> **依赖纪律**:CR-C1(升 Next)须 `pnpm install` 重锁 + `pnpm build` 验证、**升级后轮换全部应用密钥**(与 CR-H8 Fernet key 联动)。CR-H4/H9 改 schema → 若动 OpenAPI 形参须 `pnpm gen:api` + `memory/contracts.md` 记一笔。无 DB 迁移预期。
|
||||
|
||||
| 任务 | 状态 | 负责 | 依赖 | 备注 |
|
||||
|---|---|---|---|---|
|
||||
| CR-C1 🔴 升级 Next.js 修 RCE(CVE-2025-66478)+ React 补丁 + 轮换密钥 | ⬜ | @frontend | — | `apps/web/package.json` next **15.1.3→15.1.9**(或 15.5.7/16.x),react/react-dom 19.0.0→补丁版(上游 CVE-2025-55182);App Router + RSC 应用正受影响。`pnpm install` 重锁 + `pnpm build` 验;**升级后轮换全部应用密钥**(联动 CR-H8)。Next15 LTS 2026-10-21 EOL,规划 16.x 迁移 |
|
||||
| CR-C2 🔴 多章链设 `recursion_limit` | ⬜ | @backend | — | `services/chain_runner.py:210` 默认 25 supersteps、每章 4 节点→超 ~6 章即 `GraphRecursionError` 整 job 失败,「批量产一卷(K章)」在目标规模不可用。`config` 加 `recursion_limit=(last-start+1)*4+10`;补超默认上限的 mock-gateway 回归测试 |
|
||||
| CR-H1 🟠 Kimi OAuth 轮询不占 DB 会话 | ⬜ | @backend | — | `services/job_runner.py:75`+`routers/kimi_oauth.py:85` 轮询全程(最坏 >200s)占连接池连接、饿死其他请求;轮询循环无需 DB,仅最终 `upsert_oauth_credential` 开短会话 |
|
||||
| CR-H2 🟠 修 `httpx.AsyncClient` 泄漏 | ⬜ | @backend | — | `routers/kimi_oauth.py:73` `Depends(_default_http_client)` 非 generator 依赖、永不 `aclose()`;改 `async def _http_client(): async with httpx.AsyncClient(...) as http: yield http` |
|
||||
| CR-H3 🟠 Anthropic 结构化输出记真实 usage | ⬜ | @llm | — | `adapters/anthropic.py:161` 硬编码 `usage=0`→`usage_ledger` 全记 0 成本、腐蚀计费(违 ARCH §4.8);用 `create_with_completion` 取原始响应 usage |
|
||||
| CR-H4 🟠 `request_id` 贯通网关日志 | ⬜ | @llm | — | `gateway.py:183` 所有 `llm_call`/`llm_provider_failed` 无 request_id;`LlmRequest` 加 `request_id` 字段并从 API 层透传(违 ARCH §9.3 端到端追踪不变量) |
|
||||
| CR-H5 🟠 `thinking` 字段:实现或删除 | ⬜ | @llm | — | `types.py:41` 声明但所有 adapter 忽略,`thinking=True` 静默无效、无报错;按 YAGNI 删除或实现转发 + 测试 |
|
||||
| CR-H6 🟠 流式路径加 per-provider 重试 | ⬜ | @llm | — | `gateway.py:268` 流式仅 1 次尝试,首 token 前一次瞬时 429 烧光 fallback 链(与非流式 `_complete_with_retry` 不对称);用 `_retrying()` 包裹首 token 前尝试或显式文档化 |
|
||||
| CR-H7 🟠 `TOOLBOX` 注册表不可变 | ⬜ | @backend | — | `packages/skills/ww_skills/toolbox_registry.py:70` 裸 dict 可变;仿同库 `SPECS` 用 `Final[Mapping]=MappingProxyType(...)`(违不可变不变量) |
|
||||
| CR-H8 🟠 `.env.example` 移除可用 Fernet key | ⬜ | @devops | — | 已确认提交真实可用 key(注释「可直接用于本地」),换非功能占位符 + 突出轮换提示;联动 CR-C1 轮换 |
|
||||
| CR-H9 🟠 API 文本输入加 `max_length` | ⬜ | @backend | — | `schemas/{providers,generation,projects}.py` `api_key`/`brief`/`final_text`/`segment` 仅 `min_length=1`(DoS/成本失控);加上限(api_key≤512、brief≤1e4、final_text≤2e5)+ ASGI 请求体大小限 |
|
||||
| CR-H10 🟠 前端 `errorCode` 去重 | ⬜ | @frontend | — | `lib/style/{useRefine,useStyleLearn}.ts` 各自重复定义,统一 import 自 `lib/generation/cards.ts:132`(DRY 漂移隐患) |
|
||||
| CR-H11 🟠 Drawer 关闭还原焦点 | ⬜ | @frontend | — | `components/Drawer.tsx:41` 关闭后焦点掉到 body(违 WCAG 2.4.3);传 `triggerRef`、关闭时 `.focus()` |
|
||||
| CR-H12 🟠 RefineView 取消在途 refine | ⬜ | @frontend | — | `components/style/RefineView.tsx:33` 无 AbortController,段切换时旧响应可覆盖新响应;`useRefine` 暴露 abort 在 effect cleanup 调用,去 blanket exhaustive-deps disable |
|
||||
| CR-M1 🟡 后端架构整改批 | ⬜ | @backend | — | `outline.py:162` volume 下推 DB 过滤;`generation.py:326`/`toolbox.py:431` N+1 INSERT→`bulk_create`;`jobs.py:28` 裸 ORM→repo;`provider_deps.py:91` 去私有 `_client`(加 `probe_connection()`);列表端点加分页。⚠️`assemble.py:155` 8 串行查询:**AsyncSession 不支持同一 session 并发,`gather` 须每查询独立 session**,不可照搬 |
|
||||
| CR-M2 🟡 网关/adapters 整改批 | ⬜ | @llm | — | `gateway.py:90` `is_open()` 副作用违 CQS;`pricing.py` 未知 model 静默记 0 → 加 warning;adapter `str(exc)` 入异常消息可能带 vendor 响应体→只留类名+status |
|
||||
| CR-M3 🟡 LangGraph 链健壮性 | ⬜ | @llm | — | `chain_runner.py:124` accept commit 后 `run_overdue_scan` 非幂等且失败无法 resume→部分进度空洞(幂等化或纳入事务);`chain_runner.py:159` 改用公开 `get_state().next` 替私有 `__interrupt__`;`chain/nodes.py:213` 链内 4 审串行→并行/复用 review 子图 |
|
||||
| CR-M4 🟡 前端 key/a11y/cleanup 批 | ⬜ | @frontend | — | 可编辑列表去 index-key(`CharacterGenerator.tsx:135`/`GeneratorRunner.tsx:178`/`ForeshadowCard.tsx:68`);SSE `KNOWN_EVENTS` 补 `stop`(核对 `memory/contracts.md`);ARIA 补全(AppShell nav label / NavDrawer aria-controls / ForeshadowCard `<dl>` 结构);ProjectWizard 步骤焦点;`ReviewReport.tsx:301` setTimeout 卸载清理 |
|
||||
| CR-M5 🟡 前端 TS 边界类型收紧 | ⬜ | @frontend | — | `lib/api/server.ts:34` `parseJsonBody` 加最小结构校验;`useAccept.ts:60` `ApiErrorEnvelope` 从生成 schema 派生;`applyFix.ts` `hasApplicableFix` 改类型谓词去 `as string` |
|
||||
| CR-L1 🟢 LOW 清理批 | ⬜ | @backend·@llm·@frontend | — | `chain.py:256` 静默回退加 warning;SSE 端点补 OpenAPI `text/event-stream` 声明;冗余 `as`/`.valueOf()` cast 清理;测试脚本 `print`→logging |
|
||||
| CR-D1 🟡 抬高后端依赖下界(可选升级评估) | ⬜ | @devops | — | `pyproject.toml` 下界过旧(`langgraph>=0.2.40`/`anthropic>=0.34`),抬到接近 lockfile 实锁版本保可复现;后端实锁版本均当前。可选评估 vitest 3.x / tailwind 4.x |
|
||||
|
||||
> **维度小结**:后端 FastAPI 架构 0C/2H/7M·Python 规范 0C/4H/6M·LangGraph 1C/2H/5M·前端 React 0C/2H/9M·前端 TS 0C/1H/8L·安全 0C/2H/4M·依赖 1C。后端依赖全部当前大版本线;前端唯一关键缺口=Next RCE。
|
||||
|
||||
---
|
||||
|
||||
## 已封板:T6 · 创作工具箱(通用生成器框架)✅
|
||||
|
||||
> **背景**:对标竞品「创作工具箱」——把每个「XX 生成器」实现为声明式 skill(`AgentSpec` 的设计本意),用一条**通用执行路径**驱动。用户已定:① 走**通用 skill 框架**(非逐个硬编码)② 封面/图像本期跳过 ③ 文本生成器全做(上架已有 3 + 新建 8)。完整设计见 `~/.claude/plans/creation-toolbox-generators.md`。
|
||||
> **P1 目标(DoD)**:`GeneratorTool` 描述符 + `TOOLBOX` 注册表 + 通用 `GET /skills/toolbox` & `POST /projects/{id}/skills/{tool_key}/generate` 端点 + **1 个最简生成器(脑洞)端到端打通**(mock 网关 E2E 零 token)。证实「加生成器 = 加一份声明」这条路。
|
||||
@@ -126,6 +161,7 @@ T0.1 monorepo 骨架 ✅ @devops · T0.2 16 MVP 表迁移(无漂移,users st
|
||||
|
||||
> 格式:`- [YYYY-MM-DD] @skill 完成/进展 Txx — 一句话结果 + 影响的契约/文件`
|
||||
|
||||
- [2026-06-26] @orchestrator — **全项目 Code Review(七维度)完成 → `QA-CR` 整改批立项**。6 个专科 reviewer 并行(后端 FastAPI 架构 / Python 规范 / LangGraph 最佳实践 / 前端 React 架构 / 前端 TS 类型安全 / 应用安全)+ 依赖时效自查;212 py + 168 tsx 全覆盖。绿门禁下仍揪出 **2 CRITICAL + 13 HIGH + 一批 MEDIUM/LOW**:**C1** Next.js 15.1.3 RCE(CVE-2025-66478「React2Shell」,App Router+RSC 受影响,升 15.1.9/15.5.7/16.x + 轮换密钥);**C2** 多章链缺 `recursion_limit`(默认 25 supersteps、每章 4 节点→超 ~6 章即崩,批量产卷不可用)。HIGH 含 Kimi OAuth 占 DB 会话 / httpx 泄漏 / Anthropic 计费记 0 / request_id 未贯通 / `.env.example` 提交可用 Fernet key / 文本输入无 `max_length` / 前端 errorCode 重复 / Drawer 焦点 / RefineView 未取消。**确认无虞**:SSRF(base_url 仅硬编码)/SQL 注入(全 ORM)/XSS(无 dangerouslySetInnerHTML)/错误信封不泄密/Kimi device-flow 无 CSRF。已登记 `QA-CR` 任务表(CR-C1/C2 + CR-H1..H12 + CR-M1..M5 + CR-L1 + CR-D1)按 owner 分派 @frontend/@backend/@llm/@devops,落地顺序见当前阶段 background。**本批为评审立项,尚无代码改动。**
|
||||
- [2026-06-23] @orchestrator — **🎉 多章工作流链(Chain Workflow)交付 + 多 agent 交叉评审 + CRITICAL 修复 + 合并 develop(merge `513bf71`)**。对标星月「一键多章」+ 差异化「每章过四审、遇冲突才停人」。设计契约 `docs/design/chain-workflow.md`(commit `5ee9799`)。**多 agent 逐学科建造**(契约先行、目录单写者):**C1 @llm**(`7f3eaab`) LangGraph **cyclic 链图** `orchestrator/chain/{state,nodes,graph}.py`(`build_chain_graph`:write→review→decide→accept 循环 + **interrupt-on-conflict** + resume;删死代码 `build_write_graph`,`write_node` 本体留用;7 单测 mock 网关+fake session+MemorySaver)·**C2 @backend**(`29349dc`) 服务/3 端点/schema/checkpointer 接线(见下条)·**C3 @db**(`548b7ab`) langgraph 检查点 4 表迁移 `d3e4f5a6b7c8`(`autocommit_block` 避开 `CREATE INDEX CONCURRENTLY` 与 alembic 事务死锁;`env.py` `include_object` 豁免这 4 表防漂移误报)·**C4 @qa**(`061792d`) E2E `tests/test_chain_workflow_e2e.py` 真 pg + mock 网关零 token(无冲突两章全自动 + 冲突→interrupt→awaiting→resume→accept)。**3 路对抗交叉评审**(python/fastapi/不变量)在**绿门禁下仍揪出 1 CRITICAL + 7 HIGH**——证伪"测试过=没问题":CRITICAL=链把**绑请求 session 的 gateway** 传进 BackgroundTask,后台跑时 session 已关→write/review 的 `usage_ledger.record()` 写进死 session **静默丢失**(链计费全丢,违 #1;E2E 清了 ledger 却没断言其存在故漏掉)。**修复**(`d1ea83b`):网关改 `chain_gateway_builder` 按节点 session 重建(仿 `digest_gateway_builder`/`style.py`);日志脱敏(`_classify_job_error`+`exc_type`,去 `str(exc)`,§5);resume 原子 claim `JobRepo.claim_awaiting_to_running`(防并发双跑同 thread_id,非 awaiting→409)+ 所有权校验(`JobView.project_id`,跨项目→404);`ChainResumeAccepted`(去 start/count=0 哨兵);删死导入 `extract_conflicts`;**补回归断言**(链 `usage_ledger` 必有行 + `chapter_reviews` 每章一行)。**出口(DoD)✅**:后端门禁绿 ruff/format 干净 · mypy **194 Success** · alembic **无漂移**(业务表零迁移,复用 `jobs.status=awaiting_input` + `result.awaiting_chapter`;唯一新 DDL=langgraph 检查点表)· pytest **608 passed**(+6 回归测)。首次真正启用 LangGraph checkpointer/interrupt。守不变量 #1/#3/#4/#5/#9。**剩余下一步(未做)**:① 前端链 UI(`pnpm gen:api` 纳入 run/resume + 发起页/进度轮询/awaiting 裁决续跑面板,复用 `ConflictAdjudication`);② §12 文档回写 `ARCHITECTURE.md §5.2`(补 cyclic 链图 + 更正现状:写章直连流式/单章 accept 为事务/链图首启 checkpointer);③ 未 push 远端。可选:续写/扩写节点入链;UGC 链市场需多租户(deferred)。
|
||||
- [2026-06-23] @backend — **✅ Chain Workflow C2(多章链 服务+端点+schema+checkpointer 接线,分支 `feat/chain-workflow`)**。承 C1(@llm)链图(`build_chain_graph` §3.3),落地服务/端点壳:① **3 端点**`routers/chain.py`(`POST .../chains/{key}/run`→202 `ChainRunAccepted`;`POST .../chains/runs/{job_id}/resume`→202;`GET /jobs/{id}` 复用)。校验:count 1..50→422、未知 chain_key→404、项目不存在→404、resume job 不存在→404、resume 非 awaiting→**409 新码 `CONFLICT`**、无凭据→503。② **schema**`schemas/chain.py`(`ChainRunRequest/ChainRunAccepted/ChainResumeRequest`,`ConflictDecision` 复用)。③ **服务**`services/chain_runner.py`(`run_chain_job` 仿 `run_job` 壳自建独立 session 驱动链图:set_running→ainvoke→据 `__interrupt__` 置 `awaiting_input` 或 `done`/`failed`;`build_accept_op` 在 apps/api 装配验收事务闭包注入图节点,守 #3/#4;token 不入 result/日志)+ `services/chain_deps.py`(`get_checkpointer_factory`:运行时 `AsyncPostgresSaver` 上下文 / 测试 MemorySaver)。④ **零迁移**(§7):复用 `jobs`,新增 `status="awaiting_input"` + `JobRepo.set_awaiting`,awaiting 章经 `result.awaiting_chapter`;新错误码 `ErrorCode.CONFLICT`(409)。⑤ **新网关缝**`project_deps.build_chain_gateway`/`get_chain_gateway`(按请求 tier writer/analyst/light 分派——单档网关恒返该档会错路由 review/digest,见 gotcha)+ `get_digest_gateway_builder`。**单测**`apps/api/tests/test_chain.py` 12 用例(mock 网关 + MemorySaver + fake session/accept_op,无 DB/无网络/无真 LLM):run→202、resume→202、未知 key 404、count 越界(>50/0) 422、resume 非 awaiting 409、项目/ job 不存在 404、run_chain_job 无冲突→done+written 满、冲突→awaiting→resume→done、错误脱敏、accept_op 冲突缺判→CONFLICT_UNRESOLVED。**全仓门禁绿**:ruff/format 干净、mypy **193 Success**、alembic **无漂移**(无 ORM 变更)、pytest **600 passed**。守不变量 #1/#3/#4/#5/#9。记 contracts C-Chain 稳定 + 2 decision + 3 gotcha。**唯一新增 DDL(langgraph 检查点表)= C3 迁移;前端链 UI = 契约稳定后 @frontend follow-up(本期非目标)。**
|
||||
- [2026-06-22] @orchestrator — **🎉 T6 创作工具箱(通用生成器框架)全工具箱交付 + 独立复核(分支 `feat/t6-creation-toolbox`)**。多 agent 并行波次:Wave 0(@llm T6.5 ‖ @backend 描述符类型 ‖ P2 收尾)→ Wave A(@backend T6.2+T6.3 TOOLBOX+端点合龙,契约稳定点)→ Wave B(@frontend T6.6 ‖ @qa T6.4)→ Wave C 收口,目录单写者零写冲突、契约先行、零返工。**@llm T6.5**:`ww_agents` +7 输出 schema + 7 spec(book-title/blurb/name/golden-finger/glossary/opening/fine-outline,只声明 tier #2)+ `build_outline_chapter_context`(with_outline_chapter 策略)。**@frontend T6.6**:`gen:api` 纳入 3 端点;`app/projects/[id]/toolbox/page.tsx`(RSC) + `components/toolbox/{ToolboxPage,ToolCard,GeneratorRunner}` + `lib/toolbox/*`(声明驱动纯函数,按 `input_fields` 渲染表单、按 `output_kind` 渲染预览、可入库者复用 `ConflictAdjudication` 走 409→ack)+ LeftNav「工具箱」+ ⌘K `nav-toolbox`/`action-gen-*`;legacy 3 跳现页不回归。**@qa T6.4**:`tests/test_t6_toolbox_e2e.py` 5 用例真 pg + mock 网关零 token,**未发现端点 bug**。**P2 收尾**:限流→`decisions.md` 记延后(单用户原型);noopener/Committable 经查本分支早已修;`provider_deps.py` 残留 `type:ignore` 为 SDK 私有属性探活、load-bearing 保留。**最终独立复跑全仓门禁绿**:后端 ruff/format 干净、mypy **195 Success**、alembic **无漂移**(descriptor 在代码、复用既有表,零建表迁移)、pytest **583 passed**;前端 gen:api/lint/tsc/vitest **279**/build OK。守不变量 #2(只声明 tier)/#3(预览不写库、入库经验收 gate)/#9(system_prompt 进缓存前块)。**"加生成器=加一份声明" 经实证(8 个新生成器复用同一通用执行器 + 同一前端组件)。** **实景 browse 复验(工具箱卡片栅格→脑洞预览→词条入库)待做。** 后续可选 P5:封面图像轨道 / 自定义 skill 运行期 schema / 收敛已有 3 竖井。
|
||||
@@ -201,3 +237,4 @@ T0.1 monorepo 骨架 ✅ @devops · T0.2 16 MVP 表迁移(无漂移,users st
|
||||
- [2026-06-20] @frontend — **UX R3 完成**(验收前清单 HITL gate,UX §7.4,纯前端无后端改,续 `feat/ux-r1-r2-review-ergonomics`)。新纯函数 `lib/review/accept-preview.ts`(`buildAcceptPreview`:据现有 client state 列「本次验收将更新」——晋升版次 + 提炼章节摘要 + 写回裁决(有冲突时) + 验收后伏笔到期扫描(可能置 OVERDUE) + 伏笔建议待登记提醒(有建议时))。**只列后端 accept 事务确实做的事**(核对 `projects.py` accept 端点:promote+digest+decisions+overdue 扫描;**不**声称改人物 latest_state/自动落库伏笔建议);带「以上为预期,最终以验收回执为准」免责。`AcceptPanel` 加 `foreshadowCount` prop + 未阻断时渲染清单,`ReviewReport` 传 `foreshadow.length`。TDD:4 测。前端门禁绿:lint/tsc/**vitest 205**(+4)/build。下一步(按 plan):Tier4(T4-a 顶部 AI 工具条 / T4-b 本章指令框+风格预设)。
|
||||
- [2026-06-22] @backend — **✅ T6.2+T6.3 创作工具箱通用端点稳定**(通用生成器框架后端落地,分支 `feat/t6-creation-toolbox`)。新 `ww_skills.TOOLBOX`(11 条:legacy 3 + 新 8) + `get_tool`(`toolbox_registry.py`,descriptor 在代码无迁移)。3 新端点(`routers/toolbox.py`,已注册):`GET /skills/toolbox`(描述符列表,legacy 携 `legacy_route`:`codex?gen=world|character` / `/outline`)、`POST .../skills/{tool_key}/generate`(按 `spec.tier` 经新注入缝 `get_tier_gateway_builder` 建网关 → `run_generator` 结构化预览,**不写库**仅落 ledger;未知/legacy→404、项目不存在→404、无凭据→503)、`POST .../skills/{tool_key}/ingest`(仅 `ingest!=None` 工具;world_entities 走 **continuity 预检 409 gate**(`acknowledge_conflicts` 放行) + `partition_writes` 白名单 + schema→JSONB 写库,outline 细纲按 idx 拼 beats upsert;不可入库→422)。PURE context 派发 `services/toolbox_context.build_toolbox_context`(4 策略,缺章/大纲→空节拍不报错)。response/request schema `schemas/toolbox.py`(concrete 子模型,非裸 dict)。守不变量 #2/#3/#9。TDD:注册表 7 + context 8 + 端点 11 = **+26 测**。**全仓门禁绿**:ruff/format 干净、mypy **183 Success**(隔离 cache)、alembic **无漂移**(无新迁移)、pytest **578 passed**。OpenAPI 新增 3 端点 → **@frontend 须 `cd apps/web && pnpm gen:api`**(T6 前端前置)。记 contracts C3 扩(T6.2/T6.3)。
|
||||
- [2026-06-20] @frontend — **Tier4 完成**(T4-a 全局 AI 工具条 + T4-b 本章指令直通生成,分支 `feat/ux-tier4-ai-toolbar`,未并 develop)。T4-a:新 `lib/nav/ai-tools.ts`(aiToolItems) + `components/AiToolbar.tsx` 服务端组件,AppShell 顶栏下仅项目页渲染(写本章/审稿/大纲/设定库/工具箱常驻一排,写本章=朱砂主动作),用 `--chrome` CSS 变量统一 chrome 高度(项目页 7rem=header4+toolbar3,余 4rem),6 个固定高度页改用 `calc(100vh-var(--chrome,4rem))` 适配。T4-b(全链):后端 `assemble/_build_volatile` 加 `directive` 形参(「本章指令」段领衔 volatile,**绝不入 stable_core**,守缓存前缀不变量 #9),`stream_draft` 加可选 body `DraftStreamRequest{directive}` 透传、`directive_len` 入 log 不记原文、不持久化无迁移,重生成 TS 客户端;前端新 `lib/workbench/directive.ts`(STYLE_PRESETS + composeDirective 纯函数)、`useDraftStream.start(p,no,directive?)` 非空时发 JSON body、Workbench 加可折叠「本章指令」textarea + 风格预设 chips。TDD:ai-tools 4 测 + directive 4 测 + assemble directive 测 + draft 端点 directive/向后兼容 2 测。门禁全绿:后端 ruff/format/mypy(163 文件)/**pytest 479 passed**;前端 lint/tsc/**vitest 213**/build。契约记 contracts C3 扩(T4-b)。
|
||||
- [2026-06-28] @backend/@frontend — **UI Phase G 后端依赖补齐**:`ProjectResponse` 增加 `updated_at` / `pending_review_count`,`chapters` 加 `updated_at` 迁移 `8c1d2e3f4a5b`;`SqlProjectRepo` 聚合项目自身、章节、审稿时间作为最近编辑,并按草稿是否晚于最近 accepted/review 统计待审稿章数。前端 `pnpm gen:api` 后项目库默认按最近编辑排序,新增“待审稿”筛选,作品卡显示最近编辑与待审稿徽标。契约记 `memory/contracts.md` C3 扩,UI 计划记 `docs/design/ui-improvement-plan.md` 0.16。
|
||||
|
||||
@@ -9,6 +9,7 @@ from __future__ import annotations
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
@@ -41,6 +42,7 @@ class FakeProjectRepo:
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.rows: dict[uuid.UUID, tuple[uuid.UUID, ProjectView]] = {}
|
||||
self.now = datetime(2026, 6, 28, tzinfo=UTC)
|
||||
|
||||
async def create(self, owner_id: uuid.UUID, data: ProjectCreate) -> ProjectView:
|
||||
pid = uuid.uuid4()
|
||||
@@ -53,6 +55,7 @@ class FakeProjectRepo:
|
||||
theme=data.theme,
|
||||
selling_points=list(data.selling_points),
|
||||
structure=data.structure,
|
||||
updated_at=self.now,
|
||||
)
|
||||
self.rows[pid] = (owner_id, view)
|
||||
return view
|
||||
|
||||
@@ -158,8 +158,11 @@ async def test_list_projects() -> None:
|
||||
await client.post("/projects", json={"title": "乙"})
|
||||
resp = await client.get("/projects")
|
||||
assert resp.status_code == 200
|
||||
titles = {p["title"] for p in resp.json()["projects"]}
|
||||
projects = resp.json()["projects"]
|
||||
titles = {p["title"] for p in projects}
|
||||
assert titles == {"甲", "乙"}
|
||||
assert all("updated_at" in p for p in projects)
|
||||
assert all(p["pending_review_count"] == 0 for p in projects)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -169,7 +172,10 @@ async def test_get_project_detail() -> None:
|
||||
created = (await client.post("/projects", json={"title": "详情"})).json()
|
||||
resp = await client.get(f"/projects/{created['id']}")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["title"] == "详情"
|
||||
body = resp.json()
|
||||
assert body["title"] == "详情"
|
||||
assert "updated_at" in body
|
||||
assert body["pending_review_count"] == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -6,6 +6,7 @@ snake_case;前端经 OpenAPI 生成 TS 类型消费。改字段 → 前端必
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
@@ -34,6 +35,8 @@ class ProjectResponse(BaseModel):
|
||||
theme: str | None = None
|
||||
selling_points: list[str] = Field(default_factory=list)
|
||||
structure: str | None = None
|
||||
updated_at: datetime | None = Field(default=None, description="项目最近更新时间")
|
||||
pending_review_count: int = Field(default=0, ge=0, description="待审稿章节数")
|
||||
|
||||
|
||||
class ProjectListResponse(BaseModel):
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
@tailwind utilities;
|
||||
|
||||
/* 纸感设计 token(UX_SPEC §2.1) */
|
||||
:root {
|
||||
:root,
|
||||
[data-theme="paper"] {
|
||||
--color-bg: #f5f1e8;
|
||||
--color-panel: #fbf8f1;
|
||||
--color-ink: #2b2620;
|
||||
@@ -15,6 +16,32 @@
|
||||
--color-overdue: #c8893a;
|
||||
--color-pass: #5a6b4f;
|
||||
--color-info: #4a5a6b;
|
||||
--color-conflict-mark: #b5543a26;
|
||||
--color-conflict-mark-strong: #b5543a8c;
|
||||
--shadow-paper: #2b26200f;
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
[data-theme="night"] {
|
||||
--color-bg: #181614;
|
||||
--color-panel: #221f1b;
|
||||
--color-ink: #efe6d7;
|
||||
--color-ink-soft: #b9ab96;
|
||||
--color-line: #3c352c;
|
||||
--color-cinnabar: #e07866;
|
||||
--color-cinnabar-wash: #e0786620;
|
||||
--color-conflict: #ef8a72;
|
||||
--color-overdue: #d8a65d;
|
||||
--color-pass: #95b47d;
|
||||
--color-info: #8ca8ca;
|
||||
--color-conflict-mark: #ef8a7230;
|
||||
--color-conflict-mark-strong: #ef8a7290;
|
||||
--shadow-paper: #00000045;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
html {
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -22,6 +49,11 @@ body {
|
||||
color: var(--color-ink);
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--color-cinnabar-wash);
|
||||
color: var(--color-ink);
|
||||
}
|
||||
|
||||
/* 流式打字机光标:朱砂闪烁;尊重 prefers-reduced-motion(UX §10)。 */
|
||||
.typewriter-cursor {
|
||||
animation: typewriter-blink 1s step-end infinite;
|
||||
@@ -42,7 +74,7 @@ body {
|
||||
|
||||
/* 终稿正文:冲突原文持续高亮(朱砂淡底),点锚点定位时闪烁一下(UX §8.3)。 */
|
||||
.draft-mark {
|
||||
background-color: #b5543a26; /* --color-conflict @ ~15% */
|
||||
background-color: var(--color-conflict-mark);
|
||||
border-radius: 2px;
|
||||
}
|
||||
.draft-mark-flash {
|
||||
@@ -52,10 +84,10 @@ body {
|
||||
@keyframes draft-mark-flash {
|
||||
0%,
|
||||
100% {
|
||||
background-color: #b5543a26;
|
||||
background-color: var(--color-conflict-mark);
|
||||
}
|
||||
30% {
|
||||
background-color: #b5543a8c; /* --color-conflict @ ~55% */
|
||||
background-color: var(--color-conflict-mark-strong);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
|
||||
import { CommandPaletteMount } from "@/components/command/CommandPaletteMount";
|
||||
import { ThemeScript } from "@/components/ThemeScript";
|
||||
import { ToastProvider } from "@/components/Toast";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -19,6 +20,7 @@ export default function RootLayout({
|
||||
return (
|
||||
<html lang="zh-CN" suppressHydrationWarning>
|
||||
<body className="font-sans antialiased" suppressHydrationWarning>
|
||||
<ThemeScript />
|
||||
<ToastProvider>
|
||||
{children}
|
||||
<CommandPaletteMount />
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import Link from "next/link";
|
||||
import { BookOpen, Plus } from "lucide-react";
|
||||
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { ProjectCard } from "@/components/ProjectCard";
|
||||
import { ProjectLibrary } from "@/components/projects/ProjectLibrary";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
import { PageHeader } from "@/components/ui/PageHeader";
|
||||
import { fetchProjects } from "@/lib/api/server";
|
||||
import type { ProjectResponse } from "@/lib/api/types";
|
||||
import { buttonClass } from "@/lib/ui/variants";
|
||||
|
||||
// 作品库(Dashboard 入口,UX §6.1)。Server Component 读取。
|
||||
export default async function DashboardPage() {
|
||||
@@ -18,63 +22,44 @@ export default async function DashboardPage() {
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<div className="mx-auto max-w-5xl px-8 py-10">
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<h1 className="font-serif text-3xl text-ink">我的作品</h1>
|
||||
<Link
|
||||
href="/projects/new"
|
||||
className="rounded bg-cinnabar px-4 py-2 text-sm text-panel hover:opacity-90"
|
||||
>
|
||||
+ 新建作品
|
||||
</Link>
|
||||
</div>
|
||||
<div className="mx-auto max-w-5xl px-6 py-10 sm:px-8">
|
||||
<PageHeader
|
||||
title="我的作品"
|
||||
description="从一个灵感进入正文、设定、审稿与验收闭环。"
|
||||
actions={
|
||||
<Link
|
||||
href="/projects/new"
|
||||
className={buttonClass({ variant: "primary" })}
|
||||
>
|
||||
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||
新建作品
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
|
||||
{loadError ? (
|
||||
<p className="rounded border border-conflict bg-panel p-6 text-conflict">
|
||||
无法连接后端服务,请确认 API 已启动后刷新。
|
||||
</p>
|
||||
) : projects.length === 0 ? (
|
||||
<EmptyState />
|
||||
<EmptyState
|
||||
icon={BookOpen}
|
||||
title="还没有作品"
|
||||
description="从一句灵感开始,后续的设定库、大纲、写章和审稿都会围绕这本书展开。"
|
||||
action={
|
||||
<Link
|
||||
href="/projects/new"
|
||||
className={buttonClass({ variant: "primary" })}
|
||||
>
|
||||
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||
新建作品
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ul className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{projects.map((p) => (
|
||||
<li key={p.id}>
|
||||
<ProjectCard project={p} />
|
||||
</li>
|
||||
))}
|
||||
<li>
|
||||
<NewProjectCard />
|
||||
</li>
|
||||
</ul>
|
||||
<ProjectLibrary projects={projects} />
|
||||
)}
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState() {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4 rounded border border-line bg-panel py-20 text-center">
|
||||
<p className="font-serif text-2xl text-ink">还没有作品</p>
|
||||
<p className="text-ink-soft">从一句灵感开始你的第一本书。</p>
|
||||
<Link
|
||||
href="/projects/new"
|
||||
className="rounded bg-cinnabar px-5 py-2 text-sm text-panel hover:opacity-90"
|
||||
>
|
||||
+ 新建作品
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NewProjectCard() {
|
||||
return (
|
||||
<Link
|
||||
href="/projects/new"
|
||||
className="flex h-full min-h-[160px] flex-col items-center justify-center rounded border border-dashed border-line bg-panel text-center hover:border-cinnabar"
|
||||
>
|
||||
<span className="font-serif text-xl text-cinnabar">+ 新建</span>
|
||||
<span className="mt-2 text-sm text-ink-soft">从一句灵感开始一本书</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { ProvidersSettings } from "@/components/settings/ProvidersSettings";
|
||||
import { PageHeader } from "@/components/ui/PageHeader";
|
||||
import { fetchKimiOauthStatus, fetchProviders } from "@/lib/api/server";
|
||||
import type { ProvidersResponse } from "@/lib/api/types";
|
||||
|
||||
@@ -27,7 +28,11 @@ export default async function ProvidersSettingsPage() {
|
||||
|
||||
return (
|
||||
<AppShell title="设置 › 模型与提供商">
|
||||
<div className="mx-auto max-w-3xl px-8 py-10">
|
||||
<div className="mx-auto max-w-6xl px-4 py-8 sm:px-8 sm:py-10">
|
||||
<PageHeader
|
||||
title="模型与提供商"
|
||||
description="配置写手、分析、轻量三类能力档位的路由,并管理 API Key 与 Kimi Code OAuth。"
|
||||
/>
|
||||
{loadError ? (
|
||||
<p className="rounded border border-conflict bg-panel p-6 text-conflict">
|
||||
无法连接后端服务,请确认 API 已启动后刷新。
|
||||
|
||||
@@ -1,7 +1,21 @@
|
||||
import Link from "next/link";
|
||||
import {
|
||||
BookOpen,
|
||||
ClipboardCheck,
|
||||
ListTree,
|
||||
PenLine,
|
||||
Sparkles,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
import { aiToolItems } from "@/lib/nav/ai-tools";
|
||||
import { AiToolbarMoreMenu } from "@/components/AiToolbarMoreMenu";
|
||||
import {
|
||||
aiToolItems,
|
||||
primaryAiToolItems,
|
||||
secondaryAiToolItems,
|
||||
} from "@/lib/nav/ai-tools";
|
||||
import type { ActiveNav } from "@/lib/nav/items";
|
||||
import { buttonClass } from "@/lib/ui/variants";
|
||||
|
||||
interface AiToolbarProps {
|
||||
projectId: string;
|
||||
@@ -9,40 +23,71 @@ interface AiToolbarProps {
|
||||
}
|
||||
|
||||
// T4-a · 项目内页常驻 AI 工具条(顶栏下,UX §3/§5)。
|
||||
// 纯链接服务端组件:写本章=朱砂主动作,其余次级;激活项朱砂高亮。窄屏横向滚动。
|
||||
// 桌面展示完整工具条;窄屏只保留写本章/审稿,把低频入口收进更多菜单。
|
||||
export function AiToolbar({ projectId, activeNav }: AiToolbarProps) {
|
||||
const primaryItems = primaryAiToolItems(projectId);
|
||||
const secondaryItems = secondaryAiToolItems(projectId);
|
||||
return (
|
||||
<nav
|
||||
aria-label="AI 工具条"
|
||||
className="flex h-12 items-center gap-2 overflow-x-auto whitespace-nowrap border-b border-line bg-panel px-4 sm:px-6"
|
||||
className="flex h-12 items-center gap-2 border-b border-line bg-panel px-4 sm:px-6"
|
||||
>
|
||||
{aiToolItems(projectId).map((item) => {
|
||||
const isActive = item.key === activeNav;
|
||||
const className = toolClassName(item.primary === true, isActive);
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
className={className}
|
||||
>
|
||||
<span aria-hidden="true">{item.glyph}</span>
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden lg:hidden">
|
||||
{primaryItems.map((item) => {
|
||||
const isActive = item.key === activeNav;
|
||||
const className = toolClassName(item.primary === true, isActive);
|
||||
const Icon = toolIcon(item.key);
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
className={className}
|
||||
>
|
||||
<Icon className="h-4 w-4" aria-hidden="true" />
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
<AiToolbarMoreMenu items={secondaryItems} activeNav={activeNav} />
|
||||
</div>
|
||||
|
||||
<div className="hidden items-center gap-2 overflow-x-auto whitespace-nowrap lg:flex">
|
||||
{aiToolItems(projectId).map((item) => {
|
||||
const isActive = item.key === activeNav;
|
||||
const className = toolClassName(item.primary === true, isActive);
|
||||
const Icon = toolIcon(item.key);
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
className={className}
|
||||
>
|
||||
<Icon className="h-4 w-4" aria-hidden="true" />
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function toolIcon(key: ActiveNav): LucideIcon {
|
||||
if (key === "write") return PenLine;
|
||||
if (key === "review") return ClipboardCheck;
|
||||
if (key === "outline") return ListTree;
|
||||
if (key === "codex") return BookOpen;
|
||||
return Sparkles;
|
||||
}
|
||||
|
||||
function toolClassName(isPrimary: boolean, isActive: boolean): string {
|
||||
const base =
|
||||
"flex items-center gap-1.5 rounded border px-3 py-1.5 text-sm transition-colors";
|
||||
if (isActive) {
|
||||
return `${base} border-cinnabar text-cinnabar`;
|
||||
return buttonClass({ variant: "outline", size: "sm" });
|
||||
}
|
||||
if (isPrimary) {
|
||||
return `${base} border-cinnabar bg-cinnabar text-panel hover:border-cinnabar`;
|
||||
return buttonClass({ variant: "primary", size: "sm" });
|
||||
}
|
||||
return `${base} border-line text-ink-soft hover:border-cinnabar hover:text-cinnabar`;
|
||||
return buttonClass({ variant: "secondary", size: "sm" });
|
||||
}
|
||||
|
||||
94
apps/web/components/AiToolbarMoreMenu.tsx
Normal file
94
apps/web/components/AiToolbarMoreMenu.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { MoreHorizontal } from "lucide-react";
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
|
||||
import type { AiToolItem } from "@/lib/nav/ai-tools";
|
||||
import type { ActiveNav } from "@/lib/nav/items";
|
||||
import { buttonClass, cn } from "@/lib/ui/variants";
|
||||
|
||||
interface AiToolbarMoreMenuProps {
|
||||
items: AiToolItem[];
|
||||
activeNav?: ActiveNav;
|
||||
}
|
||||
|
||||
export function AiToolbarMoreMenu({
|
||||
items,
|
||||
activeNav,
|
||||
}: AiToolbarMoreMenuProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const menuId = useId();
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent): void => {
|
||||
if (event.key === "Escape") {
|
||||
setOpen(false);
|
||||
buttonRef.current?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const onPointerDown = (event: PointerEvent): void => {
|
||||
const target = event.target;
|
||||
if (target instanceof Node && !rootRef.current?.contains(target)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
window.addEventListener("pointerdown", onPointerDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
window.removeEventListener("pointerdown", onPointerDown);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className="relative">
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
aria-controls={menuId}
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
className={buttonClass({ variant: "secondary", size: "sm" })}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" aria-hidden="true" />
|
||||
更多
|
||||
</button>
|
||||
{open ? (
|
||||
<div
|
||||
id={menuId}
|
||||
role="menu"
|
||||
className="absolute right-0 z-30 mt-2 min-w-36 rounded border border-line bg-panel p-1 shadow-paper"
|
||||
>
|
||||
{items.map((item) => {
|
||||
const active = item.key === activeNav;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
role="menuitem"
|
||||
aria-current={active ? "page" : undefined}
|
||||
onClick={() => setOpen(false)}
|
||||
className={cn(
|
||||
"block rounded px-3 py-2 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cinnabar/35",
|
||||
active
|
||||
? "bg-[var(--color-cinnabar-wash)] text-cinnabar"
|
||||
: "text-ink hover:bg-bg hover:text-cinnabar",
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
import Link from "next/link";
|
||||
import type { ReactNode } from "react";
|
||||
import { Settings } from "lucide-react";
|
||||
|
||||
import type { ActiveNav } from "@/lib/nav/items";
|
||||
import { buttonClass } from "@/lib/ui/variants";
|
||||
import { AiToolbar } from "./AiToolbar";
|
||||
import { LeftNav } from "./LeftNav";
|
||||
import { NavDrawer } from "./NavDrawer";
|
||||
import { ThemeToggle } from "./ThemeToggle";
|
||||
|
||||
interface AppShellProps {
|
||||
children: ReactNode;
|
||||
@@ -34,23 +37,33 @@ export function AppShell({
|
||||
<NavDrawer projectId={projectId} activeNav={activeNav} />
|
||||
<Link
|
||||
href="/"
|
||||
className="font-serif text-xl text-cinnabar"
|
||||
className="shrink-0 whitespace-nowrap font-serif text-xl text-cinnabar"
|
||||
aria-label="返回作品库"
|
||||
>
|
||||
墨痕
|
||||
</Link>
|
||||
{title ? (
|
||||
<span className="font-serif text-lg text-ink">{title}</span>
|
||||
<span className="min-w-0 truncate font-serif text-lg text-ink">
|
||||
{title}
|
||||
</span>
|
||||
) : null}
|
||||
{subtitle ? (
|
||||
<span className="font-mono text-xs text-ink-soft">{subtitle}</span>
|
||||
<span className="hidden shrink-0 font-mono text-xs text-ink-soft sm:inline">
|
||||
{subtitle}
|
||||
</span>
|
||||
) : null}
|
||||
<nav className="ml-auto flex items-center gap-4 text-sm">
|
||||
<nav className="ml-auto flex shrink-0 items-center gap-2 text-sm">
|
||||
<ThemeToggle />
|
||||
<Link
|
||||
href="/settings/providers"
|
||||
className="text-ink-soft hover:text-cinnabar"
|
||||
className={buttonClass({
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
className: "border-transparent",
|
||||
})}
|
||||
>
|
||||
设置
|
||||
<Settings className="h-4 w-4" aria-hidden="true" />
|
||||
<span className="hidden sm:inline">设置</span>
|
||||
</Link>
|
||||
</nav>
|
||||
</header>
|
||||
@@ -59,7 +72,7 @@ export function AppShell({
|
||||
) : null}
|
||||
<div className="flex">
|
||||
<LeftNav projectId={projectId} activeNav={activeNav} />
|
||||
<main className="min-h-[calc(100vh-var(--chrome,4rem))] flex-1 bg-bg">
|
||||
<main className="min-h-[calc(100vh-var(--chrome,4rem))] min-w-0 flex-1 bg-bg">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, type ReactNode } from "react";
|
||||
import { useEffect, useRef, type ReactNode, type RefObject } from "react";
|
||||
|
||||
import { handleTabTrap } from "@/lib/a11y/focusTrap";
|
||||
|
||||
@@ -11,6 +11,7 @@ interface DrawerProps {
|
||||
side?: "left" | "right";
|
||||
// 无障碍标签(role=dialog)。
|
||||
label: string;
|
||||
triggerRef?: RefObject<HTMLElement | null>;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
@@ -21,12 +22,21 @@ export function Drawer({
|
||||
onClose,
|
||||
side = "left",
|
||||
label,
|
||||
triggerRef,
|
||||
children,
|
||||
}: DrawerProps) {
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const wasOpenRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (!open) {
|
||||
if (wasOpenRef.current) {
|
||||
wasOpenRef.current = false;
|
||||
triggerRef?.current?.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
wasOpenRef.current = true;
|
||||
const onKey = (e: KeyboardEvent): void => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
@@ -36,7 +46,7 @@ export function Drawer({
|
||||
window.addEventListener("keydown", onKey);
|
||||
panelRef.current?.focus();
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [open, onClose]);
|
||||
}, [open, onClose, triggerRef]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Menu } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import type { ActiveNav } from "@/lib/nav/items";
|
||||
import { Drawer } from "./Drawer";
|
||||
import { NavItems } from "./NavItems";
|
||||
@@ -17,17 +19,16 @@ export function NavDrawer({ projectId, activeNav }: NavDrawerProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
onClick={() => setOpen(true)}
|
||||
aria-label="打开导航"
|
||||
aria-expanded={open}
|
||||
className="-ml-1 rounded p-2 text-ink hover:text-cinnabar lg:hidden"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="-ml-2 lg:hidden"
|
||||
>
|
||||
<span aria-hidden="true" className="block text-lg leading-none">
|
||||
☰
|
||||
</span>
|
||||
</button>
|
||||
<Menu className="h-5 w-5" aria-hidden="true" />
|
||||
</Button>
|
||||
<Drawer
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
|
||||
@@ -1,6 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Blocks,
|
||||
BookOpen,
|
||||
Boxes,
|
||||
ClipboardCheck,
|
||||
Flag,
|
||||
LayoutTemplate,
|
||||
ListTree,
|
||||
Palette,
|
||||
PenLine,
|
||||
Settings,
|
||||
Sparkles,
|
||||
Workflow,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import {
|
||||
@@ -54,6 +69,22 @@ export function NavItems({ projectId, activeNav, onNavigate }: NavItemsProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function navIcon(item: NavItem): LucideIcon {
|
||||
if (item.key === "write") return PenLine;
|
||||
if (item.key === "outline") return ListTree;
|
||||
if (item.key === "foreshadow") return Flag;
|
||||
if (item.key === "review") return ClipboardCheck;
|
||||
if (item.key === "chains") return Workflow;
|
||||
if (item.key === "style") return Palette;
|
||||
if (item.key === "codex") return BookOpen;
|
||||
if (item.key === "toolbox") return Sparkles;
|
||||
if (item.key === "rules") return Boxes;
|
||||
if (item.key === "skills") return Blocks;
|
||||
if (item.href === "/templates") return LayoutTemplate;
|
||||
if (item.href === "/settings/providers") return Settings;
|
||||
return BookOpen;
|
||||
}
|
||||
|
||||
function NavLink({
|
||||
item,
|
||||
active,
|
||||
@@ -63,18 +94,20 @@ function NavLink({
|
||||
active: boolean;
|
||||
onNavigate?: () => void;
|
||||
}) {
|
||||
const Icon = navIcon(item);
|
||||
return (
|
||||
<li>
|
||||
<Link
|
||||
href={item.href}
|
||||
onClick={onNavigate}
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={`flex items-center gap-2 rounded px-3 py-2 text-sm ${
|
||||
className={`flex items-center gap-2 rounded px-3 py-2 text-sm transition-colors ${
|
||||
active
|
||||
? "border-l-2 border-cinnabar bg-[var(--color-cinnabar-wash)] text-cinnabar"
|
||||
: "text-ink hover:bg-[var(--color-cinnabar-wash)]"
|
||||
: "border-l-2 border-transparent text-ink hover:bg-[var(--color-cinnabar-wash)] hover:text-cinnabar"
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
{item.label}
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
@@ -1,26 +1,98 @@
|
||||
import Link from "next/link";
|
||||
import { BookOpen, ChevronRight, Clock3, Sparkles } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import type { ProjectResponse } from "@/lib/api/types";
|
||||
import {
|
||||
formatProjectUpdatedAt,
|
||||
pendingReviewCount,
|
||||
} from "@/lib/projects/projects";
|
||||
import { cardClass } from "@/lib/ui/variants";
|
||||
|
||||
interface ProjectCardProps {
|
||||
project: ProjectResponse;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
// 作品卡(UX §6.1):书名衬线大字、题材、一句话故事。
|
||||
// M1 无字数/章数/待办统计端点 → 暂不展示该徽标(避免编造 API)。
|
||||
export function ProjectCard({ project }: ProjectCardProps) {
|
||||
export function ProjectCard({ project, compact = false }: ProjectCardProps) {
|
||||
const pendingCount = pendingReviewCount(project);
|
||||
const updatedLabel = formatProjectUpdatedAt(project.updated_at);
|
||||
if (compact) {
|
||||
return (
|
||||
<Link
|
||||
href={`/projects/${project.id}/write`}
|
||||
className={cardClass(
|
||||
"group flex items-center gap-3 p-3 transition-colors hover:border-cinnabar",
|
||||
)}
|
||||
>
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded bg-[var(--color-cinnabar-wash)] text-cinnabar">
|
||||
<BookOpen className="h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-serif text-base text-ink">
|
||||
〈{project.title || "未命名作品"}〉
|
||||
</span>
|
||||
<span className="mt-1 flex items-center gap-2 text-xs text-ink-soft">
|
||||
{project.genre ? <Badge>{project.genre}</Badge> : null}
|
||||
{pendingCount > 0 ? (
|
||||
<Badge variant="warning">{pendingCount} 待审</Badge>
|
||||
) : null}
|
||||
<span className="truncate">{project.logline ?? "暂无一句话故事"}</span>
|
||||
</span>
|
||||
<span className="mt-1 block font-mono text-[11px] text-ink-soft">
|
||||
最近编辑 {updatedLabel}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronRight
|
||||
className="h-4 w-4 shrink-0 text-ink-soft transition-colors group-hover:text-cinnabar"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/projects/${project.id}/write`}
|
||||
className="flex h-full min-h-[160px] flex-col rounded border border-line bg-panel p-6 shadow-paper hover:border-cinnabar"
|
||||
className={cardClass(
|
||||
"group flex h-full min-h-[176px] flex-col p-6 transition-colors hover:border-cinnabar",
|
||||
)}
|
||||
>
|
||||
<h2 className="font-serif text-2xl text-ink">〈{project.title}〉</h2>
|
||||
{project.genre ? (
|
||||
<p className="mt-2 text-sm text-ink-soft">{project.genre}</p>
|
||||
) : null}
|
||||
<div className="mb-4 flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h2 className="truncate font-serif text-2xl text-ink">
|
||||
〈{project.title || "未命名作品"}〉
|
||||
</h2>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{project.genre ? <Badge>{project.genre}</Badge> : null}
|
||||
<Badge variant="accent">
|
||||
<Sparkles className="h-3 w-3" aria-hidden="true" />
|
||||
创作中
|
||||
</Badge>
|
||||
{pendingCount > 0 ? (
|
||||
<Badge variant="warning">{pendingCount} 待审稿</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<span className="rounded border border-line bg-bg p-2 text-ink-soft transition-colors group-hover:border-cinnabar group-hover:text-cinnabar">
|
||||
<BookOpen className="h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
</div>
|
||||
{project.logline ? (
|
||||
<p className="mt-3 line-clamp-3 text-sm text-ink">{project.logline}</p>
|
||||
<p className="line-clamp-3 text-sm leading-6 text-ink">
|
||||
{project.logline}
|
||||
</p>
|
||||
) : null}
|
||||
<span className="mt-3 flex items-center gap-1 font-mono text-[11px] text-ink-soft">
|
||||
<Clock3 className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
最近编辑 {updatedLabel}
|
||||
</span>
|
||||
<span className="mt-auto flex items-center gap-1 pt-4 text-xs text-cinnabar opacity-0 transition-opacity group-hover:opacity-100">
|
||||
进入写作台
|
||||
<ChevronRight className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowLeft, ArrowRight, CheckCircle2 } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
import { api } from "@/lib/api/client";
|
||||
import { useToast } from "@/components/Toast";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Field } from "@/components/ui/Field";
|
||||
import { Select } from "@/components/ui/Select";
|
||||
import { TextArea } from "@/components/ui/TextArea";
|
||||
import { TextInput } from "@/components/ui/TextInput";
|
||||
import { api } from "@/lib/api/client";
|
||||
import { buttonClass } from "@/lib/ui/variants";
|
||||
import {
|
||||
GENRES,
|
||||
SELLING_POINT_PRESETS,
|
||||
@@ -69,14 +77,17 @@ export function ProjectWizard() {
|
||||
const isLast = step === WIZARD_STEPS;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl rounded border border-line bg-panel p-8 shadow-paper">
|
||||
<div className="mx-auto max-w-2xl rounded border border-line bg-panel p-6 shadow-paper sm:p-8">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h1 className="font-serif text-2xl text-ink">新建作品</h1>
|
||||
<StepDots current={step} />
|
||||
</div>
|
||||
<p className="mb-4 text-sm text-ink-soft">
|
||||
步骤 {step}/{WIZARD_STEPS} · {STEP_TITLES[step - 1]}
|
||||
</p>
|
||||
<div className="mb-4">
|
||||
<Badge variant="accent">
|
||||
步骤 {step}/{WIZARD_STEPS}
|
||||
</Badge>
|
||||
<p className="mt-2 text-sm text-ink-soft">{STEP_TITLES[step - 1]}</p>
|
||||
</div>
|
||||
|
||||
<div className="min-h-[220px]">
|
||||
{step === 1 && <StepBasics form={form} update={update} />}
|
||||
@@ -93,32 +104,28 @@ export function ProjectWizard() {
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={goBack}
|
||||
disabled={step === 1}
|
||||
className="rounded border border-line px-4 py-2 text-sm text-ink disabled:opacity-40"
|
||||
>
|
||||
← 上一步
|
||||
</button>
|
||||
<Button onClick={goBack} disabled={step === 1} variant="secondary">
|
||||
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
|
||||
上一步
|
||||
</Button>
|
||||
{isLast ? (
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
onClick={submit}
|
||||
disabled={submitting || !canSubmit(form)}
|
||||
className="rounded bg-cinnabar px-5 py-2 text-sm text-panel disabled:opacity-40"
|
||||
variant="primary"
|
||||
>
|
||||
{submitting ? "创建中…" : "完成立项 →"}
|
||||
</button>
|
||||
<CheckCircle2 className="h-4 w-4" aria-hidden="true" />
|
||||
{submitting ? "创建中…" : "完成立项"}
|
||||
</Button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
onClick={goNext}
|
||||
disabled={!canAdvance(step, form)}
|
||||
className="rounded bg-cinnabar px-5 py-2 text-sm text-panel disabled:opacity-40"
|
||||
variant="primary"
|
||||
>
|
||||
下一步 →
|
||||
</button>
|
||||
下一步
|
||||
<ArrowRight className="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -131,7 +138,7 @@ function StepDots({ current }: { current: number }) {
|
||||
{Array.from({ length: WIZARD_STEPS }, (_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={`h-2 w-2 rounded-full ${
|
||||
className={`h-2 w-2 rounded ${
|
||||
i + 1 <= current ? "bg-cinnabar" : "bg-line"
|
||||
}`}
|
||||
/>
|
||||
@@ -145,30 +152,11 @@ interface StepProps {
|
||||
update: (patch: Partial<WizardForm>) => void;
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<label className="mb-4 block">
|
||||
<span className="mb-1 block text-sm text-ink-soft">{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
const inputCls =
|
||||
"w-full rounded border border-line bg-bg px-3 py-2 text-ink focus:border-cinnabar focus:outline-none";
|
||||
|
||||
function StepBasics({ form, update }: StepProps) {
|
||||
return (
|
||||
<div>
|
||||
<Field label="书名(必填)">
|
||||
<input
|
||||
className={inputCls}
|
||||
<TextInput
|
||||
value={form.title}
|
||||
onChange={(e) => update({ title: e.target.value })}
|
||||
placeholder="例:逐光而行"
|
||||
@@ -176,8 +164,7 @@ function StepBasics({ form, update }: StepProps) {
|
||||
/>
|
||||
</Field>
|
||||
<Field label="题材">
|
||||
<select
|
||||
className={inputCls}
|
||||
<Select
|
||||
value={form.genre}
|
||||
onChange={(e) => update({ genre: e.target.value })}
|
||||
>
|
||||
@@ -187,7 +174,7 @@ function StepBasics({ form, update }: StepProps) {
|
||||
{g}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
);
|
||||
@@ -201,8 +188,8 @@ function StepStory({
|
||||
return (
|
||||
<div>
|
||||
<Field label="一句话故事(logline)">
|
||||
<textarea
|
||||
className={`${inputCls} h-20 resize-none`}
|
||||
<TextArea
|
||||
className="h-20 resize-none"
|
||||
value={form.logline}
|
||||
onChange={(e) => update({ logline: e.target.value })}
|
||||
placeholder="废柴少年觉醒禁忌血脉,在仙门倾轧中逆势封神。"
|
||||
@@ -215,11 +202,10 @@ function StepStory({
|
||||
type="button"
|
||||
key={s}
|
||||
onClick={() => update({ structure: s })}
|
||||
className={`rounded border px-3 py-1.5 text-sm ${
|
||||
form.structure === s
|
||||
? "border-cinnabar bg-[var(--color-cinnabar-wash)] text-cinnabar"
|
||||
: "border-line text-ink"
|
||||
}`}
|
||||
className={buttonClass({
|
||||
variant: form.structure === s ? "outline" : "secondary",
|
||||
size: "sm",
|
||||
})}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
@@ -235,11 +221,12 @@ function StepStory({
|
||||
key={p}
|
||||
aria-pressed={form.sellingPoints.includes(p)}
|
||||
onClick={() => toggleSellingPoint(p)}
|
||||
className={`rounded border px-3 py-1.5 text-sm ${
|
||||
form.sellingPoints.includes(p)
|
||||
? "border-cinnabar bg-[var(--color-cinnabar-wash)] text-cinnabar"
|
||||
: "border-line text-ink"
|
||||
}`}
|
||||
className={buttonClass({
|
||||
variant: form.sellingPoints.includes(p)
|
||||
? "outline"
|
||||
: "secondary",
|
||||
size: "sm",
|
||||
})}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
@@ -254,16 +241,15 @@ function StepPremise({ form, update }: StepProps) {
|
||||
return (
|
||||
<div>
|
||||
<Field label="立意(premise)">
|
||||
<textarea
|
||||
className={`${inputCls} h-20 resize-none`}
|
||||
<TextArea
|
||||
className="h-20 resize-none"
|
||||
value={form.premise}
|
||||
onChange={(e) => update({ premise: e.target.value })}
|
||||
placeholder="故事的核心命题与前提。"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="主题(theme)">
|
||||
<input
|
||||
className={inputCls}
|
||||
<TextInput
|
||||
value={form.theme}
|
||||
onChange={(e) => update({ theme: e.target.value })}
|
||||
placeholder="例:抗争与代价"
|
||||
@@ -282,8 +268,8 @@ function StepProtagonist({ form, update }: StepProps) {
|
||||
主角与金手指(M1 暂并入总纲,后续设定库独立建模)。
|
||||
</p>
|
||||
<Field label="主角 / 金手指概要">
|
||||
<textarea
|
||||
className={`${inputCls} h-28 resize-none`}
|
||||
<TextArea
|
||||
className="h-28 resize-none"
|
||||
value={form.protagonist}
|
||||
onChange={(e) => update({ protagonist: e.target.value })}
|
||||
placeholder="主角设定、金手指来源与限制……"
|
||||
@@ -295,7 +281,7 @@ function StepProtagonist({ form, update }: StepProps) {
|
||||
|
||||
function StepWorld() {
|
||||
return (
|
||||
<div className="rounded border border-dashed border-line bg-bg p-6 text-sm text-ink-soft">
|
||||
<div className="rounded border border-dashed border-line bg-bg p-6 text-sm leading-6 text-ink-soft">
|
||||
基础世界观将在「设定库」里建模(后续里程碑)。现在可直接完成立项,进入工作台开始写第一章。
|
||||
</div>
|
||||
);
|
||||
|
||||
10
apps/web/components/ThemeScript.tsx
Normal file
10
apps/web/components/ThemeScript.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { themeBootstrapScript } from "@/lib/ui/theme";
|
||||
|
||||
export function ThemeScript() {
|
||||
return (
|
||||
<script
|
||||
dangerouslySetInnerHTML={{ __html: themeBootstrapScript() }}
|
||||
suppressHydrationWarning
|
||||
/>
|
||||
);
|
||||
}
|
||||
70
apps/web/components/ThemeToggle.tsx
Normal file
70
apps/web/components/ThemeToggle.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import {
|
||||
DEFAULT_THEME_MODE,
|
||||
THEME_STORAGE_KEY,
|
||||
nextThemeMode,
|
||||
normalizeThemeMode,
|
||||
themeModeLabel,
|
||||
themeToggleLabel,
|
||||
type ThemeMode,
|
||||
} from "@/lib/ui/theme";
|
||||
|
||||
function applyThemeMode(mode: ThemeMode) {
|
||||
document.documentElement.dataset.theme = mode;
|
||||
}
|
||||
|
||||
function readStoredThemeMode(): ThemeMode {
|
||||
try {
|
||||
return normalizeThemeMode(window.localStorage.getItem(THEME_STORAGE_KEY));
|
||||
} catch {
|
||||
return DEFAULT_THEME_MODE;
|
||||
}
|
||||
}
|
||||
|
||||
function writeStoredThemeMode(mode: ThemeMode) {
|
||||
try {
|
||||
window.localStorage.setItem(THEME_STORAGE_KEY, mode);
|
||||
} catch {
|
||||
// 存储不可用时仍允许本次页面会话切换主题。
|
||||
}
|
||||
}
|
||||
|
||||
export function ThemeToggle() {
|
||||
const [mode, setMode] = useState<ThemeMode>(DEFAULT_THEME_MODE);
|
||||
|
||||
useEffect(() => {
|
||||
const initial = readStoredThemeMode();
|
||||
setMode(initial);
|
||||
applyThemeMode(initial);
|
||||
}, []);
|
||||
|
||||
const toggle = () => {
|
||||
setMode((current) => {
|
||||
const next = nextThemeMode(current);
|
||||
writeStoredThemeMode(next);
|
||||
applyThemeMode(next);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const Icon = mode === "night" ? Sun : Moon;
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={toggle}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={themeToggleLabel(mode)}
|
||||
title={themeToggleLabel(mode)}
|
||||
className="border-transparent"
|
||||
>
|
||||
<Icon className="h-4 w-4" aria-hidden="true" />
|
||||
<span className="sr-only">{themeModeLabel(mode)}</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -143,6 +143,7 @@ export function ChainAdjudication({
|
||||
<ConflictCard
|
||||
key={i}
|
||||
index={i}
|
||||
total={conflicts.length}
|
||||
conflict={conflict}
|
||||
draft={draft}
|
||||
missing={false}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Play } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Field } from "@/components/ui/Field";
|
||||
import { SectionHeader } from "@/components/ui/SectionHeader";
|
||||
import { TextInput } from "@/components/ui/TextInput";
|
||||
import { CHAIN_KINDS, type ChainKind } from "@/lib/chain/chain";
|
||||
|
||||
interface ChainStarterProps {
|
||||
@@ -41,12 +46,10 @@ export function ChainStarter({ onStart, disabled }: ChainStarterProps) {
|
||||
if (valid && !disabled) onStart(chainKey, startNo, countNo);
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h2 className="font-serif text-lg text-ink">连续写多章</h2>
|
||||
<p className="mt-1 text-sm text-ink-soft">
|
||||
从指定章起循环「写章 → 四审 → 验收」;遇未决冲突会暂停等你裁决再续跑。
|
||||
</p>
|
||||
</div>
|
||||
<SectionHeader
|
||||
title="连续写多章"
|
||||
description="从指定章起循环「写章 → 四审 → 验收」;遇未决冲突会暂停等你裁决再续跑。"
|
||||
/>
|
||||
<fieldset className="flex flex-col gap-2">
|
||||
<legend className="text-sm text-ink-soft">链类型</legend>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
@@ -75,37 +78,35 @@ export function ChainStarter({ onStart, disabled }: ChainStarterProps) {
|
||||
</div>
|
||||
</fieldset>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<label className="block text-sm text-ink-soft">
|
||||
起始章号
|
||||
<input
|
||||
<Field label="起始章号" className="w-32">
|
||||
<TextInput
|
||||
type="number"
|
||||
min={1}
|
||||
value={start}
|
||||
onChange={(e) => setStart(e.target.value)}
|
||||
className="mt-1 w-32 rounded border border-line bg-bg px-3 py-2 text-sm text-ink"
|
||||
aria-label="起始章号"
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm text-ink-soft">
|
||||
连续章数(1..{MAX_COUNT})
|
||||
<input
|
||||
</Field>
|
||||
<Field label={`连续章数(1..${MAX_COUNT})`} className="w-40">
|
||||
<TextInput
|
||||
type="number"
|
||||
min={1}
|
||||
max={MAX_COUNT}
|
||||
value={count}
|
||||
onChange={(e) => setCount(e.target.value)}
|
||||
className="mt-1 w-32 rounded border border-line bg-bg px-3 py-2 text-sm text-ink"
|
||||
aria-label="连续章数"
|
||||
/>
|
||||
</label>
|
||||
</Field>
|
||||
</div>
|
||||
<button
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={disabled || !valid}
|
||||
className="self-start rounded bg-cinnabar px-4 py-2 text-sm text-white disabled:opacity-50"
|
||||
variant="primary"
|
||||
className="self-start"
|
||||
>
|
||||
<Play className="h-4 w-4" aria-hidden="true" />
|
||||
{disabled ? "运行中…" : "发起多章链"}
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { Clock3, Globe2, Sparkles, UserRound } from "lucide-react";
|
||||
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { CharacterGenerator } from "@/components/generation/CharacterGenerator";
|
||||
import { WorldGenerator } from "@/components/generation/WorldGenerator";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
import { PageHeader } from "@/components/ui/PageHeader";
|
||||
import type {
|
||||
CharacterCardView,
|
||||
ProjectResponse,
|
||||
@@ -60,6 +66,10 @@ export function CodexPage({
|
||||
activeNav="codex"
|
||||
>
|
||||
<div className="flex h-[calc(100vh-var(--chrome,4rem))] flex-col p-4">
|
||||
<PageHeader
|
||||
title="设定库"
|
||||
description="人物、世界观与时间线共同构成写作时的真相源。AI 生成内容也要先预览、再确认入库。"
|
||||
/>
|
||||
<div className="mb-4 flex items-center gap-2" role="tablist">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
@@ -82,20 +92,28 @@ export function CodexPage({
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
{tab === "characters" ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
<section className="rounded border border-line bg-panel p-3">
|
||||
<h3 className="mb-2 font-serif text-sm text-ink">
|
||||
<Card as="section" className="p-4">
|
||||
<h3 className="mb-3 font-serif text-sm text-ink">
|
||||
已入库人物({characters.length})
|
||||
</h3>
|
||||
{characters.length > 0 ? (
|
||||
<ul className="flex flex-col gap-2">
|
||||
<ul className="grid grid-cols-1 gap-3 lg:grid-cols-2">
|
||||
{characters.map((c, i) => (
|
||||
<li
|
||||
key={`${c.name}-${i}`}
|
||||
className="rounded bg-bg px-2 py-1.5 text-xs text-ink-soft"
|
||||
className="rounded border border-line bg-bg p-3 text-sm"
|
||||
>
|
||||
<span className="text-ink">
|
||||
{c.name}({c.role})
|
||||
</span>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded bg-[var(--color-cinnabar-wash)] text-cinnabar">
|
||||
<UserRound className="h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-serif text-base text-ink">
|
||||
{c.name}
|
||||
</p>
|
||||
<p className="text-xs text-ink-soft">{c.role}</p>
|
||||
</div>
|
||||
</div>
|
||||
{c.relations && c.relations.length > 0 ? (
|
||||
<ul className="mt-1 flex flex-wrap gap-1">
|
||||
{c.relations.map((r, j) => (
|
||||
@@ -113,24 +131,43 @@ export function CodexPage({
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-xs text-ink-soft">
|
||||
暂无入库人物,先在下方生成并入库。
|
||||
</p>
|
||||
<EmptyState
|
||||
icon={UserRound}
|
||||
title="暂无入库人物"
|
||||
description="先生成或手动整理主角、配角与关系,再让写作上下文稳定引用。"
|
||||
action={
|
||||
<Button
|
||||
onClick={() =>
|
||||
document
|
||||
.getElementById("character-generator")
|
||||
?.scrollIntoView({ block: "center" })
|
||||
}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
>
|
||||
<Sparkles className="h-4 w-4" aria-hidden="true" />
|
||||
生成角色
|
||||
</Button>
|
||||
}
|
||||
className="bg-bg/50"
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
<CharacterGenerator
|
||||
projectId={project.id}
|
||||
onIngested={(_, cards) =>
|
||||
setSessionCards((prev) => [...prev, ...cards])
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
<div id="character-generator">
|
||||
<CharacterGenerator
|
||||
projectId={project.id}
|
||||
onIngested={(_, cards) =>
|
||||
setSessionCards((prev) => [...prev, ...cards])
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{tab === "world" ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
<section className="rounded border border-line bg-panel p-3">
|
||||
<h3 className="mb-2 font-serif text-sm text-ink">
|
||||
<Card as="section" className="p-4">
|
||||
<h3 className="mb-3 font-serif text-sm text-ink">
|
||||
已入库世界观({initialWorldEntities.length})
|
||||
</h3>
|
||||
{initialWorldEntities.length > 0 ? (
|
||||
@@ -141,12 +178,16 @@ export function CodexPage({
|
||||
className="rounded border border-line bg-bg p-3 text-sm"
|
||||
>
|
||||
<header className="mb-2 flex items-center gap-2">
|
||||
<Globe2
|
||||
className="h-4 w-4 text-cinnabar"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="font-serif text-base text-ink">
|
||||
{entity.name}
|
||||
</span>
|
||||
<span className="rounded bg-panel px-2 py-0.5 text-xs text-ink-soft">
|
||||
<Badge>
|
||||
{entity.type}
|
||||
</span>
|
||||
</Badge>
|
||||
</header>
|
||||
{worldEntityRules(entity).length > 0 ? (
|
||||
<ul className="flex list-disc flex-col gap-1 pl-5 text-ink-soft">
|
||||
@@ -161,19 +202,40 @@ export function CodexPage({
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-ink-soft">
|
||||
暂无入库世界观,可在下方生成预览参考。
|
||||
</p>
|
||||
<EmptyState
|
||||
icon={Globe2}
|
||||
title="暂无入库世界观"
|
||||
description="先沉淀硬规则、地理、势力与能力边界,减少后续章节设定漂移。"
|
||||
action={
|
||||
<Button
|
||||
onClick={() =>
|
||||
document
|
||||
.getElementById("world-generator")
|
||||
?.scrollIntoView({ block: "center" })
|
||||
}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
>
|
||||
<Sparkles className="h-4 w-4" aria-hidden="true" />
|
||||
生成世界观
|
||||
</Button>
|
||||
}
|
||||
className="bg-bg/50"
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
<WorldGenerator projectId={project.id} />
|
||||
</Card>
|
||||
<div id="world-generator">
|
||||
<WorldGenerator projectId={project.id} />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{tab === "timeline" ? (
|
||||
<div className="rounded border border-dashed border-line bg-panel p-6 text-center text-sm text-ink-soft">
|
||||
时间线为派生视图(P2),将由章节摘要 + 伏笔窗口自动汇总,敬请期待。
|
||||
</div>
|
||||
<EmptyState
|
||||
icon={Clock3}
|
||||
title="时间线尚未启用"
|
||||
description="时间线会从章节摘要、伏笔窗口和验收记录派生,后续可用于全书一致性扫描。"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Flag, Plus } from "lucide-react";
|
||||
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
import { PageHeader } from "@/components/ui/PageHeader";
|
||||
import type { ForeshadowView, ProjectResponse } from "@/lib/api/types";
|
||||
import {
|
||||
LANES,
|
||||
LANE_LABELS,
|
||||
countByStatus,
|
||||
groupByStatus,
|
||||
type ForeshadowStatus,
|
||||
} from "@/lib/foreshadow/board";
|
||||
import type { BadgeVariant } from "@/lib/ui/variants";
|
||||
import { useForeshadow } from "@/lib/foreshadow/useForeshadow";
|
||||
import { KanbanColumn } from "./KanbanColumn";
|
||||
import { RegisterForm } from "./RegisterForm";
|
||||
@@ -25,7 +33,9 @@ export function ForeshadowBoard({
|
||||
initialItems,
|
||||
}: ForeshadowBoardProps) {
|
||||
const { items, busy, register, transition } = useForeshadow(initialItems);
|
||||
const [registerOpen, setRegisterOpen] = useState(false);
|
||||
const lanes = useMemo(() => groupByStatus(items), [items]);
|
||||
const counts = useMemo(() => countByStatus(items), [items]);
|
||||
|
||||
const onTransition = (
|
||||
code: string,
|
||||
@@ -46,27 +56,104 @@ export function ForeshadowBoard({
|
||||
projectId={project.id}
|
||||
activeNav="foreshadow"
|
||||
>
|
||||
<div className="flex h-[calc(100vh-var(--chrome,4rem))] flex-col p-4">
|
||||
<div className="mb-4 flex items-start justify-between gap-4">
|
||||
<h1 className="font-serif text-lg text-ink">伏笔看板</h1>
|
||||
<RegisterForm
|
||||
projectId={project.id}
|
||||
busy={busy}
|
||||
onRegister={register}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid min-h-0 flex-1 grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
{LANES.map((status) => (
|
||||
<KanbanColumn
|
||||
key={status}
|
||||
status={status}
|
||||
items={lanes[status]}
|
||||
<div className="flex min-h-[calc(100vh-var(--chrome,4rem))] flex-col p-4 xl:h-[calc(100vh-var(--chrome,4rem))] xl:min-h-0">
|
||||
<PageHeader
|
||||
title="伏笔看板"
|
||||
description="跟踪每条伏笔从埋设、推进到回收的状态,逾期项会单独提醒。"
|
||||
actions={
|
||||
<Button
|
||||
onClick={() => setRegisterOpen(true)}
|
||||
disabled={registerOpen}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
>
|
||||
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||
登记伏笔
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{registerOpen ? (
|
||||
<div className="mb-4">
|
||||
<RegisterForm
|
||||
projectId={project.id}
|
||||
busy={busy}
|
||||
onTransition={onTransition}
|
||||
onRegister={register}
|
||||
open={registerOpen}
|
||||
onOpenChange={setRegisterOpen}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{items.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Flag}
|
||||
title="还没有伏笔"
|
||||
description="登记第一条伏笔后,这里会按待推进、推进中、已回收、已逾期四种状态自动分栏。"
|
||||
action={
|
||||
<RegisterForm
|
||||
projectId={project.id}
|
||||
busy={busy}
|
||||
onRegister={register}
|
||||
/>
|
||||
}
|
||||
className="mt-6"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<ForeshadowBoardSummary counts={counts} />
|
||||
<div className="grid flex-none grid-cols-1 gap-3 md:grid-cols-2 xl:min-h-0 xl:flex-1 xl:grid-cols-4">
|
||||
{LANES.map((status) => (
|
||||
<KanbanColumn
|
||||
key={status}
|
||||
status={status}
|
||||
items={lanes[status]}
|
||||
busy={busy}
|
||||
onTransition={onTransition}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
const SUMMARY_VARIANTS: Record<ForeshadowStatus, BadgeVariant> = {
|
||||
OPEN: "accent",
|
||||
PARTIAL: "info",
|
||||
CLOSED: "success",
|
||||
OVERDUE: "warning",
|
||||
};
|
||||
|
||||
function ForeshadowBoardSummary({
|
||||
counts,
|
||||
}: {
|
||||
counts: Record<ForeshadowStatus, number>;
|
||||
}) {
|
||||
const total = LANES.reduce((sum, lane) => sum + counts[lane], 0);
|
||||
return (
|
||||
<section
|
||||
aria-label="伏笔状态概览"
|
||||
className="mb-3 grid grid-cols-2 gap-2 sm:grid-cols-4"
|
||||
>
|
||||
{LANES.map((status) => (
|
||||
<div
|
||||
key={status}
|
||||
className="rounded border border-line bg-panel px-3 py-2"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Badge variant={SUMMARY_VARIANTS[status]}>
|
||||
{LANE_LABELS[status]}
|
||||
</Badge>
|
||||
<span className="font-mono text-sm text-ink">{counts[status]}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-[11px] text-ink-soft">
|
||||
<span className="font-mono">{status}</span>
|
||||
{" · "}
|
||||
{total > 0 ? `${Math.round((counts[status] / total) * 100)}%` : "0%"}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { TextInput } from "@/components/ui/TextInput";
|
||||
import type { ForeshadowView } from "@/lib/api/types";
|
||||
import type { ForeshadowStatus } from "@/lib/foreshadow/board";
|
||||
import { LANE_LABELS, type ForeshadowStatus } from "@/lib/foreshadow/board";
|
||||
|
||||
interface ForeshadowCardProps {
|
||||
item: ForeshadowView;
|
||||
@@ -44,9 +48,10 @@ export function ForeshadowCard({
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="font-mono text-xs text-cinnabar">{item.code}</span>
|
||||
{overdue ? (
|
||||
<span className="text-xs text-overdue" aria-label="逾期">
|
||||
⚠ 逾期
|
||||
</span>
|
||||
<Badge variant="warning" aria-label="逾期">
|
||||
<AlertTriangle className="h-3 w-3" aria-hidden="true" />
|
||||
逾期
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-0.5 font-serif text-sm text-ink">{item.title}</p>
|
||||
@@ -72,15 +77,16 @@ export function ForeshadowCard({
|
||||
{transitions.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{transitions.map((to) => (
|
||||
<button
|
||||
<Button
|
||||
key={to}
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => onTransition(item.code, to, "")}
|
||||
className="rounded border border-line px-2 py-0.5 text-xs text-ink hover:border-cinnabar hover:text-cinnabar disabled:opacity-40"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="px-2 py-0.5"
|
||||
>
|
||||
→ {to === "CLOSED" ? "回收" : to}
|
||||
</button>
|
||||
→ {to === "CLOSED" ? "回收" : LANE_LABELS[to]}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
@@ -89,25 +95,27 @@ export function ForeshadowCard({
|
||||
<label htmlFor={`prog-${item.code}`} className="sr-only">
|
||||
{item.code} 进展
|
||||
</label>
|
||||
<input
|
||||
<TextInput
|
||||
id={`prog-${item.code}`}
|
||||
type="text"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder="加进展(如:23章发光)"
|
||||
className="min-w-0 flex-1 rounded border border-line bg-bg px-2 py-0.5 text-xs text-ink focus:border-cinnabar focus:outline-none"
|
||||
controlSize="sm"
|
||||
className="min-w-0 flex-1 py-0.5"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
disabled={busy || note.trim().length === 0}
|
||||
onClick={() => {
|
||||
onTransition(item.code, null, note.trim());
|
||||
setNote("");
|
||||
}}
|
||||
className="rounded border border-line px-2 py-0.5 text-xs text-ink hover:border-cinnabar disabled:opacity-40"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="px-2 py-0.5"
|
||||
>
|
||||
记
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import type { ForeshadowView } from "@/lib/api/types";
|
||||
import type { ForeshadowStatus } from "@/lib/foreshadow/board";
|
||||
import { LANE_LABELS, type ForeshadowStatus } from "@/lib/foreshadow/board";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { ForeshadowCard } from "./ForeshadowCard";
|
||||
|
||||
interface KanbanColumnProps {
|
||||
@@ -31,17 +32,23 @@ export function KanbanColumn({
|
||||
<header
|
||||
className={`flex items-center justify-between rounded-t border-b px-3 py-2 ${
|
||||
overdue
|
||||
? "border-overdue bg-overdue/10 text-overdue"
|
||||
? "border-overdue bg-overdue/10 text-ink"
|
||||
: "border-line bg-panel text-ink-soft"
|
||||
}`}
|
||||
>
|
||||
<span className="font-mono text-xs font-semibold">
|
||||
{overdue ? "⚠ " : ""}
|
||||
{status}
|
||||
<span className="flex items-center gap-2">
|
||||
{overdue ? (
|
||||
<Badge variant="warning">{LANE_LABELS[status]}</Badge>
|
||||
) : (
|
||||
<span className="text-sm font-medium text-ink">
|
||||
{LANE_LABELS[status]}
|
||||
</span>
|
||||
)}
|
||||
<span className="font-mono text-[10px] text-ink-soft">{status}</span>
|
||||
</span>
|
||||
<span className="font-mono text-xs">{items.length}</span>
|
||||
</header>
|
||||
<ul className="flex-1 space-y-2 overflow-auto p-2">
|
||||
<ul className="space-y-2 p-2 xl:flex-1 xl:overflow-auto">
|
||||
{items.length === 0 ? (
|
||||
<li className="px-1 py-3 text-xs text-ink-soft/60">—</li>
|
||||
) : (
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { Plus } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { TextInput } from "@/components/ui/TextInput";
|
||||
import type { RegisterInput } from "@/lib/foreshadow/board";
|
||||
|
||||
interface RegisterFormProps {
|
||||
projectId: string;
|
||||
busy: boolean;
|
||||
onRegister: (input: RegisterInput) => Promise<boolean>;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const EMPTY = {
|
||||
@@ -25,9 +30,16 @@ export function RegisterForm({
|
||||
projectId,
|
||||
busy,
|
||||
onRegister,
|
||||
open: controlledOpen,
|
||||
onOpenChange,
|
||||
}: RegisterFormProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [localOpen, setLocalOpen] = useState(false);
|
||||
const [form, setForm] = useState({ ...EMPTY });
|
||||
const open = controlledOpen ?? localOpen;
|
||||
const setOpen = (next: boolean): void => {
|
||||
if (controlledOpen === undefined) setLocalOpen(next);
|
||||
onOpenChange?.(next);
|
||||
};
|
||||
|
||||
const set = (key: keyof typeof EMPTY, value: string): void =>
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
@@ -51,13 +63,10 @@ export function RegisterForm({
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className="rounded bg-cinnabar px-3 py-1.5 text-sm text-panel hover:opacity-90"
|
||||
>
|
||||
+ 登记伏笔
|
||||
</button>
|
||||
<Button onClick={() => setOpen(true)} variant="primary" size="sm">
|
||||
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||
登记伏笔
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -73,99 +82,90 @@ export function RegisterForm({
|
||||
className="rounded border border-line bg-panel p-4"
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
<Field label="代号 *" id="f-code">
|
||||
<input
|
||||
<FormSlot label="代号 *" id="f-code">
|
||||
<TextInput
|
||||
id="f-code"
|
||||
value={form.code}
|
||||
onChange={(e) => set("code", e.target.value)}
|
||||
className={inputCls}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="标题 *" id="f-title" span2>
|
||||
<input
|
||||
</FormSlot>
|
||||
<FormSlot label="标题 *" id="f-title" span2>
|
||||
<TextInput
|
||||
id="f-title"
|
||||
value={form.title}
|
||||
onChange={(e) => set("title", e.target.value)}
|
||||
className={inputCls}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="埋设章" id="f-planted">
|
||||
<input
|
||||
</FormSlot>
|
||||
<FormSlot label="埋设章" id="f-planted">
|
||||
<TextInput
|
||||
id="f-planted"
|
||||
inputMode="numeric"
|
||||
value={form.plantedAt}
|
||||
onChange={(e) => set("plantedAt", e.target.value)}
|
||||
className={inputCls}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="回收窗口起" id="f-from">
|
||||
<input
|
||||
</FormSlot>
|
||||
<FormSlot label="回收窗口起" id="f-from">
|
||||
<TextInput
|
||||
id="f-from"
|
||||
inputMode="numeric"
|
||||
value={form.expectedCloseFrom}
|
||||
onChange={(e) => set("expectedCloseFrom", e.target.value)}
|
||||
className={inputCls}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="回收窗口止" id="f-to">
|
||||
<input
|
||||
</FormSlot>
|
||||
<FormSlot label="回收窗口止" id="f-to">
|
||||
<TextInput
|
||||
id="f-to"
|
||||
inputMode="numeric"
|
||||
value={form.expectedCloseTo}
|
||||
onChange={(e) => set("expectedCloseTo", e.target.value)}
|
||||
className={inputCls}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="重要度" id="f-imp">
|
||||
<input
|
||||
</FormSlot>
|
||||
<FormSlot label="重要度" id="f-imp">
|
||||
<TextInput
|
||||
id="f-imp"
|
||||
value={form.importance}
|
||||
onChange={(e) => set("importance", e.target.value)}
|
||||
placeholder="主线/支线"
|
||||
className={inputCls}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="线索内容" id="f-content" span3>
|
||||
<input
|
||||
</FormSlot>
|
||||
<FormSlot label="线索内容" id="f-content" span3>
|
||||
<TextInput
|
||||
id="f-content"
|
||||
value={form.content}
|
||||
onChange={(e) => set("content", e.target.value)}
|
||||
className={inputCls}
|
||||
/>
|
||||
</Field>
|
||||
</FormSlot>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<button
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!canSubmit}
|
||||
className="rounded bg-cinnabar px-3 py-1.5 text-sm text-panel hover:opacity-90 disabled:opacity-40"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
>
|
||||
{busy ? "登记中…" : "登记"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setOpen(false)}
|
||||
className="rounded border border-line px-3 py-1.5 text-sm text-ink-soft hover:border-cinnabar"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
const inputCls =
|
||||
"w-full rounded border border-line bg-bg px-2 py-1 text-sm text-ink focus:border-cinnabar focus:outline-none";
|
||||
|
||||
interface FieldProps {
|
||||
interface FormSlotProps {
|
||||
label: string;
|
||||
id: string;
|
||||
span2?: boolean;
|
||||
span3?: boolean;
|
||||
children: React.ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function Field({ label, id, span2, span3, children }: FieldProps) {
|
||||
function FormSlot({ label, id, span2, span3, children }: FormSlotProps) {
|
||||
const span = span3 ? "col-span-2 sm:col-span-3" : span2 ? "col-span-2" : "";
|
||||
return (
|
||||
<div className={span}>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { TextInput } from "@/components/ui/TextInput";
|
||||
import type { CharacterCardView } from "@/lib/api/types";
|
||||
|
||||
interface CharacterCardItemProps {
|
||||
@@ -32,11 +33,12 @@ export function CharacterCardItem({
|
||||
className="size-4 accent-cinnabar"
|
||||
/>
|
||||
{onEdit ? (
|
||||
<input
|
||||
<TextInput
|
||||
value={card.name}
|
||||
onChange={(e) => onEdit({ name: e.target.value })}
|
||||
aria-label="角色名"
|
||||
className="flex-1 rounded border border-line bg-bg px-2 py-1 font-serif text-base text-ink"
|
||||
controlSize="sm"
|
||||
className="min-w-0 flex-1 font-serif text-base"
|
||||
/>
|
||||
) : (
|
||||
<span className="flex-1 font-serif text-base text-ink">{card.name}</span>
|
||||
@@ -61,11 +63,12 @@ export function CharacterCardItem({
|
||||
<div className="mb-1 flex items-start gap-1 text-ink-soft">
|
||||
<span className="shrink-0 text-ink">弧光:</span>
|
||||
{onEdit ? (
|
||||
<input
|
||||
<TextInput
|
||||
value={card.arc}
|
||||
onChange={(e) => onEdit({ arc: e.target.value })}
|
||||
aria-label="人物弧光"
|
||||
className="flex-1 rounded border border-line bg-bg px-2 py-1 text-ink"
|
||||
controlSize="sm"
|
||||
className="min-w-0 flex-1"
|
||||
/>
|
||||
) : (
|
||||
<span>{card.arc}</span>
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Database, Sparkles } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Field } from "@/components/ui/Field";
|
||||
import { SectionHeader } from "@/components/ui/SectionHeader";
|
||||
import { StatusNote } from "@/components/ui/StatusNote";
|
||||
import { TextInput } from "@/components/ui/TextInput";
|
||||
import type { CharacterCardView } from "@/lib/api/types";
|
||||
import {
|
||||
MAX_CHARACTER_COUNT,
|
||||
@@ -80,45 +86,39 @@ export function CharacterGenerator({
|
||||
return (
|
||||
<section className="flex flex-col gap-4" aria-label="角色生成器">
|
||||
<div className="rounded border border-line bg-panel p-4">
|
||||
<h2 className="mb-3 font-serif text-base text-ink">角色生成器</h2>
|
||||
<label className="mb-2 block text-sm text-ink-soft">
|
||||
一句话需求
|
||||
<input
|
||||
<SectionHeader title="角色生成器" className="mb-3" />
|
||||
<Field label="一句话需求" className="mb-3">
|
||||
<TextInput
|
||||
value={brief}
|
||||
onChange={(e) => setBrief(e.target.value)}
|
||||
placeholder="如:一个亦正亦邪的剑修,背负灭门血仇"
|
||||
className="mt-1 w-full rounded border border-line bg-bg px-3 py-2 text-sm text-ink"
|
||||
/>
|
||||
</label>
|
||||
</Field>
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<label className="text-sm text-ink-soft">
|
||||
数量
|
||||
<input
|
||||
<Field label="数量" className="w-24">
|
||||
<TextInput
|
||||
type="number"
|
||||
min={MIN_CHARACTER_COUNT}
|
||||
max={MAX_CHARACTER_COUNT}
|
||||
value={count}
|
||||
onChange={(e) => setCount(Number(e.target.value))}
|
||||
className="mt-1 block w-20 rounded border border-line bg-bg px-2 py-1.5 text-sm text-ink"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex-1 text-sm text-ink-soft">
|
||||
定位(可选)
|
||||
<input
|
||||
</Field>
|
||||
<Field label="定位(可选)" className="min-w-[12rem] flex-1">
|
||||
<TextInput
|
||||
value={role}
|
||||
onChange={(e) => setRole(e.target.value)}
|
||||
placeholder="主角 / CP / 对手 / 导师 / 工具人"
|
||||
className="mt-1 block w-full rounded border border-line bg-bg px-2 py-1.5 text-sm text-ink"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
</Field>
|
||||
<Button
|
||||
onClick={() => void onGenerate()}
|
||||
disabled={generating || brief.trim().length === 0}
|
||||
className="rounded bg-cinnabar px-4 py-2 text-sm text-white disabled:opacity-50"
|
||||
variant="primary"
|
||||
>
|
||||
<Sparkles className="h-4 w-4" aria-hidden="true" />
|
||||
{generating ? "生成中…" : "生成"}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -151,20 +151,20 @@ export function CharacterGenerator({
|
||||
onCancel={() => gen.reset()}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
onClick={() => void doIngest(false)}
|
||||
disabled={ingesting || selectedCards.length === 0}
|
||||
className="self-start rounded bg-cinnabar px-4 py-2 text-sm text-white disabled:opacity-50"
|
||||
variant="primary"
|
||||
>
|
||||
<Database className="h-4 w-4" aria-hidden="true" />
|
||||
{ingesting ? "入库中…" : `入库选中 ${selectedCards.length} 张`}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{gen.ingestStatus === "done" && gen.created.length > 0 ? (
|
||||
<p className="text-sm text-pass">已入库:{gen.created.join("、")}</p>
|
||||
<StatusNote variant="success">已入库:{gen.created.join("、")}</StatusNote>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { Sparkles } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Field } from "@/components/ui/Field";
|
||||
import { SectionHeader } from "@/components/ui/SectionHeader";
|
||||
import { TextArea } from "@/components/ui/TextArea";
|
||||
import { worldEntityRules } from "@/lib/generation/cards";
|
||||
import { useWorldGen } from "@/lib/generation/useWorldGen";
|
||||
|
||||
@@ -24,25 +29,23 @@ export function WorldGenerator({ projectId }: WorldGeneratorProps) {
|
||||
return (
|
||||
<section className="flex flex-col gap-4" aria-label="世界观设计器">
|
||||
<div className="rounded border border-line bg-panel p-4">
|
||||
<h2 className="mb-3 font-serif text-base text-ink">世界观设计器</h2>
|
||||
<label className="mb-3 block text-sm text-ink-soft">
|
||||
设定方向
|
||||
<textarea
|
||||
<SectionHeader title="世界观设计器" className="mb-3" />
|
||||
<Field label="设定方向" className="mb-3">
|
||||
<TextArea
|
||||
value={brief}
|
||||
onChange={(e) => setBrief(e.target.value)}
|
||||
placeholder="如:东方修真世界,灵气复苏,宗门林立,强者寿元有限"
|
||||
rows={3}
|
||||
className="mt-1 w-full resize-y rounded border border-line bg-bg px-3 py-2 text-sm text-ink"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
</Field>
|
||||
<Button
|
||||
onClick={() => void onGenerate()}
|
||||
disabled={generating || brief.trim().length === 0}
|
||||
className="rounded bg-cinnabar px-4 py-2 text-sm text-white disabled:opacity-50"
|
||||
variant="primary"
|
||||
>
|
||||
<Sparkles className="h-4 w-4" aria-hidden="true" />
|
||||
{generating ? "生成中…" : "生成世界观"}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{gen.status === "done" && gen.entities.length > 0 ? (
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { PenLine } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import type { OutlineChapterView } from "@/lib/api/types";
|
||||
import {
|
||||
isCloseWindow,
|
||||
windowBadgeLabel,
|
||||
} from "@/lib/outline/outline";
|
||||
import { buttonClass } from "@/lib/ui/variants";
|
||||
|
||||
interface OutlineChapterRowProps {
|
||||
chapter: OutlineChapterView;
|
||||
@@ -25,31 +28,28 @@ export function OutlineChapterRow({ chapter, projectId }: OutlineChapterRowProps
|
||||
</span>
|
||||
<Link
|
||||
href={`/projects/${projectId}/write?chapter=${chapter.no}`}
|
||||
className="rounded border border-line px-1.5 py-0.5 text-xs text-ink-soft hover:border-cinnabar hover:text-cinnabar"
|
||||
className={buttonClass({ variant: "secondary", size: "sm" })}
|
||||
>
|
||||
✍ 写此章
|
||||
<PenLine className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
写此章
|
||||
</Link>
|
||||
{windows.map((w) => {
|
||||
const close = isCloseWindow(chapter, w);
|
||||
return (
|
||||
<span
|
||||
<Badge
|
||||
key={w.code}
|
||||
variant={close ? "warning" : "accent"}
|
||||
title={close ? "进入回收窗口,建议本章安排回收" : undefined}
|
||||
className={`rounded px-1.5 py-0.5 text-xs ${
|
||||
close
|
||||
? "bg-overdue/15 text-overdue"
|
||||
: "bg-[var(--color-cinnabar-wash)] text-cinnabar"
|
||||
}`}
|
||||
>
|
||||
{windowBadgeLabel(w)}
|
||||
{close ? " · 可回收" : ""}
|
||||
</span>
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{chapter.beats && chapter.beats.length > 0 ? (
|
||||
<p className="mt-1 text-sm text-ink">
|
||||
<span className="text-ink-soft">beats: </span>
|
||||
<span className="text-ink-soft">节拍:</span>
|
||||
{chapter.beats.join(" / ")}
|
||||
</p>
|
||||
) : (
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { ListTree, Sparkles } from "lucide-react";
|
||||
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
import { PageHeader } from "@/components/ui/PageHeader";
|
||||
import { Select } from "@/components/ui/Select";
|
||||
import { TextInput } from "@/components/ui/TextInput";
|
||||
import type { OutlineChapterView, ProjectResponse } from "@/lib/api/types";
|
||||
import {
|
||||
distinctVolumes,
|
||||
@@ -43,52 +49,66 @@ export function OutlineEditor({
|
||||
activeNav="outline"
|
||||
>
|
||||
<div className="flex h-[calc(100vh-var(--chrome,4rem))] flex-col p-6">
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<h1 className="font-serif text-lg text-ink">大纲</h1>
|
||||
{availableVolumes.length > 0 ? (
|
||||
<label htmlFor="view-vol" className="ml-auto text-xs text-ink-soft">
|
||||
查看
|
||||
<select
|
||||
id="view-vol"
|
||||
value={viewVolume === "all" ? "all" : String(viewVolume)}
|
||||
onChange={(e) =>
|
||||
setViewVolume(
|
||||
e.target.value === "all" ? "all" : Number(e.target.value),
|
||||
)
|
||||
}
|
||||
className="ml-1 rounded border border-line bg-bg px-2 py-1 text-sm text-ink focus:border-cinnabar focus:outline-none"
|
||||
<PageHeader
|
||||
title="大纲"
|
||||
description="按卷组织章节节拍,写作台会把当前章节拍注入生成上下文。"
|
||||
actions={
|
||||
<>
|
||||
{availableVolumes.length > 0 ? (
|
||||
<label
|
||||
htmlFor="view-vol"
|
||||
className="ml-auto text-xs text-ink-soft"
|
||||
>
|
||||
查看
|
||||
<Select
|
||||
id="view-vol"
|
||||
value={viewVolume === "all" ? "all" : String(viewVolume)}
|
||||
onChange={(e) =>
|
||||
setViewVolume(
|
||||
e.target.value === "all"
|
||||
? "all"
|
||||
: Number(e.target.value),
|
||||
)
|
||||
}
|
||||
controlSize="sm"
|
||||
className="ml-1"
|
||||
>
|
||||
<option value="all">全部</option>
|
||||
{availableVolumes.map((v) => (
|
||||
<option key={v} value={String(v)}>
|
||||
卷 {v}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</label>
|
||||
) : null}
|
||||
<label
|
||||
htmlFor="vol"
|
||||
className={`text-xs text-ink-soft${availableVolumes.length > 0 ? "" : " ml-auto"}`}
|
||||
>
|
||||
<option value="all">全部</option>
|
||||
{availableVolumes.map((v) => (
|
||||
<option key={v} value={String(v)}>
|
||||
卷 {v}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
) : null}
|
||||
<label
|
||||
htmlFor="vol"
|
||||
className={`text-xs text-ink-soft${availableVolumes.length > 0 ? "" : " ml-auto"}`}
|
||||
>
|
||||
卷号
|
||||
</label>
|
||||
<input
|
||||
id="vol"
|
||||
inputMode="numeric"
|
||||
value={volume}
|
||||
onChange={(e) => setVolume(Math.max(1, Number(e.target.value) || 1))}
|
||||
className="w-16 rounded border border-line bg-bg px-2 py-1 text-sm text-ink focus:border-cinnabar focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={generating}
|
||||
onClick={() => void generate(project.id, volume)}
|
||||
className="rounded bg-cinnabar px-3 py-1.5 text-sm text-panel hover:opacity-90 disabled:opacity-40"
|
||||
>
|
||||
{generating ? "排大纲中…" : "✦ AI 排大纲"}
|
||||
</button>
|
||||
</div>
|
||||
卷号
|
||||
</label>
|
||||
<TextInput
|
||||
id="vol"
|
||||
inputMode="numeric"
|
||||
value={volume}
|
||||
onChange={(e) =>
|
||||
setVolume(Math.max(1, Number(e.target.value) || 1))
|
||||
}
|
||||
controlSize="sm"
|
||||
className="w-16"
|
||||
/>
|
||||
<Button
|
||||
disabled={generating}
|
||||
onClick={() => void generate(project.id, volume)}
|
||||
variant="primary"
|
||||
>
|
||||
<Sparkles className="h-4 w-4" aria-hidden="true" />
|
||||
{generating ? "排大纲中…" : "AI 排大纲"}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<p className="mb-4 text-sm text-conflict">
|
||||
@@ -109,11 +129,30 @@ export function OutlineEditor({
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
{volumes.length === 0 ? (
|
||||
<p className="rounded border border-dashed border-line p-6 text-sm text-ink-soft">
|
||||
{chapters.length > 0 && viewVolume !== "all"
|
||||
? `卷 ${viewVolume} 暂无大纲。切到「全部」查看其它卷,或点「✦ AI 排大纲」为本卷生成。`
|
||||
: "暂无大纲。点「✦ AI 排大纲」生成逐章节拍与伏笔窗口。"}
|
||||
</p>
|
||||
<EmptyState
|
||||
icon={ListTree}
|
||||
title={
|
||||
chapters.length > 0 && viewVolume !== "all"
|
||||
? `卷 ${viewVolume} 暂无大纲`
|
||||
: "暂无大纲"
|
||||
}
|
||||
description={
|
||||
chapters.length > 0 && viewVolume !== "all"
|
||||
? "切到「全部」查看其它卷,或为当前卷生成章节节拍。"
|
||||
: "点击「AI 排大纲」生成逐章节拍与伏笔窗口,再回到写作台逐章推进。"
|
||||
}
|
||||
action={
|
||||
<Button
|
||||
disabled={generating}
|
||||
onClick={() => void generate(project.id, volume)}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
>
|
||||
<Sparkles className="h-4 w-4" aria-hidden="true" />
|
||||
{generating ? "排大纲中…" : "AI 排大纲"}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
volumes.map((group) => (
|
||||
<section key={group.volume} className="mb-6">
|
||||
|
||||
212
apps/web/components/projects/ProjectLibrary.tsx
Normal file
212
apps/web/components/projects/ProjectLibrary.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Clock3, LayoutGrid, List, Plus, Search } from "lucide-react";
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
|
||||
import { ProjectCard } from "@/components/ProjectCard";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
import { Select } from "@/components/ui/Select";
|
||||
import { TextInput } from "@/components/ui/TextInput";
|
||||
import type { ProjectResponse } from "@/lib/api/types";
|
||||
import {
|
||||
filterProjects,
|
||||
type ProjectFilter,
|
||||
type ProjectSort,
|
||||
type ProjectViewMode,
|
||||
} from "@/lib/projects/projects";
|
||||
import { buttonClass, cn } from "@/lib/ui/variants";
|
||||
|
||||
interface ProjectLibraryProps {
|
||||
projects: ProjectResponse[];
|
||||
}
|
||||
|
||||
const VIEW_STORAGE_KEY = "ww.project_view_mode";
|
||||
|
||||
export function ProjectLibrary({ projects }: ProjectLibraryProps) {
|
||||
const [search, setSearch] = useState("");
|
||||
const [filter, setFilter] = useState<ProjectFilter>("all");
|
||||
const [sort, setSort] = useState<ProjectSort>("updated_at");
|
||||
const [viewMode, setViewMode] = useState<ProjectViewMode>("cards");
|
||||
|
||||
useEffect(() => {
|
||||
const saved = window.localStorage.getItem(VIEW_STORAGE_KEY);
|
||||
if (saved === "cards" || saved === "compact") setViewMode(saved);
|
||||
}, []);
|
||||
|
||||
const setView = (value: ProjectViewMode): void => {
|
||||
setViewMode(value);
|
||||
window.localStorage.setItem(VIEW_STORAGE_KEY, value);
|
||||
};
|
||||
|
||||
const visibleProjects = useMemo(
|
||||
() => filterProjects(projects, { search, filter, sort }),
|
||||
[projects, search, filter, sort],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-3 rounded border border-line bg-panel p-3 md:grid-cols-[1fr_auto_auto_auto] md:items-center">
|
||||
<label className="relative block">
|
||||
<span className="sr-only">搜索作品</span>
|
||||
<Search
|
||||
className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-ink-soft"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<TextInput
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="搜索标题、题材、主题或一句话故事"
|
||||
className="pl-9"
|
||||
/>
|
||||
</label>
|
||||
<Select
|
||||
aria-label="筛选作品"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value as ProjectFilter)}
|
||||
>
|
||||
<option value="all">全部作品</option>
|
||||
<option value="pending_review">待审稿</option>
|
||||
<option value="with_genre">有题材</option>
|
||||
<option value="uncategorized">未归类</option>
|
||||
</Select>
|
||||
<Select
|
||||
aria-label="排序作品"
|
||||
value={sort}
|
||||
onChange={(e) => setSort(e.target.value as ProjectSort)}
|
||||
>
|
||||
<option value="updated_at">最近编辑</option>
|
||||
<option value="title">按标题</option>
|
||||
<option value="genre">按题材</option>
|
||||
</Select>
|
||||
<div
|
||||
className="inline-flex justify-self-start rounded border border-line bg-bg p-1 md:justify-self-end"
|
||||
role="group"
|
||||
aria-label="视图密度"
|
||||
>
|
||||
<ViewButton
|
||||
label="卡片"
|
||||
active={viewMode === "cards"}
|
||||
onClick={() => setView("cards")}
|
||||
>
|
||||
<LayoutGrid className="h-4 w-4" aria-hidden="true" />
|
||||
</ViewButton>
|
||||
<ViewButton
|
||||
label="紧凑"
|
||||
active={viewMode === "compact"}
|
||||
onClick={() => setView("compact")}
|
||||
>
|
||||
<List className="h-4 w-4" aria-hidden="true" />
|
||||
</ViewButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-ink-soft">
|
||||
<Clock3 className="mr-1 inline h-3.5 w-3.5" aria-hidden="true" />
|
||||
显示 {visibleProjects.length} / {projects.length} 个作品
|
||||
</p>
|
||||
|
||||
{visibleProjects.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Search}
|
||||
title="没有匹配的作品"
|
||||
description="换一个关键词或清空筛选,也可以直接创建一本新作品。"
|
||||
action={
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setSearch("");
|
||||
setFilter("all");
|
||||
}}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
清空筛选
|
||||
</Button>
|
||||
<Link
|
||||
href="/projects/new"
|
||||
className={buttonClass({ variant: "primary", size: "sm" })}
|
||||
>
|
||||
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||
新建作品
|
||||
</Link>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
) : viewMode === "cards" ? (
|
||||
<ul className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{visibleProjects.map((p) => (
|
||||
<li key={p.id}>
|
||||
<ProjectCard project={p} />
|
||||
</li>
|
||||
))}
|
||||
<li>
|
||||
<NewProjectCard />
|
||||
</li>
|
||||
</ul>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{visibleProjects.map((p) => (
|
||||
<li key={p.id}>
|
||||
<ProjectCard project={p} compact />
|
||||
</li>
|
||||
))}
|
||||
<li>
|
||||
<Link
|
||||
href="/projects/new"
|
||||
className="flex items-center justify-center gap-2 rounded border border-dashed border-line bg-panel/70 px-4 py-3 text-sm text-cinnabar transition-colors hover:border-cinnabar hover:bg-panel"
|
||||
>
|
||||
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||
新建作品
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ViewButton({
|
||||
active,
|
||||
label,
|
||||
children,
|
||||
onClick,
|
||||
}: {
|
||||
active: boolean;
|
||||
label: string;
|
||||
children: ReactNode;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
aria-pressed={active}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"rounded px-2 py-1.5 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cinnabar/35",
|
||||
active
|
||||
? "bg-panel text-cinnabar shadow-paper"
|
||||
: "text-ink-soft hover:text-cinnabar",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function NewProjectCard() {
|
||||
return (
|
||||
<Link
|
||||
href="/projects/new"
|
||||
className="group flex h-full min-h-[176px] flex-col items-center justify-center rounded border border-dashed border-line bg-panel/70 text-center transition-colors hover:border-cinnabar hover:bg-panel"
|
||||
>
|
||||
<span className="mb-3 flex h-10 w-10 items-center justify-center rounded bg-[var(--color-cinnabar-wash)] text-cinnabar transition-colors group-hover:bg-cinnabar group-hover:text-panel">
|
||||
<Plus className="h-5 w-5" aria-hidden="true" />
|
||||
</span>
|
||||
<span className="font-serif text-xl text-cinnabar">新建</span>
|
||||
<span className="mt-2 text-sm text-ink-soft">从一句灵感开始一本书</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { ArrowRight, CheckCircle2, ListTree } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { StatusNote } from "@/components/ui/StatusNote";
|
||||
import type { AcceptResponse } from "@/lib/api/types";
|
||||
import { buildAcceptPreview } from "@/lib/review/accept-preview";
|
||||
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
|
||||
import { buttonClass } from "@/lib/ui/variants";
|
||||
|
||||
interface AcceptPanelProps {
|
||||
projectId: string;
|
||||
@@ -42,40 +46,50 @@ export function AcceptPanel({
|
||||
if (result) {
|
||||
return (
|
||||
<div className="rounded border border-pass/50 bg-panel p-4">
|
||||
<h3 className="mb-2 text-sm font-semibold text-pass">本章已验收</h3>
|
||||
<ul className="space-y-1 text-xs text-ink-soft">
|
||||
<li>
|
||||
晋升版次:
|
||||
<span className="font-mono text-ink">v{result.accepted_version}</span>
|
||||
</li>
|
||||
<li>
|
||||
章节摘要:{result.digest_added ? "已新增一行" : "未变更"}
|
||||
</li>
|
||||
<li>
|
||||
裁决写回:
|
||||
<span className="font-mono text-ink">
|
||||
<StatusNote variant="success" title="本章已验收">
|
||||
<p className="text-sm leading-6">
|
||||
终稿已晋升为正式版本,章节摘要和裁决留痕已按验收事务写回。
|
||||
</p>
|
||||
</StatusNote>
|
||||
<dl className="mt-3 grid grid-cols-3 gap-2 text-center">
|
||||
<div className="rounded border border-line bg-bg/60 p-2">
|
||||
<dt className="text-[11px] text-ink-soft">版次</dt>
|
||||
<dd className="mt-1 font-mono text-sm text-ink">
|
||||
v{result.accepted_version}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="rounded border border-line bg-bg/60 p-2">
|
||||
<dt className="text-[11px] text-ink-soft">摘要</dt>
|
||||
<dd className="mt-1 text-sm text-ink">
|
||||
{result.digest_added ? "已新增" : "未变更"}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="rounded border border-line bg-bg/60 p-2">
|
||||
<dt className="text-[11px] text-ink-soft">裁决</dt>
|
||||
<dd className="mt-1 font-mono text-sm text-ink">
|
||||
{result.decisions_recorded}
|
||||
</span>{" "}
|
||||
条
|
||||
</li>
|
||||
{result.review_id ? (
|
||||
<li className="truncate">
|
||||
留痕:<span className="font-mono">{result.review_id}</span>
|
||||
</li>
|
||||
) : null}
|
||||
</ul>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{result.review_id ? (
|
||||
<p className="mt-2 truncate text-[11px] text-ink-soft">
|
||||
留痕:<span className="font-mono">{result.review_id}</span>
|
||||
</p>
|
||||
) : null}
|
||||
{/* 验收闭环 → 写下一章入口(修「验收后无路可走」的断点)。 */}
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<Link
|
||||
href={`/projects/${projectId}/write?chapter=${chapterNo + 1}`}
|
||||
className="rounded bg-cinnabar px-4 py-2 text-sm text-panel hover:opacity-90"
|
||||
className={buttonClass({ variant: "primary" })}
|
||||
>
|
||||
→ 写第 {chapterNo + 1} 章
|
||||
<ArrowRight className="h-4 w-4" aria-hidden="true" />
|
||||
写第 {chapterNo + 1} 章
|
||||
</Link>
|
||||
<Link
|
||||
href={`/projects/${projectId}/outline`}
|
||||
className="rounded border border-line px-4 py-2 text-sm text-ink hover:border-cinnabar hover:text-cinnabar"
|
||||
className={buttonClass({ variant: "secondary" })}
|
||||
>
|
||||
<ListTree className="h-4 w-4" aria-hidden="true" />
|
||||
返回大纲
|
||||
</Link>
|
||||
</div>
|
||||
@@ -84,20 +98,24 @@ export function AcceptPanel({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded border border-line bg-panel p-4">
|
||||
<div
|
||||
className={`rounded border bg-panel p-4 ${
|
||||
blocked ? "border-conflict/35" : "border-line"
|
||||
}`}
|
||||
>
|
||||
{blocked ? (
|
||||
<p className="mb-2 text-xs text-conflict" role="status">
|
||||
<StatusNote className="mb-3" variant="danger" role="status">
|
||||
尚有 {unresolvedCount} 项冲突未裁决,处理完才能验收。
|
||||
</p>
|
||||
</StatusNote>
|
||||
) : (
|
||||
<>
|
||||
<p className="mb-2 text-xs text-ink-soft">
|
||||
<StatusNote className="mb-3" variant={reviewed ? "success" : "info"}>
|
||||
{conflictCount === 0
|
||||
? reviewed
|
||||
? "无冲突,可直接验收。"
|
||||
: "尚未审稿——建议先点「重新审稿」确认本章无冲突,再验收。"
|
||||
: "全部冲突已裁决,可验收本章。"}
|
||||
</p>
|
||||
</StatusNote>
|
||||
{/* R3:验收前「本次将更新」清单(预期,落库口径以验收回执为准)。 */}
|
||||
<div className="mb-3 rounded border border-line/70 bg-bg/50 p-3">
|
||||
<h4 className="mb-1.5 text-xs font-semibold text-ink">
|
||||
@@ -122,19 +140,22 @@ export function AcceptPanel({
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
onClick={onAccept}
|
||||
disabled={blocked || accepting}
|
||||
aria-disabled={blocked || accepting}
|
||||
className="w-full rounded bg-cinnabar px-4 py-2 text-sm text-panel hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
>
|
||||
{accepting ? (
|
||||
<ThinkingIndicator label="验收中" className="justify-center" />
|
||||
) : (
|
||||
"全部处理完 → 验收本章"
|
||||
<>
|
||||
<CheckCircle2 className="h-4 w-4" aria-hidden="true" />
|
||||
全部处理完,验收本章
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,19 +16,21 @@ export function BeatMap({ beatMap }: BeatMapProps) {
|
||||
}
|
||||
const spark = toSparkline(beatMap);
|
||||
return (
|
||||
<div
|
||||
className="flex h-12 items-end gap-0.5"
|
||||
role="img"
|
||||
aria-label={`爽点节拍图 ${spark}`}
|
||||
>
|
||||
{cells.map((cell, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="min-w-[4px] flex-1 rounded-t bg-cinnabar/70"
|
||||
style={{ height: `${cell.heightPct}%` }}
|
||||
title={`第${i + 1}拍 强度${cell.value}`}
|
||||
/>
|
||||
))}
|
||||
<div className="max-w-full overflow-x-auto pb-1">
|
||||
<div
|
||||
className="flex h-12 min-w-full items-end gap-0.5"
|
||||
role="img"
|
||||
aria-label={`爽点节拍图 ${spark}`}
|
||||
>
|
||||
{cells.map((cell, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="min-w-[4px] flex-1 rounded-t bg-cinnabar/70"
|
||||
style={{ height: `${cell.heightPct}%` }}
|
||||
title={`第${i + 1}拍 强度${cell.value}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import type { ReviewConflict } from "@/lib/review/sse";
|
||||
import { AlertTriangle, CheckCircle2, LocateFixed, Sparkles } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { TextInput } from "@/components/ui/TextInput";
|
||||
import { buttonClass } from "@/lib/ui/variants";
|
||||
import type { DecisionDraft, Verdict } from "@/lib/review/decisions";
|
||||
import type { ReviewConflict } from "@/lib/review/sse";
|
||||
|
||||
interface ConflictCardProps {
|
||||
index: number;
|
||||
total: number;
|
||||
conflict: ReviewConflict;
|
||||
draft: DecisionDraft;
|
||||
missing: boolean;
|
||||
@@ -28,15 +34,16 @@ const VERDICT_OPTIONS: { value: Verdict; label: string; primary: boolean }[] = [
|
||||
|
||||
// 裁决按钮样式:激活=朱砂填充;未激活的主按钮=朱砂描边强调,次级=低对比描边。
|
||||
function verdictClass(active: boolean, primary: boolean): string {
|
||||
if (active) return "bg-cinnabar text-panel";
|
||||
if (primary) return "border border-cinnabar text-cinnabar hover:bg-cinnabar/10";
|
||||
return "border border-line text-ink-soft hover:border-cinnabar hover:text-ink";
|
||||
if (active) return buttonClass({ variant: "primary", size: "sm" });
|
||||
if (primary) return buttonClass({ variant: "outline", size: "sm" });
|
||||
return buttonClass({ variant: "secondary", size: "sm" });
|
||||
}
|
||||
|
||||
// 单个冲突报告卡(UX §6.4):五类徽标 + where + refs + suggestion + 裁决三态。
|
||||
// 冲突=赭红;未决/缺判时加图标+文案(不单靠色,a11y §10)。
|
||||
export function ConflictCard({
|
||||
index,
|
||||
total,
|
||||
conflict,
|
||||
draft,
|
||||
missing,
|
||||
@@ -60,13 +67,14 @@ export function ConflictCard({
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span
|
||||
className="shrink-0 rounded bg-[var(--color-conflict)]/10 px-2 py-0.5 text-xs text-conflict"
|
||||
aria-hidden="true"
|
||||
>
|
||||
⚠ {conflict.type}
|
||||
</span>
|
||||
<p className="flex-1 text-sm text-ink">{conflict.suggestion}</p>
|
||||
<Badge variant="danger" className="shrink-0">
|
||||
<AlertTriangle className="h-3 w-3" aria-hidden="true" />
|
||||
冲突 {index + 1}/{total}
|
||||
</Badge>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs text-conflict">{conflict.type}</p>
|
||||
<p className="mt-1 text-sm text-ink">{conflict.suggestion}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{conflict.original && conflict.replacement ? (
|
||||
@@ -95,9 +103,10 @@ export function ConflictCard({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onJump(index)}
|
||||
className="rounded border border-line px-2 py-0.5 hover:border-cinnabar hover:text-cinnabar"
|
||||
className={buttonClass({ variant: "secondary", size: "sm" })}
|
||||
>
|
||||
▸ 跳转 {conflict.where}
|
||||
<LocateFixed className="h-4 w-4" aria-hidden="true" />
|
||||
跳转 {conflict.where}
|
||||
</button>
|
||||
) : null}
|
||||
{conflict.refs.map((ref) => (
|
||||
@@ -121,23 +130,24 @@ export function ConflictCard({
|
||||
aria-pressed={active}
|
||||
disabled={rewriting}
|
||||
onClick={() => onVerdict(index, opt.value)}
|
||||
className={`rounded px-3 py-1 text-xs disabled:cursor-not-allowed disabled:opacity-40 ${verdictClass(
|
||||
active,
|
||||
opt.primary,
|
||||
)}`}
|
||||
className={verdictClass(active, opt.primary)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{rewriting ? (
|
||||
<span className="text-xs text-info">✦ AI 改写中…</span>
|
||||
<Badge variant="info">
|
||||
<Sparkles className="h-3 w-3" aria-hidden="true" />
|
||||
AI 改写中…
|
||||
</Badge>
|
||||
) : resolved ? (
|
||||
<span className="text-xs text-pass" aria-hidden="true">
|
||||
✓ 已裁决
|
||||
</span>
|
||||
<Badge variant="success">
|
||||
<CheckCircle2 className="h-3 w-3" aria-hidden="true" />
|
||||
已裁决
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-xs text-conflict">未裁决</span>
|
||||
<Badge variant="danger">未裁决</Badge>
|
||||
)}
|
||||
</div>
|
||||
{draft.verdict === "manual" ? (
|
||||
@@ -145,14 +155,16 @@ export function ConflictCard({
|
||||
<label htmlFor={`note-${index}`} className="sr-only">
|
||||
手改说明
|
||||
</label>
|
||||
<input
|
||||
<TextInput
|
||||
id={`note-${index}`}
|
||||
type="text"
|
||||
value={draft.note}
|
||||
onChange={(e) => onNote(index, e.target.value)}
|
||||
placeholder="手改说明(可选)"
|
||||
className="w-full rounded border border-line bg-bg px-3 py-1.5 text-sm text-ink focus:border-cinnabar focus:outline-none"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-ink-soft">
|
||||
选择手改后,说明会随验收裁决一起保存,正文请在左侧直接修改。
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { CheckCircle2, CircleDashed, Flag, Plus } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { suggestForeshadowCode } from "@/lib/foreshadow/board";
|
||||
import { api } from "@/lib/api/client";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Field } from "@/components/ui/Field";
|
||||
import { TextArea } from "@/components/ui/TextArea";
|
||||
import { TextInput } from "@/components/ui/TextInput";
|
||||
import {
|
||||
nextFreeForeshadowCode,
|
||||
suggestForeshadowCode,
|
||||
} from "@/lib/foreshadow/board";
|
||||
import { useForeshadow } from "@/lib/foreshadow/useForeshadow";
|
||||
import type { ForeshadowSuggestion } from "@/lib/review/sse";
|
||||
|
||||
@@ -36,11 +46,54 @@ export function ForeshadowSuggestions({
|
||||
const [registered, setRegistered] = useState<Set<number>>(new Set());
|
||||
const [resolved, setResolved] = useState<Set<number>>(new Set());
|
||||
const [pending, setPending] = useState<Set<number>>(new Set());
|
||||
// 项目已存在的伏笔(用于隐藏「之前会话就已保存过」的建议):
|
||||
// savedTitles = 已登记伏笔的标题;savedClosedCodes = 已 CLOSED(已回收)的 code。
|
||||
const [savedTitles, setSavedTitles] = useState<Set<string>>(new Set());
|
||||
const [savedClosedCodes, setSavedClosedCodes] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
// 看板已占用的全部代号——预填登记代号时跳过它们,避免撞码 422。
|
||||
const [savedCodes, setSavedCodes] = useState<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
void (async () => {
|
||||
const { data } = await api.GET("/projects/{project_id}/foreshadow", {
|
||||
params: { path: { project_id: projectId } },
|
||||
});
|
||||
if (!active || !data?.foreshadow) return;
|
||||
setSavedTitles(new Set(data.foreshadow.map((f) => f.title.trim())));
|
||||
setSavedClosedCodes(
|
||||
new Set(
|
||||
data.foreshadow
|
||||
.filter((f) => f.status === "CLOSED")
|
||||
.map((f) => f.code),
|
||||
),
|
||||
);
|
||||
setSavedCodes(
|
||||
new Set(data.foreshadow.map((f) => f.code).filter((c) => c)),
|
||||
);
|
||||
})();
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [projectId]);
|
||||
|
||||
// 该建议是否「已保存过」→ 从列表隐藏:
|
||||
// - 本次会话已登记/回收(registered/resolved)
|
||||
// - 新埋建议:标题已存在于看板(之前已登记)
|
||||
// - 疑似回收建议:对应 code 已 CLOSED(之前已回收)
|
||||
const isAlreadySaved = (s: ForeshadowSuggestion, i: number): boolean => {
|
||||
if (s.kind === "resolved") {
|
||||
return resolved.has(i) || (s.code ? savedClosedCodes.has(s.code) : false);
|
||||
}
|
||||
return registered.has(i) || savedTitles.has(s.title.trim());
|
||||
};
|
||||
|
||||
const openRegister = (i: number, s: ForeshadowSuggestion): void => {
|
||||
setOpenIdx(i);
|
||||
setForm({
|
||||
code: suggestForeshadowCode(s.code, i),
|
||||
code: nextFreeForeshadowCode(suggestForeshadowCode(s.code, i), savedCodes),
|
||||
title: s.title,
|
||||
plantedAt: chapterNo,
|
||||
content: s.note ?? s.where ?? "",
|
||||
@@ -73,6 +126,9 @@ export function ForeshadowSuggestions({
|
||||
markPending(i, false);
|
||||
if (ok) {
|
||||
setRegistered((prev) => new Set(prev).add(i));
|
||||
// 并入已存集合:连续登记时下一条不再取到同一空闲码,且该标题立即隐藏。
|
||||
setSavedCodes((prev) => new Set(prev).add(form.code));
|
||||
setSavedTitles((prev) => new Set(prev).add(form.title.trim()));
|
||||
closeForm();
|
||||
}
|
||||
};
|
||||
@@ -84,36 +140,42 @@ export function ForeshadowSuggestions({
|
||||
if (ok) setResolved((prev) => new Set(prev).add(i));
|
||||
};
|
||||
|
||||
// 保留原始下标 i(registered/resolved/pending/openIdx 均按 i 索引),仅过滤已保存项。
|
||||
const visible = suggestions
|
||||
.map((s, i) => ({ s, i }))
|
||||
.filter(({ s, i }) => !isAlreadySaved(s, i));
|
||||
|
||||
return (
|
||||
<section className="mb-4">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wide text-ink-soft">
|
||||
伏笔 (foreshadow-analyst)
|
||||
</h2>
|
||||
{incomplete ? (
|
||||
<p className="mt-1 text-xs text-overdue">◌ 未完成</p>
|
||||
) : suggestions.length === 0 ? (
|
||||
<Badge variant="warning" className="mt-1">
|
||||
<CircleDashed className="h-3 w-3" aria-hidden="true" />
|
||||
未完成
|
||||
</Badge>
|
||||
) : visible.length === 0 ? (
|
||||
<p className="mt-1 text-xs text-ink-soft">无伏笔建议。</p>
|
||||
) : (
|
||||
<ul className="mt-2 space-y-2">
|
||||
{suggestions.map((s, i) => {
|
||||
{visible.map(({ s, i }) => {
|
||||
const code = s.code;
|
||||
const isResolved = s.kind === "resolved";
|
||||
const done = isResolved ? resolved.has(i) : registered.has(i);
|
||||
const busy = pending.has(i);
|
||||
return (
|
||||
<li
|
||||
key={i}
|
||||
className="rounded border border-line bg-panel p-2 text-xs"
|
||||
>
|
||||
<span
|
||||
className={`mr-1 rounded px-1.5 py-0.5 ${
|
||||
isResolved
|
||||
? "bg-pass/15 text-pass"
|
||||
: "bg-[var(--color-cinnabar-wash)] text-cinnabar"
|
||||
}`}
|
||||
>
|
||||
{isResolved ? "⚑ 疑似回收" : "+ 新埋待确认"}
|
||||
</span>
|
||||
<Badge variant={isResolved ? "success" : "accent"}>
|
||||
{isResolved ? (
|
||||
<Flag className="h-3 w-3" aria-hidden="true" />
|
||||
) : (
|
||||
<Plus className="h-3 w-3" aria-hidden="true" />
|
||||
)}
|
||||
{isResolved ? "疑似回收" : "新埋待确认"}
|
||||
</Badge>
|
||||
{code ? (
|
||||
<span className="font-mono text-ink-soft"> {code}</span>
|
||||
) : null}
|
||||
@@ -123,21 +185,19 @@ export function ForeshadowSuggestions({
|
||||
) : null}
|
||||
{s.note ? <p className="mt-0.5 text-ink-soft">{s.note}</p> : null}
|
||||
|
||||
{/* 作者确认动作区 */}
|
||||
{done ? (
|
||||
<p className="mt-1.5 text-pass">
|
||||
✓ {isResolved ? "已标记回收" : "已登记"}
|
||||
</p>
|
||||
) : isResolved ? (
|
||||
{/* 作者确认动作区(已保存的建议已从列表过滤,故无需「已登记」态) */}
|
||||
{isResolved ? (
|
||||
code ? (
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
disabled={busy}
|
||||
onClick={() => void markResolved(i, code)}
|
||||
className="mt-1.5 rounded border border-pass px-2 py-0.5 text-pass hover:bg-pass/10 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
className="mt-1.5"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
{busy ? "处理中…" : "✓ 标记回收"}
|
||||
</button>
|
||||
<CheckCircle2 className="h-4 w-4" aria-hidden="true" />
|
||||
{busy ? "处理中…" : "标记回收"}
|
||||
</Button>
|
||||
) : (
|
||||
<p className="mt-1.5 text-ink-soft">
|
||||
未关联编码,去伏笔看板处理。
|
||||
@@ -152,13 +212,15 @@ export function ForeshadowSuggestions({
|
||||
onCancel={closeForm}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
onClick={() => openRegister(i, s)}
|
||||
className="mt-1.5 rounded border border-cinnabar px-2 py-0.5 text-cinnabar hover:bg-cinnabar/10"
|
||||
className="mt-1.5"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
+ 登记此伏笔
|
||||
</button>
|
||||
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||
登记此伏笔
|
||||
</Button>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
@@ -187,39 +249,28 @@ function RegisterFormInline({
|
||||
}: RegisterFormInlineProps) {
|
||||
const canSubmit =
|
||||
!busy && form.code.trim().length > 0 && form.title.trim().length > 0;
|
||||
const field =
|
||||
"w-full rounded border border-line bg-bg px-2 py-1 text-xs text-ink focus:border-cinnabar focus:outline-none";
|
||||
return (
|
||||
<div className="mt-2 space-y-1.5 border-t border-line pt-2">
|
||||
<div>
|
||||
<label htmlFor="fs-reg-code" className="text-ink-soft">
|
||||
编码
|
||||
</label>
|
||||
<input
|
||||
<Field label="编码" htmlFor="fs-reg-code">
|
||||
<TextInput
|
||||
id="fs-reg-code"
|
||||
type="text"
|
||||
value={form.code}
|
||||
onChange={(e) => onChange({ ...form, code: e.target.value })}
|
||||
className={field}
|
||||
controlSize="sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="fs-reg-title" className="text-ink-soft">
|
||||
标题
|
||||
</label>
|
||||
<input
|
||||
</Field>
|
||||
<Field label="标题" htmlFor="fs-reg-title">
|
||||
<TextInput
|
||||
id="fs-reg-title"
|
||||
type="text"
|
||||
value={form.title}
|
||||
onChange={(e) => onChange({ ...form, title: e.target.value })}
|
||||
className={field}
|
||||
controlSize="sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="fs-reg-planted" className="text-ink-soft">
|
||||
埋设章号
|
||||
</label>
|
||||
<input
|
||||
</Field>
|
||||
<Field label="埋设章号" htmlFor="fs-reg-planted">
|
||||
<TextInput
|
||||
id="fs-reg-planted"
|
||||
type="number"
|
||||
min={1}
|
||||
@@ -227,38 +278,36 @@ function RegisterFormInline({
|
||||
onChange={(e) =>
|
||||
onChange({ ...form, plantedAt: Number(e.target.value) || 1 })
|
||||
}
|
||||
className={field}
|
||||
controlSize="sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="fs-reg-content" className="text-ink-soft">
|
||||
线索/正文(可选)
|
||||
</label>
|
||||
<textarea
|
||||
</Field>
|
||||
<Field label="线索/正文(可选)" htmlFor="fs-reg-content">
|
||||
<TextArea
|
||||
id="fs-reg-content"
|
||||
value={form.content}
|
||||
onChange={(e) => onChange({ ...form, content: e.target.value })}
|
||||
rows={2}
|
||||
className={`${field} resize-y`}
|
||||
controlSize="sm"
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
<div className="flex gap-2 pt-0.5">
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
disabled={!canSubmit}
|
||||
onClick={onSubmit}
|
||||
className="rounded bg-cinnabar px-2 py-0.5 text-panel hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
>
|
||||
<CheckCircle2 className="h-4 w-4" aria-hidden="true" />
|
||||
{busy ? "登记中…" : "确认登记"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
</Button>
|
||||
<Button
|
||||
disabled={busy}
|
||||
onClick={onCancel}
|
||||
className="rounded border border-line px-2 py-0.5 text-ink-soft hover:border-cinnabar hover:text-ink disabled:opacity-40"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { AlertTriangle, CheckCircle2, CircleDashed } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import type { PaceReport } from "@/lib/review/sse";
|
||||
import { BeatMap } from "./BeatMap";
|
||||
|
||||
@@ -16,7 +19,10 @@ export function PacePanel({ pace, incomplete }: PacePanelProps) {
|
||||
节奏 (pace-checker)
|
||||
</h2>
|
||||
{incomplete ? (
|
||||
<p className="mt-1 text-xs text-overdue">◌ 未完成</p>
|
||||
<Badge variant="warning" className="mt-1">
|
||||
<CircleDashed className="h-3 w-3" aria-hidden="true" />
|
||||
未完成
|
||||
</Badge>
|
||||
) : pace === null ? (
|
||||
<p className="mt-1 text-xs text-ink-soft">无节奏报告。</p>
|
||||
) : (
|
||||
@@ -29,15 +35,24 @@ export function PacePanel({ pace, incomplete }: PacePanelProps) {
|
||||
<p>
|
||||
章末钩子:
|
||||
{pace.hook ? (
|
||||
<span className="text-pass">✓ 有</span>
|
||||
<Badge variant="success">
|
||||
<CheckCircle2 className="h-3 w-3" aria-hidden="true" />
|
||||
有
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-overdue">⚠ 无(建议补悬念)</span>
|
||||
<Badge variant="warning">
|
||||
<AlertTriangle className="h-3 w-3" aria-hidden="true" />
|
||||
无(建议补悬念)
|
||||
</Badge>
|
||||
)}
|
||||
</p>
|
||||
|
||||
{pace.water.length > 0 ? (
|
||||
<div>
|
||||
<p className="text-overdue">⚠ 注水段</p>
|
||||
<Badge variant="warning">
|
||||
<AlertTriangle className="h-3 w-3" aria-hidden="true" />
|
||||
注水段
|
||||
</Badge>
|
||||
<ul className="mt-1 space-y-1">
|
||||
{pace.water.map((w, i) => (
|
||||
<li key={i} className="text-ink-soft">
|
||||
@@ -47,7 +62,10 @@ export function PacePanel({ pace, incomplete }: PacePanelProps) {
|
||||
</ul>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-pass">✓ 无明显注水</p>
|
||||
<Badge variant="success">
|
||||
<CheckCircle2 className="h-3 w-3" aria-hidden="true" />
|
||||
无明显注水
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2,8 +2,16 @@
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
RotateCcw,
|
||||
Square,
|
||||
} from "lucide-react";
|
||||
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import type { ProjectResponse, ReviewHistoryItem } from "@/lib/api/types";
|
||||
import { friendlyError } from "@/lib/errors/messages";
|
||||
import {
|
||||
@@ -44,6 +52,8 @@ import { AnnotatedText } from "./AnnotatedText";
|
||||
import { ConflictCard } from "./ConflictCard";
|
||||
import { ForeshadowSuggestions } from "./ForeshadowSuggestions";
|
||||
import { PacePanel } from "./PacePanel";
|
||||
import { ReviewSectionPanel } from "./ReviewSectionPanel";
|
||||
import { ReviewSummaryRail } from "./ReviewSummaryRail";
|
||||
import { StylePanel } from "@/components/style/StylePanel";
|
||||
import { RefineView } from "@/components/style/RefineView";
|
||||
|
||||
@@ -370,6 +380,13 @@ export function ReviewReport({
|
||||
const resolved = allResolved(drafts);
|
||||
const unresolved = unresolvedIndices(drafts).length;
|
||||
const reviewing = review.isReviewing;
|
||||
const hasReport =
|
||||
review.state.phase === "done" ||
|
||||
review.state.sections.length > 0 ||
|
||||
seededConflicts.length > 0 ||
|
||||
foreshadow.length > 0 ||
|
||||
pace !== null ||
|
||||
style !== null;
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
@@ -378,33 +395,27 @@ export function ReviewReport({
|
||||
projectId={project.id}
|
||||
activeNav="review"
|
||||
>
|
||||
<div className="grid h-[calc(100vh-var(--chrome,4rem))] grid-cols-1 lg:grid-cols-[1fr_24rem]">
|
||||
<div className="flex min-h-[calc(100vh-var(--chrome,4rem))] flex-col lg:grid lg:h-[calc(100vh-var(--chrome,4rem))] lg:min-h-0 lg:grid-cols-[minmax(0,1fr)_24rem]">
|
||||
{/* 左:终稿正文 + 就地标注 */}
|
||||
<section className="flex min-w-0 flex-col bg-bg">
|
||||
<div className="flex items-center gap-3 border-b border-line bg-panel px-6 py-3">
|
||||
<h1 className="font-serif text-lg text-ink">
|
||||
<section className="flex min-h-[70vh] min-w-0 flex-col bg-bg lg:min-h-0">
|
||||
<div className="flex items-center gap-3 border-b border-line bg-panel px-4 py-3 sm:px-6">
|
||||
<h1 className="min-w-0 flex-1 truncate font-serif text-lg text-ink">
|
||||
第 {chapterNo} 章 审稿报告
|
||||
</h1>
|
||||
<span className="font-mono text-xs text-ink-soft">
|
||||
<span className="hidden shrink-0 font-mono text-xs text-ink-soft sm:inline">
|
||||
{conflictCount} 冲突
|
||||
</span>
|
||||
<div className="ml-auto">
|
||||
<div className="shrink-0">
|
||||
{reviewing ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={review.stop}
|
||||
className="rounded border border-conflict px-3 py-1.5 text-sm text-conflict"
|
||||
>
|
||||
<Button onClick={review.stop} variant="danger" size="sm">
|
||||
<Square className="h-4 w-4" aria-hidden="true" />
|
||||
停
|
||||
</button>
|
||||
</Button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onReReview}
|
||||
className="rounded bg-cinnabar px-3 py-1.5 text-sm text-panel hover:opacity-90"
|
||||
>
|
||||
✦ 重新审稿
|
||||
</button>
|
||||
<Button onClick={onReReview} variant="primary" size="sm">
|
||||
<RotateCcw className="h-4 w-4" aria-hidden="true" />
|
||||
重新审稿
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -413,7 +424,7 @@ export function ReviewReport({
|
||||
<ReviewErrorNote error={review.state.error} />
|
||||
) : null}
|
||||
|
||||
<div className="flex-1 overflow-auto px-6 py-6">
|
||||
<div className="flex-1 overflow-auto px-4 py-4 sm:px-6 sm:py-6">
|
||||
<div className="mb-2 flex items-start justify-between gap-3">
|
||||
<p className="text-xs text-ink-soft">
|
||||
可直接在下方正文修改(改动自动保存草稿 — 摘要从终稿提炼);点冲突的「跳转」或顶部锚点定位到对应文字。
|
||||
@@ -438,101 +449,187 @@ export function ReviewReport({
|
||||
</section>
|
||||
|
||||
{/* 右:审稿分区 + 冲突裁决 + 验收 gate */}
|
||||
<aside className="flex flex-col overflow-auto border-l border-line bg-panel px-4 py-4">
|
||||
<section className="mb-4">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wide text-ink-soft">
|
||||
一致性 (continuity)
|
||||
</h2>
|
||||
<SectionStatusLine
|
||||
reviewing={reviewing}
|
||||
done={review.state.phase === "done"}
|
||||
conflictCount={conflictCount}
|
||||
sections={review.state.sections}
|
||||
/>
|
||||
</section>
|
||||
<aside
|
||||
className="flex flex-col border-t border-line bg-panel px-4 py-4 lg:overflow-auto lg:border-l lg:border-t-0"
|
||||
aria-label="审稿报告与验收"
|
||||
>
|
||||
<ReviewSummaryRail
|
||||
conflictCount={conflictCount}
|
||||
unresolvedCount={unresolved}
|
||||
reviewing={reviewing}
|
||||
hasReport={hasReport}
|
||||
onNextUnresolved={jumpToNextUnresolved}
|
||||
/>
|
||||
|
||||
<section>
|
||||
{conflicts.length === 0 ? (
|
||||
<p className="rounded border border-dashed border-line p-4 text-xs text-ink-soft">
|
||||
{review.state.phase === "done" || seededConflicts.length === 0
|
||||
? "未发现一致性冲突。"
|
||||
: "进页未带审稿留痕,点「重新审稿」开始。"}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* R2:未裁决进度 + 跳到下一条未裁决 */}
|
||||
<div className="flex items-center justify-between text-xs text-ink-soft">
|
||||
<span>
|
||||
{unresolved > 0 ? (
|
||||
<span className="text-conflict">
|
||||
{unresolved}/{conflictCount} 待裁决
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-pass">✓ 全部已裁决</span>
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={jumpToNextUnresolved}
|
||||
disabled={unresolved === 0}
|
||||
className="rounded border border-line px-2 py-0.5 hover:border-cinnabar hover:text-cinnabar disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
跳到下一条未裁决 ▾
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* B:讲清裁决保存模型——「采纳/裁决」仅本地暂存,验收时一并落库 */}
|
||||
<p className="rounded border border-dashed border-line/70 bg-bg/40 px-3 py-1.5 text-[11px] text-ink-soft">
|
||||
裁决先暂存在本地,点下方「验收本章」时与终稿一并保存落库(验收前刷新会丢失未保存的裁决)。
|
||||
<div className="space-y-3">
|
||||
<ReviewSectionPanel
|
||||
title="一致性"
|
||||
subtitle="continuity"
|
||||
statusLabel={
|
||||
reviewing
|
||||
? "审稿中"
|
||||
: conflictCount > 0
|
||||
? `${conflictCount} 冲突`
|
||||
: hasReport
|
||||
? "通过"
|
||||
: "待审"
|
||||
}
|
||||
statusVariant={
|
||||
reviewing
|
||||
? "info"
|
||||
: conflictCount > 0
|
||||
? "danger"
|
||||
: hasReport
|
||||
? "success"
|
||||
: "neutral"
|
||||
}
|
||||
defaultOpen={conflictCount > 0 || !hasReport}
|
||||
>
|
||||
<SectionStatusLine
|
||||
reviewing={reviewing}
|
||||
done={review.state.phase === "done"}
|
||||
conflictCount={conflictCount}
|
||||
sections={review.state.sections}
|
||||
/>
|
||||
{conflicts.length === 0 ? (
|
||||
<p className="mt-2 rounded border border-dashed border-line p-4 text-xs text-ink-soft">
|
||||
{review.state.phase === "done" || seededConflicts.length === 0
|
||||
? "未发现一致性冲突。"
|
||||
: "进页未带审稿留痕,点「重新审稿」开始。"}
|
||||
</p>
|
||||
) : (
|
||||
<div className="mt-3 space-y-4">
|
||||
<p className="rounded border border-dashed border-line/70 bg-bg/40 px-3 py-1.5 text-[11px] text-ink-soft">
|
||||
裁决先暂存在本地,点下方「验收本章」时与终稿一并保存落库。
|
||||
</p>
|
||||
|
||||
{groups.map((group) => (
|
||||
<div key={group.type}>
|
||||
<h3 className="mb-2 flex items-center gap-2 text-xs font-semibold text-ink">
|
||||
{group.type}
|
||||
<span className="rounded-full bg-[var(--color-conflict)]/10 px-2 py-0.5 font-mono text-conflict">
|
||||
{group.items.length}
|
||||
</span>
|
||||
</h3>
|
||||
<ul className="space-y-3">
|
||||
{group.items.map(({ conflict: c, index: i }) => (
|
||||
<ConflictCard
|
||||
key={i}
|
||||
index={i}
|
||||
conflict={c}
|
||||
draft={drafts[i] ?? { verdict: null, note: "" }}
|
||||
missing={missing.has(i)}
|
||||
snippet={conflictSnippet(finalText, c.where)}
|
||||
locatable={locatable.has(i)}
|
||||
rewriting={rewriting.has(i)}
|
||||
onVerdict={onVerdict}
|
||||
onNote={onNote}
|
||||
onJump={focusRegion}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
{groups.map((group) => {
|
||||
const unresolvedInGroup = group.items.filter(
|
||||
({ index }) => !drafts[index]?.verdict,
|
||||
).length;
|
||||
return (
|
||||
<details
|
||||
key={group.type}
|
||||
open={unresolvedInGroup > 0}
|
||||
className="rounded border border-line/70 bg-bg/35 p-2"
|
||||
>
|
||||
<summary className="flex cursor-pointer list-none items-center justify-between gap-2 text-xs font-semibold text-ink">
|
||||
<span className="flex items-center gap-2">
|
||||
{group.type}
|
||||
<Badge
|
||||
variant={
|
||||
unresolvedInGroup > 0 ? "danger" : "success"
|
||||
}
|
||||
>
|
||||
{group.items.length}
|
||||
</Badge>
|
||||
</span>
|
||||
<span className="font-mono text-[11px] text-ink-soft">
|
||||
{unresolvedInGroup > 0
|
||||
? `${unresolvedInGroup} 未裁决`
|
||||
: "已处理"}
|
||||
</span>
|
||||
</summary>
|
||||
<ul className="mt-3 space-y-3">
|
||||
{group.items.map(({ conflict: c, index: i }) => (
|
||||
<ConflictCard
|
||||
key={i}
|
||||
index={i}
|
||||
total={conflictCount}
|
||||
conflict={c}
|
||||
draft={drafts[i] ?? { verdict: null, note: "" }}
|
||||
missing={missing.has(i)}
|
||||
snippet={conflictSnippet(finalText, c.where)}
|
||||
locatable={locatable.has(i)}
|
||||
rewriting={rewriting.has(i)}
|
||||
onVerdict={onVerdict}
|
||||
onNote={onNote}
|
||||
onJump={focusRegion}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</ReviewSectionPanel>
|
||||
|
||||
<div className="mt-4 border-t border-line pt-4">
|
||||
<ReviewSectionPanel
|
||||
title="伏笔"
|
||||
subtitle="foreshadow-analyst"
|
||||
statusLabel={
|
||||
sectionStatus("foreshadow") === "incomplete"
|
||||
? "未完成"
|
||||
: foreshadow.length > 0
|
||||
? `${foreshadow.length} 建议`
|
||||
: "无建议"
|
||||
}
|
||||
statusVariant={
|
||||
sectionStatus("foreshadow") === "incomplete"
|
||||
? "warning"
|
||||
: foreshadow.length > 0
|
||||
? "accent"
|
||||
: "success"
|
||||
}
|
||||
defaultOpen={foreshadow.length > 0}
|
||||
>
|
||||
<ForeshadowSuggestions
|
||||
suggestions={foreshadow}
|
||||
incomplete={sectionStatus("foreshadow") === "incomplete"}
|
||||
projectId={project.id}
|
||||
chapterNo={chapterNo}
|
||||
/>
|
||||
</ReviewSectionPanel>
|
||||
<ReviewSectionPanel
|
||||
title="节奏"
|
||||
subtitle="pace-checker"
|
||||
statusLabel={
|
||||
sectionStatus("pace") === "incomplete"
|
||||
? "未完成"
|
||||
: pace
|
||||
? "已完成"
|
||||
: "无报告"
|
||||
}
|
||||
statusVariant={
|
||||
sectionStatus("pace") === "incomplete"
|
||||
? "warning"
|
||||
: pace
|
||||
? "info"
|
||||
: "neutral"
|
||||
}
|
||||
defaultOpen={pace !== null}
|
||||
>
|
||||
<PacePanel
|
||||
pace={pace}
|
||||
incomplete={sectionStatus("pace") === "incomplete"}
|
||||
/>
|
||||
</ReviewSectionPanel>
|
||||
<ReviewSectionPanel
|
||||
title="文风"
|
||||
subtitle="style"
|
||||
statusLabel={
|
||||
sectionStatus("style") === "incomplete"
|
||||
? "未完成"
|
||||
: style
|
||||
? "已完成"
|
||||
: "无报告"
|
||||
}
|
||||
statusVariant={
|
||||
sectionStatus("style") === "incomplete"
|
||||
? "warning"
|
||||
: style
|
||||
? "info"
|
||||
: "neutral"
|
||||
}
|
||||
defaultOpen={style !== null}
|
||||
>
|
||||
<StylePanel
|
||||
style={style}
|
||||
incomplete={sectionStatus("style") === "incomplete"}
|
||||
onRefine={onRefineSegment}
|
||||
/>
|
||||
</ReviewSectionPanel>
|
||||
{refineText !== null ? (
|
||||
<RefineView
|
||||
projectId={project.id}
|
||||
@@ -613,9 +710,15 @@ function SectionStatusLine({
|
||||
return (
|
||||
<p className="mt-1 text-xs">
|
||||
{conflictCount > 0 ? (
|
||||
<span className="text-conflict">⚠ {conflictCount} 冲突</span>
|
||||
<Badge variant="danger">
|
||||
<AlertTriangle className="h-3 w-3" aria-hidden="true" />
|
||||
{conflictCount} 冲突
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-pass">✓ 通过</span>
|
||||
<Badge variant="success">
|
||||
<CheckCircle2 className="h-3 w-3" aria-hidden="true" />
|
||||
通过
|
||||
</Badge>
|
||||
)}
|
||||
</p>
|
||||
);
|
||||
|
||||
54
apps/web/components/review/ReviewSectionPanel.tsx
Normal file
54
apps/web/components/review/ReviewSectionPanel.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { cn, type BadgeVariant } from "@/lib/ui/variants";
|
||||
|
||||
interface ReviewSectionPanelProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
statusLabel: string;
|
||||
statusVariant: BadgeVariant;
|
||||
children: ReactNode;
|
||||
defaultOpen?: boolean;
|
||||
}
|
||||
|
||||
export function ReviewSectionPanel({
|
||||
title,
|
||||
subtitle,
|
||||
statusLabel,
|
||||
statusVariant,
|
||||
children,
|
||||
defaultOpen = false,
|
||||
}: ReviewSectionPanelProps) {
|
||||
return (
|
||||
<details
|
||||
className="group rounded border border-line bg-bg/45"
|
||||
open={defaultOpen}
|
||||
>
|
||||
<summary className="flex cursor-pointer list-none items-center justify-between gap-3 px-3 py-2">
|
||||
<span className="min-w-0">
|
||||
<span className="block font-serif text-sm text-ink">{title}</span>
|
||||
{subtitle ? (
|
||||
<span className="block truncate text-xs text-ink-soft">
|
||||
{subtitle}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-2">
|
||||
<Badge variant={statusVariant}>{statusLabel}</Badge>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-4 w-4 text-ink-soft transition-transform",
|
||||
"group-open:rotate-180",
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
</summary>
|
||||
<div className="border-t border-line px-3 py-3">{children}</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
65
apps/web/components/review/ReviewSummaryRail.tsx
Normal file
65
apps/web/components/review/ReviewSummaryRail.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { AlertTriangle, CheckCircle2, CircleDashed } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
|
||||
interface ReviewSummaryRailProps {
|
||||
conflictCount: number;
|
||||
unresolvedCount: number;
|
||||
reviewing: boolean;
|
||||
hasReport: boolean;
|
||||
onNextUnresolved: () => void;
|
||||
}
|
||||
|
||||
export function ReviewSummaryRail({
|
||||
conflictCount,
|
||||
unresolvedCount,
|
||||
reviewing,
|
||||
hasReport,
|
||||
onNextUnresolved,
|
||||
}: ReviewSummaryRailProps) {
|
||||
return (
|
||||
<section className="sticky top-0 z-10 -mx-4 mb-4 border-b border-line bg-panel px-4 pb-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="font-serif text-base text-ink">审稿摘要</h2>
|
||||
<p className="mt-1 text-xs text-ink-soft">
|
||||
{reviewing
|
||||
? "四审正在更新"
|
||||
: hasReport
|
||||
? "先处理未裁决冲突,再验收本章"
|
||||
: "暂无审稿留痕,可先重新审稿"}
|
||||
</p>
|
||||
</div>
|
||||
{reviewing ? (
|
||||
<Badge variant="info">
|
||||
<CircleDashed className="h-3 w-3" aria-hidden="true" />
|
||||
审稿中
|
||||
</Badge>
|
||||
) : unresolvedCount > 0 ? (
|
||||
<Badge variant="danger">
|
||||
<AlertTriangle className="h-3 w-3" aria-hidden="true" />
|
||||
{unresolvedCount}/{conflictCount} 待裁决
|
||||
</Badge>
|
||||
) : conflictCount > 0 ? (
|
||||
<Badge variant="success">
|
||||
<CheckCircle2 className="h-3 w-3" aria-hidden="true" />
|
||||
已裁决
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant={hasReport ? "success" : "neutral"}>
|
||||
{hasReport ? "无冲突" : "未审稿"}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
onClick={onNextUnresolved}
|
||||
disabled={unresolvedCount === 0}
|
||||
variant="secondary"
|
||||
className="mt-3 w-full"
|
||||
>
|
||||
跳到下一条未裁决
|
||||
</Button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
import { Field } from "@/components/ui/Field";
|
||||
import { PageHeader } from "@/components/ui/PageHeader";
|
||||
import { Select } from "@/components/ui/Select";
|
||||
import { TextArea } from "@/components/ui/TextArea";
|
||||
import type { ProjectResponse, RuleView } from "@/lib/api/types";
|
||||
import {
|
||||
RULE_LEVELS,
|
||||
@@ -38,41 +45,48 @@ export function RulesPage({ project, initialRules }: RulesPageProps) {
|
||||
activeNav="rules"
|
||||
>
|
||||
<div className="mx-auto flex max-w-3xl flex-col gap-6 p-6">
|
||||
<PageHeader
|
||||
title="规则"
|
||||
description="维护本作、题材、世界观和章节级硬约束,写作与审稿会共同引用这些规则。"
|
||||
/>
|
||||
<form
|
||||
onSubmit={onSubmit}
|
||||
className="rounded border border-line bg-panel p-4"
|
||||
>
|
||||
<h1 className="mb-3 font-serif text-lg text-ink">新增规则</h1>
|
||||
<h2 className="mb-3 font-serif text-lg text-ink">新增规则</h2>
|
||||
<div className="mb-3 flex items-end gap-3">
|
||||
<label className="text-sm text-ink-soft">
|
||||
级别
|
||||
<select
|
||||
<Field label="级别" htmlFor="rule-level">
|
||||
<Select
|
||||
id="rule-level"
|
||||
value={level}
|
||||
onChange={(e) => setLevel(e.target.value as RuleLevel)}
|
||||
className="mt-1 block rounded border border-line bg-bg px-2 py-1.5 text-sm text-ink"
|
||||
>
|
||||
{RULE_LEVELS.map((lv) => (
|
||||
<option key={lv} value={lv}>
|
||||
{RULE_LEVEL_LABELS[lv]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder="如:本作禁用现代科技词汇;称呼一律用「道友」"
|
||||
rows={3}
|
||||
className="w-full resize-y rounded border border-line bg-bg px-3 py-2 text-sm text-ink"
|
||||
/>
|
||||
<button
|
||||
<Field label="规则内容" htmlFor="rule-content">
|
||||
<TextArea
|
||||
id="rule-content"
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder="如:本作禁用现代科技词汇;称呼一律用「道友」"
|
||||
rows={3}
|
||||
/>
|
||||
</Field>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={busy || content.trim().length === 0}
|
||||
className="mt-3 rounded bg-cinnabar px-4 py-2 text-sm text-white disabled:opacity-50"
|
||||
className="mt-3"
|
||||
variant="primary"
|
||||
>
|
||||
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||
{busy ? "保存中…" : "新增规则"}
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
@@ -85,7 +99,22 @@ export function RulesPage({ project, initialRules }: RulesPageProps) {
|
||||
</span>
|
||||
</h2>
|
||||
{groups[lv].length === 0 ? (
|
||||
<p className="text-xs text-ink-soft">(暂无)</p>
|
||||
<EmptyState
|
||||
icon={Plus}
|
||||
title="暂无规则"
|
||||
description={`还没有${RULE_LEVEL_LABELS[lv]}规则。`}
|
||||
action={
|
||||
<Button
|
||||
onClick={() => setLevel(lv)}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||
添加{RULE_LEVEL_LABELS[lv]}规则
|
||||
</Button>
|
||||
}
|
||||
className="px-4 py-6"
|
||||
/>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{groups[lv].map((rule) => (
|
||||
@@ -101,8 +130,9 @@ export function RulesPage({ project, initialRules }: RulesPageProps) {
|
||||
disabled={busy}
|
||||
onClick={() => void remove(project.id, rule.id)}
|
||||
aria-label="删除规则"
|
||||
className="shrink-0 text-xs text-ink-soft hover:text-cinnabar disabled:opacity-50"
|
||||
className="inline-flex shrink-0 items-center gap-1 rounded border border-transparent px-2 py-1 text-xs text-ink-soft transition-colors hover:border-conflict/25 hover:bg-conflict/10 hover:text-conflict disabled:opacity-50"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" aria-hidden="true" />
|
||||
删除
|
||||
</button>
|
||||
</li>
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { ExternalLink, Link2Off, PlugZap } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { SectionHeader } from "@/components/ui/SectionHeader";
|
||||
import { StatusNote } from "@/components/ui/StatusNote";
|
||||
import { authOpenUrl, formatExpiresAt } from "@/lib/settings/kimiOauth";
|
||||
import { useKimiOauth } from "@/lib/settings/useKimiOauth";
|
||||
|
||||
@@ -19,17 +24,22 @@ export function KimiCodeOauth({
|
||||
const expiresLabel = formatExpiresAt(expiresAt);
|
||||
|
||||
return (
|
||||
<section className="mb-10">
|
||||
<h2 className="mb-1 font-serif text-xl text-ink">Kimi Code(OAuth)</h2>
|
||||
<p className="mb-3 text-sm text-ink-soft">
|
||||
订阅 plan 经 OAuth 设备授权连接(无需 API Key)。连接后可在上方档位路由里选用
|
||||
<span className="font-mono"> kimi-code · kimi-for-coding</span>。
|
||||
</p>
|
||||
<section>
|
||||
<SectionHeader
|
||||
title="Kimi Code(OAuth)"
|
||||
description={
|
||||
<>
|
||||
订阅 plan 经 OAuth 设备授权连接(无需 API Key)。连接后可在上方档位路由里选用
|
||||
<span className="font-mono"> kimi-code · kimi-for-coding</span>。
|
||||
</>
|
||||
}
|
||||
className="mb-3"
|
||||
/>
|
||||
|
||||
<div className="rounded border border-line bg-panel p-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span
|
||||
className={`h-2 w-2 rounded-full ${
|
||||
className={`h-2 w-2 rounded ${
|
||||
phase === "connected" ? "bg-pass" : "bg-line"
|
||||
}`}
|
||||
aria-hidden="true"
|
||||
@@ -45,27 +55,29 @@ export function KimiCodeOauth({
|
||||
|
||||
<div className="ml-auto flex gap-2">
|
||||
{phase === "connected" ? (
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
onClick={() => void oauth.disconnect()}
|
||||
disabled={busy}
|
||||
className="rounded border border-line px-3 py-1.5 text-sm text-ink disabled:opacity-40"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
<Link2Off className="h-4 w-4" aria-hidden="true" />
|
||||
{busy ? "处理中…" : "断开"}
|
||||
</button>
|
||||
</Button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
onClick={() => void oauth.connect()}
|
||||
disabled={busy}
|
||||
className="rounded bg-cinnabar px-3 py-1.5 text-sm text-panel disabled:opacity-40"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
>
|
||||
<PlugZap className="h-4 w-4" aria-hidden="true" />
|
||||
{busy
|
||||
? "等待授权…"
|
||||
: phase === "error"
|
||||
? "重新连接 Kimi Code(OAuth)"
|
||||
: "连接 Kimi Code(OAuth)"}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -77,10 +89,16 @@ export function KimiCodeOauth({
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{phase !== "awaiting" ? (
|
||||
<StatusNote className="mt-3" variant="info">
|
||||
OAuth 连接只保存加密后的访问凭据;模型选择仍在“档位路由”里统一配置。
|
||||
</StatusNote>
|
||||
) : null}
|
||||
|
||||
{phase === "error" && error ? (
|
||||
<p className="mt-3 text-sm text-conflict" role="alert">
|
||||
<StatusNote className="mt-3" variant="danger" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
</StatusNote>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
@@ -102,23 +120,22 @@ function DeviceInstructions({ userCode, openUrl }: DeviceInstructionsProps) {
|
||||
|
||||
return (
|
||||
<div className="motion-safe:transition-opacity mt-4 rounded border border-dashed border-line bg-bg p-4">
|
||||
<p className="mb-2 text-sm text-ink-soft">
|
||||
请在浏览器中打开授权页,并确认页面显示的设备码与下方一致:
|
||||
</p>
|
||||
<output
|
||||
aria-label="设备授权码"
|
||||
tabIndex={0}
|
||||
className="mb-3 block select-all rounded bg-panel px-4 py-3 text-center font-mono text-2xl tracking-[0.3em] text-ink"
|
||||
>
|
||||
{userCode}
|
||||
</output>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openAuth}
|
||||
className="rounded border border-cinnabar px-3 py-1.5 text-sm text-cinnabar"
|
||||
>
|
||||
在浏览器中打开授权页
|
||||
</button>
|
||||
<StatusNote className="mb-3" variant="info">
|
||||
请在浏览器中打开授权页,并确认页面显示的设备码与下方一致。
|
||||
</StatusNote>
|
||||
<div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center">
|
||||
<output
|
||||
aria-label="设备授权码"
|
||||
tabIndex={0}
|
||||
className="block select-all rounded bg-panel px-4 py-3 text-center font-mono text-2xl tracking-[0.3em] text-ink"
|
||||
>
|
||||
{userCode}
|
||||
</output>
|
||||
<Button onClick={openAuth} variant="outline" size="sm">
|
||||
<ExternalLink className="h-4 w-4" aria-hidden="true" />
|
||||
打开授权页
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-ink-soft">
|
||||
授权完成后本页会自动检测连接状态(设备码过期后请重新连接)。
|
||||
</p>
|
||||
|
||||
@@ -1,10 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
CheckCircle2,
|
||||
KeyRound,
|
||||
PlugZap,
|
||||
Route,
|
||||
Save,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { useState, type ReactNode } from "react";
|
||||
|
||||
import { api } from "@/lib/api/client";
|
||||
import { useToast } from "@/components/Toast";
|
||||
import { KimiCodeOauth } from "@/components/settings/KimiCodeOauth";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
import { SectionHeader } from "@/components/ui/SectionHeader";
|
||||
import { SegmentedControl } from "@/components/ui/SegmentedControl";
|
||||
import { Select } from "@/components/ui/Select";
|
||||
import { StatusNote } from "@/components/ui/StatusNote";
|
||||
import { TextInput } from "@/components/ui/TextInput";
|
||||
import { api } from "@/lib/api/client";
|
||||
import {
|
||||
API_KEY_PROVIDERS,
|
||||
KNOWN_PROVIDERS,
|
||||
@@ -30,12 +46,22 @@ interface TestResult {
|
||||
capabilities: CapabilitiesView;
|
||||
}
|
||||
|
||||
type SettingsSection = "routing" | "oauth" | "keys";
|
||||
|
||||
const SECTION_OPTIONS: Array<{ value: SettingsSection; label: string }> = [
|
||||
{ value: "routing", label: "路由" },
|
||||
{ value: "oauth", label: "OAuth" },
|
||||
{ value: "keys", label: "API Key" },
|
||||
];
|
||||
|
||||
// 设置页主体(UX §6.10):档位路由(可编辑)+ API-key 凭据行 + Kimi Code OAuth 连接区。
|
||||
export function ProvidersSettings({
|
||||
initial,
|
||||
kimiOauth,
|
||||
}: ProvidersSettingsProps) {
|
||||
const toast = useToast();
|
||||
const [activeSection, setActiveSection] =
|
||||
useState<SettingsSection>("routing");
|
||||
const [providers, setProviders] = useState(initial.providers ?? []);
|
||||
const [routing, setRouting] = useState<RoutingDraft[]>(
|
||||
toRoutingDrafts(initial.tier_routing ?? []),
|
||||
@@ -116,146 +142,284 @@ export function ProvidersSettings({
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<section className="mb-10">
|
||||
<h2 className="mb-3 font-serif text-xl text-ink">能力档位路由</h2>
|
||||
<ul className="divide-y divide-line rounded border border-line bg-panel">
|
||||
{routing.map((row) => (
|
||||
<li
|
||||
key={row.tier}
|
||||
className="flex flex-wrap items-center gap-3 px-4 py-3 text-sm"
|
||||
>
|
||||
<span className="w-20 text-ink">
|
||||
{TIER_LABELS[row.tier] ?? row.tier}
|
||||
</span>
|
||||
<label className="sr-only" htmlFor={`route-provider-${row.tier}`}>
|
||||
{TIER_LABELS[row.tier] ?? row.tier} 提供商
|
||||
</label>
|
||||
<select
|
||||
id={`route-provider-${row.tier}`}
|
||||
value={row.provider}
|
||||
onChange={(e) => updateRouting(row.tier, e.target.value)}
|
||||
className="rounded border border-line bg-bg px-2 py-1.5 text-sm text-ink focus:border-cinnabar focus:outline-none"
|
||||
>
|
||||
<option value="">(未配置)</option>
|
||||
{KNOWN_PROVIDERS.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<label className="sr-only" htmlFor={`route-model-${row.tier}`}>
|
||||
{TIER_LABELS[row.tier] ?? row.tier} 模型
|
||||
</label>
|
||||
<input
|
||||
id={`route-model-${row.tier}`}
|
||||
value={row.model}
|
||||
onChange={(e) => updateRoutingModel(row.tier, e.target.value)}
|
||||
placeholder="model"
|
||||
className="min-w-[10rem] flex-1 rounded border border-line bg-bg px-3 py-1.5 font-mono text-sm text-ink focus:border-cinnabar focus:outline-none"
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="mt-3 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void saveRouting()}
|
||||
disabled={savingRouting}
|
||||
className="rounded bg-cinnabar px-3 py-1.5 text-sm text-panel disabled:opacity-40"
|
||||
>
|
||||
{savingRouting ? "保存中…" : "保存档位路由"}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
<div className="grid gap-6 lg:grid-cols-[12rem_1fr]">
|
||||
<aside className="hidden lg:block">
|
||||
<nav
|
||||
aria-label="设置分组"
|
||||
className="sticky top-4 rounded border border-line bg-panel p-2"
|
||||
>
|
||||
<SettingsNavButton
|
||||
active={activeSection === "routing"}
|
||||
icon={<Route className="h-4 w-4" aria-hidden="true" />}
|
||||
label="档位路由"
|
||||
detail={`${routing.filter((r) => r.provider && r.model).length}/3 已配置`}
|
||||
onClick={() => setActiveSection("routing")}
|
||||
/>
|
||||
<SettingsNavButton
|
||||
active={activeSection === "oauth"}
|
||||
icon={<PlugZap className="h-4 w-4" aria-hidden="true" />}
|
||||
label="OAuth"
|
||||
detail={kimiOauth.connected ? "Kimi 已连接" : "未连接"}
|
||||
onClick={() => setActiveSection("oauth")}
|
||||
/>
|
||||
<SettingsNavButton
|
||||
active={activeSection === "keys"}
|
||||
icon={<KeyRound className="h-4 w-4" aria-hidden="true" />}
|
||||
label="API Key"
|
||||
detail={`${providers.length} 个凭据`}
|
||||
onClick={() => setActiveSection("keys")}
|
||||
/>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<KimiCodeOauth
|
||||
initialConnected={kimiOauth.connected}
|
||||
initialExpiresAt={kimiOauth.expiresAt}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<SegmentedControl
|
||||
options={SECTION_OPTIONS}
|
||||
value={activeSection}
|
||||
onChange={setActiveSection}
|
||||
ariaLabel="设置分组"
|
||||
className="mb-4 w-full justify-center lg:hidden"
|
||||
/>
|
||||
|
||||
<section>
|
||||
<h2 className="mb-3 font-serif text-xl text-ink">提供商凭据</h2>
|
||||
{providers.length === 0 ? (
|
||||
<p className="mb-4 rounded border border-dashed border-line bg-panel p-4 text-sm text-ink-soft">
|
||||
至少连接一个提供商即可开始写作(求质量选 Anthropic / 求性价比选
|
||||
DeepSeek)。
|
||||
</p>
|
||||
) : null}
|
||||
<ul className="divide-y divide-line rounded border border-line bg-panel">
|
||||
{API_KEY_PROVIDERS.map((prov) => {
|
||||
const masked = maskedFor(prov.id);
|
||||
const result = results[prov.id];
|
||||
return (
|
||||
<li key={prov.id} className="px-4 py-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span
|
||||
className={`h-2 w-2 rounded-full ${masked ? "bg-pass" : "bg-line"}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="w-28 text-sm text-ink">{prov.label}</span>
|
||||
{masked ? (
|
||||
<span className="font-mono text-xs text-ink-soft">
|
||||
{masked}
|
||||
</span>
|
||||
) : null}
|
||||
<label className="sr-only" htmlFor={`key-${prov.id}`}>
|
||||
{prov.label} API Key
|
||||
{activeSection === "routing" ? (
|
||||
<SettingsPanel>
|
||||
<SectionHeader
|
||||
title="能力档位路由"
|
||||
description="写手、分析、轻量三类能力可分别指向不同模型。保存时只提交完整填写的行。"
|
||||
/>
|
||||
<StatusNote className="mt-3" variant="info">
|
||||
写章、审稿和摘要提炼会分别读取对应档位;未完整填写的行不会覆盖现有路由。
|
||||
</StatusNote>
|
||||
<ul className="mt-4 divide-y divide-line rounded border border-line bg-panel">
|
||||
{routing.map((row) => (
|
||||
<li
|
||||
key={row.tier}
|
||||
className="grid gap-3 px-4 py-3 text-sm md:grid-cols-[6rem_minmax(10rem,14rem)_1fr]"
|
||||
>
|
||||
<span className="self-center text-ink">
|
||||
{TIER_LABELS[row.tier] ?? row.tier}
|
||||
</span>
|
||||
<label
|
||||
className="sr-only"
|
||||
htmlFor={`route-provider-${row.tier}`}
|
||||
>
|
||||
{TIER_LABELS[row.tier] ?? row.tier} 提供商
|
||||
</label>
|
||||
<input
|
||||
id={`key-${prov.id}`}
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={drafts[prov.id] ?? ""}
|
||||
<Select
|
||||
id={`route-provider-${row.tier}`}
|
||||
value={row.provider}
|
||||
onChange={(e) => updateRouting(row.tier, e.target.value)}
|
||||
>
|
||||
<option value="">(未配置)</option>
|
||||
{KNOWN_PROVIDERS.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<label className="sr-only" htmlFor={`route-model-${row.tier}`}>
|
||||
{TIER_LABELS[row.tier] ?? row.tier} 模型
|
||||
</label>
|
||||
<TextInput
|
||||
id={`route-model-${row.tier}`}
|
||||
value={row.model}
|
||||
onChange={(e) =>
|
||||
setDrafts((prev) => ({
|
||||
...prev,
|
||||
[prov.id]: e.target.value,
|
||||
}))
|
||||
updateRoutingModel(row.tier, e.target.value)
|
||||
}
|
||||
placeholder={masked ? "输入新 Key 以更新" : "输入 API Key"}
|
||||
className="min-w-[12rem] flex-1 rounded border border-line bg-bg px-3 py-1.5 text-sm text-ink focus:border-cinnabar focus:outline-none"
|
||||
placeholder="model"
|
||||
className="font-mono"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => saveCredential(prov.id)}
|
||||
disabled={savingId === prov.id}
|
||||
className="rounded bg-cinnabar px-3 py-1.5 text-sm text-panel disabled:opacity-40"
|
||||
>
|
||||
{savingId === prov.id
|
||||
? "保存中…"
|
||||
: masked
|
||||
? "更新凭据"
|
||||
: "添加凭据"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => testConnection(prov.id)}
|
||||
disabled={testingId === prov.id}
|
||||
className="rounded border border-line px-3 py-1.5 text-sm text-ink disabled:opacity-40"
|
||||
>
|
||||
{testingId === prov.id ? "测试中…" : "测试连接"}
|
||||
</button>
|
||||
</div>
|
||||
{result ? (
|
||||
<div className="mt-2 flex items-center gap-2 pl-5">
|
||||
<span
|
||||
className={`text-xs ${result.ok ? "text-pass" : "text-conflict"}`}
|
||||
>
|
||||
{result.ok ? "✓ 已连接" : "✗ 未连接"}
|
||||
</span>
|
||||
<CapabilityBadges caps={result.capabilities} />
|
||||
</div>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="mt-3 flex justify-end">
|
||||
<Button
|
||||
onClick={() => void saveRouting()}
|
||||
disabled={savingRouting}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
>
|
||||
<Save className="h-4 w-4" aria-hidden="true" />
|
||||
{savingRouting ? "保存中…" : "保存档位路由"}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsPanel>
|
||||
) : null}
|
||||
|
||||
{activeSection === "oauth" ? (
|
||||
<SettingsPanel>
|
||||
<KimiCodeOauth
|
||||
initialConnected={kimiOauth.connected}
|
||||
initialExpiresAt={kimiOauth.expiresAt}
|
||||
/>
|
||||
</SettingsPanel>
|
||||
) : null}
|
||||
|
||||
{activeSection === "keys" ? (
|
||||
<SettingsPanel>
|
||||
<SectionHeader
|
||||
title="提供商凭据"
|
||||
description="API Key 只用于后端探活和调用,列表中只显示脱敏后的已保存凭据。"
|
||||
/>
|
||||
<div className="mt-3 grid gap-2 text-xs text-ink-soft sm:grid-cols-3">
|
||||
<div className="rounded border border-line bg-panel px-3 py-2">
|
||||
已保存 <span className="font-mono text-ink">{providers.length}</span>
|
||||
</div>
|
||||
<div className="rounded border border-line bg-panel px-3 py-2">
|
||||
可配置{" "}
|
||||
<span className="font-mono text-ink">
|
||||
{API_KEY_PROVIDERS.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="rounded border border-line bg-panel px-3 py-2">
|
||||
测试结果{" "}
|
||||
<span className="font-mono text-ink">
|
||||
{Object.keys(results).length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{providers.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={PlugZap}
|
||||
title="还没有可用提供商"
|
||||
description="至少连接一个提供商即可开始写作。求质量可选 Anthropic,求性价比可选 DeepSeek。"
|
||||
className="mt-4"
|
||||
/>
|
||||
) : null}
|
||||
<ul className="mt-4 divide-y divide-line rounded border border-line bg-panel">
|
||||
{API_KEY_PROVIDERS.map((prov) => {
|
||||
const masked = maskedFor(prov.id);
|
||||
const result = results[prov.id];
|
||||
return (
|
||||
<li key={prov.id} className="px-4 py-4">
|
||||
<div className="grid gap-3 lg:grid-cols-[10rem_8rem_minmax(12rem,1fr)_auto_auto] lg:items-center">
|
||||
<span className="flex items-center gap-2 text-sm text-ink">
|
||||
<span
|
||||
className={`h-2 w-2 rounded ${
|
||||
masked ? "bg-pass" : "bg-line"
|
||||
}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{prov.label}
|
||||
</span>
|
||||
<span className="font-mono text-xs text-ink-soft">
|
||||
{masked ?? "未配置"}
|
||||
</span>
|
||||
<label className="sr-only" htmlFor={`key-${prov.id}`}>
|
||||
{prov.label} API Key
|
||||
</label>
|
||||
<TextInput
|
||||
id={`key-${prov.id}`}
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={drafts[prov.id] ?? ""}
|
||||
onChange={(e) =>
|
||||
setDrafts((prev) => ({
|
||||
...prev,
|
||||
[prov.id]: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder={
|
||||
masked ? "输入新 Key 以更新" : "输入 API Key"
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => saveCredential(prov.id)}
|
||||
disabled={
|
||||
savingId === prov.id ||
|
||||
(drafts[prov.id] ?? "").trim().length === 0
|
||||
}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
>
|
||||
<Save className="h-4 w-4" aria-hidden="true" />
|
||||
{savingId === prov.id
|
||||
? "保存中…"
|
||||
: masked
|
||||
? "更新"
|
||||
: "添加"}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => testConnection(prov.id)}
|
||||
disabled={testingId === prov.id}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
<PlugZap className="h-4 w-4" aria-hidden="true" />
|
||||
{testingId === prov.id ? "测试中…" : "测试"}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-2 text-xs leading-5 text-ink-soft lg:pl-[10.5rem]">
|
||||
{masked
|
||||
? "已保存脱敏凭据;输入新 Key 可覆盖更新。"
|
||||
: "保存后再测试连接,成功后即可在档位路由中使用。"}
|
||||
</p>
|
||||
{result ? (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 lg:pl-[10.5rem]">
|
||||
<Badge variant={result.ok ? "success" : "danger"}>
|
||||
{result.ok ? (
|
||||
<CheckCircle2
|
||||
className="h-3 w-3"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
<XCircle className="h-3 w-3" aria-hidden="true" />
|
||||
)}
|
||||
{result.ok ? "已连接" : "未连接"}
|
||||
</Badge>
|
||||
<CapabilityBadges caps={result.capabilities} />
|
||||
</div>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</SettingsPanel>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsPanel({ children }: { children: ReactNode }) {
|
||||
return <section className="min-w-0">{children}</section>;
|
||||
}
|
||||
|
||||
interface SettingsNavButtonProps {
|
||||
active: boolean;
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
detail: string;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
function SettingsNavButton({
|
||||
active,
|
||||
icon,
|
||||
label,
|
||||
detail,
|
||||
onClick,
|
||||
}: SettingsNavButtonProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
onClick={onClick}
|
||||
className={`mb-1 flex w-full items-start gap-2 rounded px-3 py-2 text-left transition-colors ${
|
||||
active
|
||||
? "bg-[var(--color-cinnabar-wash)] text-cinnabar"
|
||||
: "text-ink hover:bg-bg hover:text-cinnabar"
|
||||
}`}
|
||||
>
|
||||
<span className="mt-0.5 shrink-0">{icon}</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-medium">{label}</span>
|
||||
<span className="block truncate text-xs text-ink-soft">{detail}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function CapabilityBadges({ caps }: { caps: CapabilitiesView }) {
|
||||
const badges: { on: boolean; label: string }[] = [
|
||||
{ on: caps.structured_output, label: "结构化" },
|
||||
@@ -267,10 +431,10 @@ function CapabilityBadges({ caps }: { caps: CapabilitiesView }) {
|
||||
{badges.map((b) => (
|
||||
<span
|
||||
key={b.label}
|
||||
className={`rounded px-2 py-0.5 text-[11px] ${
|
||||
className={`rounded border px-2 py-0.5 text-[11px] ${
|
||||
b.on
|
||||
? "bg-[var(--color-cinnabar-wash)] text-cinnabar"
|
||||
: "bg-bg text-ink-soft/50"
|
||||
? "border-cinnabar/20 bg-[var(--color-cinnabar-wash)] text-cinnabar"
|
||||
: "border-line bg-bg text-ink-soft/50"
|
||||
}`}
|
||||
>
|
||||
{b.label}
|
||||
|
||||
@@ -1,6 +1,20 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { Blocks, Database, Pencil, ShieldCheck } from "lucide-react";
|
||||
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
import { PageHeader } from "@/components/ui/PageHeader";
|
||||
import { SectionHeader } from "@/components/ui/SectionHeader";
|
||||
import { StatusNote } from "@/components/ui/StatusNote";
|
||||
import type { ProjectResponse, SkillView } from "@/lib/api/types";
|
||||
import { groupByScope, scopeLabel, tierLabel } from "@/lib/skills/skills";
|
||||
import {
|
||||
groupByScope,
|
||||
scopeLabel,
|
||||
summarizeSkills,
|
||||
tierLabel,
|
||||
} from "@/lib/skills/skills";
|
||||
|
||||
interface SkillsPageProps {
|
||||
project: ProjectResponse;
|
||||
@@ -10,6 +24,7 @@ interface SkillsPageProps {
|
||||
// 技能库(UX §7):只读注册表视图(name/scope/tier/reads/writes)。Server Component(纯读)。
|
||||
export function SkillsPage({ project, skills }: SkillsPageProps) {
|
||||
const groups = groupByScope(skills);
|
||||
const summary = summarizeSkills(skills);
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
@@ -18,60 +33,161 @@ export function SkillsPage({ project, skills }: SkillsPageProps) {
|
||||
projectId={project.id}
|
||||
activeNav="skills"
|
||||
>
|
||||
<div className="mx-auto flex max-w-4xl flex-col gap-6 p-6">
|
||||
<h1 className="font-serif text-lg text-ink">技能库</h1>
|
||||
<p className="text-xs text-ink-soft">
|
||||
声明式 Skill 注册表:每个技能声明能力档位与可读/写的表(沙箱白名单,越权产出丢弃并审计)。
|
||||
</p>
|
||||
{groups.length === 0 ? (
|
||||
<p className="text-sm text-ink-soft">(暂无已注册技能)</p>
|
||||
) : (
|
||||
groups.map((group) => (
|
||||
<section key={group.scope}>
|
||||
<h2 className="mb-2 font-serif text-sm text-ink">
|
||||
{scopeLabel(group.scope)}
|
||||
<span className="ml-2 text-xs text-ink-soft">
|
||||
{group.skills.length}
|
||||
</span>
|
||||
</h2>
|
||||
<ul className="flex flex-col gap-2">
|
||||
{group.skills.map((skill) => (
|
||||
<li
|
||||
key={skill.name}
|
||||
className="rounded border border-line bg-panel p-3 text-sm"
|
||||
>
|
||||
<div className="mb-1 flex flex-wrap items-center gap-2">
|
||||
<span className="font-mono text-ink">{skill.name}</span>
|
||||
<span className="rounded bg-bg px-2 py-0.5 text-xs text-ink-soft">
|
||||
{tierLabel(skill.tier)}
|
||||
<div className="mx-auto flex max-w-6xl flex-col gap-6 p-6">
|
||||
<PageHeader
|
||||
title="技能库"
|
||||
description="声明式 Skill 注册表:每个技能声明能力档位与可读/写的表,越权产出会被丢弃并审计。"
|
||||
/>
|
||||
<div className="grid gap-6 lg:grid-cols-[18rem_minmax(0,1fr)]">
|
||||
<aside className="flex flex-col gap-4 lg:sticky lg:top-[8rem] lg:self-start">
|
||||
<Card as="section" className="p-4">
|
||||
<SectionHeader
|
||||
title="注册表概览"
|
||||
description="按来源、档位和表权限汇总当前可用技能。"
|
||||
/>
|
||||
<dl className="mt-4 grid grid-cols-3 gap-2 text-center">
|
||||
<div className="rounded border border-line bg-paper p-3">
|
||||
<dt className="text-xs text-ink-soft">总数</dt>
|
||||
<dd className="mt-1 font-serif text-2xl text-ink">
|
||||
{summary.total}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="rounded border border-line bg-paper p-3">
|
||||
<dt className="text-xs text-ink-soft">可写</dt>
|
||||
<dd className="mt-1 font-serif text-2xl text-ink">
|
||||
{summary.writableCount}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="rounded border border-line bg-paper p-3">
|
||||
<dt className="text-xs text-ink-soft">只读</dt>
|
||||
<dd className="mt-1 font-serif text-2xl text-ink">
|
||||
{summary.readonlyCount}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{summary.scopes.length > 0 ? (
|
||||
summary.scopes.map((item) => (
|
||||
<Badge key={item.key} variant="info">
|
||||
{scopeLabel(item.key)} {item.count}
|
||||
</Badge>
|
||||
))
|
||||
) : (
|
||||
<span className="text-xs text-ink-soft">暂无来源</span>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card as="section" className="p-4">
|
||||
<SectionHeader title="能力档位" />
|
||||
{summary.tiers.length > 0 ? (
|
||||
<ul className="mt-3 flex flex-col gap-2 text-sm">
|
||||
{summary.tiers.map((item) => (
|
||||
<li
|
||||
key={item.key}
|
||||
className="flex items-center justify-between border-b border-line/70 py-2 last:border-b-0"
|
||||
>
|
||||
<span className="text-ink">{tierLabel(item.key)}</span>
|
||||
<span className="font-mono text-xs text-ink-soft">
|
||||
{item.count}
|
||||
</span>
|
||||
{skill.genre ? (
|
||||
<span className="rounded bg-bg px-2 py-0.5 text-xs text-ink-soft">
|
||||
{skill.genre}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-4 text-xs text-ink-soft">
|
||||
<span>
|
||||
读:
|
||||
{skill.reads && skill.reads.length > 0
|
||||
? skill.reads.join("、")
|
||||
: "—"}
|
||||
</span>
|
||||
<span>
|
||||
写:
|
||||
{skill.writes && skill.writes.length > 0
|
||||
? skill.writes.join("、")
|
||||
: "—"}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="mt-3 text-sm text-ink-soft">暂无档位</p>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<StatusNote title="写入边界" variant="info">
|
||||
<p className="text-sm leading-6">
|
||||
可写表:{formatTables(summary.writableTables)}
|
||||
</p>
|
||||
<p className="mt-1 text-xs leading-5 text-ink-soft">
|
||||
技能产出仍会经过表权限白名单;审稿与验收相关写入继续由人工确认流程收口。
|
||||
</p>
|
||||
</StatusNote>
|
||||
</aside>
|
||||
|
||||
<div className="flex min-w-0 flex-col gap-5">
|
||||
{groups.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Blocks}
|
||||
title="暂无已注册技能"
|
||||
description="技能注册后会在这里按作用域展示能力档位、可读表与可写表。"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{groups.map((group) => (
|
||||
<section key={group.scope}>
|
||||
<SectionHeader
|
||||
title={scopeLabel(group.scope)}
|
||||
description={`${group.skills.length} 个技能`}
|
||||
/>
|
||||
<ul className="mt-3 grid gap-3">
|
||||
{group.skills.map((skill) => (
|
||||
<SkillListItem key={skill.name} skill={skill} />
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
function SkillListItem({ skill }: { skill: SkillView }) {
|
||||
return (
|
||||
<li className="rounded border border-line bg-panel p-4 text-sm shadow-paper">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="break-all font-mono text-ink">{skill.name}</span>
|
||||
<Badge variant="info">{tierLabel(skill.tier)}</Badge>
|
||||
{skill.genre ? <Badge>{skill.genre}</Badge> : null}
|
||||
</div>
|
||||
<div className="mt-3 grid gap-2 text-xs text-ink-soft md:grid-cols-2">
|
||||
<TableLine
|
||||
icon={Database}
|
||||
label="读"
|
||||
tables={skill.reads ?? []}
|
||||
/>
|
||||
<TableLine icon={Pencil} label="写" tables={skill.writes ?? []} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2 text-xs text-ink-soft">
|
||||
<ShieldCheck className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
<span>
|
||||
{skill.writes && skill.writes.length > 0 ? "受控写入" : "只读"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function TableLine({
|
||||
icon: Icon,
|
||||
label,
|
||||
tables,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
tables: readonly string[];
|
||||
}) {
|
||||
return (
|
||||
<span className="flex min-w-0 items-start gap-1.5">
|
||||
<Icon className="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden="true" />
|
||||
<span className="shrink-0">{label}:</span>
|
||||
<span className="min-w-0 break-words">{formatTables(tables)}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function formatTables(tables: readonly string[]): string {
|
||||
return tables.length > 0 ? tables.join("、") : "—";
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useRefine } from "@/lib/style/useRefine";
|
||||
import { CheckCircle2, X } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { useRefine } from "@/lib/style/useRefine";
|
||||
|
||||
interface RefineViewProps {
|
||||
projectId: string;
|
||||
@@ -46,15 +49,17 @@ export function RefineView({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-ink-soft hover:text-cinnabar"
|
||||
className="rounded border border-transparent p-1 text-ink-soft hover:border-line hover:text-cinnabar"
|
||||
aria-label="关闭回炉对比"
|
||||
>
|
||||
✕
|
||||
<X className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{segment.trim().length === 0 ? (
|
||||
<p className="text-overdue">该段在终稿中为空,请先在终稿中保留该段正文。</p>
|
||||
<Badge variant="warning">
|
||||
该段在终稿中为空,请先在终稿中保留该段正文。
|
||||
</Badge>
|
||||
) : refiner.status === "refining" ? (
|
||||
<p className="text-info">
|
||||
<ThinkingIndicator label="回炉中" />
|
||||
@@ -76,24 +81,21 @@ export function RefineView({
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (refiner.result) {
|
||||
onAdopt(refiner.result.original, refiner.result.refined);
|
||||
}
|
||||
}}
|
||||
className="rounded bg-cinnabar px-3 py-1.5 text-panel hover:opacity-90"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
>
|
||||
<CheckCircle2 className="h-4 w-4" aria-hidden="true" />
|
||||
采纳合入正文
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded border border-line px-3 py-1.5 text-ink hover:border-cinnabar"
|
||||
>
|
||||
</Button>
|
||||
<Button onClick={onClose} variant="secondary" size="sm">
|
||||
放弃
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { CheckCircle2, CircleDashed, RotateCcw } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import type { StyleDriftReport, StyleDriftSegment } from "@/lib/review/sse";
|
||||
|
||||
interface StylePanelProps {
|
||||
@@ -26,7 +30,10 @@ export function StylePanel({ style, incomplete, onRefine }: StylePanelProps) {
|
||||
文风 (style-auditor)
|
||||
</h2>
|
||||
{incomplete ? (
|
||||
<p className="mt-1 text-xs text-overdue">◌ 未完成</p>
|
||||
<Badge variant="warning" className="mt-1">
|
||||
<CircleDashed className="h-3 w-3" aria-hidden="true" />
|
||||
未完成
|
||||
</Badge>
|
||||
) : style === null ? (
|
||||
<p className="mt-1 text-xs text-ink-soft">
|
||||
无文风报告(学文风后第四审才能打分)。
|
||||
@@ -47,7 +54,10 @@ export function StylePanel({ style, incomplete, onRefine }: StylePanelProps) {
|
||||
</p>
|
||||
|
||||
{style.segments.length === 0 ? (
|
||||
<p className="text-pass">✓ 无明显文风漂移</p>
|
||||
<Badge variant="success">
|
||||
<CheckCircle2 className="h-3 w-3" aria-hidden="true" />
|
||||
无明显文风漂移
|
||||
</Badge>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{style.segments.map((seg, i) => (
|
||||
@@ -57,9 +67,9 @@ export function StylePanel({ style, incomplete, onRefine }: StylePanelProps) {
|
||||
data-testid="drift-segment"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-overdue">
|
||||
<Badge variant="warning" className="font-mono">
|
||||
第 {seg.idx} 段
|
||||
</span>
|
||||
</Badge>
|
||||
<span className="font-mono text-ink-soft">
|
||||
相似 {seg.score}%
|
||||
</span>
|
||||
@@ -68,13 +78,14 @@ export function StylePanel({ style, incomplete, onRefine }: StylePanelProps) {
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-1.5 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
onClick={() => onRefine(seg)}
|
||||
className="rounded bg-cinnabar px-2 py-1 text-panel hover:opacity-90"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" aria-hidden="true" />
|
||||
一键回炉
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -4,6 +4,10 @@ import { useCallback, useState, type ChangeEvent } from "react";
|
||||
|
||||
import { hasUsableSamples, type StyleLearnMode } from "@/lib/style/style";
|
||||
import { useToast } from "@/components/Toast";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Field } from "@/components/ui/Field";
|
||||
import { StatusNote } from "@/components/ui/StatusNote";
|
||||
import { TextArea } from "@/components/ui/TextArea";
|
||||
|
||||
interface StyleUploadProps {
|
||||
busy: boolean;
|
||||
@@ -61,21 +65,15 @@ export function StyleUpload({
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="style-samples"
|
||||
className="mb-1 block text-sm font-semibold text-ink"
|
||||
>
|
||||
样本正文(空行分段;可粘贴多段)
|
||||
</label>
|
||||
<textarea
|
||||
<Field label="样本正文(空行分段;可粘贴多段)" htmlFor="style-samples">
|
||||
<TextArea
|
||||
id="style-samples"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
placeholder="粘贴你想学习文风的章节正文…"
|
||||
className="min-h-[30vh] w-full resize-y rounded border border-line bg-panel p-3 font-serif text-[15px] leading-[1.9] text-ink focus:border-cinnabar focus:outline-none"
|
||||
className="min-h-[30vh] bg-panel font-serif text-[15px] leading-[1.9]"
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="cursor-pointer rounded border border-line px-3 py-1.5 text-sm text-ink hover:border-cinnabar hover:text-cinnabar">
|
||||
@@ -88,22 +86,17 @@ export function StyleUpload({
|
||||
className="sr-only"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
disabled={busy}
|
||||
className="rounded bg-cinnabar px-4 py-1.5 text-sm text-panel hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
<Button type="button" onClick={submit} disabled={busy} variant="primary">
|
||||
{hasFingerprint ? "重新学习文风" : "学习文风"}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{pollStatus === "polling" ? (
|
||||
<p className="text-xs text-info" aria-live="polite">
|
||||
<StatusNote variant="info" aria-live="polite">
|
||||
提取文风指纹中…({progress}%)
|
||||
</p>
|
||||
</StatusNote>
|
||||
) : pollStatus === "error" ? (
|
||||
<p className="text-xs text-conflict">学文风失败,请稍后重试。</p>
|
||||
<StatusNote variant="danger">学文风失败,请稍后重试。</StatusNote>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
import { useToast } from "@/components/Toast";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Field } from "@/components/ui/Field";
|
||||
import { SectionHeader } from "@/components/ui/SectionHeader";
|
||||
import { TextArea } from "@/components/ui/TextArea";
|
||||
import { TextInput } from "@/components/ui/TextInput";
|
||||
import { api } from "@/lib/api/client";
|
||||
import type { TemplateResponse } from "@/lib/api/types";
|
||||
import {
|
||||
@@ -89,56 +94,49 @@ export function TemplatesManager({ initial }: TemplatesManagerProps) {
|
||||
void onCreate();
|
||||
}}
|
||||
>
|
||||
<h2 className="font-serif text-lg text-ink">新建模板</h2>
|
||||
<label className="block text-sm text-ink-soft">
|
||||
标题 *
|
||||
<input
|
||||
<SectionHeader title="新建模板" />
|
||||
<Field label="标题" required>
|
||||
<TextInput
|
||||
type="text"
|
||||
value={draft.title}
|
||||
onChange={(e) => setField("title", e.target.value)}
|
||||
className="mt-1 w-full rounded border border-line bg-bg px-3 py-2 text-sm text-ink"
|
||||
aria-label="标题"
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm text-ink-soft">
|
||||
正文 *(一键填入生成器的 brief/原文)
|
||||
<textarea
|
||||
</Field>
|
||||
<Field label="正文" required help="一键填入生成器的 brief/原文。">
|
||||
<TextArea
|
||||
value={draft.body}
|
||||
onChange={(e) => setField("body", e.target.value)}
|
||||
rows={4}
|
||||
className="mt-1 w-full resize-y rounded border border-line bg-bg px-3 py-2 text-sm text-ink"
|
||||
aria-label="正文"
|
||||
/>
|
||||
</label>
|
||||
</Field>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<label className="block text-sm text-ink-soft">
|
||||
分类(可选)
|
||||
<input
|
||||
<Field label="分类(可选)" className="w-full sm:w-48">
|
||||
<TextInput
|
||||
type="text"
|
||||
value={draft.category}
|
||||
onChange={(e) => setField("category", e.target.value)}
|
||||
className="mt-1 w-48 rounded border border-line bg-bg px-3 py-2 text-sm text-ink"
|
||||
aria-label="分类"
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm text-ink-soft">
|
||||
关联生成器 key(可选)
|
||||
<input
|
||||
</Field>
|
||||
<Field label="关联生成器 key(可选)" className="w-full sm:w-48">
|
||||
<TextInput
|
||||
type="text"
|
||||
value={draft.toolKey}
|
||||
onChange={(e) => setField("toolKey", e.target.value)}
|
||||
className="mt-1 w-48 rounded border border-line bg-bg px-3 py-2 text-sm text-ink"
|
||||
aria-label="关联生成器 key"
|
||||
/>
|
||||
</label>
|
||||
</Field>
|
||||
</div>
|
||||
<button
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={creating || !isTemplateDraftValid(draft)}
|
||||
className="self-start rounded bg-cinnabar px-4 py-2 text-sm text-white disabled:opacity-50"
|
||||
variant="primary"
|
||||
className="self-start"
|
||||
>
|
||||
{creating ? "新建中…" : "新建模板"}
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<section className="flex flex-col gap-3" aria-label="模板列表">
|
||||
@@ -163,14 +161,16 @@ export function TemplatesManager({ initial }: TemplatesManagerProps) {
|
||||
{t.body}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void onDelete(t.id)}
|
||||
className="shrink-0 rounded border border-line px-3 py-1 text-ink-soft hover:text-conflict"
|
||||
variant="danger"
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
aria-label={`删除模板 ${t.title}`}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { ArrowLeft, Database, Sparkles } from "lucide-react";
|
||||
|
||||
import { useToast } from "@/components/Toast";
|
||||
import { ConflictAdjudication } from "@/components/generation/ConflictAdjudication";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { Field } from "@/components/ui/Field";
|
||||
import { TextArea } from "@/components/ui/TextArea";
|
||||
import { TextInput } from "@/components/ui/TextInput";
|
||||
import type { ToolDescriptorView, ToolInputFieldView } from "@/lib/api/types";
|
||||
import {
|
||||
buildGenerateRequest,
|
||||
@@ -115,25 +121,30 @@ export function GeneratorRunner({
|
||||
|
||||
return (
|
||||
<section
|
||||
className="flex flex-col gap-4 rounded border border-line bg-panel p-5"
|
||||
className="flex flex-col gap-5 rounded border border-line bg-panel p-5 shadow-paper"
|
||||
aria-label={tool.title}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="font-serif text-lg text-ink">{tool.title}</h2>
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||
<h2 className="font-serif text-lg text-ink">{tool.title}</h2>
|
||||
{tool.ingestable ? (
|
||||
<Badge variant="info">
|
||||
<Database className="h-3 w-3" aria-hidden="true" />
|
||||
可入库
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-ink-soft">{tool.subtitle}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded border border-line px-3 py-1 text-sm text-ink-soft hover:text-ink"
|
||||
>
|
||||
<Button onClick={onClose} variant="secondary" size="sm">
|
||||
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
|
||||
返回
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="flex flex-col gap-3"
|
||||
className="grid gap-3 rounded border border-line bg-bg/50 p-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void onGenerate();
|
||||
@@ -148,13 +159,10 @@ export function GeneratorRunner({
|
||||
onChange={(v) => setField(field.name, v)}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={generating}
|
||||
className="self-start rounded bg-cinnabar px-4 py-2 text-sm text-white disabled:opacity-50"
|
||||
>
|
||||
<Button type="submit" disabled={generating} variant="primary">
|
||||
<Sparkles className="h-4 w-4" aria-hidden="true" />
|
||||
{generating ? "生成中…" : "生成"}
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{gen.genStatus === "preview" && gen.preview ? (
|
||||
@@ -181,18 +189,18 @@ export function GeneratorRunner({
|
||||
</ul>
|
||||
|
||||
{canIngest && gen.ingestStatus !== "conflict" ? (
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
onClick={() => void runIngest(false)}
|
||||
disabled={ingesting || (!singleObject && selected.size === 0)}
|
||||
className="self-start rounded border border-cinnabar px-4 py-2 text-sm text-cinnabar disabled:opacity-50"
|
||||
variant="outline"
|
||||
>
|
||||
<Database className="h-4 w-4" aria-hidden="true" />
|
||||
{ingesting
|
||||
? "入库中…"
|
||||
: singleObject
|
||||
? `入库为规则至 ${table}`
|
||||
: `入库选中(${selected.size})至 ${table}`}
|
||||
</button>
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{gen.ingestStatus === "conflict" && gen.conflicts ? (
|
||||
@@ -223,33 +231,24 @@ interface FormFieldProps {
|
||||
|
||||
// 声明式控件映射:type → text / textarea / number(select 暂同 text,后端未给 options)。
|
||||
function FormField({ field, value, onChange }: FormFieldProps) {
|
||||
const labelText = field.required ? `${field.label} *` : field.label;
|
||||
const common =
|
||||
"mt-1 w-full rounded border border-line bg-bg px-3 py-2 text-sm text-ink";
|
||||
return (
|
||||
<label className="block text-sm text-ink-soft">
|
||||
{labelText}
|
||||
<Field label={field.label} help={field.help} required={field.required}>
|
||||
{field.type === "textarea" ? (
|
||||
<textarea
|
||||
<TextArea
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
rows={3}
|
||||
className={`${common} resize-y`}
|
||||
aria-label={field.label}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
<TextInput
|
||||
type={field.type === "number" ? "number" : "text"}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className={common}
|
||||
aria-label={field.label}
|
||||
/>
|
||||
)}
|
||||
{field.help ? (
|
||||
<span className="mt-1 block text-xs text-ink-soft/80">{field.help}</span>
|
||||
) : null}
|
||||
</label>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowRight, Database, Sparkles } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import type { ToolDescriptorView } from "@/lib/api/types";
|
||||
import { cardClass } from "@/lib/ui/variants";
|
||||
|
||||
interface ToolCardProps {
|
||||
tool: ToolDescriptorView;
|
||||
@@ -15,27 +19,32 @@ export function ToolCard({ tool, onOpen }: ToolCardProps) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen(tool)}
|
||||
className="flex h-full min-h-[140px] flex-col rounded border border-line bg-panel p-5 text-left shadow-paper transition-colors hover:border-cinnabar focus-visible:border-cinnabar focus-visible:outline-none"
|
||||
className={cardClass(
|
||||
"group flex h-full min-h-[164px] flex-col p-5 text-left transition-colors hover:border-cinnabar focus-visible:border-cinnabar focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cinnabar/30",
|
||||
)}
|
||||
>
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||
<h2 className="font-serif text-xl text-ink">{tool.title}</h2>
|
||||
{tool.genre ? (
|
||||
<span className="rounded bg-bg px-2 py-0.5 text-xs text-ink-soft">
|
||||
{tool.genre}
|
||||
</span>
|
||||
) : null}
|
||||
{tool.is_legacy ? null : (
|
||||
<span className="rounded bg-[var(--color-cinnabar-wash)] px-2 py-0.5 text-xs text-cinnabar">
|
||||
NEW
|
||||
</span>
|
||||
)}
|
||||
{tool.genre ? <Badge>{tool.genre}</Badge> : null}
|
||||
{tool.is_legacy ? null : <Badge variant="accent">新</Badge>}
|
||||
{tool.ingestable ? (
|
||||
<span className="rounded bg-bg px-2 py-0.5 text-xs text-ink-soft">
|
||||
<Badge variant="info">
|
||||
<Database className="h-3 w-3" aria-hidden="true" />
|
||||
可入库
|
||||
</span>
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="line-clamp-2 text-sm text-ink-soft">{tool.subtitle}</p>
|
||||
<div className="mt-auto flex items-center gap-2 pt-4 text-xs text-ink-soft">
|
||||
<Sparkles className="h-3.5 w-3.5 text-cinnabar" aria-hidden="true" />
|
||||
<span className="truncate">
|
||||
{tool.input_fields?.[0]?.label ?? "现有页面"} → 结构化预览
|
||||
</span>
|
||||
<ArrowRight
|
||||
className="ml-auto h-3.5 w-3.5 text-cinnabar opacity-0 transition-opacity group-hover:opacity-100"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Sparkles } from "lucide-react";
|
||||
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
import { PageHeader } from "@/components/ui/PageHeader";
|
||||
import type { ProjectResponse, ToolDescriptorView } from "@/lib/api/types";
|
||||
import { isLegacyTool, resolveLegacyRoute } from "@/lib/toolbox/toolbox";
|
||||
import { ToolCard } from "./ToolCard";
|
||||
@@ -63,13 +66,11 @@ export function ToolboxPage({
|
||||
activeNav="toolbox"
|
||||
>
|
||||
<div className="mx-auto flex max-w-5xl flex-col gap-6 p-6">
|
||||
<header>
|
||||
<h1 className="font-serif text-lg text-ink">创作工具箱</h1>
|
||||
<p className="mt-1 text-xs text-ink-soft">
|
||||
声明驱动的生成器集合:脑洞 / 书名 / 简介 / 名字 / 金手指 / 词条 / 黄金开篇 / 细纲…
|
||||
选一个开始,结构化产物可一键入库(经一致性预检)。
|
||||
</p>
|
||||
</header>
|
||||
<PageHeader
|
||||
title="创作工具箱"
|
||||
eyebrow="generator toolbox"
|
||||
description="脑洞、书名、简介、名字、金手指、词条、黄金开篇与细纲。每个工具都先生成结构化预览,可入库内容会经过一致性预检。"
|
||||
/>
|
||||
|
||||
{active ? (
|
||||
<GeneratorRunner
|
||||
@@ -78,7 +79,11 @@ export function ToolboxPage({
|
||||
onClose={() => setActive(null)}
|
||||
/>
|
||||
) : sorted.length === 0 ? (
|
||||
<p className="text-sm text-ink-soft">(暂无可用生成器)</p>
|
||||
<EmptyState
|
||||
icon={Sparkles}
|
||||
title="暂无可用生成器"
|
||||
description="后端暂未返回工具描述符。工具箱会在描述符可用后自动渲染卡片与输入表单。"
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{sorted.map((tool) => (
|
||||
|
||||
16
apps/web/components/ui/Badge.tsx
Normal file
16
apps/web/components/ui/Badge.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { HTMLAttributes, ReactNode } from "react";
|
||||
|
||||
import { badgeClass, type BadgeVariant } from "@/lib/ui/variants";
|
||||
|
||||
interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
|
||||
children: ReactNode;
|
||||
variant?: BadgeVariant;
|
||||
}
|
||||
|
||||
export function Badge({ children, className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<span className={badgeClass({ variant, className })} {...props}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
36
apps/web/components/ui/Button.tsx
Normal file
36
apps/web/components/ui/Button.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from "react";
|
||||
|
||||
import {
|
||||
buttonClass,
|
||||
type ButtonSize,
|
||||
type ButtonVariant,
|
||||
} from "@/lib/ui/variants";
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
children: ReactNode;
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
}
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
|
||||
{
|
||||
children,
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
type = "button",
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type={type}
|
||||
className={buttonClass({ variant, size, className })}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
21
apps/web/components/ui/Card.tsx
Normal file
21
apps/web/components/ui/Card.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { HTMLAttributes, ReactNode } from "react";
|
||||
|
||||
import { cardClass } from "@/lib/ui/variants";
|
||||
|
||||
interface CardProps extends HTMLAttributes<HTMLElement> {
|
||||
children: ReactNode;
|
||||
as?: "article" | "div" | "section";
|
||||
}
|
||||
|
||||
export function Card({
|
||||
as: Component = "div",
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: CardProps) {
|
||||
return (
|
||||
<Component className={cardClass(className)} {...props}>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
38
apps/web/components/ui/EmptyState.tsx
Normal file
38
apps/web/components/ui/EmptyState.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/ui/variants";
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
description: string;
|
||||
action?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function EmptyState({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
className,
|
||||
}: EmptyStateProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded border border-dashed border-line bg-panel/70 px-6 py-10 text-center",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="mx-auto mb-3 flex h-10 w-10 items-center justify-center rounded bg-[var(--color-cinnabar-wash)] text-cinnabar">
|
||||
<Icon className="h-5 w-5" aria-hidden="true" />
|
||||
</div>
|
||||
<h2 className="font-serif text-lg text-ink">{title}</h2>
|
||||
<p className="mx-auto mt-2 max-w-md text-sm leading-6 text-ink-soft">
|
||||
{description}
|
||||
</p>
|
||||
{action ? <div className="mt-4">{action}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
40
apps/web/components/ui/Field.tsx
Normal file
40
apps/web/components/ui/Field.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import {
|
||||
cn,
|
||||
fieldErrorClass,
|
||||
fieldHelpClass,
|
||||
fieldLabelClass,
|
||||
} from "@/lib/ui/variants";
|
||||
|
||||
interface FieldProps {
|
||||
label: string;
|
||||
children: ReactNode;
|
||||
htmlFor?: string;
|
||||
help?: ReactNode;
|
||||
error?: ReactNode;
|
||||
required?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Field({
|
||||
label,
|
||||
children,
|
||||
htmlFor,
|
||||
help,
|
||||
error,
|
||||
required = false,
|
||||
className,
|
||||
}: FieldProps) {
|
||||
const labelText = required ? `${label} *` : label;
|
||||
return (
|
||||
<div className={cn("space-y-1.5", className)}>
|
||||
<label htmlFor={htmlFor} className={fieldLabelClass()}>
|
||||
{labelText}
|
||||
</label>
|
||||
{children}
|
||||
{error ? <p className={fieldErrorClass()}>{error}</p> : null}
|
||||
{!error && help ? <p className={fieldHelpClass()}>{help}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
apps/web/components/ui/PageHeader.tsx
Normal file
38
apps/web/components/ui/PageHeader.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: string;
|
||||
eyebrow?: string;
|
||||
description?: string;
|
||||
actions?: ReactNode;
|
||||
}
|
||||
|
||||
export function PageHeader({
|
||||
title,
|
||||
eyebrow,
|
||||
description,
|
||||
actions,
|
||||
}: PageHeaderProps) {
|
||||
return (
|
||||
<header className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
{eyebrow ? (
|
||||
<p className="mb-1 font-mono text-xs uppercase tracking-wide text-ink-soft/70">
|
||||
{eyebrow}
|
||||
</p>
|
||||
) : null}
|
||||
<h1 className="font-serif text-2xl text-ink">{title}</h1>
|
||||
{description ? (
|
||||
<p className="mt-2 max-w-2xl text-sm leading-6 text-ink-soft">
|
||||
{description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{actions ? (
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2">
|
||||
{actions}
|
||||
</div>
|
||||
) : null}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
29
apps/web/components/ui/SectionHeader.tsx
Normal file
29
apps/web/components/ui/SectionHeader.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { cn } from "@/lib/ui/variants";
|
||||
|
||||
interface SectionHeaderProps {
|
||||
title: string;
|
||||
description?: ReactNode;
|
||||
action?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SectionHeader({
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
className,
|
||||
}: SectionHeaderProps) {
|
||||
return (
|
||||
<div className={cn("flex items-start justify-between gap-4", className)}>
|
||||
<div className="min-w-0">
|
||||
<h2 className="font-serif text-base text-ink">{title}</h2>
|
||||
{description ? (
|
||||
<p className="mt-1 text-sm leading-6 text-ink-soft">{description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
{action ? <div className="shrink-0">{action}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
apps/web/components/ui/SegmentedControl.tsx
Normal file
48
apps/web/components/ui/SegmentedControl.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { cn, segmentedClass } from "@/lib/ui/variants";
|
||||
|
||||
export interface SegmentOption<T extends string> {
|
||||
value: T;
|
||||
label: ReactNode;
|
||||
}
|
||||
|
||||
interface SegmentedControlProps<T extends string> {
|
||||
options: Array<SegmentOption<T>>;
|
||||
value: T;
|
||||
onChange: (value: T) => void;
|
||||
ariaLabel: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SegmentedControl<T extends string>({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
ariaLabel,
|
||||
className,
|
||||
}: SegmentedControlProps<T>) {
|
||||
return (
|
||||
<div className={segmentedClass(className)} role="group" aria-label={ariaLabel}>
|
||||
{options.map((option) => {
|
||||
const selected = option.value === value;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
aria-pressed={selected}
|
||||
onClick={() => onChange(option.value)}
|
||||
className={cn(
|
||||
"rounded px-3 py-1.5 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cinnabar/35",
|
||||
selected
|
||||
? "bg-panel text-cinnabar shadow-paper"
|
||||
: "text-ink-soft hover:text-cinnabar",
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
30
apps/web/components/ui/Select.tsx
Normal file
30
apps/web/components/ui/Select.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { SelectHTMLAttributes } from "react";
|
||||
|
||||
import { cn, inputClass, type InputState } from "@/lib/ui/variants";
|
||||
|
||||
interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
|
||||
controlSize?: "sm" | "md";
|
||||
state?: InputState;
|
||||
}
|
||||
|
||||
const selectSizes: Record<NonNullable<SelectProps["controlSize"]>, string> = {
|
||||
sm: "px-2 py-1 text-xs",
|
||||
md: "px-3 py-2 text-sm",
|
||||
};
|
||||
|
||||
export function Select({
|
||||
className,
|
||||
controlSize = "md",
|
||||
state,
|
||||
...props
|
||||
}: SelectProps) {
|
||||
return (
|
||||
<select
|
||||
className={inputClass({
|
||||
state,
|
||||
className: cn(selectSizes[controlSize], className),
|
||||
})}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
46
apps/web/components/ui/StatusNote.tsx
Normal file
46
apps/web/components/ui/StatusNote.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { HTMLAttributes, ReactNode } from "react";
|
||||
import { AlertCircle, CheckCircle2, Info, TriangleAlert } from "lucide-react";
|
||||
|
||||
import {
|
||||
cn,
|
||||
statusNoteClass,
|
||||
type StatusNoteVariant,
|
||||
} from "@/lib/ui/variants";
|
||||
|
||||
interface StatusNoteProps extends HTMLAttributes<HTMLDivElement> {
|
||||
children: ReactNode;
|
||||
title?: string;
|
||||
variant?: StatusNoteVariant;
|
||||
}
|
||||
|
||||
export function StatusNote({
|
||||
children,
|
||||
title,
|
||||
variant = "info",
|
||||
className,
|
||||
...props
|
||||
}: StatusNoteProps) {
|
||||
const Icon = iconForVariant(variant);
|
||||
return (
|
||||
<div className={statusNoteClass({ variant, className })} {...props}>
|
||||
<div className="flex gap-2">
|
||||
<Icon className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
<div className="min-w-0">
|
||||
{title ? (
|
||||
<p className="font-medium leading-5 text-ink">{title}</p>
|
||||
) : null}
|
||||
<div className={cn(title ? "mt-1" : "", "text-current")}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function iconForVariant(variant: StatusNoteVariant) {
|
||||
if (variant === "success") return CheckCircle2;
|
||||
if (variant === "warning") return TriangleAlert;
|
||||
if (variant === "danger") return AlertCircle;
|
||||
return Info;
|
||||
}
|
||||
30
apps/web/components/ui/TextArea.tsx
Normal file
30
apps/web/components/ui/TextArea.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { TextareaHTMLAttributes } from "react";
|
||||
|
||||
import { cn, inputClass, type InputState } from "@/lib/ui/variants";
|
||||
|
||||
interface TextAreaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||
controlSize?: "sm" | "md";
|
||||
state?: InputState;
|
||||
}
|
||||
|
||||
const textAreaSizes: Record<NonNullable<TextAreaProps["controlSize"]>, string> = {
|
||||
sm: "px-2 py-1 text-xs leading-5",
|
||||
md: "px-3 py-2 text-sm leading-6",
|
||||
};
|
||||
|
||||
export function TextArea({
|
||||
className,
|
||||
controlSize = "md",
|
||||
state,
|
||||
...props
|
||||
}: TextAreaProps) {
|
||||
return (
|
||||
<textarea
|
||||
className={inputClass({
|
||||
state,
|
||||
className: cn("resize-y", textAreaSizes[controlSize], className),
|
||||
})}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
30
apps/web/components/ui/TextInput.tsx
Normal file
30
apps/web/components/ui/TextInput.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { InputHTMLAttributes } from "react";
|
||||
|
||||
import { cn, inputClass, type InputState } from "@/lib/ui/variants";
|
||||
|
||||
interface TextInputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
controlSize?: "sm" | "md";
|
||||
state?: InputState;
|
||||
}
|
||||
|
||||
const inputSizes: Record<NonNullable<TextInputProps["controlSize"]>, string> = {
|
||||
sm: "px-2 py-1 text-xs",
|
||||
md: "px-3 py-2 text-sm",
|
||||
};
|
||||
|
||||
export function TextInput({
|
||||
className,
|
||||
controlSize = "md",
|
||||
state,
|
||||
...props
|
||||
}: TextInputProps) {
|
||||
return (
|
||||
<input
|
||||
className={inputClass({
|
||||
state,
|
||||
className: cn(inputSizes[controlSize], className),
|
||||
})}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useId, useRef, useState, type RefObject } from "react";
|
||||
import {
|
||||
BookOpen,
|
||||
ClipboardCheck,
|
||||
FileText,
|
||||
Library,
|
||||
PanelLeft,
|
||||
PanelRight,
|
||||
PenLine,
|
||||
Square,
|
||||
} from "lucide-react";
|
||||
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { Drawer } from "@/components/Drawer";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { SectionHeader } from "@/components/ui/SectionHeader";
|
||||
import { StatusNote } from "@/components/ui/StatusNote";
|
||||
import { TextArea } from "@/components/ui/TextArea";
|
||||
import type { ProjectResponse } from "@/lib/api/types";
|
||||
import { friendlyError } from "@/lib/errors/messages";
|
||||
import { useAutosave } from "@/lib/autosave/useAutosave";
|
||||
@@ -14,6 +28,7 @@ import {
|
||||
type ChapterEntry,
|
||||
} from "@/lib/workbench/chapter";
|
||||
import { composeDirective, STYLE_PRESETS } from "@/lib/workbench/directive";
|
||||
import { buttonClass } from "@/lib/ui/variants";
|
||||
import { ChapterList, ChapterListContent } from "./ChapterList";
|
||||
import { ChapterAssistant, AssistantContent } from "./ChapterAssistant";
|
||||
import { Editor } from "./Editor";
|
||||
@@ -50,6 +65,8 @@ export function Workbench({
|
||||
const autosave = useAutosave(project.id, chapterNo, initialText);
|
||||
const stream = useDraftStream();
|
||||
const lastStreamText = useRef("");
|
||||
const chapterTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
const assistantTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
// 流式 token 累积进编辑器(打字机);停止/结束后已生成部分留在草稿。
|
||||
useEffect(() => {
|
||||
@@ -95,7 +112,7 @@ export function Workbench({
|
||||
projectId={project.id}
|
||||
activeNav="write"
|
||||
>
|
||||
<div className="grid h-[calc(100vh-var(--chrome,4rem))] grid-cols-1 lg:grid-cols-[12rem_1fr_18rem]">
|
||||
<div className="grid min-h-[calc(100vh-var(--chrome,4rem))] grid-cols-1 lg:h-[calc(100vh-var(--chrome,4rem))] lg:grid-cols-[12rem_1fr_18rem]">
|
||||
<ChapterList
|
||||
projectId={project.id}
|
||||
chapters={chapters}
|
||||
@@ -103,6 +120,13 @@ export function Workbench({
|
||||
/>
|
||||
|
||||
<section className="flex min-w-0 flex-col bg-bg">
|
||||
<MobileContextBar
|
||||
chapterNo={chapterNo}
|
||||
onOpenChapters={() => setMobilePanel("chapters")}
|
||||
onOpenAssistant={() => setMobilePanel("assistant")}
|
||||
chapterTriggerRef={chapterTriggerRef}
|
||||
assistantTriggerRef={assistantTriggerRef}
|
||||
/>
|
||||
<DirectivePanel
|
||||
directive={directive}
|
||||
onDirectiveChange={setDirective}
|
||||
@@ -126,8 +150,6 @@ export function Workbench({
|
||||
streamError={stream.state.error}
|
||||
onWrite={onWrite}
|
||||
onStop={stream.stop}
|
||||
onOpenChapters={() => setMobilePanel("chapters")}
|
||||
onOpenAssistant={() => setMobilePanel("assistant")}
|
||||
/>
|
||||
</section>
|
||||
|
||||
@@ -140,6 +162,7 @@ export function Workbench({
|
||||
onClose={closePanel}
|
||||
side="left"
|
||||
label="目录"
|
||||
triggerRef={chapterTriggerRef}
|
||||
>
|
||||
<ChapterListContent
|
||||
projectId={project.id}
|
||||
@@ -152,6 +175,7 @@ export function Workbench({
|
||||
onClose={closePanel}
|
||||
side="right"
|
||||
label="本章助手"
|
||||
triggerRef={assistantTriggerRef}
|
||||
>
|
||||
<AssistantContent projectId={project.id} chapterNo={chapterNo} />
|
||||
</Drawer>
|
||||
@@ -159,6 +183,48 @@ export function Workbench({
|
||||
);
|
||||
}
|
||||
|
||||
interface MobileContextBarProps {
|
||||
chapterNo: number;
|
||||
onOpenChapters: () => void;
|
||||
onOpenAssistant: () => void;
|
||||
chapterTriggerRef: RefObject<HTMLButtonElement | null>;
|
||||
assistantTriggerRef: RefObject<HTMLButtonElement | null>;
|
||||
}
|
||||
|
||||
function MobileContextBar({
|
||||
chapterNo,
|
||||
onOpenChapters,
|
||||
onOpenAssistant,
|
||||
chapterTriggerRef,
|
||||
assistantTriggerRef,
|
||||
}: MobileContextBarProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2 border-b border-line bg-panel px-4 py-2 lg:hidden">
|
||||
<Button
|
||||
ref={chapterTriggerRef}
|
||||
onClick={onOpenChapters}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
<PanelLeft className="h-4 w-4" aria-hidden="true" />
|
||||
目录
|
||||
</Button>
|
||||
<span className="min-w-0 truncate font-mono text-xs text-ink-soft">
|
||||
第 {chapterNo} 章
|
||||
</span>
|
||||
<Button
|
||||
ref={assistantTriggerRef}
|
||||
onClick={onOpenAssistant}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
<PanelRight className="h-4 w-4" aria-hidden="true" />
|
||||
助手
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface DirectivePanelProps {
|
||||
directive: string;
|
||||
onDirectiveChange: (value: string) => void;
|
||||
@@ -174,42 +240,55 @@ function DirectivePanel({
|
||||
presetIds,
|
||||
onTogglePreset,
|
||||
}: DirectivePanelProps) {
|
||||
const panelId = useId();
|
||||
const activeCount = presetIds.length + (directive.trim().length > 0 ? 1 : 0);
|
||||
return (
|
||||
<details className="border-b border-line bg-panel px-6 py-2">
|
||||
<summary className="cursor-pointer text-sm text-ink-soft hover:text-cinnabar">
|
||||
本章指令(可选)
|
||||
<details className="border-b border-line bg-panel px-4 py-3 sm:px-6">
|
||||
<summary
|
||||
className="flex cursor-pointer list-none items-center justify-between gap-3 text-sm text-ink-soft hover:text-cinnabar"
|
||||
aria-controls={panelId}
|
||||
>
|
||||
<span className="font-serif text-base text-ink">本章生成控制台</span>
|
||||
<span className="rounded border border-line bg-bg px-2 py-0.5 text-xs text-ink-soft">
|
||||
{activeCount > 0 ? `${activeCount} 项指令` : "可选"}
|
||||
</span>
|
||||
</summary>
|
||||
<div className="mt-2 space-y-2">
|
||||
<div id={panelId} className="mt-3 space-y-3">
|
||||
<SectionHeader
|
||||
title="写作指令"
|
||||
description="用于补充或覆盖本章大纲节拍,只影响本次生成。"
|
||||
/>
|
||||
<label className="block">
|
||||
<span className="sr-only">本章指令</span>
|
||||
<textarea
|
||||
<TextArea
|
||||
value={directive}
|
||||
onChange={(e) => onDirectiveChange(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="本章想怎么写?(可选,覆盖/补充大纲节拍)"
|
||||
className="w-full resize-y rounded border border-line bg-bg px-3 py-2 text-sm text-ink placeholder:text-ink-soft focus:border-cinnabar focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="flex flex-wrap gap-2" role="group" aria-label="风格预设">
|
||||
{STYLE_PRESETS.map((preset) => {
|
||||
const active = presetIds.includes(preset.id);
|
||||
return (
|
||||
<button
|
||||
<Button
|
||||
key={preset.id}
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
onClick={() => onTogglePreset(preset.id)}
|
||||
className={
|
||||
active
|
||||
? "rounded-full border border-cinnabar bg-cinnabar px-3 py-1 text-xs text-panel"
|
||||
: "rounded-full border border-line px-3 py-1 text-xs text-ink-soft hover:border-cinnabar hover:text-cinnabar"
|
||||
}
|
||||
variant={active ? "outline" : "secondary"}
|
||||
size="sm"
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{activeCount > 0 ? (
|
||||
<StatusNote variant="info">
|
||||
写本章时会把已选预设和自由指令合并进本次生成请求。
|
||||
</StatusNote>
|
||||
) : null}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
@@ -250,8 +329,6 @@ interface ToolbarProps {
|
||||
streamError: { code: string; message: string } | null;
|
||||
onWrite: () => void;
|
||||
onStop: () => void;
|
||||
onOpenChapters: () => void;
|
||||
onOpenAssistant: () => void;
|
||||
}
|
||||
|
||||
function Toolbar({
|
||||
@@ -264,36 +341,22 @@ function Toolbar({
|
||||
streamError,
|
||||
onWrite,
|
||||
onStop,
|
||||
onOpenChapters,
|
||||
onOpenAssistant,
|
||||
}: ToolbarProps) {
|
||||
return (
|
||||
<div className="border-t border-line bg-panel px-4 py-3 sm:px-6">
|
||||
<div className="sticky bottom-0 z-10 border-t border-line bg-panel/95 px-4 py-2 backdrop-blur sm:px-6 sm:py-3">
|
||||
{streamError ? <StreamErrorNote streamError={streamError} /> : null}
|
||||
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenChapters}
|
||||
className="rounded border border-line px-3 py-2 text-sm text-ink hover:border-cinnabar hover:text-cinnabar lg:hidden"
|
||||
>
|
||||
目录
|
||||
</button>
|
||||
<div className="grid gap-2 sm:flex sm:flex-wrap sm:items-center sm:gap-3">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
{streaming ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onStop}
|
||||
className="rounded border border-conflict px-4 py-2 text-sm text-conflict"
|
||||
>
|
||||
<Button onClick={onStop} variant="danger" size="sm">
|
||||
<Square className="h-4 w-4" aria-hidden="true" />
|
||||
停
|
||||
</button>
|
||||
</Button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onWrite}
|
||||
className="rounded bg-cinnabar px-4 py-2 text-sm text-panel hover:opacity-90"
|
||||
>
|
||||
✍ 写本章
|
||||
</button>
|
||||
<Button onClick={onWrite} variant="primary" size="sm">
|
||||
<PenLine className="h-4 w-4" aria-hidden="true" />
|
||||
写本章
|
||||
</Button>
|
||||
)}
|
||||
{streaming ? (
|
||||
<span className="font-mono text-xs text-cinnabar motion-safe:animate-pulse">
|
||||
@@ -306,30 +369,32 @@ function Toolbar({
|
||||
)}
|
||||
<Link
|
||||
href={`/projects/${projectId}/outline`}
|
||||
className="rounded border border-line px-4 py-2 text-sm text-ink hover:border-cinnabar hover:text-cinnabar"
|
||||
className={buttonClass({ variant: "secondary", size: "sm" })}
|
||||
>
|
||||
<BookOpen className="h-4 w-4" aria-hidden="true" />
|
||||
大纲
|
||||
</Link>
|
||||
<Link
|
||||
href={`/projects/${projectId}/foreshadow`}
|
||||
className="rounded border border-line px-4 py-2 text-sm text-ink hover:border-cinnabar hover:text-cinnabar"
|
||||
className={buttonClass({
|
||||
variant: "secondary",
|
||||
size: "sm",
|
||||
className: "hidden sm:inline-flex",
|
||||
})}
|
||||
>
|
||||
<Library className="h-4 w-4" aria-hidden="true" />
|
||||
伏笔
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenAssistant}
|
||||
className="rounded border border-line px-3 py-2 text-sm text-ink hover:border-cinnabar hover:text-cinnabar lg:hidden"
|
||||
>
|
||||
助手
|
||||
</button>
|
||||
<Link
|
||||
href={`/projects/${projectId}/review?chapter=${chapterNo}`}
|
||||
className="rounded border border-cinnabar px-4 py-2 text-sm text-cinnabar hover:bg-[var(--color-cinnabar-wash)]"
|
||||
className={buttonClass({ variant: "outline", size: "sm" })}
|
||||
>
|
||||
审稿 →
|
||||
<ClipboardCheck className="h-4 w-4" aria-hidden="true" />
|
||||
审稿
|
||||
</Link>
|
||||
<span className="ml-auto font-mono text-xs text-ink-soft">
|
||||
</div>
|
||||
<span className="min-w-0 font-mono text-xs text-ink-soft sm:ml-auto">
|
||||
<FileText className="mr-1 inline h-3.5 w-3.5" aria-hidden="true" />
|
||||
{saveStatus === "saving"
|
||||
? "保存中…"
|
||||
: saveStatus === "error"
|
||||
|
||||
11
apps/web/lib/api/schema.d.ts
vendored
11
apps/web/lib/api/schema.d.ts
vendored
@@ -1539,6 +1539,17 @@ export interface components {
|
||||
selling_points?: string[];
|
||||
/** Structure */
|
||||
structure?: string | null;
|
||||
/**
|
||||
* Updated At
|
||||
* @description 项目最近更新时间
|
||||
*/
|
||||
updated_at?: string | null;
|
||||
/**
|
||||
* Pending Review Count
|
||||
* @description 待审稿章节数
|
||||
* @default 0
|
||||
*/
|
||||
pending_review_count: number;
|
||||
};
|
||||
/**
|
||||
* ProviderCredentialInput
|
||||
|
||||
110
apps/web/lib/autosave/useAutosave.test.ts
Normal file
110
apps/web/lib/autosave/useAutosave.test.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { useAutosave } from "./useAutosave";
|
||||
import { AUTOSAVE_DELAY_MS } from "./autosave";
|
||||
|
||||
// 后端客户端与 Toast 是 hook 的副作用边界,单测一律 mock;防抖逻辑(autosave.ts)保留真实。
|
||||
const put = vi.fn();
|
||||
const toast = vi.fn();
|
||||
vi.mock("@/lib/api/client", () => ({ api: { PUT: (...a: unknown[]) => put(...a) } }));
|
||||
vi.mock("@/components/Toast", () => ({ useToast: () => toast }));
|
||||
|
||||
describe("useAutosave", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
put.mockReset();
|
||||
toast.mockReset();
|
||||
put.mockResolvedValue({ error: null });
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("无初始草稿时初始为 idle、无标签", () => {
|
||||
// Arrange + Act
|
||||
const { result } = renderHook(() => useAutosave("p1", 1));
|
||||
// Assert
|
||||
expect(result.current.status).toBe("idle");
|
||||
expect(result.current.savedLabel).toBeNull();
|
||||
});
|
||||
|
||||
it("种入已存草稿时初始即为 saved 并显示「已保存」", () => {
|
||||
const { result } = renderHook(() => useAutosave("p1", 1, "旧草稿"));
|
||||
expect(result.current.status).toBe("saved");
|
||||
expect(result.current.savedLabel).toBe("已保存");
|
||||
});
|
||||
|
||||
it("防抖合并:快速多次改动只在静默后保存一次", async () => {
|
||||
// Arrange
|
||||
const { result } = renderHook(() => useAutosave("p1", 2));
|
||||
|
||||
// Act:连续三次输入,未到延迟前不应保存。
|
||||
act(() => {
|
||||
result.current.onChange("a");
|
||||
result.current.onChange("ab");
|
||||
result.current.onChange("abc");
|
||||
});
|
||||
expect(put).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(AUTOSAVE_DELAY_MS);
|
||||
});
|
||||
|
||||
// Assert:只发一次 PUT,且是最后一次文本;状态落到 saved + 时间标签。
|
||||
expect(put).toHaveBeenCalledTimes(1);
|
||||
expect(put).toHaveBeenCalledWith(
|
||||
"/projects/{project_id}/chapters/{chapter_no}/draft",
|
||||
{
|
||||
params: { path: { project_id: "p1", chapter_no: 2 } },
|
||||
body: { text: "abc" },
|
||||
},
|
||||
);
|
||||
expect(result.current.status).toBe("saved");
|
||||
expect(result.current.savedLabel).toMatch(/^保存于 \d{2}:\d{2}$/);
|
||||
});
|
||||
|
||||
it("flush 立即触发待保存项,无需等待延迟", async () => {
|
||||
const { result } = renderHook(() => useAutosave("p1", 3));
|
||||
act(() => result.current.onChange("即时"));
|
||||
await act(async () => {
|
||||
result.current.flush();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(put).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.status).toBe("saved");
|
||||
});
|
||||
|
||||
it("文本与已存基线相同时早退、不发请求", async () => {
|
||||
const { result } = renderHook(() => useAutosave("p1", 4, "基线"));
|
||||
act(() => result.current.onChange("基线"));
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(AUTOSAVE_DELAY_MS);
|
||||
});
|
||||
expect(put).not.toHaveBeenCalled();
|
||||
expect(result.current.status).toBe("saved");
|
||||
});
|
||||
|
||||
it("后端返回 error:状态回滚为 error 并弹 toast", async () => {
|
||||
put.mockResolvedValue({ error: { detail: "boom" } });
|
||||
const { result } = renderHook(() => useAutosave("p1", 5));
|
||||
act(() => result.current.onChange("变更"));
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(AUTOSAVE_DELAY_MS);
|
||||
});
|
||||
expect(result.current.status).toBe("error");
|
||||
expect(toast).toHaveBeenCalledWith(expect.any(String), "error");
|
||||
});
|
||||
|
||||
it("卸载时取消待保存定时器,不再发请求", async () => {
|
||||
const { result, unmount } = renderHook(() => useAutosave("p1", 6));
|
||||
act(() => result.current.onChange("待保存"));
|
||||
unmount();
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(AUTOSAVE_DELAY_MS * 2);
|
||||
});
|
||||
expect(put).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -4,9 +4,12 @@ import type { ForeshadowView } from "@/lib/api/types";
|
||||
import {
|
||||
buildRegisterRequest,
|
||||
buildTransitionRequest,
|
||||
countByStatus,
|
||||
extractReason,
|
||||
groupByStatus,
|
||||
isWindowApproaching,
|
||||
LANE_LABELS,
|
||||
nextFreeForeshadowCode,
|
||||
suggestForeshadowCode,
|
||||
validationReasonMessage,
|
||||
} from "./board";
|
||||
@@ -24,6 +27,24 @@ describe("suggestForeshadowCode", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("nextFreeForeshadowCode", () => {
|
||||
it("keeps the preferred code when it is free", () => {
|
||||
expect(nextFreeForeshadowCode("FS-04", new Set(["FS-01", "FS-02"]))).toBe(
|
||||
"FS-04",
|
||||
);
|
||||
});
|
||||
|
||||
it("skips taken codes and returns the first free FS-NN", () => {
|
||||
const taken = new Set(["FS-01", "FS-02", "FS-03", "FS-05", "FS-OLD"]);
|
||||
// preferred FS-01 已占 → 取首个空闲 FS-04
|
||||
expect(nextFreeForeshadowCode("FS-01", taken)).toBe("FS-04");
|
||||
});
|
||||
|
||||
it("returns FS-01 when nothing is taken", () => {
|
||||
expect(nextFreeForeshadowCode("FS-01", new Set())).toBe("FS-01");
|
||||
});
|
||||
});
|
||||
|
||||
const view = (over: Partial<ForeshadowView>): ForeshadowView => ({
|
||||
code: "F-001",
|
||||
title: "线索",
|
||||
@@ -53,6 +74,32 @@ describe("groupByStatus", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("countByStatus", () => {
|
||||
it("counts items in the same lane model used by the board", () => {
|
||||
expect(
|
||||
countByStatus([
|
||||
view({ status: "OPEN" }),
|
||||
view({ status: "OPEN" }),
|
||||
view({ status: "PARTIAL" }),
|
||||
view({ status: "CLOSED" }),
|
||||
view({ status: "OVERDUE" }),
|
||||
view({ status: "WEIRD" }),
|
||||
]),
|
||||
).toEqual({ OPEN: 3, PARTIAL: 1, CLOSED: 1, OVERDUE: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("LANE_LABELS", () => {
|
||||
it("keeps author-facing labels distinct from status codes", () => {
|
||||
expect(LANE_LABELS).toEqual({
|
||||
OPEN: "待推进",
|
||||
PARTIAL: "推进中",
|
||||
CLOSED: "已回收",
|
||||
OVERDUE: "已逾期",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("isWindowApproaching", () => {
|
||||
it("is true when current chapter is inside [from,to] for OPEN/PARTIAL", () => {
|
||||
const f = view({
|
||||
|
||||
@@ -18,13 +18,14 @@ export const LANES: readonly ForeshadowStatus[] = [
|
||||
] as const;
|
||||
|
||||
export const LANE_LABELS: Record<ForeshadowStatus, string> = {
|
||||
OPEN: "OPEN",
|
||||
PARTIAL: "PARTIAL",
|
||||
CLOSED: "CLOSED",
|
||||
OVERDUE: "OVERDUE",
|
||||
OPEN: "待推进",
|
||||
PARTIAL: "推进中",
|
||||
CLOSED: "已回收",
|
||||
OVERDUE: "已逾期",
|
||||
};
|
||||
|
||||
export type ForeshadowLanes = Record<ForeshadowStatus, ForeshadowView[]>;
|
||||
export type ForeshadowLaneCounts = Record<ForeshadowStatus, number>;
|
||||
|
||||
function emptyLanes(): ForeshadowLanes {
|
||||
return { OPEN: [], PARTIAL: [], CLOSED: [], OVERDUE: [] };
|
||||
@@ -46,6 +47,18 @@ export function groupByStatus(
|
||||
return lanes;
|
||||
}
|
||||
|
||||
export function countByStatus(
|
||||
items: readonly ForeshadowView[] | undefined,
|
||||
): ForeshadowLaneCounts {
|
||||
const lanes = groupByStatus(items);
|
||||
return {
|
||||
OPEN: lanes.OPEN.length,
|
||||
PARTIAL: lanes.PARTIAL.length,
|
||||
CLOSED: lanes.CLOSED.length,
|
||||
OVERDUE: lanes.OVERDUE.length,
|
||||
};
|
||||
}
|
||||
|
||||
// 接近回收窗口判定:current 在 [from, to] 内,或已超 from 但尚未 CLOSED(提示安排回收)。
|
||||
// 仅对 OPEN/PARTIAL 提示;CLOSED 已回收、OVERDUE 已逾期另有强调。
|
||||
export function isWindowApproaching(
|
||||
@@ -70,6 +83,21 @@ export function suggestForeshadowCode(
|
||||
return `FS-${String(index + 1).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
// 在已占用代号(taken,区分大小写按原样)中,为预填代号挑一个不冲突的值:
|
||||
// preferred 未被占用就用它;否则从 FS-01 起取首个空闲的 FS-<两位序号>。
|
||||
// 纯函数(可单测)。避免登记时与库里已存代号撞码 → 422 duplicate。
|
||||
export function nextFreeForeshadowCode(
|
||||
preferred: string,
|
||||
taken: ReadonlySet<string>,
|
||||
): string {
|
||||
if (preferred && !taken.has(preferred)) return preferred;
|
||||
for (let n = 1; n < 1000; n++) {
|
||||
const candidate = `FS-${String(n).padStart(2, "0")}`;
|
||||
if (!taken.has(candidate)) return candidate;
|
||||
}
|
||||
return preferred; // 理论不可达:1000 个代号全占用时退回原值
|
||||
}
|
||||
|
||||
export interface RegisterInput {
|
||||
projectId: string;
|
||||
code: string;
|
||||
|
||||
134
apps/web/lib/foreshadow/useForeshadow.test.ts
Normal file
134
apps/web/lib/foreshadow/useForeshadow.test.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { useForeshadow } from "./useForeshadow";
|
||||
import type { ForeshadowView } from "@/lib/api/types";
|
||||
|
||||
// 后端客户端与 Toast 是 hook 的外部副作用边界,单测一律 mock。
|
||||
const post = vi.fn();
|
||||
const patch = vi.fn();
|
||||
const toast = vi.fn();
|
||||
vi.mock("@/lib/api/client", () => ({
|
||||
api: {
|
||||
POST: (...a: unknown[]) => post(...a),
|
||||
PATCH: (...a: unknown[]) => patch(...a),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/components/Toast", () => ({ useToast: () => toast }));
|
||||
|
||||
function makeRow(code: string, status = "OPEN"): ForeshadowView {
|
||||
return { code, title: code, status } as ForeshadowView;
|
||||
}
|
||||
|
||||
const registerInput = { projectId: "p1", code: "FS-01", title: "线索" };
|
||||
|
||||
describe("useForeshadow", () => {
|
||||
beforeEach(() => {
|
||||
post.mockReset();
|
||||
patch.mockReset();
|
||||
toast.mockReset();
|
||||
});
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
it("初始 items 取自传入、busy=false", () => {
|
||||
const initial = [makeRow("FS-01")];
|
||||
const { result } = renderHook(() => useForeshadow(initial));
|
||||
expect(result.current.items).toEqual(initial);
|
||||
expect(result.current.busy).toBe(false);
|
||||
});
|
||||
|
||||
it("登记成功:追加返回行并弹成功 toast", async () => {
|
||||
const row = makeRow("FS-02");
|
||||
post.mockResolvedValue({ data: row, error: null });
|
||||
|
||||
const { result } = renderHook(() => useForeshadow([makeRow("FS-01")]));
|
||||
let ok;
|
||||
await act(async () => {
|
||||
ok = await result.current.register(registerInput);
|
||||
});
|
||||
|
||||
expect(ok).toBe(true);
|
||||
expect(result.current.items).toHaveLength(2);
|
||||
expect(result.current.items[1]).toEqual(row);
|
||||
expect(result.current.busy).toBe(false);
|
||||
expect(toast).toHaveBeenCalledWith("已登记伏笔 FS-02", "success");
|
||||
});
|
||||
|
||||
it("登记失败(422 duplicate):不改 items 并弹 reason toast", async () => {
|
||||
post.mockResolvedValue({
|
||||
data: null,
|
||||
error: { error: { details: { reason: "duplicate" } } },
|
||||
});
|
||||
|
||||
const initial = [makeRow("FS-01")];
|
||||
const { result } = renderHook(() => useForeshadow(initial));
|
||||
let ok;
|
||||
await act(async () => {
|
||||
ok = await result.current.register(registerInput);
|
||||
});
|
||||
|
||||
expect(ok).toBe(false);
|
||||
expect(result.current.items).toEqual(initial);
|
||||
expect(toast).toHaveBeenCalledWith("该伏笔代号已存在,请换一个。", "error");
|
||||
});
|
||||
|
||||
it("转移成功:乐观改 status 后用服务端权威行替换", async () => {
|
||||
const server = makeRow("FS-01", "CLOSED");
|
||||
patch.mockResolvedValue({ data: server, error: null });
|
||||
|
||||
const { result } = renderHook(() => useForeshadow([makeRow("FS-01")]));
|
||||
let ok;
|
||||
await act(async () => {
|
||||
ok = await result.current.transition("FS-01", {
|
||||
projectId: "p1",
|
||||
toStatus: "CLOSED",
|
||||
});
|
||||
});
|
||||
|
||||
expect(ok).toBe(true);
|
||||
expect(result.current.items[0]).toEqual(server);
|
||||
expect(toast).toHaveBeenCalledWith("已更新 FS-01", "success");
|
||||
});
|
||||
|
||||
it("转移失败:回滚到旧 items 并弹 reason toast", async () => {
|
||||
patch.mockResolvedValue({
|
||||
data: null,
|
||||
error: { error: { details: { reason: "invalid_transition" } } },
|
||||
});
|
||||
|
||||
const initial = [makeRow("FS-01", "PARTIAL")];
|
||||
const { result } = renderHook(() => useForeshadow(initial));
|
||||
let ok;
|
||||
await act(async () => {
|
||||
ok = await result.current.transition("FS-01", {
|
||||
projectId: "p1",
|
||||
toStatus: "CLOSED",
|
||||
});
|
||||
});
|
||||
|
||||
expect(ok).toBe(false);
|
||||
expect(result.current.items).toEqual(initial);
|
||||
expect(toast).toHaveBeenCalledWith(
|
||||
"非法状态转移(CLOSED 为终态,不可再改)。",
|
||||
"error",
|
||||
);
|
||||
});
|
||||
|
||||
it("转移无 toStatus 时不乐观改、仅按服务端结果替换", async () => {
|
||||
const server = makeRow("FS-01", "OPEN");
|
||||
patch.mockResolvedValue({ data: server, error: null });
|
||||
|
||||
const { result } = renderHook(() => useForeshadow([makeRow("FS-01")]));
|
||||
let ok;
|
||||
await act(async () => {
|
||||
ok = await result.current.transition("FS-01", {
|
||||
projectId: "p1",
|
||||
progressEntry: { chapter: 5, note: "推进" },
|
||||
});
|
||||
});
|
||||
|
||||
expect(ok).toBe(true);
|
||||
expect(result.current.items[0]).toEqual(server);
|
||||
});
|
||||
});
|
||||
187
apps/web/lib/generation/useCharacterGen.test.ts
Normal file
187
apps/web/lib/generation/useCharacterGen.test.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { CharacterCardView } from "@/lib/api/types";
|
||||
import { useCharacterGen } from "./useCharacterGen";
|
||||
|
||||
// 后端客户端与 Toast 是 hook 的外部副作用边界,单测一律 mock。
|
||||
const post = vi.fn();
|
||||
const toast = vi.fn();
|
||||
vi.mock("@/lib/api/client", () => ({ api: { POST: (...a: unknown[]) => post(...a) } }));
|
||||
vi.mock("@/components/Toast", () => ({ useToast: () => toast }));
|
||||
|
||||
const conflictEnvelope = {
|
||||
error: {
|
||||
code: "CONFLICT_UNRESOLVED",
|
||||
details: {
|
||||
conflicts: [{ type: "改名", where: "第3章", refs: ["林风"], suggestion: "沿用旧名" }],
|
||||
conflict_count: 1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe("useCharacterGen", () => {
|
||||
beforeEach(() => {
|
||||
post.mockReset();
|
||||
toast.mockReset();
|
||||
});
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
it("初始为 idle、空卡、入库 idle、无冲突", () => {
|
||||
const { result } = renderHook(() => useCharacterGen());
|
||||
expect(result.current.genStatus).toBe("idle");
|
||||
expect(result.current.cards).toEqual([]);
|
||||
expect(result.current.ingestStatus).toBe("idle");
|
||||
expect(result.current.conflicts).toBeNull();
|
||||
expect(result.current.created).toEqual([]);
|
||||
});
|
||||
|
||||
// —— generate ——
|
||||
|
||||
it("生成成功:genStatus 走到 preview 并填充卡片", async () => {
|
||||
const cards = [{ name: "林风", role: "主角" }];
|
||||
post.mockResolvedValue({ data: { cards }, error: null });
|
||||
|
||||
const { result } = renderHook(() => useCharacterGen());
|
||||
await act(async () => {
|
||||
await result.current.generate({ projectId: "p1", brief: "热血少年", count: 3, role: "主角" });
|
||||
});
|
||||
|
||||
expect(result.current.genStatus).toBe("preview");
|
||||
expect(result.current.cards).toEqual(cards);
|
||||
expect(toast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("生成成功但 cards 缺失:回退为空数组", async () => {
|
||||
post.mockResolvedValue({ data: { cards: null }, error: null });
|
||||
const { result } = renderHook(() => useCharacterGen());
|
||||
await act(async () => {
|
||||
await result.current.generate({ projectId: "p1", brief: "b", count: 1 });
|
||||
});
|
||||
expect(result.current.genStatus).toBe("preview");
|
||||
expect(result.current.cards).toEqual([]);
|
||||
});
|
||||
|
||||
it("生成后端返回 error:genStatus=error 且弹错误 toast", async () => {
|
||||
post.mockResolvedValue({ data: null, error: { detail: "配额不足" } });
|
||||
const { result } = renderHook(() => useCharacterGen());
|
||||
await act(async () => {
|
||||
await result.current.generate({ projectId: "p1", brief: "b", count: 2 });
|
||||
});
|
||||
expect(result.current.genStatus).toBe("error");
|
||||
expect(toast).toHaveBeenCalledWith(expect.any(String), "error");
|
||||
});
|
||||
|
||||
it("生成请求抛异常:genStatus=error 且弹网络异常 toast", async () => {
|
||||
post.mockRejectedValue(new Error("network down"));
|
||||
const { result } = renderHook(() => useCharacterGen());
|
||||
await act(async () => {
|
||||
await result.current.generate({ projectId: "p1", brief: "b", count: 2 });
|
||||
});
|
||||
expect(result.current.genStatus).toBe("error");
|
||||
expect(toast).toHaveBeenCalledWith("生成请求异常,请检查网络。", "error");
|
||||
});
|
||||
|
||||
// —— ingest ——
|
||||
|
||||
it("入库空选择:拒绝并提示,返回 false", async () => {
|
||||
const { result } = renderHook(() => useCharacterGen());
|
||||
let ok = true;
|
||||
await act(async () => {
|
||||
ok = await result.current.ingest("p1", []);
|
||||
});
|
||||
expect(ok).toBe(false);
|
||||
expect(toast).toHaveBeenCalledWith("请至少选择一张角色卡。", "error");
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("入库成功:ingestStatus=done、记录 created、弹成功 toast,返回 true", async () => {
|
||||
post.mockResolvedValue({ data: { created: ["c1", "c2"] }, error: null });
|
||||
const { result } = renderHook(() => useCharacterGen());
|
||||
let ok = false;
|
||||
await act(async () => {
|
||||
ok = await result.current.ingest("p1", [{ name: "林风" } as CharacterCardView]);
|
||||
});
|
||||
expect(ok).toBe(true);
|
||||
expect(result.current.ingestStatus).toBe("done");
|
||||
expect(result.current.created).toEqual(["c1", "c2"]);
|
||||
expect(result.current.conflicts).toBeNull();
|
||||
expect(toast).toHaveBeenCalledWith("已入库 2 个角色", "success");
|
||||
});
|
||||
|
||||
it("入库成功但 created 缺失:回退空数组并提示 0 个", async () => {
|
||||
post.mockResolvedValue({ data: { created: null }, error: null });
|
||||
const { result } = renderHook(() => useCharacterGen());
|
||||
await act(async () => {
|
||||
await result.current.ingest("p1", [{ name: "林风" } as CharacterCardView]);
|
||||
});
|
||||
expect(result.current.created).toEqual([]);
|
||||
expect(toast).toHaveBeenCalledWith("已入库 0 个角色", "success");
|
||||
});
|
||||
|
||||
it("入库成功且有越权写表:额外弹 info toast", async () => {
|
||||
post.mockResolvedValue({
|
||||
data: { created: ["c1"], rejected_tables: ["secrets", "rules"] },
|
||||
error: null,
|
||||
});
|
||||
const { result } = renderHook(() => useCharacterGen());
|
||||
await act(async () => {
|
||||
await result.current.ingest("p1", [{ name: "林风" } as CharacterCardView]);
|
||||
});
|
||||
expect(toast).toHaveBeenCalledWith("越权写表被丢弃:secrets、rules", "info");
|
||||
});
|
||||
|
||||
it("入库 409 冲突:ingestStatus=conflict 并暴露 conflicts,返回 false", async () => {
|
||||
post.mockResolvedValue({ data: null, error: conflictEnvelope });
|
||||
const { result } = renderHook(() => useCharacterGen());
|
||||
let ok = true;
|
||||
await act(async () => {
|
||||
ok = await result.current.ingest("p1", [{ name: "林风" } as CharacterCardView]);
|
||||
});
|
||||
expect(ok).toBe(false);
|
||||
expect(result.current.ingestStatus).toBe("conflict");
|
||||
expect(result.current.conflicts?.conflictCount).toBe(1);
|
||||
expect(result.current.conflicts?.conflicts).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("入库非冲突 error:ingestStatus=error 且弹错误 toast,返回 false", async () => {
|
||||
post.mockResolvedValue({ data: null, error: { error: { code: "LLM_UNAVAILABLE" } } });
|
||||
const { result } = renderHook(() => useCharacterGen());
|
||||
let ok = true;
|
||||
await act(async () => {
|
||||
ok = await result.current.ingest("p1", [{ name: "林风" } as CharacterCardView]);
|
||||
});
|
||||
expect(ok).toBe(false);
|
||||
expect(result.current.ingestStatus).toBe("error");
|
||||
expect(toast).toHaveBeenCalledWith(expect.any(String), "error");
|
||||
});
|
||||
|
||||
it("入库请求抛异常:ingestStatus=error 且弹网络异常 toast,返回 false", async () => {
|
||||
post.mockRejectedValue(new Error("boom"));
|
||||
const { result } = renderHook(() => useCharacterGen());
|
||||
let ok = true;
|
||||
await act(async () => {
|
||||
ok = await result.current.ingest("p1", [{ name: "林风" } as CharacterCardView]);
|
||||
});
|
||||
expect(ok).toBe(false);
|
||||
expect(result.current.ingestStatus).toBe("error");
|
||||
expect(toast).toHaveBeenCalledWith("入库请求异常,请检查网络。", "error");
|
||||
});
|
||||
|
||||
// —— reset ——
|
||||
|
||||
it("reset 清回初始态", async () => {
|
||||
post.mockResolvedValue({ data: { cards: [{ name: "林风" } as CharacterCardView] }, error: null });
|
||||
const { result } = renderHook(() => useCharacterGen());
|
||||
await act(async () => {
|
||||
await result.current.generate({ projectId: "p1", brief: "b", count: 1 });
|
||||
});
|
||||
act(() => result.current.reset());
|
||||
expect(result.current.genStatus).toBe("idle");
|
||||
expect(result.current.cards).toEqual([]);
|
||||
expect(result.current.ingestStatus).toBe("idle");
|
||||
expect(result.current.conflicts).toBeNull();
|
||||
expect(result.current.created).toEqual([]);
|
||||
});
|
||||
});
|
||||
80
apps/web/lib/generation/useWorldGen.test.ts
Normal file
80
apps/web/lib/generation/useWorldGen.test.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { useWorldGen } from "./useWorldGen";
|
||||
|
||||
// 后端客户端与 Toast 是 hook 的外部副作用边界,单测一律 mock。
|
||||
const post = vi.fn();
|
||||
const toast = vi.fn();
|
||||
vi.mock("@/lib/api/client", () => ({ api: { POST: (...a: unknown[]) => post(...a) } }));
|
||||
vi.mock("@/components/Toast", () => ({ useToast: () => toast }));
|
||||
|
||||
describe("useWorldGen", () => {
|
||||
beforeEach(() => {
|
||||
post.mockReset();
|
||||
toast.mockReset();
|
||||
});
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
it("初始为 idle、无实体", () => {
|
||||
const { result } = renderHook(() => useWorldGen());
|
||||
expect(result.current.status).toBe("idle");
|
||||
expect(result.current.entities).toEqual([]);
|
||||
});
|
||||
|
||||
it("生成成功:status 走到 done 并填充实体", async () => {
|
||||
const entities = [{ id: "e1", kind: "place", name: "雾港" }];
|
||||
post.mockResolvedValue({ data: { entities }, error: null });
|
||||
|
||||
const { result } = renderHook(() => useWorldGen());
|
||||
await act(async () => {
|
||||
await result.current.generate("p1", "一个海港城市");
|
||||
});
|
||||
|
||||
expect(result.current.status).toBe("done");
|
||||
expect(result.current.entities).toEqual(entities);
|
||||
expect(toast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("data 为空时实体回退为空数组", async () => {
|
||||
post.mockResolvedValue({ data: { entities: null }, error: null });
|
||||
const { result } = renderHook(() => useWorldGen());
|
||||
await act(async () => {
|
||||
await result.current.generate("p1", "brief");
|
||||
});
|
||||
expect(result.current.status).toBe("done");
|
||||
expect(result.current.entities).toEqual([]);
|
||||
});
|
||||
|
||||
it("后端返回 error:status=error 且弹错误 toast", async () => {
|
||||
post.mockResolvedValue({ data: null, error: { detail: "配额不足" } });
|
||||
const { result } = renderHook(() => useWorldGen());
|
||||
await act(async () => {
|
||||
await result.current.generate("p1", "brief");
|
||||
});
|
||||
expect(result.current.status).toBe("error");
|
||||
expect(toast).toHaveBeenCalledWith(expect.any(String), "error");
|
||||
});
|
||||
|
||||
it("请求抛异常:status=error 且弹网络异常 toast", async () => {
|
||||
post.mockRejectedValue(new Error("network down"));
|
||||
const { result } = renderHook(() => useWorldGen());
|
||||
await act(async () => {
|
||||
await result.current.generate("p1", "brief");
|
||||
});
|
||||
expect(result.current.status).toBe("error");
|
||||
expect(toast).toHaveBeenCalledWith("生成请求异常,请检查网络。", "error");
|
||||
});
|
||||
|
||||
it("reset 清回 idle 与空实体", async () => {
|
||||
post.mockResolvedValue({ data: { entities: [{ id: "e1" }] }, error: null });
|
||||
const { result } = renderHook(() => useWorldGen());
|
||||
await act(async () => {
|
||||
await result.current.generate("p1", "brief");
|
||||
});
|
||||
act(() => result.current.reset());
|
||||
expect(result.current.status).toBe("idle");
|
||||
expect(result.current.entities).toEqual([]);
|
||||
});
|
||||
});
|
||||
139
apps/web/lib/jobs/useJobPoll.test.ts
Normal file
139
apps/web/lib/jobs/useJobPoll.test.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { useJobPoll } from "./useJobPoll";
|
||||
|
||||
// 后端客户端是 hook 的副作用边界;状态机(job.ts)保留真实。
|
||||
const get = vi.fn();
|
||||
vi.mock("@/lib/api/client", () => ({ api: { GET: (...a: unknown[]) => get(...a) } }));
|
||||
|
||||
const POLL_INTERVAL_MS = 1500;
|
||||
|
||||
describe("useJobPoll", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
get.mockReset();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("初始为 polling、进度 0、无 job", () => {
|
||||
const { result } = renderHook(() => useJobPoll());
|
||||
expect(result.current.status).toBe("polling");
|
||||
expect(result.current.progress).toBe(0);
|
||||
expect(result.current.job).toBeNull();
|
||||
});
|
||||
|
||||
it("轮询循环:running 持续轮询,done 终态后停止", async () => {
|
||||
// Arrange:首拍 running(50),次拍 done(100)。
|
||||
get
|
||||
.mockResolvedValueOnce({
|
||||
data: { id: "j1", status: "running", progress: 50 },
|
||||
error: null,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
data: { id: "j1", status: "done", progress: 100, result: {} },
|
||||
error: null,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useJobPoll());
|
||||
|
||||
// Act:启动轮询,首拍立即执行。
|
||||
await act(async () => {
|
||||
result.current.poll("j1");
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
expect(result.current.status).toBe("polling");
|
||||
expect(result.current.progress).toBe(50);
|
||||
|
||||
// 推进一个轮询间隔触发次拍 → 终态 done。
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS);
|
||||
});
|
||||
|
||||
// Assert:done、进度 100;不再继续轮询(仅两次 GET)。
|
||||
expect(result.current.status).toBe("done");
|
||||
expect(result.current.progress).toBe(100);
|
||||
expect(get).toHaveBeenCalledTimes(2);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 3);
|
||||
});
|
||||
expect(get).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("job failed:进入 error 终态并带后端错误文案", async () => {
|
||||
get.mockResolvedValue({
|
||||
data: { id: "j1", status: "failed", progress: 30, error: "配额耗尽" },
|
||||
error: null,
|
||||
});
|
||||
const { result } = renderHook(() => useJobPoll());
|
||||
await act(async () => {
|
||||
result.current.poll("j1");
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
expect(result.current.status).toBe("error");
|
||||
expect(result.current.error).toBe("配额耗尽");
|
||||
});
|
||||
|
||||
it("后端返回 error 信封:dispatch 失败并显示通用文案", async () => {
|
||||
get.mockResolvedValue({ data: null, error: { detail: "500" } });
|
||||
const { result } = renderHook(() => useJobPoll());
|
||||
await act(async () => {
|
||||
result.current.poll("j1");
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
expect(result.current.status).toBe("error");
|
||||
expect(result.current.error).toBe("轮询任务状态失败");
|
||||
});
|
||||
|
||||
it("请求抛异常:捕获并以异常消息进入 error", async () => {
|
||||
get.mockRejectedValue(new Error("network down"));
|
||||
const { result } = renderHook(() => useJobPoll());
|
||||
await act(async () => {
|
||||
result.current.poll("j1");
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
expect(result.current.status).toBe("error");
|
||||
expect(result.current.error).toBe("network down");
|
||||
});
|
||||
|
||||
it("reset 停止当前轮询:后续不再发起 GET", async () => {
|
||||
get.mockResolvedValue({
|
||||
data: { id: "j1", status: "running", progress: 10 },
|
||||
error: null,
|
||||
});
|
||||
const { result } = renderHook(() => useJobPoll());
|
||||
await act(async () => {
|
||||
result.current.poll("j1");
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
const callsBefore = get.mock.calls.length;
|
||||
act(() => result.current.reset());
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 2);
|
||||
});
|
||||
expect(get.mock.calls.length).toBe(callsBefore);
|
||||
});
|
||||
|
||||
it("卸载清理:定时器被清除,不再继续轮询", async () => {
|
||||
get.mockResolvedValue({
|
||||
data: { id: "j1", status: "running", progress: 10 },
|
||||
error: null,
|
||||
});
|
||||
const { result, unmount } = renderHook(() => useJobPoll());
|
||||
await act(async () => {
|
||||
result.current.poll("j1");
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
const callsBefore = get.mock.calls.length;
|
||||
unmount();
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 2);
|
||||
});
|
||||
expect(get.mock.calls.length).toBe(callsBefore);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { aiToolItems } from "./ai-tools";
|
||||
import {
|
||||
aiToolItems,
|
||||
primaryAiToolItems,
|
||||
secondaryAiToolItems,
|
||||
} from "./ai-tools";
|
||||
|
||||
describe("aiToolItems", () => {
|
||||
it("returns 5 items", () => {
|
||||
@@ -25,4 +29,16 @@ describe("aiToolItems", () => {
|
||||
expect(review?.href).toBe("/projects/p1/review");
|
||||
expect(review?.key).toBe("review");
|
||||
});
|
||||
|
||||
it("keeps high-frequency tools outside the mobile more menu", () => {
|
||||
expect(primaryAiToolItems("p1").map((item) => item.key)).toEqual([
|
||||
"write",
|
||||
"review",
|
||||
]);
|
||||
expect(secondaryAiToolItems("p1").map((item) => item.key)).toEqual([
|
||||
"outline",
|
||||
"codex",
|
||||
"toolbox",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,8 +8,6 @@ import type { ActiveNav } from "./items";
|
||||
export interface AiToolItem {
|
||||
href: string;
|
||||
label: string;
|
||||
// 行首字形(纸感,非语义;aria-hidden 渲染)。
|
||||
glyph: string;
|
||||
// 高亮键(与 AppShell activeNav 比对)。
|
||||
key: ActiveNav;
|
||||
// 主动作(写本章)朱砂强调,其余次级。
|
||||
@@ -20,10 +18,18 @@ export interface AiToolItem {
|
||||
export function aiToolItems(projectId: string): AiToolItem[] {
|
||||
const base = `/projects/${projectId}`;
|
||||
return [
|
||||
{ href: `${base}/write`, label: "写本章", glyph: "✍", key: "write", primary: true },
|
||||
{ href: `${base}/review`, label: "审稿", glyph: "✦", key: "review" },
|
||||
{ href: `${base}/outline`, label: "大纲", glyph: "❡", key: "outline" },
|
||||
{ href: `${base}/codex`, label: "设定库", glyph: "❖", key: "codex" },
|
||||
{ href: `${base}/toolbox`, label: "工具箱", glyph: "✧", key: "toolbox" },
|
||||
{ href: `${base}/write`, label: "写本章", key: "write", primary: true },
|
||||
{ href: `${base}/review`, label: "审稿", key: "review" },
|
||||
{ href: `${base}/outline`, label: "大纲", key: "outline" },
|
||||
{ href: `${base}/codex`, label: "设定库", key: "codex" },
|
||||
{ href: `${base}/toolbox`, label: "工具箱", key: "toolbox" },
|
||||
];
|
||||
}
|
||||
|
||||
export function primaryAiToolItems(projectId: string): AiToolItem[] {
|
||||
return aiToolItems(projectId).slice(0, 2);
|
||||
}
|
||||
|
||||
export function secondaryAiToolItems(projectId: string): AiToolItem[] {
|
||||
return aiToolItems(projectId).slice(2);
|
||||
}
|
||||
|
||||
89
apps/web/lib/outline/useOutline.test.ts
Normal file
89
apps/web/lib/outline/useOutline.test.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { useOutline } from "./useOutline";
|
||||
import type { OutlineChapterView } from "@/lib/api/types";
|
||||
|
||||
// 后端客户端与 Toast 是 hook 的外部副作用边界,单测一律 mock。
|
||||
const post = vi.fn();
|
||||
const toast = vi.fn();
|
||||
vi.mock("@/lib/api/client", () => ({ api: { POST: (...a: unknown[]) => post(...a) } }));
|
||||
vi.mock("@/components/Toast", () => ({ useToast: () => toast }));
|
||||
|
||||
function makeChapter(no: number): OutlineChapterView {
|
||||
return { no, volume: 1 };
|
||||
}
|
||||
|
||||
describe("useOutline", () => {
|
||||
beforeEach(() => {
|
||||
post.mockReset();
|
||||
toast.mockReset();
|
||||
});
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
it("空初始:status=idle", () => {
|
||||
const { result } = renderHook(() => useOutline([]));
|
||||
expect(result.current.status).toBe("idle");
|
||||
expect(result.current.chapters).toEqual([]);
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
|
||||
it("非空初始:status=ready 并保留章节", () => {
|
||||
const initial = [makeChapter(1)];
|
||||
const { result } = renderHook(() => useOutline(initial));
|
||||
expect(result.current.status).toBe("ready");
|
||||
expect(result.current.chapters).toEqual(initial);
|
||||
});
|
||||
|
||||
it("生成成功:status 走到 ready 并填充章节并弹成功 toast", async () => {
|
||||
const chapters = [makeChapter(1), makeChapter(2)];
|
||||
post.mockResolvedValue({ data: { chapters }, error: null });
|
||||
|
||||
const { result } = renderHook(() => useOutline([]));
|
||||
await act(async () => {
|
||||
await result.current.generate("p1", 1);
|
||||
});
|
||||
|
||||
expect(result.current.status).toBe("ready");
|
||||
expect(result.current.chapters).toEqual(chapters);
|
||||
expect(result.current.error).toBeNull();
|
||||
expect(toast).toHaveBeenCalledWith("大纲已生成", "success");
|
||||
});
|
||||
|
||||
it("data.chapters 为空时章节回退为空数组", async () => {
|
||||
post.mockResolvedValue({ data: { chapters: null }, error: null });
|
||||
const { result } = renderHook(() => useOutline([]));
|
||||
await act(async () => {
|
||||
await result.current.generate("p1", 1);
|
||||
});
|
||||
expect(result.current.status).toBe("ready");
|
||||
expect(result.current.chapters).toEqual([]);
|
||||
});
|
||||
|
||||
it("后端返回业务错误:status=error 并 surface 友好文案", async () => {
|
||||
post.mockResolvedValue({
|
||||
data: null,
|
||||
error: { error: { code: "LLM_UNAVAILABLE" } },
|
||||
});
|
||||
const { result } = renderHook(() => useOutline([]));
|
||||
await act(async () => {
|
||||
await result.current.generate("p1", 1);
|
||||
});
|
||||
|
||||
expect(result.current.status).toBe("error");
|
||||
expect(result.current.error?.code).toBe("OUTLINE_FAILED");
|
||||
expect(result.current.error?.message).toContain("provider");
|
||||
expect(toast).toHaveBeenCalledWith(expect.any(String), "error");
|
||||
});
|
||||
|
||||
it("data 为空且无 error 也走错误分支", async () => {
|
||||
post.mockResolvedValue({ data: null, error: null });
|
||||
const { result } = renderHook(() => useOutline([]));
|
||||
await act(async () => {
|
||||
await result.current.generate("p1", 1);
|
||||
});
|
||||
expect(result.current.status).toBe("error");
|
||||
expect(result.current.error?.code).toBe("OUTLINE_FAILED");
|
||||
});
|
||||
});
|
||||
129
apps/web/lib/projects/projects.test.ts
Normal file
129
apps/web/lib/projects/projects.test.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { ProjectResponse } from "@/lib/api/types";
|
||||
import {
|
||||
filterProjects,
|
||||
formatProjectUpdatedAt,
|
||||
pendingReviewCount,
|
||||
} from "./projects";
|
||||
|
||||
const projects: ProjectResponse[] = [
|
||||
{
|
||||
id: "1",
|
||||
title: "逐光而行",
|
||||
genre: "玄幻",
|
||||
logline: "少年逆袭",
|
||||
premise: null,
|
||||
theme: "抗争",
|
||||
selling_points: [],
|
||||
structure: null,
|
||||
updated_at: "2026-06-27T10:00:00Z",
|
||||
pending_review_count: 0,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
title: "城南旧案",
|
||||
genre: null,
|
||||
logline: "悬疑调查",
|
||||
premise: null,
|
||||
theme: null,
|
||||
selling_points: [],
|
||||
structure: null,
|
||||
updated_at: "2026-06-28T08:00:00Z",
|
||||
pending_review_count: 2,
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
title: "星港来信",
|
||||
genre: "科幻",
|
||||
logline: "远航与归乡",
|
||||
premise: null,
|
||||
theme: null,
|
||||
selling_points: [],
|
||||
structure: null,
|
||||
updated_at: "2026-06-26T09:00:00Z",
|
||||
pending_review_count: 1,
|
||||
},
|
||||
];
|
||||
|
||||
describe("filterProjects", () => {
|
||||
it("searches title, genre, logline and theme", () => {
|
||||
expect(
|
||||
filterProjects(projects, {
|
||||
search: "悬疑",
|
||||
filter: "all",
|
||||
sort: "title",
|
||||
}).map((p) => p.title),
|
||||
).toEqual(["城南旧案"]);
|
||||
|
||||
expect(
|
||||
filterProjects(projects, {
|
||||
search: "抗争",
|
||||
filter: "all",
|
||||
sort: "title",
|
||||
}).map((p) => p.title),
|
||||
).toEqual(["逐光而行"]);
|
||||
});
|
||||
|
||||
it("filters by categorization state", () => {
|
||||
expect(
|
||||
filterProjects(projects, {
|
||||
search: "",
|
||||
filter: "with_genre",
|
||||
sort: "title",
|
||||
}),
|
||||
).toHaveLength(2);
|
||||
expect(
|
||||
filterProjects(projects, {
|
||||
search: "",
|
||||
filter: "uncategorized",
|
||||
sort: "title",
|
||||
}).map((p) => p.title),
|
||||
).toEqual(["城南旧案"]);
|
||||
});
|
||||
|
||||
it("filters projects waiting for review", () => {
|
||||
expect(
|
||||
filterProjects(projects, {
|
||||
search: "",
|
||||
filter: "pending_review",
|
||||
sort: "title",
|
||||
}).map((p) => p.title),
|
||||
).toEqual(["城南旧案", "星港来信"]);
|
||||
});
|
||||
|
||||
it("sorts by recently edited first", () => {
|
||||
expect(
|
||||
filterProjects(projects, {
|
||||
search: "",
|
||||
filter: "all",
|
||||
sort: "updated_at",
|
||||
}).map((p) => p.title),
|
||||
).toEqual(["城南旧案", "逐光而行", "星港来信"]);
|
||||
});
|
||||
|
||||
it("sorts by genre with title fallback", () => {
|
||||
expect(
|
||||
filterProjects(projects, {
|
||||
search: "",
|
||||
filter: "all",
|
||||
sort: "genre",
|
||||
}).map((p) => p.title),
|
||||
).toEqual(["星港来信", "逐光而行", "城南旧案"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("project metadata helpers", () => {
|
||||
it("normalizes pending review count", () => {
|
||||
expect(pendingReviewCount(projects[1]!)).toBe(2);
|
||||
expect(pendingReviewCount({ ...projects[1]!, pending_review_count: -1 })).toBe(0);
|
||||
});
|
||||
|
||||
it("formats updated_at with a fallback", () => {
|
||||
expect(formatProjectUpdatedAt(null)).toBe("暂无编辑记录");
|
||||
expect(formatProjectUpdatedAt("bad")).toBe("暂无编辑记录");
|
||||
expect(formatProjectUpdatedAt("2026-06-28T08:00:00Z")).not.toBe(
|
||||
"暂无编辑记录",
|
||||
);
|
||||
});
|
||||
});
|
||||
80
apps/web/lib/projects/projects.ts
Normal file
80
apps/web/lib/projects/projects.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import type { ProjectResponse } from "@/lib/api/types";
|
||||
|
||||
export type ProjectFilter =
|
||||
| "all"
|
||||
| "with_genre"
|
||||
| "uncategorized"
|
||||
| "pending_review";
|
||||
export type ProjectSort = "updated_at" | "title" | "genre";
|
||||
export type ProjectViewMode = "cards" | "compact";
|
||||
|
||||
export interface ProjectQuery {
|
||||
search: string;
|
||||
filter: ProjectFilter;
|
||||
sort: ProjectSort;
|
||||
}
|
||||
|
||||
export function filterProjects(
|
||||
projects: readonly ProjectResponse[],
|
||||
query: ProjectQuery,
|
||||
): ProjectResponse[] {
|
||||
const needle = query.search.trim().toLocaleLowerCase();
|
||||
const filtered = projects.filter((project) => {
|
||||
if (query.filter === "with_genre" && !project.genre) return false;
|
||||
if (query.filter === "uncategorized" && project.genre) return false;
|
||||
if (query.filter === "pending_review" && pendingReviewCount(project) === 0) {
|
||||
return false;
|
||||
}
|
||||
if (needle.length === 0) return true;
|
||||
|
||||
return [project.title, project.genre, project.logline, project.theme]
|
||||
.filter((value): value is string => typeof value === "string")
|
||||
.some((value) => value.toLocaleLowerCase().includes(needle));
|
||||
});
|
||||
|
||||
return [...filtered].sort((a, b) => compareProject(a, b, query.sort));
|
||||
}
|
||||
|
||||
function compareProject(
|
||||
a: ProjectResponse,
|
||||
b: ProjectResponse,
|
||||
sort: ProjectSort,
|
||||
): number {
|
||||
if (sort === "updated_at") {
|
||||
const byUpdated = timestamp(b.updated_at) - timestamp(a.updated_at);
|
||||
if (byUpdated !== 0) return byUpdated;
|
||||
}
|
||||
if (sort === "genre") {
|
||||
if (a.genre && !b.genre) return -1;
|
||||
if (!a.genre && b.genre) return 1;
|
||||
const byGenre = label(a.genre).localeCompare(label(b.genre), "zh-Hans-CN");
|
||||
if (byGenre !== 0) return byGenre;
|
||||
}
|
||||
return label(a.title).localeCompare(label(b.title), "zh-Hans-CN");
|
||||
}
|
||||
|
||||
export function pendingReviewCount(project: ProjectResponse): number {
|
||||
return Math.max(0, project.pending_review_count ?? 0);
|
||||
}
|
||||
|
||||
export function formatProjectUpdatedAt(value: string | null | undefined): string {
|
||||
if (!value) return "暂无编辑记录";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "暂无编辑记录";
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function label(value: string | null | undefined): string {
|
||||
return value?.trim() || "未命名";
|
||||
}
|
||||
|
||||
function timestamp(value: string | null | undefined): number {
|
||||
if (!value) return 0;
|
||||
const time = new Date(value).getTime();
|
||||
return Number.isNaN(time) ? 0 : time;
|
||||
}
|
||||
132
apps/web/lib/review/useAccept.test.ts
Normal file
132
apps/web/lib/review/useAccept.test.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { useAccept } from "./useAccept";
|
||||
import type { DecisionDraft } from "./decisions";
|
||||
|
||||
// 后端客户端与 Toast 是 hook 的外部副作用边界,单测一律 mock。
|
||||
const post = vi.fn();
|
||||
const toast = vi.fn();
|
||||
vi.mock("@/lib/api/client", () => ({ api: { POST: (...a: unknown[]) => post(...a) } }));
|
||||
vi.mock("@/components/Toast", () => ({ useToast: () => toast }));
|
||||
|
||||
const drafts: readonly DecisionDraft[] = [{ verdict: "accept", note: "" }];
|
||||
|
||||
describe("useAccept", () => {
|
||||
beforeEach(() => {
|
||||
post.mockReset();
|
||||
toast.mockReset();
|
||||
});
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
it("初始为 idle、无结果", () => {
|
||||
const { result } = renderHook(() => useAccept());
|
||||
expect(result.current.status).toBe("idle");
|
||||
expect(result.current.result).toBeNull();
|
||||
});
|
||||
|
||||
it("验收成功:status=accepted 并回填结果且弹成功 toast", async () => {
|
||||
const data = { updated: ["digest"] };
|
||||
post.mockResolvedValue({ data, error: null });
|
||||
|
||||
const { result } = renderHook(() => useAccept());
|
||||
let outcome;
|
||||
await act(async () => {
|
||||
outcome = await result.current.accept("p1", 3, "正文", drafts);
|
||||
});
|
||||
|
||||
expect(result.current.status).toBe("accepted");
|
||||
expect(result.current.result).toEqual(data);
|
||||
expect(outcome).toEqual({
|
||||
result: data,
|
||||
missingIndices: [],
|
||||
conflictUnresolved: false,
|
||||
});
|
||||
expect(toast).toHaveBeenCalledWith("本章已验收", "success");
|
||||
});
|
||||
|
||||
it("409 CONFLICT_UNRESOLVED:解析缺判下标且不弹 toast", async () => {
|
||||
post.mockResolvedValue({
|
||||
data: null,
|
||||
error: {
|
||||
error: {
|
||||
code: "CONFLICT_UNRESOLVED",
|
||||
details: { missing_conflict_indices: [0, 2] },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useAccept());
|
||||
let outcome;
|
||||
await act(async () => {
|
||||
outcome = await result.current.accept("p1", 3, "正文", drafts);
|
||||
});
|
||||
|
||||
expect(result.current.status).toBe("error");
|
||||
expect(outcome).toEqual({
|
||||
result: null,
|
||||
missingIndices: [0, 2],
|
||||
conflictUnresolved: true,
|
||||
});
|
||||
expect(toast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("CONFLICT_UNRESOLVED 缺 details 时缺判下标回退为空数组", async () => {
|
||||
post.mockResolvedValue({
|
||||
data: null,
|
||||
error: { error: { code: "CONFLICT_UNRESOLVED" } },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useAccept());
|
||||
let outcome;
|
||||
await act(async () => {
|
||||
outcome = await result.current.accept("p1", 3, "正文", drafts);
|
||||
});
|
||||
|
||||
expect(outcome).toEqual({
|
||||
result: null,
|
||||
missingIndices: [],
|
||||
conflictUnresolved: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("其它错误:status=error 并弹失败 toast(正文未丢)", async () => {
|
||||
post.mockResolvedValue({
|
||||
data: null,
|
||||
error: { error: { code: "INTERNAL" } },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useAccept());
|
||||
let outcome;
|
||||
await act(async () => {
|
||||
outcome = await result.current.accept("p1", 3, "正文", drafts);
|
||||
});
|
||||
|
||||
expect(result.current.status).toBe("error");
|
||||
expect(outcome).toEqual({
|
||||
result: null,
|
||||
missingIndices: [],
|
||||
conflictUnresolved: false,
|
||||
});
|
||||
expect(toast).toHaveBeenCalledWith("验收失败,请重试(正文未丢失)", "error");
|
||||
});
|
||||
|
||||
it("error 为空但 data 也为空时走通用失败分支", async () => {
|
||||
post.mockResolvedValue({ data: null, error: undefined });
|
||||
|
||||
const { result } = renderHook(() => useAccept());
|
||||
let outcome;
|
||||
await act(async () => {
|
||||
outcome = await result.current.accept("p1", 3, "正文", drafts);
|
||||
});
|
||||
|
||||
expect(result.current.status).toBe("error");
|
||||
expect(outcome).toEqual({
|
||||
result: null,
|
||||
missingIndices: [],
|
||||
conflictUnresolved: false,
|
||||
});
|
||||
expect(toast).toHaveBeenCalledWith("验收失败,请重试(正文未丢失)", "error");
|
||||
});
|
||||
});
|
||||
235
apps/web/lib/review/useReviewStream.test.ts
Normal file
235
apps/web/lib/review/useReviewStream.test.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { useReviewStream } from "./useReviewStream";
|
||||
|
||||
// fetch(审稿 SSE 流)是 hook 的外部副作用边界,单测一律 stub 全局 fetch。
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
function sseStream(chunks: string[]): ReadableStream<Uint8Array> {
|
||||
const enc = new TextEncoder();
|
||||
return new ReadableStream({
|
||||
start(c) {
|
||||
for (const ch of chunks) c.enqueue(enc.encode(ch));
|
||||
c.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function sseResponse(chunks: string[]): Response {
|
||||
return new Response(sseStream(chunks), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/event-stream" },
|
||||
});
|
||||
}
|
||||
|
||||
describe("useReviewStream", () => {
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("初始为 idle、各结果集为空、未在审稿", () => {
|
||||
const { result } = renderHook(() => useReviewStream());
|
||||
expect(result.current.state.phase).toBe("idle");
|
||||
expect(result.current.state.conflicts).toEqual([]);
|
||||
expect(result.current.state.sections).toEqual([]);
|
||||
expect(result.current.isReviewing).toBe(false);
|
||||
});
|
||||
|
||||
it("流式累积四类结果:section/conflict/foreshadow/pace/style 折叠进状态", async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
sseResponse([
|
||||
'event:section\ndata:{"name":"一致性","status":"started"}\n\n',
|
||||
'event:section\ndata:{"name":"一致性","status":"done"}\n\n',
|
||||
'event:conflict\ndata:{"type":"性格漂移","where":"第3段","suggestion":"改回冷静","refs":["c1"]}\n\n',
|
||||
'event:foreshadow\ndata:{"kind":"planted","title":"神秘信物","code":"F1"}\n\n',
|
||||
'event:pace\ndata:{"water":[{"where":"开头","reason":"铺垫过长"}],"hook":true,"beat_map":[1,2,3]}\n\n',
|
||||
'event:style\ndata:{"score":0.82,"segments":[{"idx":0,"text":"原文","score":0.5,"label":"偏离"}]}\n\n',
|
||||
]),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useReviewStream());
|
||||
await act(async () => {
|
||||
await result.current.start("p1", 1, "草稿正文");
|
||||
});
|
||||
|
||||
const s = result.current.state;
|
||||
// section 按 name upsert:同名只留最后状态。
|
||||
expect(s.sections).toEqual([{ name: "一致性", status: "done" }]);
|
||||
expect(s.conflicts).toHaveLength(1);
|
||||
expect(s.conflicts[0]).toMatchObject({ type: "性格漂移", where: "第3段" });
|
||||
expect(s.foreshadow).toHaveLength(1);
|
||||
expect(s.foreshadow[0]).toMatchObject({ kind: "planted", title: "神秘信物" });
|
||||
expect(s.pace).toMatchObject({ hook: true, beat_map: [1, 2, 3] });
|
||||
expect(s.style?.score).toBe(0.82);
|
||||
expect(s.style?.segments[0]).toMatchObject({ idx: 0, text: "原文" });
|
||||
});
|
||||
|
||||
it("以 JSON body POST 当前草稿正文", async () => {
|
||||
fetchMock.mockResolvedValue(sseResponse(['event:done\ndata:{"length":0}\n\n']));
|
||||
|
||||
const { result } = renderHook(() => useReviewStream());
|
||||
await act(async () => {
|
||||
await result.current.start("p1", 7, "需要重审的草稿");
|
||||
});
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0]!;
|
||||
expect(String(url)).toContain("/projects/p1/chapters/7/review");
|
||||
expect(init.method).toBe("POST");
|
||||
expect(JSON.parse(init.body as string)).toEqual({ draft: "需要重审的草稿" });
|
||||
});
|
||||
|
||||
it("收到 done 帧后 phase 走到 done", async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
sseResponse([
|
||||
'event:conflict\ndata:{"type":"设定违例","where":"第1段","suggestion":"补设定"}\n\n',
|
||||
'event:done\ndata:{"length":120}\n\n',
|
||||
]),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useReviewStream());
|
||||
await act(async () => {
|
||||
await result.current.start("p1", 1, "draft");
|
||||
});
|
||||
|
||||
expect(result.current.state.phase).toBe("done");
|
||||
expect(result.current.state.conflicts).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("收到 error 帧:phase=error 且带错误码与文案", async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
sseResponse(['event:error\ndata:{"code":"TIMEOUT","message":"审稿超时"}\n\n']),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useReviewStream());
|
||||
await act(async () => {
|
||||
await result.current.start("p1", 1, "draft");
|
||||
});
|
||||
|
||||
expect(result.current.state.phase).toBe("error");
|
||||
expect(result.current.state.error).toMatchObject({
|
||||
code: "TIMEOUT",
|
||||
message: "审稿超时",
|
||||
});
|
||||
});
|
||||
|
||||
it("流前错误(!res.ok):解析 JSON 信封提取错误码与文案", async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({ error: { code: "LLM_UNAVAILABLE", message: "无可用凭据" } }),
|
||||
{ status: 503 },
|
||||
),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useReviewStream());
|
||||
await act(async () => {
|
||||
await result.current.start("p1", 1, "draft");
|
||||
});
|
||||
|
||||
expect(result.current.state.phase).toBe("error");
|
||||
expect(result.current.state.error).toMatchObject({
|
||||
code: "LLM_UNAVAILABLE",
|
||||
message: "无可用凭据",
|
||||
});
|
||||
});
|
||||
|
||||
it("流前错误且非 JSON 信封:沿用默认 REVIEW_FAILED 文案", async () => {
|
||||
fetchMock.mockResolvedValue(new Response("oops", { status: 500 }));
|
||||
|
||||
const { result } = renderHook(() => useReviewStream());
|
||||
await act(async () => {
|
||||
await result.current.start("p1", 1, "draft");
|
||||
});
|
||||
|
||||
expect(result.current.state.phase).toBe("error");
|
||||
expect(result.current.state.error?.code).toBe("REVIEW_FAILED");
|
||||
expect(result.current.state.error?.message).toContain("500");
|
||||
});
|
||||
|
||||
it("网络抛异常(非 Abort):phase=error 且 code=NETWORK", async () => {
|
||||
fetchMock.mockRejectedValue(new Error("connection reset"));
|
||||
|
||||
const { result } = renderHook(() => useReviewStream());
|
||||
await act(async () => {
|
||||
await result.current.start("p1", 1, "draft");
|
||||
});
|
||||
|
||||
expect(result.current.state.phase).toBe("error");
|
||||
expect(result.current.state.error).toMatchObject({
|
||||
code: "NETWORK",
|
||||
message: "connection reset",
|
||||
});
|
||||
});
|
||||
|
||||
it("非 Error 抛出:回退为未知网络错误文案", async () => {
|
||||
fetchMock.mockRejectedValue(42);
|
||||
|
||||
const { result } = renderHook(() => useReviewStream());
|
||||
await act(async () => {
|
||||
await result.current.start("p1", 1, "draft");
|
||||
});
|
||||
|
||||
expect(result.current.state.error?.message).toBe("未知网络错误");
|
||||
});
|
||||
|
||||
it("AbortError 被吞掉:不进入 error,phase 维持 reviewing", async () => {
|
||||
fetchMock.mockRejectedValue(new DOMException("aborted", "AbortError"));
|
||||
|
||||
const { result } = renderHook(() => useReviewStream());
|
||||
await act(async () => {
|
||||
await result.current.start("p1", 1, "draft");
|
||||
});
|
||||
|
||||
expect(result.current.state.phase).toBe("reviewing");
|
||||
expect(result.current.state.error).toBeNull();
|
||||
});
|
||||
|
||||
it("stop() 主动停止:abort 连接并将 phase 置 aborted", async () => {
|
||||
const hangingBody = new ReadableStream<Uint8Array>({});
|
||||
fetchMock.mockResolvedValue(new Response(hangingBody, { status: 200 }));
|
||||
|
||||
const { result } = renderHook(() => useReviewStream());
|
||||
act(() => {
|
||||
void result.current.start("p1", 1, "draft");
|
||||
});
|
||||
await act(async () => {
|
||||
result.current.stop();
|
||||
});
|
||||
|
||||
expect(result.current.state.phase).toBe("aborted");
|
||||
expect(result.current.isReviewing).toBe(false);
|
||||
});
|
||||
|
||||
it("seed() 用历史留痕种入状态(无需重审即可查看)", () => {
|
||||
const { result } = renderHook(() => useReviewStream());
|
||||
const seedData = {
|
||||
conflicts: [
|
||||
{
|
||||
type: "时间线倒错",
|
||||
where: "第2段",
|
||||
refs: [],
|
||||
suggestion: "对齐时间",
|
||||
original: null,
|
||||
replacement: null,
|
||||
},
|
||||
],
|
||||
foreshadow: [{ kind: "resolved" as const, title: "旧伏笔" }],
|
||||
pace: { water: [], hook: false, beat_map: [1] },
|
||||
style: { score: 0.9, segments: [] },
|
||||
};
|
||||
|
||||
act(() => result.current.seed(seedData));
|
||||
|
||||
expect(result.current.state.conflicts).toEqual(seedData.conflicts);
|
||||
expect(result.current.state.foreshadow).toEqual(seedData.foreshadow);
|
||||
expect(result.current.state.pace).toEqual(seedData.pace);
|
||||
expect(result.current.state.style).toEqual(seedData.style);
|
||||
expect(result.current.state.phase).toBe("idle");
|
||||
});
|
||||
});
|
||||
94
apps/web/lib/rules/useRules.test.ts
Normal file
94
apps/web/lib/rules/useRules.test.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { RuleView } from "@/lib/api/types";
|
||||
import { useRules } from "./useRules";
|
||||
|
||||
const post = vi.fn();
|
||||
const del = vi.fn();
|
||||
const toast = vi.fn();
|
||||
vi.mock("@/lib/api/client", () => ({
|
||||
api: { POST: (...a: unknown[]) => post(...a), DELETE: (...a: unknown[]) => del(...a) },
|
||||
}));
|
||||
vi.mock("@/components/Toast", () => ({ useToast: () => toast }));
|
||||
|
||||
const seed: RuleView[] = [{ id: "r0", level: "global", content: "已有规则" }];
|
||||
|
||||
describe("useRules", () => {
|
||||
beforeEach(() => {
|
||||
post.mockReset();
|
||||
del.mockReset();
|
||||
toast.mockReset();
|
||||
});
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
it("初始 items = 传入值,busy=false", () => {
|
||||
const { result } = renderHook(() => useRules(seed));
|
||||
expect(result.current.items).toEqual(seed);
|
||||
expect(result.current.busy).toBe(false);
|
||||
});
|
||||
|
||||
it("add 空白正文:弹错、不请求、返回 false", async () => {
|
||||
const { result } = renderHook(() => useRules(seed));
|
||||
let ok = true;
|
||||
await act(async () => {
|
||||
ok = await result.current.add("p1", "global", " ");
|
||||
});
|
||||
expect(ok).toBe(false);
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
expect(toast).toHaveBeenCalledWith("请填写规则正文。", "error");
|
||||
});
|
||||
|
||||
it("add 成功:用服务端权威行替换乐观行、弹成功、返回 true", async () => {
|
||||
const authoritative: RuleView = { id: "r1", level: "global", content: "新规则" };
|
||||
post.mockResolvedValue({ data: authoritative, error: null });
|
||||
|
||||
const { result } = renderHook(() => useRules(seed));
|
||||
let ok = false;
|
||||
await act(async () => {
|
||||
ok = await result.current.add("p1", "global", "新规则");
|
||||
});
|
||||
|
||||
expect(ok).toBe(true);
|
||||
expect(result.current.items).toEqual([...seed, authoritative]);
|
||||
expect(result.current.busy).toBe(false);
|
||||
expect(toast).toHaveBeenCalledWith("已新增规则", "success");
|
||||
});
|
||||
|
||||
it("add 后端 error:回滚到快照、弹错、返回 false", async () => {
|
||||
post.mockResolvedValue({ data: null, error: { detail: "boom" } });
|
||||
const { result } = renderHook(() => useRules(seed));
|
||||
let ok = true;
|
||||
await act(async () => {
|
||||
ok = await result.current.add("p1", "global", "新规则");
|
||||
});
|
||||
expect(ok).toBe(false);
|
||||
expect(result.current.items).toEqual(seed);
|
||||
expect(toast).toHaveBeenCalledWith("新增规则失败,请稍后重试。", "error");
|
||||
});
|
||||
|
||||
it("remove 成功:乐观移除、弹成功、返回 true", async () => {
|
||||
del.mockResolvedValue({ error: null });
|
||||
const { result } = renderHook(() => useRules(seed));
|
||||
let ok = false;
|
||||
await act(async () => {
|
||||
ok = await result.current.remove("p1", "r0");
|
||||
});
|
||||
expect(ok).toBe(true);
|
||||
expect(result.current.items).toEqual([]);
|
||||
expect(toast).toHaveBeenCalledWith("已删除规则", "success");
|
||||
});
|
||||
|
||||
it("remove 后端 error:回滚、弹错、返回 false", async () => {
|
||||
del.mockResolvedValue({ error: { detail: "boom" } });
|
||||
const { result } = renderHook(() => useRules(seed));
|
||||
let ok = true;
|
||||
await act(async () => {
|
||||
ok = await result.current.remove("p1", "r0");
|
||||
});
|
||||
expect(ok).toBe(false);
|
||||
expect(result.current.items).toEqual(seed);
|
||||
expect(toast).toHaveBeenCalledWith("删除规则失败,请稍后重试。", "error");
|
||||
});
|
||||
});
|
||||
219
apps/web/lib/settings/useKimiOauth.test.ts
Normal file
219
apps/web/lib/settings/useKimiOauth.test.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { useKimiOauth } from "./useKimiOauth";
|
||||
|
||||
// 后端客户端(POST start/disconnect + GET job/status,经真实 useJobPoll)与 Toast 是副作用边界。
|
||||
// kimiOauth.ts/job.ts 纯逻辑保留真实。
|
||||
const post = vi.fn();
|
||||
const get = vi.fn();
|
||||
const toast = vi.fn();
|
||||
vi.mock("@/lib/api/client", () => ({
|
||||
api: {
|
||||
POST: (...a: unknown[]) => post(...a),
|
||||
GET: (...a: unknown[]) => get(...a),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/components/Toast", () => ({ useToast: () => toast }));
|
||||
|
||||
const START = "/settings/providers/kimi-code/oauth/start";
|
||||
const DISCONNECT = "/settings/providers/kimi-code/oauth/disconnect";
|
||||
const STATUS = "/settings/providers/kimi-code/oauth/status";
|
||||
const JOB = "/jobs/{job_id}";
|
||||
|
||||
const startOk = {
|
||||
data: {
|
||||
job_id: "job-1",
|
||||
user_code: "ABCD-1234",
|
||||
verification_uri: "https://kimi.example/verify",
|
||||
verification_uri_complete: "https://kimi.example/verify?code=ABCD-1234",
|
||||
expires_in: 600,
|
||||
interval: 5,
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
|
||||
// 把 GET 按 path 路由:job 轮询 vs 状态查询。
|
||||
function routeGet(jobResult: unknown, statusResult: unknown): void {
|
||||
get.mockImplementation((path: string) => {
|
||||
if (path === JOB) return Promise.resolve(jobResult);
|
||||
return Promise.resolve(statusResult);
|
||||
});
|
||||
}
|
||||
|
||||
describe("useKimiOauth", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
post.mockReset();
|
||||
get.mockReset();
|
||||
toast.mockReset();
|
||||
vi.spyOn(window, "open").mockReturnValue(null);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("初始未连接:phase=idle、无 device、不忙、无错误", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useKimiOauth({ initialConnected: false, initialExpiresAt: null }),
|
||||
);
|
||||
expect(result.current.phase).toBe("idle");
|
||||
expect(result.current.device).toBeNull();
|
||||
expect(result.current.busy).toBe(false);
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
|
||||
it("初始已连接:phase=connected 并带过期时刻", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useKimiOauth({
|
||||
initialConnected: true,
|
||||
initialExpiresAt: "2026-12-31T00:00:00Z",
|
||||
}),
|
||||
);
|
||||
expect(result.current.phase).toBe("connected");
|
||||
expect(result.current.expiresAt).toBe("2026-12-31T00:00:00Z");
|
||||
});
|
||||
|
||||
it("connect 成功(pending→authorized):展示 device、轮询 done 后置已连接并弹成功 toast", async () => {
|
||||
// Arrange:start 拿到 device + job_id;job 首拍即 done 且 connected;状态查询确认已连接。
|
||||
post.mockImplementation((path: string) =>
|
||||
path === START ? Promise.resolve(startOk) : Promise.resolve({ data: {}, error: null }),
|
||||
);
|
||||
routeGet(
|
||||
{ data: { id: "job-1", status: "done", progress: 100, result: { connected: true } }, error: null },
|
||||
{ data: { connected: true, expires_at: "2027-01-01T00:00:00Z" }, error: null },
|
||||
);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useKimiOauth({ initialConnected: false, initialExpiresAt: null }),
|
||||
);
|
||||
|
||||
// Act:发起连接,刷掉 job 轮询 + refreshStatus 的微任务。
|
||||
await act(async () => {
|
||||
await result.current.connect();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
|
||||
// Assert:已展示 device、phase=connected、刷新到新过期时刻、弹成功 toast。
|
||||
expect(result.current.device?.userCode).toBe("ABCD-1234");
|
||||
expect(window.open).toHaveBeenCalledWith(
|
||||
"https://kimi.example/verify?code=ABCD-1234",
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
);
|
||||
expect(result.current.phase).toBe("connected");
|
||||
expect(result.current.expiresAt).toBe("2027-01-01T00:00:00Z");
|
||||
expect(toast).toHaveBeenCalledWith("已连接 Kimi Code。", "success");
|
||||
});
|
||||
|
||||
it("connect 后授权进行中:job 仍 running → phase=awaiting 且 busy", async () => {
|
||||
post.mockImplementation((path: string) =>
|
||||
path === START ? Promise.resolve(startOk) : Promise.resolve({ data: {}, error: null }),
|
||||
);
|
||||
routeGet(
|
||||
{ data: { id: "job-1", status: "running", progress: 20 }, error: null },
|
||||
{ data: { connected: false }, error: null },
|
||||
);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useKimiOauth({ initialConnected: false, initialExpiresAt: null }),
|
||||
);
|
||||
await act(async () => {
|
||||
await result.current.connect();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
|
||||
expect(result.current.phase).toBe("awaiting");
|
||||
expect(result.current.busy).toBe(true);
|
||||
});
|
||||
|
||||
it("connect 轮询失败(job failed):phase=error 并弹失败 toast", async () => {
|
||||
post.mockImplementation((path: string) =>
|
||||
path === START ? Promise.resolve(startOk) : Promise.resolve({ data: {}, error: null }),
|
||||
);
|
||||
routeGet(
|
||||
{ data: { id: "job-1", status: "failed", progress: 0, error: "授权已过期" }, error: null },
|
||||
{ data: { connected: false }, error: null },
|
||||
);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useKimiOauth({ initialConnected: false, initialExpiresAt: null }),
|
||||
);
|
||||
await act(async () => {
|
||||
await result.current.connect();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
|
||||
expect(result.current.phase).toBe("error");
|
||||
expect(result.current.error).toBe("授权已过期");
|
||||
expect(toast).toHaveBeenCalledWith(
|
||||
expect.stringContaining("连接失败"),
|
||||
"error",
|
||||
);
|
||||
});
|
||||
|
||||
it("connect 发起失败(start 返回 error):保持 idle 并弹错误 toast", async () => {
|
||||
post.mockResolvedValue({ data: null, error: { detail: "rate limited" } });
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useKimiOauth({ initialConnected: false, initialExpiresAt: null }),
|
||||
);
|
||||
await act(async () => {
|
||||
await result.current.connect();
|
||||
});
|
||||
|
||||
expect(result.current.phase).toBe("idle");
|
||||
expect(result.current.device).toBeNull();
|
||||
expect(toast).toHaveBeenCalledWith(
|
||||
"发起 Kimi Code 连接失败,请稍后重试。",
|
||||
"error",
|
||||
);
|
||||
});
|
||||
|
||||
it("disconnect 成功:复位为未连接并弹成功 toast", async () => {
|
||||
post.mockResolvedValue({ data: { ok: true }, error: null });
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useKimiOauth({
|
||||
initialConnected: true,
|
||||
initialExpiresAt: "2026-12-31T00:00:00Z",
|
||||
}),
|
||||
);
|
||||
await act(async () => {
|
||||
await result.current.disconnect();
|
||||
});
|
||||
|
||||
expect(result.current.phase).toBe("idle");
|
||||
expect(result.current.device).toBeNull();
|
||||
expect(result.current.expiresAt).toBeNull();
|
||||
expect(toast).toHaveBeenCalledWith("已断开 Kimi Code。", "success");
|
||||
});
|
||||
|
||||
it("disconnect 失败(返回 error):弹错误 toast、不复位", async () => {
|
||||
post.mockResolvedValue({ data: null, error: { detail: "boom" } });
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useKimiOauth({
|
||||
initialConnected: true,
|
||||
initialExpiresAt: "2026-12-31T00:00:00Z",
|
||||
}),
|
||||
);
|
||||
await act(async () => {
|
||||
await result.current.disconnect();
|
||||
});
|
||||
|
||||
expect(result.current.phase).toBe("connected");
|
||||
expect(toast).toHaveBeenCalledWith(
|
||||
"断开 Kimi Code 失败,请稍后重试。",
|
||||
"error",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { SkillView } from "@/lib/api/types";
|
||||
import { groupByScope, scopeLabel, tierLabel } from "./skills";
|
||||
import { groupByScope, scopeLabel, summarizeSkills, tierLabel } from "./skills";
|
||||
|
||||
const skill = (over: Partial<SkillView>): SkillView => ({
|
||||
name: "x",
|
||||
@@ -36,3 +36,61 @@ describe("groupByScope", () => {
|
||||
expect(groupByScope(undefined)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("summarizeSkills", () => {
|
||||
it("counts scopes, tiers, readonly skills and unique tables", () => {
|
||||
const summary = summarizeSkills([
|
||||
skill({
|
||||
name: "a",
|
||||
scope: "builtin",
|
||||
tier: "analyst",
|
||||
reads: ["projects", "outline"],
|
||||
writes: [],
|
||||
}),
|
||||
skill({
|
||||
name: "b",
|
||||
scope: "custom",
|
||||
tier: "writer",
|
||||
reads: ["projects"],
|
||||
writes: ["chapters"],
|
||||
}),
|
||||
skill({
|
||||
name: "c",
|
||||
scope: "builtin",
|
||||
tier: "analyst",
|
||||
reads: ["world_entities"],
|
||||
writes: ["outline", "chapters"],
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(summary.total).toBe(3);
|
||||
expect(summary.readonlyCount).toBe(1);
|
||||
expect(summary.writableCount).toBe(2);
|
||||
expect(summary.scopes).toEqual([
|
||||
{ key: "builtin", count: 2 },
|
||||
{ key: "custom", count: 1 },
|
||||
]);
|
||||
expect(summary.tiers).toEqual([
|
||||
{ key: "analyst", count: 2 },
|
||||
{ key: "writer", count: 1 },
|
||||
]);
|
||||
expect(summary.readableTables).toEqual([
|
||||
"outline",
|
||||
"projects",
|
||||
"world_entities",
|
||||
]);
|
||||
expect(summary.writableTables).toEqual(["chapters", "outline"]);
|
||||
});
|
||||
|
||||
it("handles undefined", () => {
|
||||
expect(summarizeSkills(undefined)).toEqual({
|
||||
total: 0,
|
||||
readonlyCount: 0,
|
||||
writableCount: 0,
|
||||
scopes: [],
|
||||
tiers: [],
|
||||
readableTables: [],
|
||||
writableTables: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,6 +27,21 @@ export function tierLabel(tier: string): string {
|
||||
|
||||
export type SkillGroups = { scope: string; skills: SkillView[] }[];
|
||||
|
||||
export interface SkillCount {
|
||||
key: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface SkillsSummary {
|
||||
total: number;
|
||||
readonlyCount: number;
|
||||
writableCount: number;
|
||||
scopes: SkillCount[];
|
||||
tiers: SkillCount[];
|
||||
readableTables: string[];
|
||||
writableTables: string[];
|
||||
}
|
||||
|
||||
// 按 scope 分组(保持服务端 name 升序);scope 顺序按首次出现。
|
||||
export function groupByScope(
|
||||
skills: readonly SkillView[] | undefined,
|
||||
@@ -42,3 +57,41 @@ export function groupByScope(
|
||||
}
|
||||
return order.map((scope) => ({ scope, skills: map.get(scope) ?? [] }));
|
||||
}
|
||||
|
||||
export function summarizeSkills(
|
||||
skills: readonly SkillView[] | undefined,
|
||||
): SkillsSummary {
|
||||
const scopeCounts = new Map<string, number>();
|
||||
const tierCounts = new Map<string, number>();
|
||||
const readableTables = new Set<string>();
|
||||
const writableTables = new Set<string>();
|
||||
let writableCount = 0;
|
||||
|
||||
for (const skill of skills ?? []) {
|
||||
scopeCounts.set(skill.scope, (scopeCounts.get(skill.scope) ?? 0) + 1);
|
||||
tierCounts.set(skill.tier, (tierCounts.get(skill.tier) ?? 0) + 1);
|
||||
for (const table of skill.reads ?? []) {
|
||||
readableTables.add(table);
|
||||
}
|
||||
if (skill.writes && skill.writes.length > 0) {
|
||||
writableCount += 1;
|
||||
for (const table of skill.writes) {
|
||||
writableTables.add(table);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
total: skills?.length ?? 0,
|
||||
readonlyCount: (skills?.length ?? 0) - writableCount,
|
||||
writableCount,
|
||||
scopes: countsFromMap(scopeCounts),
|
||||
tiers: countsFromMap(tierCounts),
|
||||
readableTables: [...readableTables].sort(),
|
||||
writableTables: [...writableTables].sort(),
|
||||
};
|
||||
}
|
||||
|
||||
function countsFromMap(map: Map<string, number>): SkillCount[] {
|
||||
return [...map.entries()].map(([key, count]) => ({ key, count }));
|
||||
}
|
||||
|
||||
220
apps/web/lib/stream/useDraftStream.test.ts
Normal file
220
apps/web/lib/stream/useDraftStream.test.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { useDraftStream } from "./useDraftStream";
|
||||
|
||||
// fetch(SSE 流)是 hook 的外部副作用边界,单测一律 stub 全局 fetch。
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
// 把若干 SSE 文本块封进 ReadableStream,模拟后端逐块下发的 chunked body。
|
||||
function sseStream(chunks: string[]): ReadableStream<Uint8Array> {
|
||||
const enc = new TextEncoder();
|
||||
return new ReadableStream({
|
||||
start(c) {
|
||||
for (const ch of chunks) c.enqueue(enc.encode(ch));
|
||||
c.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 构造一个流式成功响应(200 + text/event-stream body)。
|
||||
function sseResponse(chunks: string[]): Response {
|
||||
return new Response(sseStream(chunks), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/event-stream" },
|
||||
});
|
||||
}
|
||||
|
||||
describe("useDraftStream", () => {
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("初始为 idle、空文本、未在流式", () => {
|
||||
const { result } = renderHook(() => useDraftStream());
|
||||
expect(result.current.state.phase).toBe("idle");
|
||||
expect(result.current.state.text).toBe("");
|
||||
expect(result.current.isStreaming).toBe(false);
|
||||
});
|
||||
|
||||
it("流式累积 token:多个 token 帧拼成完整文本", async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
sseResponse([
|
||||
'event:token\ndata:{"text":"你好"}\n\n',
|
||||
'event:token\ndata:{"text":",世界"}\n\n',
|
||||
]),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useDraftStream());
|
||||
await act(async () => {
|
||||
await result.current.start("p1", 1);
|
||||
});
|
||||
|
||||
expect(result.current.state.text).toBe("你好,世界");
|
||||
expect(result.current.state.error).toBeNull();
|
||||
});
|
||||
|
||||
it("收到 done 帧后 phase 走到 done", async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
sseResponse([
|
||||
'event:token\ndata:{"text":"abc"}\n\n',
|
||||
'event:done\ndata:{"length":3}\n\n',
|
||||
]),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useDraftStream());
|
||||
await act(async () => {
|
||||
await result.current.start("p1", 1);
|
||||
});
|
||||
|
||||
expect(result.current.state.phase).toBe("done");
|
||||
expect(result.current.state.text).toBe("abc");
|
||||
});
|
||||
|
||||
it("带本章指令时以 JSON body POST 指令", async () => {
|
||||
fetchMock.mockResolvedValue(sseResponse(['event:done\ndata:{"length":0}\n\n']));
|
||||
|
||||
const { result } = renderHook(() => useDraftStream());
|
||||
await act(async () => {
|
||||
await result.current.start("p1", 2, " 写得热血一点 ");
|
||||
});
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0];
|
||||
expect(init.method).toBe("POST");
|
||||
expect(init.headers["Content-Type"]).toBe("application/json");
|
||||
expect(JSON.parse(init.body as string)).toEqual({ directive: "写得热血一点" });
|
||||
});
|
||||
|
||||
it("指令为空白时退回裸 POST(不带 JSON body)", async () => {
|
||||
fetchMock.mockResolvedValue(sseResponse(['event:done\ndata:{"length":0}\n\n']));
|
||||
|
||||
const { result } = renderHook(() => useDraftStream());
|
||||
await act(async () => {
|
||||
await result.current.start("p1", 2, " ");
|
||||
});
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0];
|
||||
expect(init.body).toBeUndefined();
|
||||
expect(init.headers["Content-Type"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("收到 error 帧:phase=error 且带错误码与文案", async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
sseResponse([
|
||||
'event:error\ndata:{"code":"RATE_LIMIT","message":"配额不足"}\n\n',
|
||||
]),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useDraftStream());
|
||||
await act(async () => {
|
||||
await result.current.start("p1", 1);
|
||||
});
|
||||
|
||||
expect(result.current.state.phase).toBe("error");
|
||||
expect(result.current.state.error).toMatchObject({
|
||||
code: "RATE_LIMIT",
|
||||
message: "配额不足",
|
||||
});
|
||||
});
|
||||
|
||||
it("流前错误(!res.ok):解析 JSON 信封提取错误码与文案", async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({ error: { code: "LLM_UNAVAILABLE", message: "无可用凭据" } }),
|
||||
{ status: 503 },
|
||||
),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useDraftStream());
|
||||
await act(async () => {
|
||||
await result.current.start("p1", 1);
|
||||
});
|
||||
|
||||
expect(result.current.state.phase).toBe("error");
|
||||
expect(result.current.state.error).toMatchObject({
|
||||
code: "LLM_UNAVAILABLE",
|
||||
message: "无可用凭据",
|
||||
});
|
||||
});
|
||||
|
||||
it("流前错误且非 JSON 信封:沿用默认 STREAM_FAILED 文案", async () => {
|
||||
fetchMock.mockResolvedValue(new Response("<html>oops</html>", { status: 500 }));
|
||||
|
||||
const { result } = renderHook(() => useDraftStream());
|
||||
await act(async () => {
|
||||
await result.current.start("p1", 1);
|
||||
});
|
||||
|
||||
expect(result.current.state.phase).toBe("error");
|
||||
expect(result.current.state.error?.code).toBe("STREAM_FAILED");
|
||||
expect(result.current.state.error?.message).toContain("500");
|
||||
});
|
||||
|
||||
it("网络抛异常(非 Abort):phase=error 且 code=NETWORK", async () => {
|
||||
fetchMock.mockRejectedValue(new Error("connection reset"));
|
||||
|
||||
const { result } = renderHook(() => useDraftStream());
|
||||
await act(async () => {
|
||||
await result.current.start("p1", 1);
|
||||
});
|
||||
|
||||
expect(result.current.state.phase).toBe("error");
|
||||
expect(result.current.state.error).toMatchObject({
|
||||
code: "NETWORK",
|
||||
message: "connection reset",
|
||||
});
|
||||
});
|
||||
|
||||
it("非 Error 抛出:回退为未知网络错误文案", async () => {
|
||||
fetchMock.mockRejectedValue("boom-string");
|
||||
|
||||
const { result } = renderHook(() => useDraftStream());
|
||||
await act(async () => {
|
||||
await result.current.start("p1", 1);
|
||||
});
|
||||
|
||||
expect(result.current.state.error?.message).toBe("未知网络错误");
|
||||
});
|
||||
|
||||
it("AbortError 被吞掉:不进入 error,phase 维持 streaming", async () => {
|
||||
fetchMock.mockRejectedValue(new DOMException("aborted", "AbortError"));
|
||||
|
||||
const { result } = renderHook(() => useDraftStream());
|
||||
await act(async () => {
|
||||
await result.current.start("p1", 1);
|
||||
});
|
||||
|
||||
expect(result.current.state.phase).toBe("streaming");
|
||||
expect(result.current.state.error).toBeNull();
|
||||
});
|
||||
|
||||
it("stop() 主动停止:abort 连接并将 phase 置 aborted", async () => {
|
||||
// 用一个永不结束的 body,让流挂起后被 stop 中断(已生成部分保留)。
|
||||
const hangingBody = new ReadableStream<Uint8Array>({});
|
||||
fetchMock.mockResolvedValue(new Response(hangingBody, { status: 200 }));
|
||||
|
||||
const { result } = renderHook(() => useDraftStream());
|
||||
act(() => {
|
||||
void result.current.start("p1", 1);
|
||||
});
|
||||
await act(async () => {
|
||||
result.current.stop();
|
||||
});
|
||||
|
||||
expect(result.current.state.phase).toBe("aborted");
|
||||
expect(result.current.isStreaming).toBe(false);
|
||||
});
|
||||
|
||||
it("reset(text) 用给定文本重置回 idle", () => {
|
||||
const { result } = renderHook(() => useDraftStream());
|
||||
act(() => result.current.reset("已落草稿"));
|
||||
expect(result.current.state.phase).toBe("idle");
|
||||
expect(result.current.state.text).toBe("已落草稿");
|
||||
});
|
||||
});
|
||||
125
apps/web/lib/style/useRefine.test.ts
Normal file
125
apps/web/lib/style/useRefine.test.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { useRefine } from "./useRefine";
|
||||
|
||||
// 后端客户端与 Toast 是 hook 的外部副作用边界,单测一律 mock。
|
||||
const post = vi.fn();
|
||||
const toast = vi.fn();
|
||||
vi.mock("@/lib/api/client", () => ({ api: { POST: (...a: unknown[]) => post(...a) } }));
|
||||
vi.mock("@/components/Toast", () => ({ useToast: () => toast }));
|
||||
|
||||
describe("useRefine", () => {
|
||||
beforeEach(() => {
|
||||
post.mockReset();
|
||||
toast.mockReset();
|
||||
});
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
it("初始为 idle、无结果", () => {
|
||||
const { result } = renderHook(() => useRefine());
|
||||
expect(result.current.status).toBe("idle");
|
||||
expect(result.current.result).toBeNull();
|
||||
});
|
||||
|
||||
it("回炉成功:status=done 并返回新旧 diff", async () => {
|
||||
post.mockResolvedValue({
|
||||
data: { original: "旧文", refined: "新文" },
|
||||
error: null,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useRefine());
|
||||
let outcome: unknown;
|
||||
await act(async () => {
|
||||
outcome = await result.current.refine("p1", 3, "旧文", "更紧凑");
|
||||
});
|
||||
|
||||
expect(outcome).toEqual({ original: "旧文", refined: "新文" });
|
||||
expect(result.current.status).toBe("done");
|
||||
expect(result.current.result).toEqual({ original: "旧文", refined: "新文" });
|
||||
expect(toast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("成功路径以 trim 后的段提交、可省略空指令", async () => {
|
||||
post.mockResolvedValue({
|
||||
data: { original: "段", refined: "改" },
|
||||
error: null,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useRefine());
|
||||
await act(async () => {
|
||||
await result.current.refine("p1", 1, " 段 ");
|
||||
});
|
||||
|
||||
expect(post).toHaveBeenCalledWith(
|
||||
"/projects/{project_id}/chapters/{chapter_no}/refine",
|
||||
expect.objectContaining({ body: { segment: "段" } }),
|
||||
);
|
||||
});
|
||||
|
||||
it("LLM_UNAVAILABLE:status=error 且提示去设置页", async () => {
|
||||
post.mockResolvedValue({
|
||||
data: null,
|
||||
error: { error: { code: "LLM_UNAVAILABLE" } },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useRefine());
|
||||
let outcome: unknown;
|
||||
await act(async () => {
|
||||
outcome = await result.current.refine("p1", 2, "段");
|
||||
});
|
||||
|
||||
expect(outcome).toBeNull();
|
||||
expect(result.current.status).toBe("error");
|
||||
expect(toast).toHaveBeenCalledWith(
|
||||
"未配置提供商,请先去设置页连一家。",
|
||||
"error",
|
||||
);
|
||||
});
|
||||
|
||||
it("其余后端错误:status=error 且弹通用回炉失败 toast", async () => {
|
||||
post.mockResolvedValue({
|
||||
data: null,
|
||||
error: { error: { code: "INTERNAL" } },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useRefine());
|
||||
await act(async () => {
|
||||
await result.current.refine("p1", 2, "段");
|
||||
});
|
||||
|
||||
expect(result.current.status).toBe("error");
|
||||
expect(toast).toHaveBeenCalledWith("回炉失败,请稍后重试。", "error");
|
||||
});
|
||||
|
||||
it("请求抛异常:status=error 且弹网络异常 toast", async () => {
|
||||
post.mockRejectedValue(new Error("network down"));
|
||||
|
||||
const { result } = renderHook(() => useRefine());
|
||||
let outcome: unknown;
|
||||
await act(async () => {
|
||||
outcome = await result.current.refine("p1", 2, "段");
|
||||
});
|
||||
|
||||
expect(outcome).toBeNull();
|
||||
expect(result.current.status).toBe("error");
|
||||
expect(toast).toHaveBeenCalledWith("回炉请求异常,请检查网络。", "error");
|
||||
});
|
||||
|
||||
it("reset 清回 idle 与空结果", async () => {
|
||||
post.mockResolvedValue({
|
||||
data: { original: "a", refined: "b" },
|
||||
error: null,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useRefine());
|
||||
await act(async () => {
|
||||
await result.current.refine("p1", 1, "a");
|
||||
});
|
||||
act(() => result.current.reset());
|
||||
|
||||
expect(result.current.status).toBe("idle");
|
||||
expect(result.current.result).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
// @vitest-environment jsdom
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { learnSummary, useStyleLearn } from "./useStyleLearn";
|
||||
import type { JobView } from "@/lib/jobs/job";
|
||||
import type { UseJobPoll } from "@/lib/jobs/useJobPoll";
|
||||
|
||||
// P1-6 回归守卫:学文风轮询完成(done 边沿)时,effect 必须用「最近一次 learn 传入的
|
||||
// projectId」去拉指纹,而非闭包捕获的旧值。useStyleLearn 用 ref 追踪 projectId 来保证这点。
|
||||
@@ -39,3 +45,239 @@ describe("useStyleLearn projectId 追踪(P1-6 stale-closure 守卫)", () =>
|
||||
expect(t.onDone()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── 真实 hook 覆盖:renderHook 驱动编排(POST 受理 → 轮询 → done 拉指纹)─────────
|
||||
const post = vi.fn();
|
||||
const get = vi.fn();
|
||||
const toast = vi.fn();
|
||||
vi.mock("@/lib/api/client", () => ({
|
||||
api: {
|
||||
POST: (...a: unknown[]) => post(...a),
|
||||
GET: (...a: unknown[]) => get(...a),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/components/Toast", () => ({ useToast: () => toast }));
|
||||
|
||||
const STYLE_PATH = "/projects/{project_id}/style";
|
||||
const JOBS_PATH = "/jobs/{job_id}";
|
||||
|
||||
interface GetOpts {
|
||||
params: { path: { project_id?: string; job_id?: string } };
|
||||
}
|
||||
interface PostOpts {
|
||||
params: { path: { project_id: string } };
|
||||
}
|
||||
|
||||
// 微任务 + 0ms 定时器全部冲洗(轮询 tick→dispatch→effect→refetch 链需多跳)。
|
||||
async function flush(): Promise<void> {
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
}
|
||||
|
||||
const FINGERPRINT_BODY = {
|
||||
dimensions: [{ name: "节奏", value: "快", evidence: ["短句"] }],
|
||||
version: 2,
|
||||
};
|
||||
|
||||
describe("useStyleLearn 真实 hook 编排", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
post.mockReset();
|
||||
get.mockReset();
|
||||
toast.mockReset();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("初始:未提交则 busy=false、pollStatus=idle、指纹为传入初值", () => {
|
||||
const initial = { dimensions: [], version: 1 };
|
||||
const { result } = renderHook(() => useStyleLearn(initial));
|
||||
|
||||
expect(result.current.busy).toBe(false);
|
||||
expect(result.current.pollStatus).toBe("idle");
|
||||
expect(result.current.progress).toBe(0);
|
||||
expect(result.current.fingerprint).toBe(initial);
|
||||
});
|
||||
|
||||
it("受理失败(通用错误):返回 false、弹通用 toast、busy 归位", async () => {
|
||||
post.mockResolvedValue({ data: null, error: { error: { code: "INTERNAL" } } });
|
||||
|
||||
const { result } = renderHook(() => useStyleLearn(null));
|
||||
let ok: boolean | undefined;
|
||||
await act(async () => {
|
||||
ok = await result.current.learn("p1", ["样本"], "create");
|
||||
});
|
||||
|
||||
expect(ok).toBe(false);
|
||||
expect(toast).toHaveBeenCalledWith("学文风受理失败,请稍后重试。", "error");
|
||||
expect(result.current.busy).toBe(false);
|
||||
expect(get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("受理失败(LLM_UNAVAILABLE):提示去设置页连一家", async () => {
|
||||
post.mockResolvedValue({
|
||||
data: null,
|
||||
error: { error: { code: "LLM_UNAVAILABLE" } },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useStyleLearn(null));
|
||||
let ok: boolean | undefined;
|
||||
await act(async () => {
|
||||
ok = await result.current.learn("p1", ["样本"], "create");
|
||||
});
|
||||
|
||||
expect(ok).toBe(false);
|
||||
expect(toast).toHaveBeenCalledWith(
|
||||
"未配置提供商,请先去设置页连一家。",
|
||||
"error",
|
||||
);
|
||||
});
|
||||
|
||||
it("受理成功 → 轮询 done → 拉最新指纹并弹成功 toast", async () => {
|
||||
post.mockResolvedValue({ data: { job_id: "job-1" }, error: null });
|
||||
get.mockImplementation((path: string, opts: GetOpts) => {
|
||||
if (path === JOBS_PATH) {
|
||||
return Promise.resolve({
|
||||
data: { id: "job-1", status: "done", progress: 100 },
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ data: FINGERPRINT_BODY, error: null });
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useStyleLearn(null));
|
||||
let ok: boolean | undefined;
|
||||
await act(async () => {
|
||||
ok = await result.current.learn("p1", ["样本"], "create");
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(ok).toBe(true);
|
||||
expect(result.current.pollStatus).toBe("done");
|
||||
expect(result.current.fingerprint).toEqual({
|
||||
dimensions: [{ name: "节奏", value: "快", evidence: ["短句"] }],
|
||||
version: 2,
|
||||
});
|
||||
expect(toast).toHaveBeenCalledWith("文风指纹已更新。", "success");
|
||||
});
|
||||
|
||||
it("done 但拉指纹失败(GET /style error):指纹不变、仍弹成功 toast", async () => {
|
||||
post.mockResolvedValue({ data: { job_id: "job-1" }, error: null });
|
||||
get.mockImplementation((path: string) => {
|
||||
if (path === JOBS_PATH) {
|
||||
return Promise.resolve({
|
||||
data: { id: "job-1", status: "done", progress: 100 },
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ data: null, error: { detail: "404" } });
|
||||
});
|
||||
|
||||
const initial = { dimensions: [], version: 9 };
|
||||
const { result } = renderHook(() => useStyleLearn(initial));
|
||||
await act(async () => {
|
||||
await result.current.learn("p1", ["样本"], "create");
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(result.current.fingerprint).toBe(initial);
|
||||
expect(toast).toHaveBeenCalledWith("文风指纹已更新。", "success");
|
||||
});
|
||||
|
||||
it("轮询失败(job failed):弹学文风失败 toast 带原因", async () => {
|
||||
post.mockResolvedValue({ data: { job_id: "job-1" }, error: null });
|
||||
get.mockImplementation((path: string) => {
|
||||
if (path === JOBS_PATH) {
|
||||
return Promise.resolve({
|
||||
data: { id: "job-1", status: "failed", progress: 40, error: "模型超时" },
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ data: FINGERPRINT_BODY, error: null });
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useStyleLearn(null));
|
||||
await act(async () => {
|
||||
await result.current.learn("p1", ["样本"], "create");
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(result.current.pollStatus).toBe("error");
|
||||
expect(toast).toHaveBeenCalledWith("学文风失败:模型超时", "error");
|
||||
});
|
||||
|
||||
it("切项目:done 边沿用最近一次 learn 的 projectId 拉指纹(stale-closure 守卫)", async () => {
|
||||
const styleProjectIds: string[] = [];
|
||||
post.mockImplementation((_path: string, opts: PostOpts) => {
|
||||
const pid = opts.params.path.project_id;
|
||||
return Promise.resolve({
|
||||
data: { job_id: pid === "project-A" ? "job-A" : "job-B" },
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
get.mockImplementation((path: string, opts: GetOpts) => {
|
||||
if (path === JOBS_PATH) {
|
||||
const jobId = opts.params.path.job_id;
|
||||
// A 仍在跑(排队 setTimeout 下一拍);B 直接 done。
|
||||
const status = jobId === "job-A" ? "running" : "done";
|
||||
return Promise.resolve({
|
||||
data: { id: jobId, status, progress: status === "done" ? 100 : 30 },
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
styleProjectIds.push(opts.params.path.project_id ?? "?");
|
||||
return Promise.resolve({ data: FINGERPRINT_BODY, error: null });
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useStyleLearn(null));
|
||||
await act(async () => {
|
||||
await result.current.learn("project-A", ["a"], "create");
|
||||
});
|
||||
await act(async () => {
|
||||
await result.current.learn("project-B", ["b"], "update");
|
||||
});
|
||||
await flush();
|
||||
|
||||
// done 边沿拉指纹用的应是后切的 project-B,而非陈旧的 project-A。
|
||||
expect(styleProjectIds).toContain("project-B");
|
||||
expect(styleProjectIds).not.toContain("project-A");
|
||||
expect(toast).toHaveBeenCalledWith("文风指纹已更新。", "success");
|
||||
});
|
||||
});
|
||||
|
||||
// learnSummary 是导出的纯函数:done 且有 job 时回显版本/维度数,否则 null。
|
||||
describe("learnSummary", () => {
|
||||
const baseJob: JobView = {
|
||||
id: "j",
|
||||
kind: "style_learn",
|
||||
status: "done",
|
||||
progress: 100,
|
||||
result: { version: 3, dims_count: 16 },
|
||||
error: null,
|
||||
};
|
||||
const pollOf = (over: Partial<UseJobPoll>): UseJobPoll =>
|
||||
({
|
||||
status: "done",
|
||||
progress: 100,
|
||||
job: baseJob,
|
||||
error: null,
|
||||
poll: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
...over,
|
||||
}) as UseJobPoll;
|
||||
|
||||
it("done 且有 job:回显 version 与 dimsCount", () => {
|
||||
expect(learnSummary(pollOf({}))).toEqual({ version: 3, dimsCount: 16 });
|
||||
});
|
||||
|
||||
it("非 done:返回 null", () => {
|
||||
expect(learnSummary(pollOf({ status: "polling" }))).toBeNull();
|
||||
});
|
||||
|
||||
it("done 但无 job:返回 null", () => {
|
||||
expect(learnSummary(pollOf({ job: null }))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
216
apps/web/lib/toolbox/useGenerator.test.ts
Normal file
216
apps/web/lib/toolbox/useGenerator.test.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { useGenerator } from "./useGenerator";
|
||||
import type { IngestBuildInput } from "./ingest";
|
||||
|
||||
// 后端客户端与 Toast 是 hook 的外部副作用边界,单测一律 mock。
|
||||
const post = vi.fn();
|
||||
const toast = vi.fn();
|
||||
vi.mock("@/lib/api/client", () => ({ api: { POST: (...a: unknown[]) => post(...a) } }));
|
||||
vi.mock("@/components/Toast", () => ({ useToast: () => toast }));
|
||||
|
||||
const conflictEnvelope = {
|
||||
error: {
|
||||
code: "CONFLICT_UNRESOLVED",
|
||||
details: {
|
||||
conflicts: [{ type: "冲突", where: "world", refs: [], suggestion: "改写" }],
|
||||
conflict_count: 1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// 行式可入库产物(金手指 → world_entities)。
|
||||
const goldenInput: IngestBuildInput = {
|
||||
outputKind: "GoldenFingerResult",
|
||||
rows: [{ name: "吞噬之眼", mechanism: "吞噬", growth: "无限", limits: "反噬" }],
|
||||
};
|
||||
|
||||
describe("useGenerator", () => {
|
||||
beforeEach(() => {
|
||||
post.mockReset();
|
||||
toast.mockReset();
|
||||
});
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
it("初始为 idle、无预览、入库 idle、无冲突", () => {
|
||||
const { result } = renderHook(() => useGenerator());
|
||||
expect(result.current.genStatus).toBe("idle");
|
||||
expect(result.current.preview).toBeNull();
|
||||
expect(result.current.rawPreview).toBeUndefined();
|
||||
expect(result.current.outputKind).toBeNull();
|
||||
expect(result.current.ingestStatus).toBe("idle");
|
||||
expect(result.current.conflicts).toBeNull();
|
||||
expect(result.current.created).toEqual([]);
|
||||
});
|
||||
|
||||
// —— generate ——
|
||||
|
||||
it("生成成功:genStatus=preview,映射预览模型 + 记录 rawPreview/outputKind", async () => {
|
||||
const preview = { ideas: [{ premise: "废柴逆袭", hook: "扮猪吃虎" }] };
|
||||
post.mockResolvedValue({
|
||||
data: { output_kind: "IdeaListResult", preview },
|
||||
error: null,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useGenerator());
|
||||
await act(async () => {
|
||||
await result.current.generate("p1", "idea", { brief: "脑洞" });
|
||||
});
|
||||
|
||||
expect(result.current.genStatus).toBe("preview");
|
||||
expect(result.current.outputKind).toBe("IdeaListResult");
|
||||
expect(result.current.rawPreview).toEqual(preview);
|
||||
expect(result.current.preview?.kind).toBe("IdeaListResult");
|
||||
expect(result.current.preview?.items[0]?.heading).toBe("废柴逆袭");
|
||||
expect(toast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("生成后端返回 error:genStatus=error 且弹错误 toast", async () => {
|
||||
post.mockResolvedValue({ data: null, error: { detail: "失败" } });
|
||||
const { result } = renderHook(() => useGenerator());
|
||||
await act(async () => {
|
||||
await result.current.generate("p1", "idea", { brief: "x" });
|
||||
});
|
||||
expect(result.current.genStatus).toBe("error");
|
||||
expect(toast).toHaveBeenCalledWith(expect.any(String), "error");
|
||||
});
|
||||
|
||||
it("生成请求抛异常:genStatus=error 且弹网络异常 toast", async () => {
|
||||
post.mockRejectedValue(new Error("network down"));
|
||||
const { result } = renderHook(() => useGenerator());
|
||||
await act(async () => {
|
||||
await result.current.generate("p1", "idea", { brief: "x" });
|
||||
});
|
||||
expect(result.current.genStatus).toBe("error");
|
||||
expect(toast).toHaveBeenCalledWith("生成请求异常,请检查网络。", "error");
|
||||
});
|
||||
|
||||
// —— ingest ——
|
||||
|
||||
it("入库不支持的产物(buildIngestRequest 返 null):提示并返回 false", async () => {
|
||||
const { result } = renderHook(() => useGenerator());
|
||||
let ok = true;
|
||||
await act(async () => {
|
||||
ok = await result.current.ingest("p1", "opening", {
|
||||
outputKind: "OpeningResult",
|
||||
rows: [{ text: "正文" }],
|
||||
});
|
||||
});
|
||||
expect(ok).toBe(false);
|
||||
expect(toast).toHaveBeenCalledWith("该生成器不支持入库。", "error");
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("入库空行:提示至少选择一项并返回 false", async () => {
|
||||
const { result } = renderHook(() => useGenerator());
|
||||
let ok = true;
|
||||
await act(async () => {
|
||||
ok = await result.current.ingest("p1", "golden", {
|
||||
outputKind: "GoldenFingerResult",
|
||||
rows: [],
|
||||
});
|
||||
});
|
||||
expect(ok).toBe(false);
|
||||
expect(toast).toHaveBeenCalledWith("请至少选择一项入库。", "error");
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("入库成功:ingestStatus=done、记录 created、弹成功 toast,返回 true", async () => {
|
||||
post.mockResolvedValue({
|
||||
data: { created: ["w1", "w2"], table: "world_entities" },
|
||||
error: null,
|
||||
});
|
||||
const { result } = renderHook(() => useGenerator());
|
||||
let ok = false;
|
||||
await act(async () => {
|
||||
ok = await result.current.ingest("p1", "golden", goldenInput);
|
||||
});
|
||||
expect(ok).toBe(true);
|
||||
expect(result.current.ingestStatus).toBe("done");
|
||||
expect(result.current.created).toEqual(["w1", "w2"]);
|
||||
expect(result.current.conflicts).toBeNull();
|
||||
expect(toast).toHaveBeenCalledWith("已入库 2 项至 world_entities", "success");
|
||||
});
|
||||
|
||||
it("入库成功但 created 缺失:回退空数组并提示 0 项", async () => {
|
||||
post.mockResolvedValue({ data: { created: null, table: "world_entities" }, error: null });
|
||||
const { result } = renderHook(() => useGenerator());
|
||||
await act(async () => {
|
||||
await result.current.ingest("p1", "golden", goldenInput);
|
||||
});
|
||||
expect(result.current.created).toEqual([]);
|
||||
expect(toast).toHaveBeenCalledWith("已入库 0 项至 world_entities", "success");
|
||||
});
|
||||
|
||||
it("入库成功且有越权写表:额外弹 info toast", async () => {
|
||||
post.mockResolvedValue({
|
||||
data: { created: ["w1"], table: "world_entities", rejected_tables: ["secrets"] },
|
||||
error: null,
|
||||
});
|
||||
const { result } = renderHook(() => useGenerator());
|
||||
await act(async () => {
|
||||
await result.current.ingest("p1", "golden", goldenInput);
|
||||
});
|
||||
expect(toast).toHaveBeenCalledWith("越权写表被丢弃:secrets", "info");
|
||||
});
|
||||
|
||||
it("入库 409 冲突:ingestStatus=conflict 并暴露 conflicts,返回 false", async () => {
|
||||
post.mockResolvedValue({ data: null, error: conflictEnvelope });
|
||||
const { result } = renderHook(() => useGenerator());
|
||||
let ok = true;
|
||||
await act(async () => {
|
||||
ok = await result.current.ingest("p1", "golden", goldenInput);
|
||||
});
|
||||
expect(ok).toBe(false);
|
||||
expect(result.current.ingestStatus).toBe("conflict");
|
||||
expect(result.current.conflicts?.conflictCount).toBe(1);
|
||||
expect(result.current.conflicts?.conflicts).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("入库非冲突 error:ingestStatus=error 且弹错误 toast,返回 false", async () => {
|
||||
post.mockResolvedValue({ data: null, error: { error: { code: "LLM_UNAVAILABLE" } } });
|
||||
const { result } = renderHook(() => useGenerator());
|
||||
let ok = true;
|
||||
await act(async () => {
|
||||
ok = await result.current.ingest("p1", "golden", goldenInput);
|
||||
});
|
||||
expect(ok).toBe(false);
|
||||
expect(result.current.ingestStatus).toBe("error");
|
||||
expect(toast).toHaveBeenCalledWith(expect.any(String), "error");
|
||||
});
|
||||
|
||||
it("入库请求抛异常:ingestStatus=error 且弹网络异常 toast,返回 false", async () => {
|
||||
post.mockRejectedValue(new Error("boom"));
|
||||
const { result } = renderHook(() => useGenerator());
|
||||
let ok = true;
|
||||
await act(async () => {
|
||||
ok = await result.current.ingest("p1", "golden", goldenInput);
|
||||
});
|
||||
expect(ok).toBe(false);
|
||||
expect(result.current.ingestStatus).toBe("error");
|
||||
expect(toast).toHaveBeenCalledWith("入库请求异常,请检查网络。", "error");
|
||||
});
|
||||
|
||||
// —— reset ——
|
||||
|
||||
it("reset 清回初始态", async () => {
|
||||
post.mockResolvedValue({
|
||||
data: { output_kind: "IdeaListResult", preview: { ideas: [{ premise: "p" }] } },
|
||||
error: null,
|
||||
});
|
||||
const { result } = renderHook(() => useGenerator());
|
||||
await act(async () => {
|
||||
await result.current.generate("p1", "idea", { brief: "x" });
|
||||
});
|
||||
act(() => result.current.reset());
|
||||
expect(result.current.genStatus).toBe("idle");
|
||||
expect(result.current.preview).toBeNull();
|
||||
expect(result.current.rawPreview).toBeUndefined();
|
||||
expect(result.current.outputKind).toBeNull();
|
||||
expect(result.current.ingestStatus).toBe("idle");
|
||||
expect(result.current.conflicts).toBeNull();
|
||||
expect(result.current.created).toEqual([]);
|
||||
});
|
||||
});
|
||||
46
apps/web/lib/ui/theme.test.ts
Normal file
46
apps/web/lib/ui/theme.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
DEFAULT_THEME_MODE,
|
||||
isThemeMode,
|
||||
nextThemeMode,
|
||||
normalizeThemeMode,
|
||||
themeBootstrapScript,
|
||||
themeModeLabel,
|
||||
themeToggleLabel,
|
||||
} from "./theme";
|
||||
|
||||
describe("theme mode", () => {
|
||||
it("accepts only supported theme modes", () => {
|
||||
expect(isThemeMode("paper")).toBe(true);
|
||||
expect(isThemeMode("night")).toBe(true);
|
||||
expect(isThemeMode("dark")).toBe(false);
|
||||
expect(isThemeMode(null)).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back to paper for unknown stored values", () => {
|
||||
expect(normalizeThemeMode("night")).toBe("night");
|
||||
expect(normalizeThemeMode("")).toBe(DEFAULT_THEME_MODE);
|
||||
expect(normalizeThemeMode("auto")).toBe(DEFAULT_THEME_MODE);
|
||||
});
|
||||
|
||||
it("toggles between paper and night", () => {
|
||||
expect(nextThemeMode("paper")).toBe("night");
|
||||
expect(nextThemeMode("night")).toBe("paper");
|
||||
});
|
||||
|
||||
it("returns labels for the current mode and the next action", () => {
|
||||
expect(themeModeLabel("paper")).toBe("纸感模式");
|
||||
expect(themeModeLabel("night")).toBe("夜读模式");
|
||||
expect(themeToggleLabel("paper")).toBe("切换到夜读模式");
|
||||
expect(themeToggleLabel("night")).toBe("切换到纸感模式");
|
||||
});
|
||||
|
||||
it("builds a bootstrap script that applies the stored mode before hydration", () => {
|
||||
const script = themeBootstrapScript();
|
||||
|
||||
expect(script).toContain("localStorage.getItem(\"ww.theme_mode\")");
|
||||
expect(script).toContain("document.documentElement.dataset.theme");
|
||||
expect(script).toContain("mode !== \"paper\" && mode !== \"night\"");
|
||||
});
|
||||
});
|
||||
36
apps/web/lib/ui/theme.ts
Normal file
36
apps/web/lib/ui/theme.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
export type ThemeMode = "paper" | "night";
|
||||
|
||||
export const DEFAULT_THEME_MODE: ThemeMode = "paper";
|
||||
export const THEME_STORAGE_KEY = "ww.theme_mode";
|
||||
|
||||
export function isThemeMode(value: unknown): value is ThemeMode {
|
||||
return value === "paper" || value === "night";
|
||||
}
|
||||
|
||||
export function normalizeThemeMode(value: unknown): ThemeMode {
|
||||
return isThemeMode(value) ? value : DEFAULT_THEME_MODE;
|
||||
}
|
||||
|
||||
export function nextThemeMode(current: ThemeMode): ThemeMode {
|
||||
return current === "night" ? "paper" : "night";
|
||||
}
|
||||
|
||||
export function themeModeLabel(mode: ThemeMode): string {
|
||||
return mode === "night" ? "夜读模式" : "纸感模式";
|
||||
}
|
||||
|
||||
export function themeToggleLabel(mode: ThemeMode): string {
|
||||
return mode === "night" ? "切换到纸感模式" : "切换到夜读模式";
|
||||
}
|
||||
|
||||
export function themeBootstrapScript(): string {
|
||||
return `
|
||||
try {
|
||||
var mode = localStorage.getItem(${JSON.stringify(THEME_STORAGE_KEY)});
|
||||
if (mode !== "paper" && mode !== "night") mode = ${JSON.stringify(DEFAULT_THEME_MODE)};
|
||||
document.documentElement.dataset.theme = mode;
|
||||
} catch (error) {
|
||||
document.documentElement.dataset.theme = ${JSON.stringify(DEFAULT_THEME_MODE)};
|
||||
}
|
||||
`.trim();
|
||||
}
|
||||
52
apps/web/lib/ui/variants.test.ts
Normal file
52
apps/web/lib/ui/variants.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
badgeClass,
|
||||
buttonClass,
|
||||
cn,
|
||||
inputClass,
|
||||
segmentedClass,
|
||||
statusNoteClass,
|
||||
} from "./variants";
|
||||
|
||||
describe("ui variants", () => {
|
||||
it("joins classes without falsey values", () => {
|
||||
expect(cn("a", false, undefined, null, "b")).toBe("a b");
|
||||
});
|
||||
|
||||
it("builds stable primary button classes", () => {
|
||||
const klass = buttonClass({ variant: "primary", size: "sm" });
|
||||
|
||||
expect(klass).toContain("bg-cinnabar");
|
||||
expect(klass).toContain("px-3");
|
||||
expect(klass).toContain("focus-visible:ring-2");
|
||||
});
|
||||
|
||||
it("keeps warning badges readable on the paper background", () => {
|
||||
const klass = badgeClass({ variant: "warning" });
|
||||
|
||||
expect(klass).toContain("bg-overdue/10");
|
||||
expect(klass).toContain("text-ink");
|
||||
});
|
||||
|
||||
it("marks invalid inputs with conflict styling", () => {
|
||||
const klass = inputClass({ state: "error" });
|
||||
|
||||
expect(klass).toContain("border-conflict");
|
||||
expect(klass).toContain("focus-visible:ring-conflict/25");
|
||||
});
|
||||
|
||||
it("gives warning status notes a non-color icon slot friendly shell", () => {
|
||||
const klass = statusNoteClass({ variant: "warning" });
|
||||
|
||||
expect(klass).toContain("border-overdue");
|
||||
expect(klass).toContain("px-3");
|
||||
});
|
||||
|
||||
it("builds segmented control chrome", () => {
|
||||
const klass = segmentedClass();
|
||||
|
||||
expect(klass).toContain("inline-flex");
|
||||
expect(klass).toContain("border-line");
|
||||
});
|
||||
});
|
||||
141
apps/web/lib/ui/variants.ts
Normal file
141
apps/web/lib/ui/variants.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
export type ButtonVariant =
|
||||
| "primary"
|
||||
| "secondary"
|
||||
| "outline"
|
||||
| "ghost"
|
||||
| "danger";
|
||||
|
||||
export type ButtonSize = "sm" | "md" | "icon";
|
||||
|
||||
export type BadgeVariant =
|
||||
| "neutral"
|
||||
| "accent"
|
||||
| "success"
|
||||
| "warning"
|
||||
| "danger"
|
||||
| "info";
|
||||
|
||||
export type InputState = "default" | "error";
|
||||
|
||||
export type StatusNoteVariant = "info" | "success" | "warning" | "danger";
|
||||
|
||||
export function cn(...classes: Array<string | false | null | undefined>): string {
|
||||
return classes.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
const buttonBase =
|
||||
"inline-flex items-center justify-center gap-1.5 rounded border text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cinnabar/35 disabled:cursor-not-allowed disabled:opacity-45";
|
||||
|
||||
const buttonVariants: Record<ButtonVariant, string> = {
|
||||
primary:
|
||||
"border-cinnabar bg-cinnabar text-panel shadow-paper hover:bg-cinnabar/95",
|
||||
secondary:
|
||||
"border-line bg-panel text-ink hover:border-cinnabar hover:text-cinnabar",
|
||||
outline:
|
||||
"border-cinnabar bg-transparent text-cinnabar hover:bg-[var(--color-cinnabar-wash)]",
|
||||
ghost:
|
||||
"border-transparent bg-transparent text-ink-soft hover:bg-[var(--color-cinnabar-wash)] hover:text-cinnabar",
|
||||
danger:
|
||||
"border-conflict bg-transparent text-conflict hover:bg-conflict/10",
|
||||
};
|
||||
|
||||
const buttonSizes: Record<ButtonSize, string> = {
|
||||
sm: "px-3 py-1.5 text-xs",
|
||||
md: "px-4 py-2",
|
||||
icon: "h-9 w-9 p-0",
|
||||
};
|
||||
|
||||
export function buttonClass({
|
||||
variant = "secondary",
|
||||
size = "md",
|
||||
className,
|
||||
}: {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
className?: string;
|
||||
} = {}): string {
|
||||
return cn(buttonBase, buttonVariants[variant], buttonSizes[size], className);
|
||||
}
|
||||
|
||||
const badgeBase =
|
||||
"inline-flex items-center gap-1 rounded border px-2 py-0.5 text-xs leading-5";
|
||||
|
||||
const badgeVariants: Record<BadgeVariant, string> = {
|
||||
neutral: "border-line bg-bg text-ink-soft",
|
||||
accent:
|
||||
"border-cinnabar/20 bg-[var(--color-cinnabar-wash)] text-cinnabar",
|
||||
success: "border-pass/25 bg-pass/10 text-pass",
|
||||
warning: "border-overdue/35 bg-overdue/10 text-ink",
|
||||
danger: "border-conflict/25 bg-conflict/10 text-conflict",
|
||||
info: "border-info/25 bg-info/10 text-info",
|
||||
};
|
||||
|
||||
export function badgeClass({
|
||||
variant = "neutral",
|
||||
className,
|
||||
}: {
|
||||
variant?: BadgeVariant;
|
||||
className?: string;
|
||||
} = {}): string {
|
||||
return cn(badgeBase, badgeVariants[variant], className);
|
||||
}
|
||||
|
||||
export function cardClass(className?: string): string {
|
||||
return cn("rounded border border-line bg-panel shadow-paper", className);
|
||||
}
|
||||
|
||||
const fieldTextBase = "block text-sm font-medium text-ink";
|
||||
|
||||
export function fieldLabelClass(className?: string): string {
|
||||
return cn(fieldTextBase, className);
|
||||
}
|
||||
|
||||
export function fieldHelpClass(className?: string): string {
|
||||
return cn("mt-1 text-xs leading-5 text-ink-soft", className);
|
||||
}
|
||||
|
||||
export function fieldErrorClass(className?: string): string {
|
||||
return cn("mt-1 text-xs leading-5 text-conflict", className);
|
||||
}
|
||||
|
||||
const inputBase =
|
||||
"w-full rounded border bg-bg text-ink transition-colors placeholder:text-ink-soft/65 focus:outline-none focus-visible:ring-2 focus-visible:ring-cinnabar/30 disabled:cursor-not-allowed disabled:bg-line/20 disabled:text-ink-soft";
|
||||
|
||||
const inputStates: Record<InputState, string> = {
|
||||
default: "border-line focus:border-cinnabar",
|
||||
error: "border-conflict focus:border-conflict focus-visible:ring-conflict/25",
|
||||
};
|
||||
|
||||
export function inputClass({
|
||||
state = "default",
|
||||
className,
|
||||
}: {
|
||||
state?: InputState;
|
||||
className?: string;
|
||||
} = {}): string {
|
||||
return cn(inputBase, inputStates[state], className);
|
||||
}
|
||||
|
||||
const statusNoteBase =
|
||||
"rounded border px-3 py-2 text-sm leading-6";
|
||||
|
||||
const statusNoteVariants: Record<StatusNoteVariant, string> = {
|
||||
info: "border-info/25 bg-info/10 text-info",
|
||||
success: "border-pass/25 bg-pass/10 text-pass",
|
||||
warning: "border-overdue/35 bg-overdue/10 text-ink",
|
||||
danger: "border-conflict/25 bg-conflict/10 text-conflict",
|
||||
};
|
||||
|
||||
export function statusNoteClass({
|
||||
variant = "info",
|
||||
className,
|
||||
}: {
|
||||
variant?: StatusNoteVariant;
|
||||
className?: string;
|
||||
} = {}): string {
|
||||
return cn(statusNoteBase, statusNoteVariants[variant], className);
|
||||
}
|
||||
|
||||
export function segmentedClass(className?: string): string {
|
||||
return cn("inline-flex rounded border border-line bg-bg p-1", className);
|
||||
}
|
||||
181
apps/web/lib/workbench/useInjection.test.ts
Normal file
181
apps/web/lib/workbench/useInjection.test.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { useInjection } from "./useInjection";
|
||||
import type { InjectionResponse } from "./injection";
|
||||
|
||||
// api 客户端是 hook 的外部副作用边界;GET 拉确定性结果,PUT 写覆盖。单测一律 mock。
|
||||
const get = vi.fn();
|
||||
const put = vi.fn();
|
||||
vi.mock("@/lib/api/client", () => ({
|
||||
api: {
|
||||
GET: (...a: unknown[]) => get(...a),
|
||||
PUT: (...a: unknown[]) => put(...a),
|
||||
},
|
||||
}));
|
||||
|
||||
// 最小 InjectionResponse 桩(仅覆盖纯逻辑读取的字段)。
|
||||
function makeResponse(over: Partial<InjectionResponse> = {}): InjectionResponse {
|
||||
return {
|
||||
pinned: [],
|
||||
excluded: [],
|
||||
recent_n: 3,
|
||||
entities: [],
|
||||
...over,
|
||||
} as InjectionResponse;
|
||||
}
|
||||
|
||||
const CHAR = { kind: "character", name: "张三" } as const;
|
||||
|
||||
describe("useInjection", () => {
|
||||
beforeEach(() => {
|
||||
get.mockReset();
|
||||
put.mockReset();
|
||||
});
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
it("挂载成功:拉到确定性结果并落到 data,loading 归位", async () => {
|
||||
const body = makeResponse({ recent_n: 5 });
|
||||
get.mockResolvedValue({ data: body, error: null });
|
||||
|
||||
const { result } = renderHook(() => useInjection("p1", 3));
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
expect(result.current.data).toEqual(body);
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
|
||||
it("挂载后端返回 error:给可读文案、data 置空", async () => {
|
||||
get.mockResolvedValue({ data: null, error: { detail: "boom" } });
|
||||
|
||||
const { result } = renderHook(() => useInjection("p1", 3));
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
expect(result.current.error).toBe("注入信息暂不可用");
|
||||
expect(result.current.data).toBeNull();
|
||||
});
|
||||
|
||||
it("挂载网络层抛错:捕获并给可读文案", async () => {
|
||||
get.mockRejectedValue(new Error("network down"));
|
||||
|
||||
const { result } = renderHook(() => useInjection("p1", 3));
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
expect(result.current.error).toBe("注入信息暂不可用");
|
||||
expect(result.current.data).toBeNull();
|
||||
});
|
||||
|
||||
it("togglePin 成功:PUT 覆盖后以服务端确定结果回放 data", async () => {
|
||||
get.mockResolvedValue({ data: makeResponse(), error: null });
|
||||
const updated = makeResponse({ pinned: [{ ...CHAR }] });
|
||||
put.mockResolvedValue({ data: updated, error: null });
|
||||
|
||||
const { result } = renderHook(() => useInjection("p1", 3));
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.togglePin(CHAR);
|
||||
});
|
||||
|
||||
expect(put).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.data).toEqual(updated);
|
||||
expect(result.current.saving).toBe(false);
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
|
||||
it("exclude / restore / setRecentN 各自经 PUT 提交覆盖", async () => {
|
||||
get.mockResolvedValue({ data: makeResponse(), error: null });
|
||||
put.mockResolvedValue({ data: makeResponse(), error: null });
|
||||
|
||||
const { result } = renderHook(() => useInjection("p1", 3));
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.exclude(CHAR);
|
||||
});
|
||||
await act(async () => {
|
||||
await result.current.restore(CHAR);
|
||||
});
|
||||
await act(async () => {
|
||||
await result.current.setRecentN(8);
|
||||
});
|
||||
|
||||
expect(put).toHaveBeenCalledTimes(3);
|
||||
// 最后一次 setRecentN 的 body.recent_n 已钳到合法区间。
|
||||
expect(put).toHaveBeenLastCalledWith(
|
||||
"/projects/{project_id}/chapters/{chapter_no}/injection",
|
||||
expect.objectContaining({ body: expect.objectContaining({ recent_n: 8 }) }),
|
||||
);
|
||||
});
|
||||
|
||||
it("保存后端返回 error:给可读文案、本地 data 不变(天然回滚)", async () => {
|
||||
const loaded = makeResponse({ recent_n: 4 });
|
||||
get.mockResolvedValue({ data: loaded, error: null });
|
||||
put.mockResolvedValue({ data: null, error: { detail: "422" } });
|
||||
|
||||
const { result } = renderHook(() => useInjection("p1", 3));
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.togglePin(CHAR);
|
||||
});
|
||||
|
||||
expect(result.current.error).toBe("保存注入设置失败,请重试");
|
||||
expect(result.current.data).toEqual(loaded);
|
||||
});
|
||||
|
||||
it("保存网络层抛错:本地状态不动、给可读文案、不抛", async () => {
|
||||
const loaded = makeResponse();
|
||||
get.mockResolvedValue({ data: loaded, error: null });
|
||||
put.mockRejectedValue(new Error("network down"));
|
||||
|
||||
const { result } = renderHook(() => useInjection("p1", 3));
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.togglePin(CHAR);
|
||||
});
|
||||
|
||||
expect(result.current.error).toBe("保存注入设置失败,请重试");
|
||||
expect(result.current.data).toEqual(loaded);
|
||||
});
|
||||
|
||||
it("data 未就绪时 mutate 早返回、不发 PUT", async () => {
|
||||
get.mockResolvedValue({ data: null, error: { detail: "fail" } });
|
||||
|
||||
const { result } = renderHook(() => useInjection("p1", 3));
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.togglePin(CHAR);
|
||||
});
|
||||
|
||||
expect(put).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("并发锁:保存在途时再次触发被拦下,只发一次 PUT", async () => {
|
||||
get.mockResolvedValue({ data: makeResponse(), error: null });
|
||||
// 第一次 PUT 挂起,制造在途窗口。
|
||||
let resolveFirst: (v: unknown) => void = () => {};
|
||||
put.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((res) => {
|
||||
resolveFirst = res;
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useInjection("p1", 3));
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
await act(async () => {
|
||||
// 不 await 第一次:保存在途时立即触发第二次,应被 savingRef 拦下。
|
||||
const first = result.current.togglePin(CHAR);
|
||||
await result.current.exclude(CHAR);
|
||||
resolveFirst({ data: makeResponse(), error: null });
|
||||
await first;
|
||||
});
|
||||
|
||||
expect(put).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -45,17 +45,25 @@ export function useInjection(projectId: string, chapterNo: number): UseInjection
|
||||
setError(null);
|
||||
|
||||
void (async () => {
|
||||
const { data: body, error: err } = await api.GET(INJECTION_PATH, {
|
||||
params: { path: { project_id: projectId, chapter_no: chapterNo } },
|
||||
});
|
||||
if (cancelled) return;
|
||||
if (err || !body) {
|
||||
try {
|
||||
const { data: body, error: err } = await api.GET(INJECTION_PATH, {
|
||||
params: { path: { project_id: projectId, chapter_no: chapterNo } },
|
||||
});
|
||||
if (cancelled) return;
|
||||
if (err || !body) {
|
||||
setError("注入信息暂不可用");
|
||||
setData(null);
|
||||
} else {
|
||||
setData(body);
|
||||
}
|
||||
} catch {
|
||||
// 网络层失败(后端不可达/CORS):openapi-fetch 直接抛而非返回 error 信封。
|
||||
if (cancelled) return;
|
||||
setError("注入信息暂不可用");
|
||||
setData(null);
|
||||
} else {
|
||||
setData(body);
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
setLoading(false);
|
||||
})();
|
||||
|
||||
return () => {
|
||||
@@ -80,6 +88,9 @@ export function useInjection(projectId: string, chapterNo: number): UseInjection
|
||||
} else {
|
||||
setData(body); // 以服务端确定结果为准(不变量 #6)。
|
||||
}
|
||||
} catch {
|
||||
// 网络层失败:本地状态不动(天然回滚),给可读文案、不抛。
|
||||
setError("保存注入设置失败,请重试");
|
||||
} finally {
|
||||
savingRef.current = false;
|
||||
setSaving(false);
|
||||
|
||||
@@ -9,22 +9,29 @@
|
||||
"lint": "next lint",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"gen:api": "node scripts/gen-api.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-react": "^1.21.0",
|
||||
"next": "15.1.3",
|
||||
"openapi-fetch": "^0.13.0",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/node": "^22.10.0",
|
||||
"@types/react": "19.0.0",
|
||||
"@types/react-dom": "19.0.0",
|
||||
"@vitest/coverage-v8": "^2.1.9",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint-config-next": "15.1.3",
|
||||
"jsdom": "^29.1.1",
|
||||
"openapi-typescript": "^7.5.0",
|
||||
"playwright": "^1.61.1",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.7.2",
|
||||
|
||||
900
apps/web/pnpm-lock.yaml
generated
900
apps/web/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,9 @@
|
||||
packages:
|
||||
- .
|
||||
allowBuilds:
|
||||
esbuild: set this to true or false
|
||||
sharp: set this to true or false
|
||||
unrs-resolver: set this to true or false
|
||||
esbuild: true
|
||||
sharp: true
|
||||
unrs-resolver: true
|
||||
onlyBuiltDependencies:
|
||||
- esbuild
|
||||
- sharp
|
||||
|
||||
@@ -2,7 +2,11 @@ import type { Config } from "tailwindcss";
|
||||
|
||||
// 纸感·文学温暖主题(UX_SPEC §2)。颜色经 CSS 变量落地,便于后续夜读模式切换。
|
||||
const config: Config = {
|
||||
content: ["./app/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}"],
|
||||
content: [
|
||||
"./app/**/*.{ts,tsx}",
|
||||
"./components/**/*.{ts,tsx}",
|
||||
"./lib/**/*.{ts,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
@@ -23,7 +27,7 @@ const config: Config = {
|
||||
mono: ['"JetBrains Mono"', "ui-monospace"],
|
||||
},
|
||||
borderRadius: { DEFAULT: "6px" },
|
||||
boxShadow: { paper: "0 1px 3px #2B26200F" },
|
||||
boxShadow: { paper: "0 1px 3px var(--shadow-paper)" },
|
||||
maxWidth: { prose: "720px" },
|
||||
},
|
||||
},
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -10,5 +10,21 @@ export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["lib/**/*.test.ts"],
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
// 覆盖范围 = lib/** 全部逻辑层(纯逻辑 + React hooks)。
|
||||
// hooks(use*.ts) 用 jsdom + @testing-library/react 的 renderHook 单测(见各 *.test.ts 的
|
||||
// `// @vitest-environment jsdom` 头)。组件(.tsx)仍由 E2E/人工验收覆盖,不在 vitest 范围。
|
||||
// 排除项:测试文件本身、OpenAPI codegen 生成物。
|
||||
include: ["lib/**/*.ts"],
|
||||
exclude: ["lib/**/*.test.ts", "lib/api/schema.d.ts"],
|
||||
reporter: ["text", "text-summary"],
|
||||
thresholds: {
|
||||
lines: 80,
|
||||
functions: 80,
|
||||
branches: 80,
|
||||
statements: 80,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user