Compare commits

...

5 Commits

Author SHA1 Message Date
Yaojia Wang
90a66437d7 feat: improve app ui and project metadata 2026-06-28 07:31:20 +02:00
Yaojia Wang
3bd464d400 docs: 登记 QA-CR 全项目 code review 整改批(21 项任务表) 2026-06-27 06:35:55 +02:00
Yaojia Wang
a6f5d085e5 test: 接入 jsdom 测试栈, 把 15 个 React hooks 纳入 80% 覆盖率门禁
新增 jsdom + @testing-library/react, 为 lib/** 全部 use*.ts hooks 写 renderHook 单测:
生成/CRUD(world/character/generator/outline/accept/foreshadow/rules)、
SSE流(draftStream/reviewStream)、定时轮询(autosave/jobPoll/kimiOauth)、
文风与注入(refine/styleLearn/injection)。覆盖率 mock api 客户端+Toast, 不打真实 LLM。
vitest.config.ts 去掉 use*.ts 排除; 前端覆盖率 63%→95%。CLAUDE.md 同步说明。
2026-06-27 06:21:42 +02:00
Yaojia Wang
a02c6b6e4f test: 接入覆盖率工具并在 CI 强制 ≥80% 门禁
后端: 加 pytest-cov + [tool.coverage] 配置, pytest --cov-fail-under=80 (基线~88%)。
前端: 加 @vitest/coverage-v8, vitest.config.ts 设 80% 阈值, 新增 test:coverage 脚本 (基线~93%)。
前端覆盖范围限定 lib/** 纯逻辑层; hooks(use*.ts)待 jsdom 测试栈接入后纳入。
CI backend/frontend job 改跑覆盖率门禁命令。CLAUDE.md 同步 DoD 与 Toolchain。
2026-06-26 21:11:57 +02:00
Yaojia Wang
e9477d84d8 docs: 增加每次编码的硬门禁(Definition of Done)与提交纪律
明确 TDD/≥80%覆盖/三类自动化测试必跑/全门禁绿/合规/每次commit/禁署名尾注;
如实标注覆盖率工具尚未接入并列出补齐待办。
2026-06-26 21:03:35 +02:00
108 changed files with 8340 additions and 1175 deletions

View File

@@ -34,8 +34,8 @@ jobs:
run: | run: |
uv run alembic upgrade head uv run alembic upgrade head
uv run alembic check uv run alembic check
- name: pytest - name: pytest + coverage gate (>=80%)
run: uv run pytest -q run: uv run pytest -q --cov --cov-report=term-missing --cov-fail-under=80
# 打包冒烟:构建 ww-agents wheel → 装进裸 venv → import ww_agents 并断言 SPECS。 # 打包冒烟:构建 ww-agents wheel → 装进裸 venv → import ww_agents 并断言 SPECS。
# 证明 prompts/*.md 随 wheel 分发(源码树 pytest 测不出此漏带——见 # 证明 prompts/*.md 随 wheel 分发(源码树 pytest 测不出此漏带——见
@@ -85,6 +85,6 @@ jobs:
- name: typecheck - name: typecheck
working-directory: apps/web working-directory: apps/web
run: pnpm typecheck run: pnpm typecheck
- name: test - name: test + coverage gate (>=80%)
working-directory: apps/web working-directory: apps/web
run: pnpm test run: pnpm test:coverage

View File

@@ -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`. - **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). - **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 的门禁15必须已全绿——不提交跑不过测试或 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 已落地) ## Toolchain (Phase 0 已落地)
工具**uv**(Python workspace4 members: `apps/api` + `packages/{shared,config,db}`) + **pnpm**(前端`apps/web`)。Python 3.12+Node 22 工具**uv**(Python workspace4 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`)。 - **依赖安装**`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` - **本地起服务**`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 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` - **后端门禁**`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` - **前端门禁**`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`)。 - **重生成 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`)。 - **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/pytestfrontend job gen:api/lint/typecheck/test)。 - **CI**`.github/workflows/ci.yml`backend job pg service ruff/mypy/alembic/pytestfrontend job gen:api/lint/typecheck/test)。

View File

@@ -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 keyH8② 合并前 计费两项H3+ request_idH4+ max_lengthH9+ 前端三 HIGHH10/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 修 RCECVE-2025-66478+ React 补丁 + 轮换密钥 | ⬜ | @frontend | — | `apps/web/package.json` next **15.1.3→15.1.9**(或 15.5.7/16.xreact/react-dom 19.0.0→补丁版(上游 CVE-2025-55182App 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 → 加 warningadapter `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` 静默回退加 warningSSE 端点补 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`。 > **背景**对标竞品「创作工具箱」——把每个「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。证实「加生成器 = 加一份声明」这条路。 > **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 — 一句话结果 + 影响的契约/文件` > 格式:`- [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 RCECVE-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 未取消。**确认无虞**SSRFbase_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 修复 + 合并 developmerge `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 **静默丢失**(链计费全丢,违 #1E2E 清了 ledger 却没断言其存在故漏掉)。**修复**(`d1ea83b`):网关改 `chain_gateway_builder` 按节点 session 重建(仿 `digest_gateway_builder`/`style.py`);日志脱敏(`_classify_job_error`+`exc_type`,去 `str(exc)`§5resume 原子 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] @orchestrator**🎉 多章工作流链Chain Workflow交付 + 多 agent 交叉评审 + CRITICAL 修复 + 合并 developmerge `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 **静默丢失**(链计费全丢,违 #1E2E 清了 ledger 却没断言其存在故漏掉)。**修复**(`d1ea83b`):网关改 `chain_gateway_builder` 按节点 session 重建(仿 `digest_gateway_builder`/`style.py`);日志脱敏(`_classify_job_error`+`exc_type`,去 `str(exc)`§5resume 原子 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/#4token 不入 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/无网络/无真 LLMrun→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。**唯一新增 DDLlanggraph 检查点表)= C3 迁移;前端链 UI = 契约稳定后 @frontend follow-up本期非目标。** - [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/#4token 不入 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/无网络/无真 LLMrun→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。**唯一新增 DDLlanggraph 检查点表)= 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 specbook-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/#9system_prompt 进缓存前块)。**"加生成器=加一份声明" 经实证8 个新生成器复用同一通用执行器 + 同一前端组件)。** **实景 browse 复验(工具箱卡片栅格→脑洞预览→词条入库)待做。** 后续可选 P5封面图像轨道 / 自定义 skill 运行期 schema / 收敛已有 3 竖井。 - [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 specbook-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/#9system_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 gateUX §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`。TDD4 测。前端门禁绿lint/tsc/**vitest 205**(+4)/build。下一步按 planTier4T4-a 顶部 AI 工具条 / T4-b 本章指令框+风格预设)。 - [2026-06-20] @frontend**UX R3 完成**(验收前清单 HITL gateUX §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`。TDD4 测。前端门禁绿lint/tsc/**vitest 205**(+4)/build。下一步按 planTier4T4-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-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余 4rem6 个固定高度页改用 `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。TDDai-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-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余 4rem6 个固定高度页改用 `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。TDDai-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。

View File

@@ -9,6 +9,7 @@ from __future__ import annotations
import uuid import uuid
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from datetime import UTC, datetime
from typing import Any from typing import Any
from pydantic import BaseModel from pydantic import BaseModel
@@ -41,6 +42,7 @@ class FakeProjectRepo:
def __init__(self) -> None: def __init__(self) -> None:
self.rows: dict[uuid.UUID, tuple[uuid.UUID, ProjectView]] = {} 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: async def create(self, owner_id: uuid.UUID, data: ProjectCreate) -> ProjectView:
pid = uuid.uuid4() pid = uuid.uuid4()
@@ -53,6 +55,7 @@ class FakeProjectRepo:
theme=data.theme, theme=data.theme,
selling_points=list(data.selling_points), selling_points=list(data.selling_points),
structure=data.structure, structure=data.structure,
updated_at=self.now,
) )
self.rows[pid] = (owner_id, view) self.rows[pid] = (owner_id, view)
return view return view

View File

@@ -158,8 +158,11 @@ async def test_list_projects() -> None:
await client.post("/projects", json={"title": ""}) await client.post("/projects", json={"title": ""})
resp = await client.get("/projects") resp = await client.get("/projects")
assert resp.status_code == 200 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 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 @pytest.mark.asyncio
@@ -169,7 +172,10 @@ async def test_get_project_detail() -> None:
created = (await client.post("/projects", json={"title": "详情"})).json() created = (await client.post("/projects", json={"title": "详情"})).json()
resp = await client.get(f"/projects/{created['id']}") resp = await client.get(f"/projects/{created['id']}")
assert resp.status_code == 200 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 @pytest.mark.asyncio

View File

@@ -6,6 +6,7 @@ snake_case前端经 OpenAPI 生成 TS 类型消费。改字段 → 前端必
from __future__ import annotations from __future__ import annotations
import uuid import uuid
from datetime import datetime
from typing import Any, Literal from typing import Any, Literal
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -34,6 +35,8 @@ class ProjectResponse(BaseModel):
theme: str | None = None theme: str | None = None
selling_points: list[str] = Field(default_factory=list) selling_points: list[str] = Field(default_factory=list)
structure: str | None = None 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): class ProjectListResponse(BaseModel):

View File

@@ -3,7 +3,8 @@
@tailwind utilities; @tailwind utilities;
/* 纸感设计 tokenUX_SPEC §2.1 */ /* 纸感设计 tokenUX_SPEC §2.1 */
:root { :root,
[data-theme="paper"] {
--color-bg: #f5f1e8; --color-bg: #f5f1e8;
--color-panel: #fbf8f1; --color-panel: #fbf8f1;
--color-ink: #2b2620; --color-ink: #2b2620;
@@ -15,6 +16,32 @@
--color-overdue: #c8893a; --color-overdue: #c8893a;
--color-pass: #5a6b4f; --color-pass: #5a6b4f;
--color-info: #4a5a6b; --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 { body {
@@ -22,6 +49,11 @@ body {
color: var(--color-ink); color: var(--color-ink);
} }
::selection {
background: var(--color-cinnabar-wash);
color: var(--color-ink);
}
/* 流式打字机光标:朱砂闪烁;尊重 prefers-reduced-motionUX §10。 */ /* 流式打字机光标:朱砂闪烁;尊重 prefers-reduced-motionUX §10。 */
.typewriter-cursor { .typewriter-cursor {
animation: typewriter-blink 1s step-end infinite; animation: typewriter-blink 1s step-end infinite;
@@ -42,7 +74,7 @@ body {
/* 终稿正文冲突原文持续高亮朱砂淡底点锚点定位时闪烁一下UX §8.3)。 */ /* 终稿正文冲突原文持续高亮朱砂淡底点锚点定位时闪烁一下UX §8.3)。 */
.draft-mark { .draft-mark {
background-color: #b5543a26; /* --color-conflict @ ~15% */ background-color: var(--color-conflict-mark);
border-radius: 2px; border-radius: 2px;
} }
.draft-mark-flash { .draft-mark-flash {
@@ -52,10 +84,10 @@ body {
@keyframes draft-mark-flash { @keyframes draft-mark-flash {
0%, 0%,
100% { 100% {
background-color: #b5543a26; background-color: var(--color-conflict-mark);
} }
30% { 30% {
background-color: #b5543a8c; /* --color-conflict @ ~55% */ background-color: var(--color-conflict-mark-strong);
} }
} }

View File

@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import "./globals.css"; import "./globals.css";
import { CommandPaletteMount } from "@/components/command/CommandPaletteMount"; import { CommandPaletteMount } from "@/components/command/CommandPaletteMount";
import { ThemeScript } from "@/components/ThemeScript";
import { ToastProvider } from "@/components/Toast"; import { ToastProvider } from "@/components/Toast";
export const metadata: Metadata = { export const metadata: Metadata = {
@@ -19,6 +20,7 @@ export default function RootLayout({
return ( return (
<html lang="zh-CN" suppressHydrationWarning> <html lang="zh-CN" suppressHydrationWarning>
<body className="font-sans antialiased" suppressHydrationWarning> <body className="font-sans antialiased" suppressHydrationWarning>
<ThemeScript />
<ToastProvider> <ToastProvider>
{children} {children}
<CommandPaletteMount /> <CommandPaletteMount />

View File

@@ -1,9 +1,13 @@
import Link from "next/link"; import Link from "next/link";
import { BookOpen, Plus } from "lucide-react";
import { AppShell } from "@/components/AppShell"; 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 { fetchProjects } from "@/lib/api/server";
import type { ProjectResponse } from "@/lib/api/types"; import type { ProjectResponse } from "@/lib/api/types";
import { buttonClass } from "@/lib/ui/variants";
// 作品库Dashboard 入口UX §6.1。Server Component 读取。 // 作品库Dashboard 入口UX §6.1。Server Component 读取。
export default async function DashboardPage() { export default async function DashboardPage() {
@@ -18,63 +22,44 @@ export default async function DashboardPage() {
return ( return (
<AppShell> <AppShell>
<div className="mx-auto max-w-5xl px-8 py-10"> <div className="mx-auto max-w-5xl px-6 py-10 sm:px-8">
<div className="mb-8 flex items-center justify-between"> <PageHeader
<h1 className="font-serif text-3xl text-ink"></h1> title="我的作品"
description="从一个灵感进入正文、设定、审稿与验收闭环。"
actions={
<Link <Link
href="/projects/new" href="/projects/new"
className="rounded bg-cinnabar px-4 py-2 text-sm text-panel hover:opacity-90" className={buttonClass({ variant: "primary" })}
> >
<Plus className="h-4 w-4" aria-hidden="true" />
</Link> </Link>
</div> }
/>
{loadError ? ( {loadError ? (
<p className="rounded border border-conflict bg-panel p-6 text-conflict"> <p className="rounded border border-conflict bg-panel p-6 text-conflict">
API API
</p> </p>
) : projects.length === 0 ? ( ) : 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"> <ProjectLibrary projects={projects} />
{projects.map((p) => (
<li key={p.id}>
<ProjectCard project={p} />
</li>
))}
<li>
<NewProjectCard />
</li>
</ul>
)} )}
</div> </div>
</AppShell> </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>
);
}

View File

@@ -1,5 +1,6 @@
import { AppShell } from "@/components/AppShell"; import { AppShell } from "@/components/AppShell";
import { ProvidersSettings } from "@/components/settings/ProvidersSettings"; import { ProvidersSettings } from "@/components/settings/ProvidersSettings";
import { PageHeader } from "@/components/ui/PageHeader";
import { fetchKimiOauthStatus, fetchProviders } from "@/lib/api/server"; import { fetchKimiOauthStatus, fetchProviders } from "@/lib/api/server";
import type { ProvidersResponse } from "@/lib/api/types"; import type { ProvidersResponse } from "@/lib/api/types";
@@ -27,7 +28,11 @@ export default async function ProvidersSettingsPage() {
return ( return (
<AppShell title="设置 模型与提供商"> <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 ? ( {loadError ? (
<p className="rounded border border-conflict bg-panel p-6 text-conflict"> <p className="rounded border border-conflict bg-panel p-6 text-conflict">
API API

View File

@@ -1,7 +1,21 @@
import Link from "next/link"; 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 type { ActiveNav } from "@/lib/nav/items";
import { buttonClass } from "@/lib/ui/variants";
interface AiToolbarProps { interface AiToolbarProps {
projectId: string; projectId: string;
@@ -9,16 +23,20 @@ interface AiToolbarProps {
} }
// T4-a · 项目内页常驻 AI 工具条顶栏下UX §3/§5 // T4-a · 项目内页常驻 AI 工具条顶栏下UX §3/§5
// 纯链接服务端组件:写本章=朱砂主动作,其余次级;激活项朱砂高亮。窄屏横向滚动 // 桌面展示完整工具条;窄屏只保留写本章/审稿,把低频入口收进更多菜单
export function AiToolbar({ projectId, activeNav }: AiToolbarProps) { export function AiToolbar({ projectId, activeNav }: AiToolbarProps) {
const primaryItems = primaryAiToolItems(projectId);
const secondaryItems = secondaryAiToolItems(projectId);
return ( return (
<nav <nav
aria-label="AI 工具条" 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) => { <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 isActive = item.key === activeNav;
const className = toolClassName(item.primary === true, isActive); const className = toolClassName(item.primary === true, isActive);
const Icon = toolIcon(item.key);
return ( return (
<Link <Link
key={item.href} key={item.href}
@@ -26,23 +44,50 @@ export function AiToolbar({ projectId, activeNav }: AiToolbarProps) {
aria-current={isActive ? "page" : undefined} aria-current={isActive ? "page" : undefined}
className={className} className={className}
> >
<span aria-hidden="true">{item.glyph}</span> <Icon className="h-4 w-4" aria-hidden="true" />
<span>{item.label}</span> <span>{item.label}</span>
</Link> </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> </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 { 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) { if (isActive) {
return `${base} border-cinnabar text-cinnabar`; return buttonClass({ variant: "outline", size: "sm" });
} }
if (isPrimary) { 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" });
} }

View 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>
);
}

View File

@@ -1,10 +1,13 @@
import Link from "next/link"; import Link from "next/link";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { Settings } from "lucide-react";
import type { ActiveNav } from "@/lib/nav/items"; import type { ActiveNav } from "@/lib/nav/items";
import { buttonClass } from "@/lib/ui/variants";
import { AiToolbar } from "./AiToolbar"; import { AiToolbar } from "./AiToolbar";
import { LeftNav } from "./LeftNav"; import { LeftNav } from "./LeftNav";
import { NavDrawer } from "./NavDrawer"; import { NavDrawer } from "./NavDrawer";
import { ThemeToggle } from "./ThemeToggle";
interface AppShellProps { interface AppShellProps {
children: ReactNode; children: ReactNode;
@@ -34,23 +37,33 @@ export function AppShell({
<NavDrawer projectId={projectId} activeNav={activeNav} /> <NavDrawer projectId={projectId} activeNav={activeNav} />
<Link <Link
href="/" href="/"
className="font-serif text-xl text-cinnabar" className="shrink-0 whitespace-nowrap font-serif text-xl text-cinnabar"
aria-label="返回作品库" aria-label="返回作品库"
> >
</Link> </Link>
{title ? ( {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} ) : null}
{subtitle ? ( {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} ) : 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 <Link
href="/settings/providers" 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> </Link>
</nav> </nav>
</header> </header>
@@ -59,7 +72,7 @@ export function AppShell({
) : null} ) : null}
<div className="flex"> <div className="flex">
<LeftNav projectId={projectId} activeNav={activeNav} /> <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} {children}
</main> </main>
</div> </div>

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useRef, type ReactNode } from "react"; import { useEffect, useRef, type ReactNode, type RefObject } from "react";
import { handleTabTrap } from "@/lib/a11y/focusTrap"; import { handleTabTrap } from "@/lib/a11y/focusTrap";
@@ -11,6 +11,7 @@ interface DrawerProps {
side?: "left" | "right"; side?: "left" | "right";
// 无障碍标签role=dialog // 无障碍标签role=dialog
label: string; label: string;
triggerRef?: RefObject<HTMLElement | null>;
children: ReactNode; children: ReactNode;
} }
@@ -21,12 +22,21 @@ export function Drawer({
onClose, onClose,
side = "left", side = "left",
label, label,
triggerRef,
children, children,
}: DrawerProps) { }: DrawerProps) {
const panelRef = useRef<HTMLDivElement>(null); const panelRef = useRef<HTMLDivElement>(null);
const wasOpenRef = useRef(false);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) {
if (wasOpenRef.current) {
wasOpenRef.current = false;
triggerRef?.current?.focus();
}
return;
}
wasOpenRef.current = true;
const onKey = (e: KeyboardEvent): void => { const onKey = (e: KeyboardEvent): void => {
if (e.key === "Escape") { if (e.key === "Escape") {
e.preventDefault(); e.preventDefault();
@@ -36,7 +46,7 @@ export function Drawer({
window.addEventListener("keydown", onKey); window.addEventListener("keydown", onKey);
panelRef.current?.focus(); panelRef.current?.focus();
return () => window.removeEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey);
}, [open, onClose]); }, [open, onClose, triggerRef]);
if (!open) return null; if (!open) return null;

View File

@@ -1,7 +1,9 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { Menu } from "lucide-react";
import { Button } from "@/components/ui/Button";
import type { ActiveNav } from "@/lib/nav/items"; import type { ActiveNav } from "@/lib/nav/items";
import { Drawer } from "./Drawer"; import { Drawer } from "./Drawer";
import { NavItems } from "./NavItems"; import { NavItems } from "./NavItems";
@@ -17,17 +19,16 @@ export function NavDrawer({ projectId, activeNav }: NavDrawerProps) {
return ( return (
<> <>
<button <Button
type="button"
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
aria-label="打开导航" aria-label="打开导航"
aria-expanded={open} 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"> <Menu className="h-5 w-5" aria-hidden="true" />
</Button>
</span>
</button>
<Drawer <Drawer
open={open} open={open}
onClose={() => setOpen(false)} onClose={() => setOpen(false)}

View File

@@ -1,6 +1,21 @@
"use client"; "use client";
import Link from "next/link"; 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 { usePathname } from "next/navigation";
import { 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({ function NavLink({
item, item,
active, active,
@@ -63,18 +94,20 @@ function NavLink({
active: boolean; active: boolean;
onNavigate?: () => void; onNavigate?: () => void;
}) { }) {
const Icon = navIcon(item);
return ( return (
<li> <li>
<Link <Link
href={item.href} href={item.href}
onClick={onNavigate} onClick={onNavigate}
aria-current={active ? "page" : undefined} 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 active
? "border-l-2 border-cinnabar bg-[var(--color-cinnabar-wash)] text-cinnabar" ? "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} {item.label}
</Link> </Link>
</li> </li>

View File

@@ -1,26 +1,98 @@
import Link from "next/link"; 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 type { ProjectResponse } from "@/lib/api/types";
import {
formatProjectUpdatedAt,
pendingReviewCount,
} from "@/lib/projects/projects";
import { cardClass } from "@/lib/ui/variants";
interface ProjectCardProps { interface ProjectCardProps {
project: ProjectResponse; project: ProjectResponse;
compact?: boolean;
} }
// 作品卡UX §6.1):书名衬线大字、题材、一句话故事。 // 作品卡UX §6.1):书名衬线大字、题材、一句话故事。
// M1 无字数/章数/待办统计端点 → 暂不展示该徽标(避免编造 API // 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 ( return (
<Link <Link
href={`/projects/${project.id}/write`} 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 items-center gap-3 p-3 transition-colors hover:border-cinnabar",
)}
> >
<h2 className="font-serif text-2xl text-ink">{project.title}</h2> <span className="flex h-9 w-9 shrink-0 items-center justify-center rounded bg-[var(--color-cinnabar-wash)] text-cinnabar">
{project.genre ? ( <BookOpen className="h-4 w-4" aria-hidden="true" />
<p className="mt-2 text-sm text-ink-soft">{project.genre}</p> </span>
) : null} <span className="min-w-0 flex-1">
{project.logline ? ( <span className="block truncate font-serif text-base text-ink">
<p className="mt-3 line-clamp-3 text-sm text-ink">{project.logline}</p> {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} ) : 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={cardClass(
"group flex h-full min-h-[176px] flex-col p-6 transition-colors hover:border-cinnabar",
)}
>
<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="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> </Link>
); );
} }

View File

@@ -1,10 +1,18 @@
"use client"; "use client";
import { ArrowLeft, ArrowRight, CheckCircle2 } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
import { api } from "@/lib/api/client";
import { useToast } from "@/components/Toast"; 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 { import {
GENRES, GENRES,
SELLING_POINT_PRESETS, SELLING_POINT_PRESETS,
@@ -69,14 +77,17 @@ export function ProjectWizard() {
const isLast = step === WIZARD_STEPS; const isLast = step === WIZARD_STEPS;
return ( 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"> <div className="mb-6 flex items-center justify-between">
<h1 className="font-serif text-2xl text-ink"></h1> <h1 className="font-serif text-2xl text-ink"></h1>
<StepDots current={step} /> <StepDots current={step} />
</div> </div>
<p className="mb-4 text-sm text-ink-soft"> <div className="mb-4">
{step}/{WIZARD_STEPS} · {STEP_TITLES[step - 1]} <Badge variant="accent">
</p> {step}/{WIZARD_STEPS}
</Badge>
<p className="mt-2 text-sm text-ink-soft">{STEP_TITLES[step - 1]}</p>
</div>
<div className="min-h-[220px]"> <div className="min-h-[220px]">
{step === 1 && <StepBasics form={form} update={update} />} {step === 1 && <StepBasics form={form} update={update} />}
@@ -93,32 +104,28 @@ export function ProjectWizard() {
</div> </div>
<div className="mt-8 flex items-center justify-between"> <div className="mt-8 flex items-center justify-between">
<button <Button onClick={goBack} disabled={step === 1} variant="secondary">
type="button" <ArrowLeft className="h-4 w-4" aria-hidden="true" />
onClick={goBack}
disabled={step === 1} </Button>
className="rounded border border-line px-4 py-2 text-sm text-ink disabled:opacity-40"
>
</button>
{isLast ? ( {isLast ? (
<button <Button
type="button"
onClick={submit} onClick={submit}
disabled={submitting || !canSubmit(form)} disabled={submitting || !canSubmit(form)}
className="rounded bg-cinnabar px-5 py-2 text-sm text-panel disabled:opacity-40" variant="primary"
> >
{submitting ? "创建中…" : "完成立项 →"} <CheckCircle2 className="h-4 w-4" aria-hidden="true" />
</button> {submitting ? "创建中…" : "完成立项"}
</Button>
) : ( ) : (
<button <Button
type="button"
onClick={goNext} onClick={goNext}
disabled={!canAdvance(step, form)} 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>
</div> </div>
@@ -131,7 +138,7 @@ function StepDots({ current }: { current: number }) {
{Array.from({ length: WIZARD_STEPS }, (_, i) => ( {Array.from({ length: WIZARD_STEPS }, (_, i) => (
<span <span
key={i} key={i}
className={`h-2 w-2 rounded-full ${ className={`h-2 w-2 rounded ${
i + 1 <= current ? "bg-cinnabar" : "bg-line" i + 1 <= current ? "bg-cinnabar" : "bg-line"
}`} }`}
/> />
@@ -145,30 +152,11 @@ interface StepProps {
update: (patch: Partial<WizardForm>) => void; 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) { function StepBasics({ form, update }: StepProps) {
return ( return (
<div> <div>
<Field label="书名(必填)"> <Field label="书名(必填)">
<input <TextInput
className={inputCls}
value={form.title} value={form.title}
onChange={(e) => update({ title: e.target.value })} onChange={(e) => update({ title: e.target.value })}
placeholder="例:逐光而行" placeholder="例:逐光而行"
@@ -176,8 +164,7 @@ function StepBasics({ form, update }: StepProps) {
/> />
</Field> </Field>
<Field label="题材"> <Field label="题材">
<select <Select
className={inputCls}
value={form.genre} value={form.genre}
onChange={(e) => update({ genre: e.target.value })} onChange={(e) => update({ genre: e.target.value })}
> >
@@ -187,7 +174,7 @@ function StepBasics({ form, update }: StepProps) {
{g} {g}
</option> </option>
))} ))}
</select> </Select>
</Field> </Field>
</div> </div>
); );
@@ -201,8 +188,8 @@ function StepStory({
return ( return (
<div> <div>
<Field label="一句话故事logline"> <Field label="一句话故事logline">
<textarea <TextArea
className={`${inputCls} h-20 resize-none`} className="h-20 resize-none"
value={form.logline} value={form.logline}
onChange={(e) => update({ logline: e.target.value })} onChange={(e) => update({ logline: e.target.value })}
placeholder="废柴少年觉醒禁忌血脉,在仙门倾轧中逆势封神。" placeholder="废柴少年觉醒禁忌血脉,在仙门倾轧中逆势封神。"
@@ -215,11 +202,10 @@ function StepStory({
type="button" type="button"
key={s} key={s}
onClick={() => update({ structure: s })} onClick={() => update({ structure: s })}
className={`rounded border px-3 py-1.5 text-sm ${ className={buttonClass({
form.structure === s variant: form.structure === s ? "outline" : "secondary",
? "border-cinnabar bg-[var(--color-cinnabar-wash)] text-cinnabar" size: "sm",
: "border-line text-ink" })}
}`}
> >
{s} {s}
</button> </button>
@@ -235,11 +221,12 @@ function StepStory({
key={p} key={p}
aria-pressed={form.sellingPoints.includes(p)} aria-pressed={form.sellingPoints.includes(p)}
onClick={() => toggleSellingPoint(p)} onClick={() => toggleSellingPoint(p)}
className={`rounded border px-3 py-1.5 text-sm ${ className={buttonClass({
form.sellingPoints.includes(p) variant: form.sellingPoints.includes(p)
? "border-cinnabar bg-[var(--color-cinnabar-wash)] text-cinnabar" ? "outline"
: "border-line text-ink" : "secondary",
}`} size: "sm",
})}
> >
{p} {p}
</button> </button>
@@ -254,16 +241,15 @@ function StepPremise({ form, update }: StepProps) {
return ( return (
<div> <div>
<Field label="立意premise"> <Field label="立意premise">
<textarea <TextArea
className={`${inputCls} h-20 resize-none`} className="h-20 resize-none"
value={form.premise} value={form.premise}
onChange={(e) => update({ premise: e.target.value })} onChange={(e) => update({ premise: e.target.value })}
placeholder="故事的核心命题与前提。" placeholder="故事的核心命题与前提。"
/> />
</Field> </Field>
<Field label="主题theme"> <Field label="主题theme">
<input <TextInput
className={inputCls}
value={form.theme} value={form.theme}
onChange={(e) => update({ theme: e.target.value })} onChange={(e) => update({ theme: e.target.value })}
placeholder="例:抗争与代价" placeholder="例:抗争与代价"
@@ -282,8 +268,8 @@ function StepProtagonist({ form, update }: StepProps) {
M1 M1
</p> </p>
<Field label="主角 / 金手指概要"> <Field label="主角 / 金手指概要">
<textarea <TextArea
className={`${inputCls} h-28 resize-none`} className="h-28 resize-none"
value={form.protagonist} value={form.protagonist}
onChange={(e) => update({ protagonist: e.target.value })} onChange={(e) => update({ protagonist: e.target.value })}
placeholder="主角设定、金手指来源与限制……" placeholder="主角设定、金手指来源与限制……"
@@ -295,7 +281,7 @@ function StepProtagonist({ form, update }: StepProps) {
function StepWorld() { function StepWorld() {
return ( 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> </div>
); );

View File

@@ -0,0 +1,10 @@
import { themeBootstrapScript } from "@/lib/ui/theme";
export function ThemeScript() {
return (
<script
dangerouslySetInnerHTML={{ __html: themeBootstrapScript() }}
suppressHydrationWarning
/>
);
}

View 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>
);
}

View File

@@ -143,6 +143,7 @@ export function ChainAdjudication({
<ConflictCard <ConflictCard
key={i} key={i}
index={i} index={i}
total={conflicts.length}
conflict={conflict} conflict={conflict}
draft={draft} draft={draft}
missing={false} missing={false}

View File

@@ -1,7 +1,12 @@
"use client"; "use client";
import { useState } from "react"; 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"; import { CHAIN_KINDS, type ChainKind } from "@/lib/chain/chain";
interface ChainStarterProps { interface ChainStarterProps {
@@ -41,12 +46,10 @@ export function ChainStarter({ onStart, disabled }: ChainStarterProps) {
if (valid && !disabled) onStart(chainKey, startNo, countNo); if (valid && !disabled) onStart(chainKey, startNo, countNo);
}} }}
> >
<div> <SectionHeader
<h2 className="font-serif text-lg text-ink"></h2> title="连续写多章"
<p className="mt-1 text-sm text-ink-soft"> description="从指定章起循环「写章 → 四审 → 验收」;遇未决冲突会暂停等你裁决再续跑。"
/>
</p>
</div>
<fieldset className="flex flex-col gap-2"> <fieldset className="flex flex-col gap-2">
<legend className="text-sm text-ink-soft"></legend> <legend className="text-sm text-ink-soft"></legend>
<div className="flex flex-wrap gap-4"> <div className="flex flex-wrap gap-4">
@@ -75,37 +78,35 @@ export function ChainStarter({ onStart, disabled }: ChainStarterProps) {
</div> </div>
</fieldset> </fieldset>
<div className="flex flex-wrap gap-4"> <div className="flex flex-wrap gap-4">
<label className="block text-sm text-ink-soft"> <Field label="起始章号" className="w-32">
<TextInput
<input
type="number" type="number"
min={1} min={1}
value={start} value={start}
onChange={(e) => setStart(e.target.value)} 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="起始章号" aria-label="起始章号"
/> />
</label> </Field>
<label className="block text-sm text-ink-soft"> <Field label={`连续章数1..${MAX_COUNT}`} className="w-40">
1..{MAX_COUNT} <TextInput
<input
type="number" type="number"
min={1} min={1}
max={MAX_COUNT} max={MAX_COUNT}
value={count} value={count}
onChange={(e) => setCount(e.target.value)} 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="连续章数" aria-label="连续章数"
/> />
</label> </Field>
</div> </div>
<button <Button
type="submit" type="submit"
disabled={disabled || !valid} 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 ? "运行中…" : "发起多章链"} {disabled ? "运行中…" : "发起多章链"}
</button> </Button>
</form> </form>
); );
} }

View File

@@ -1,10 +1,16 @@
"use client"; "use client";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { Clock3, Globe2, Sparkles, UserRound } from "lucide-react";
import { AppShell } from "@/components/AppShell"; import { AppShell } from "@/components/AppShell";
import { CharacterGenerator } from "@/components/generation/CharacterGenerator"; import { CharacterGenerator } from "@/components/generation/CharacterGenerator";
import { WorldGenerator } from "@/components/generation/WorldGenerator"; 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 { import type {
CharacterCardView, CharacterCardView,
ProjectResponse, ProjectResponse,
@@ -60,6 +66,10 @@ export function CodexPage({
activeNav="codex" activeNav="codex"
> >
<div className="flex h-[calc(100vh-var(--chrome,4rem))] flex-col p-4"> <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"> <div className="mb-4 flex items-center gap-2" role="tablist">
{TABS.map((t) => ( {TABS.map((t) => (
<button <button
@@ -82,20 +92,28 @@ export function CodexPage({
<div className="min-h-0 flex-1 overflow-auto"> <div className="min-h-0 flex-1 overflow-auto">
{tab === "characters" ? ( {tab === "characters" ? (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<section className="rounded border border-line bg-panel p-3"> <Card as="section" className="p-4">
<h3 className="mb-2 font-serif text-sm text-ink"> <h3 className="mb-3 font-serif text-sm text-ink">
{characters.length} {characters.length}
</h3> </h3>
{characters.length > 0 ? ( {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) => ( {characters.map((c, i) => (
<li <li
key={`${c.name}-${i}`} 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"> <div className="mb-2 flex items-center gap-2">
{c.name}{c.role} <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> </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 ? ( {c.relations && c.relations.length > 0 ? (
<ul className="mt-1 flex flex-wrap gap-1"> <ul className="mt-1 flex flex-wrap gap-1">
{c.relations.map((r, j) => ( {c.relations.map((r, j) => (
@@ -113,11 +131,29 @@ export function CodexPage({
))} ))}
</ul> </ul>
) : ( ) : (
<p className="text-xs text-ink-soft"> <EmptyState
icon={UserRound}
</p> 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> </Card>
<div id="character-generator">
<CharacterGenerator <CharacterGenerator
projectId={project.id} projectId={project.id}
onIngested={(_, cards) => onIngested={(_, cards) =>
@@ -125,12 +161,13 @@ export function CodexPage({
} }
/> />
</div> </div>
</div>
) : null} ) : null}
{tab === "world" ? ( {tab === "world" ? (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<section className="rounded border border-line bg-panel p-3"> <Card as="section" className="p-4">
<h3 className="mb-2 font-serif text-sm text-ink"> <h3 className="mb-3 font-serif text-sm text-ink">
{initialWorldEntities.length} {initialWorldEntities.length}
</h3> </h3>
{initialWorldEntities.length > 0 ? ( {initialWorldEntities.length > 0 ? (
@@ -141,12 +178,16 @@ export function CodexPage({
className="rounded border border-line bg-bg p-3 text-sm" className="rounded border border-line bg-bg p-3 text-sm"
> >
<header className="mb-2 flex items-center gap-2"> <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"> <span className="font-serif text-base text-ink">
{entity.name} {entity.name}
</span> </span>
<span className="rounded bg-panel px-2 py-0.5 text-xs text-ink-soft"> <Badge>
{entity.type} {entity.type}
</span> </Badge>
</header> </header>
{worldEntityRules(entity).length > 0 ? ( {worldEntityRules(entity).length > 0 ? (
<ul className="flex list-disc flex-col gap-1 pl-5 text-ink-soft"> <ul className="flex list-disc flex-col gap-1 pl-5 text-ink-soft">
@@ -161,19 +202,40 @@ export function CodexPage({
))} ))}
</div> </div>
) : ( ) : (
<p className="text-xs text-ink-soft"> <EmptyState
icon={Globe2}
</p> 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> </Card>
<div id="world-generator">
<WorldGenerator projectId={project.id} /> <WorldGenerator projectId={project.id} />
</div> </div>
</div>
) : null} ) : null}
{tab === "timeline" ? ( {tab === "timeline" ? (
<div className="rounded border border-dashed border-line bg-panel p-6 text-center text-sm text-ink-soft"> <EmptyState
线P2 + icon={Clock3}
</div> title="时间线尚未启用"
description="时间线会从章节摘要、伏笔窗口和验收记录派生,后续可用于全书一致性扫描。"
/>
) : null} ) : null}
</div> </div>
</div> </div>

View File

@@ -1,14 +1,22 @@
"use client"; "use client";
import { useMemo } from "react"; import { useMemo, useState } from "react";
import { Flag, Plus } from "lucide-react";
import { AppShell } from "@/components/AppShell"; 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 type { ForeshadowView, ProjectResponse } from "@/lib/api/types";
import { import {
LANES, LANES,
LANE_LABELS,
countByStatus,
groupByStatus, groupByStatus,
type ForeshadowStatus, type ForeshadowStatus,
} from "@/lib/foreshadow/board"; } from "@/lib/foreshadow/board";
import type { BadgeVariant } from "@/lib/ui/variants";
import { useForeshadow } from "@/lib/foreshadow/useForeshadow"; import { useForeshadow } from "@/lib/foreshadow/useForeshadow";
import { KanbanColumn } from "./KanbanColumn"; import { KanbanColumn } from "./KanbanColumn";
import { RegisterForm } from "./RegisterForm"; import { RegisterForm } from "./RegisterForm";
@@ -25,7 +33,9 @@ export function ForeshadowBoard({
initialItems, initialItems,
}: ForeshadowBoardProps) { }: ForeshadowBoardProps) {
const { items, busy, register, transition } = useForeshadow(initialItems); const { items, busy, register, transition } = useForeshadow(initialItems);
const [registerOpen, setRegisterOpen] = useState(false);
const lanes = useMemo(() => groupByStatus(items), [items]); const lanes = useMemo(() => groupByStatus(items), [items]);
const counts = useMemo(() => countByStatus(items), [items]);
const onTransition = ( const onTransition = (
code: string, code: string,
@@ -46,16 +56,51 @@ export function ForeshadowBoard({
projectId={project.id} projectId={project.id}
activeNav="foreshadow" activeNav="foreshadow"
> >
<div className="flex h-[calc(100vh-var(--chrome,4rem))] flex-col p-4"> <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">
<div className="mb-4 flex items-start justify-between gap-4"> <PageHeader
<h1 className="font-serif text-lg text-ink"></h1> 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}
onRegister={register}
open={registerOpen}
onOpenChange={setRegisterOpen}
/>
</div>
) : null}
{items.length === 0 ? (
<EmptyState
icon={Flag}
title="还没有伏笔"
description="登记第一条伏笔后,这里会按待推进、推进中、已回收、已逾期四种状态自动分栏。"
action={
<RegisterForm <RegisterForm
projectId={project.id} projectId={project.id}
busy={busy} busy={busy}
onRegister={register} onRegister={register}
/> />
</div> }
<div className="grid min-h-0 flex-1 grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-4"> 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) => ( {LANES.map((status) => (
<KanbanColumn <KanbanColumn
key={status} key={status}
@@ -66,7 +111,49 @@ export function ForeshadowBoard({
/> />
))} ))}
</div> </div>
</>
)}
</div> </div>
</AppShell> </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>
);
}

View File

@@ -1,9 +1,13 @@
"use client"; "use client";
import { AlertTriangle } from "lucide-react";
import { useState } from "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 { ForeshadowView } from "@/lib/api/types";
import type { ForeshadowStatus } from "@/lib/foreshadow/board"; import { LANE_LABELS, type ForeshadowStatus } from "@/lib/foreshadow/board";
interface ForeshadowCardProps { interface ForeshadowCardProps {
item: ForeshadowView; item: ForeshadowView;
@@ -44,9 +48,10 @@ export function ForeshadowCard({
<div className="flex items-baseline gap-2"> <div className="flex items-baseline gap-2">
<span className="font-mono text-xs text-cinnabar">{item.code}</span> <span className="font-mono text-xs text-cinnabar">{item.code}</span>
{overdue ? ( {overdue ? (
<span className="text-xs text-overdue" aria-label="逾期"> <Badge variant="warning" aria-label="逾期">
<AlertTriangle className="h-3 w-3" aria-hidden="true" />
</span>
</Badge>
) : null} ) : null}
</div> </div>
<p className="mt-0.5 font-serif text-sm text-ink">{item.title}</p> <p className="mt-0.5 font-serif text-sm text-ink">{item.title}</p>
@@ -72,15 +77,16 @@ export function ForeshadowCard({
{transitions.length > 0 ? ( {transitions.length > 0 ? (
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{transitions.map((to) => ( {transitions.map((to) => (
<button <Button
key={to} key={to}
type="button"
disabled={busy} disabled={busy}
onClick={() => onTransition(item.code, to, "")} 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} {to === "CLOSED" ? "回收" : LANE_LABELS[to]}
</button> </Button>
))} ))}
</div> </div>
) : null} ) : null}
@@ -89,25 +95,27 @@ export function ForeshadowCard({
<label htmlFor={`prog-${item.code}`} className="sr-only"> <label htmlFor={`prog-${item.code}`} className="sr-only">
{item.code} {item.code}
</label> </label>
<input <TextInput
id={`prog-${item.code}`} id={`prog-${item.code}`}
type="text" type="text"
value={note} value={note}
onChange={(e) => setNote(e.target.value)} onChange={(e) => setNote(e.target.value)}
placeholder="加进展23章发光" 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 <Button
type="button"
disabled={busy || note.trim().length === 0} disabled={busy || note.trim().length === 0}
onClick={() => { onClick={() => {
onTransition(item.code, null, note.trim()); onTransition(item.code, null, note.trim());
setNote(""); 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> </div>
) : null} ) : null}
</div> </div>

View File

@@ -1,7 +1,8 @@
"use client"; "use client";
import type { ForeshadowView } from "@/lib/api/types"; 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"; import { ForeshadowCard } from "./ForeshadowCard";
interface KanbanColumnProps { interface KanbanColumnProps {
@@ -31,17 +32,23 @@ export function KanbanColumn({
<header <header
className={`flex items-center justify-between rounded-t border-b px-3 py-2 ${ className={`flex items-center justify-between rounded-t border-b px-3 py-2 ${
overdue overdue
? "border-overdue bg-overdue/10 text-overdue" ? "border-overdue bg-overdue/10 text-ink"
: "border-line bg-panel text-ink-soft" : "border-line bg-panel text-ink-soft"
}`} }`}
> >
<span className="font-mono text-xs font-semibold"> <span className="flex items-center gap-2">
{overdue ? "⚠ " : ""} {overdue ? (
{status} <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>
<span className="font-mono text-xs">{items.length}</span> <span className="font-mono text-xs">{items.length}</span>
</header> </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 ? ( {items.length === 0 ? (
<li className="px-1 py-3 text-xs text-ink-soft/60"></li> <li className="px-1 py-3 text-xs text-ink-soft/60"></li>
) : ( ) : (

View File

@@ -1,13 +1,18 @@
"use client"; "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"; import type { RegisterInput } from "@/lib/foreshadow/board";
interface RegisterFormProps { interface RegisterFormProps {
projectId: string; projectId: string;
busy: boolean; busy: boolean;
onRegister: (input: RegisterInput) => Promise<boolean>; onRegister: (input: RegisterInput) => Promise<boolean>;
open?: boolean;
onOpenChange?: (open: boolean) => void;
} }
const EMPTY = { const EMPTY = {
@@ -25,9 +30,16 @@ export function RegisterForm({
projectId, projectId,
busy, busy,
onRegister, onRegister,
open: controlledOpen,
onOpenChange,
}: RegisterFormProps) { }: RegisterFormProps) {
const [open, setOpen] = useState(false); const [localOpen, setLocalOpen] = useState(false);
const [form, setForm] = useState({ ...EMPTY }); 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 => const set = (key: keyof typeof EMPTY, value: string): void =>
setForm((prev) => ({ ...prev, [key]: value })); setForm((prev) => ({ ...prev, [key]: value }));
@@ -51,13 +63,10 @@ export function RegisterForm({
if (!open) { if (!open) {
return ( return (
<button <Button onClick={() => setOpen(true)} variant="primary" size="sm">
type="button" <Plus className="h-4 w-4" aria-hidden="true" />
onClick={() => setOpen(true)}
className="rounded bg-cinnabar px-3 py-1.5 text-sm text-panel hover:opacity-90" </Button>
>
</button>
); );
} }
@@ -73,99 +82,90 @@ export function RegisterForm({
className="rounded border border-line bg-panel p-4" className="rounded border border-line bg-panel p-4"
> >
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3"> <div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
<Field label="代号 *" id="f-code"> <FormSlot label="代号 *" id="f-code">
<input <TextInput
id="f-code" id="f-code"
value={form.code} value={form.code}
onChange={(e) => set("code", e.target.value)} onChange={(e) => set("code", e.target.value)}
className={inputCls}
/> />
</Field> </FormSlot>
<Field label="标题 *" id="f-title" span2> <FormSlot label="标题 *" id="f-title" span2>
<input <TextInput
id="f-title" id="f-title"
value={form.title} value={form.title}
onChange={(e) => set("title", e.target.value)} onChange={(e) => set("title", e.target.value)}
className={inputCls}
/> />
</Field> </FormSlot>
<Field label="埋设章" id="f-planted"> <FormSlot label="埋设章" id="f-planted">
<input <TextInput
id="f-planted" id="f-planted"
inputMode="numeric" inputMode="numeric"
value={form.plantedAt} value={form.plantedAt}
onChange={(e) => set("plantedAt", e.target.value)} onChange={(e) => set("plantedAt", e.target.value)}
className={inputCls}
/> />
</Field> </FormSlot>
<Field label="回收窗口起" id="f-from"> <FormSlot label="回收窗口起" id="f-from">
<input <TextInput
id="f-from" id="f-from"
inputMode="numeric" inputMode="numeric"
value={form.expectedCloseFrom} value={form.expectedCloseFrom}
onChange={(e) => set("expectedCloseFrom", e.target.value)} onChange={(e) => set("expectedCloseFrom", e.target.value)}
className={inputCls}
/> />
</Field> </FormSlot>
<Field label="回收窗口止" id="f-to"> <FormSlot label="回收窗口止" id="f-to">
<input <TextInput
id="f-to" id="f-to"
inputMode="numeric" inputMode="numeric"
value={form.expectedCloseTo} value={form.expectedCloseTo}
onChange={(e) => set("expectedCloseTo", e.target.value)} onChange={(e) => set("expectedCloseTo", e.target.value)}
className={inputCls}
/> />
</Field> </FormSlot>
<Field label="重要度" id="f-imp"> <FormSlot label="重要度" id="f-imp">
<input <TextInput
id="f-imp" id="f-imp"
value={form.importance} value={form.importance}
onChange={(e) => set("importance", e.target.value)} onChange={(e) => set("importance", e.target.value)}
placeholder="主线/支线" placeholder="主线/支线"
className={inputCls}
/> />
</Field> </FormSlot>
<Field label="线索内容" id="f-content" span3> <FormSlot label="线索内容" id="f-content" span3>
<input <TextInput
id="f-content" id="f-content"
value={form.content} value={form.content}
onChange={(e) => set("content", e.target.value)} onChange={(e) => set("content", e.target.value)}
className={inputCls}
/> />
</Field> </FormSlot>
</div> </div>
<div className="mt-3 flex items-center gap-2"> <div className="mt-3 flex items-center gap-2">
<button <Button
type="submit" type="submit"
disabled={!canSubmit} 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 ? "登记中…" : "登记"} {busy ? "登记中…" : "登记"}
</button> </Button>
<button <Button
type="button"
onClick={() => setOpen(false)} 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> </div>
</form> </form>
); );
} }
const inputCls = interface FormSlotProps {
"w-full rounded border border-line bg-bg px-2 py-1 text-sm text-ink focus:border-cinnabar focus:outline-none";
interface FieldProps {
label: string; label: string;
id: string; id: string;
span2?: boolean; span2?: boolean;
span3?: 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" : ""; const span = span3 ? "col-span-2 sm:col-span-3" : span2 ? "col-span-2" : "";
return ( return (
<div className={span}> <div className={span}>

View File

@@ -1,5 +1,6 @@
"use client"; "use client";
import { TextInput } from "@/components/ui/TextInput";
import type { CharacterCardView } from "@/lib/api/types"; import type { CharacterCardView } from "@/lib/api/types";
interface CharacterCardItemProps { interface CharacterCardItemProps {
@@ -32,11 +33,12 @@ export function CharacterCardItem({
className="size-4 accent-cinnabar" className="size-4 accent-cinnabar"
/> />
{onEdit ? ( {onEdit ? (
<input <TextInput
value={card.name} value={card.name}
onChange={(e) => onEdit({ name: e.target.value })} onChange={(e) => onEdit({ name: e.target.value })}
aria-label="角色名" 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> <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"> <div className="mb-1 flex items-start gap-1 text-ink-soft">
<span className="shrink-0 text-ink"></span> <span className="shrink-0 text-ink"></span>
{onEdit ? ( {onEdit ? (
<input <TextInput
value={card.arc} value={card.arc}
onChange={(e) => onEdit({ arc: e.target.value })} onChange={(e) => onEdit({ arc: e.target.value })}
aria-label="人物弧光" 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> <span>{card.arc}</span>

View File

@@ -1,7 +1,13 @@
"use client"; "use client";
import { useCallback, useEffect, useMemo, useState } from "react"; 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 type { CharacterCardView } from "@/lib/api/types";
import { import {
MAX_CHARACTER_COUNT, MAX_CHARACTER_COUNT,
@@ -80,45 +86,39 @@ export function CharacterGenerator({
return ( return (
<section className="flex flex-col gap-4" aria-label="角色生成器"> <section className="flex flex-col gap-4" aria-label="角色生成器">
<div className="rounded border border-line bg-panel p-4"> <div className="rounded border border-line bg-panel p-4">
<h2 className="mb-3 font-serif text-base text-ink"></h2> <SectionHeader title="角色生成器" className="mb-3" />
<label className="mb-2 block text-sm text-ink-soft"> <Field label="一句话需求" className="mb-3">
<TextInput
<input
value={brief} value={brief}
onChange={(e) => setBrief(e.target.value)} onChange={(e) => setBrief(e.target.value)}
placeholder="如:一个亦正亦邪的剑修,背负灭门血仇" 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"> <div className="flex flex-wrap items-end gap-3">
<label className="text-sm text-ink-soft"> <Field label="数量" className="w-24">
<TextInput
<input
type="number" type="number"
min={MIN_CHARACTER_COUNT} min={MIN_CHARACTER_COUNT}
max={MAX_CHARACTER_COUNT} max={MAX_CHARACTER_COUNT}
value={count} value={count}
onChange={(e) => setCount(Number(e.target.value))} 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> </Field>
<label className="flex-1 text-sm text-ink-soft"> <Field label="定位(可选)" className="min-w-[12rem] flex-1">
<TextInput
<input
value={role} value={role}
onChange={(e) => setRole(e.target.value)} onChange={(e) => setRole(e.target.value)}
placeholder="主角 / CP / 对手 / 导师 / 工具人" placeholder="主角 / CP / 对手 / 导师 / 工具人"
className="mt-1 block w-full rounded border border-line bg-bg px-2 py-1.5 text-sm text-ink"
/> />
</label> </Field>
<button <Button
type="button"
onClick={() => void onGenerate()} onClick={() => void onGenerate()}
disabled={generating || brief.trim().length === 0} 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 ? "生成中…" : "生成"} {generating ? "生成中…" : "生成"}
</button> </Button>
</div> </div>
</div> </div>
@@ -151,20 +151,20 @@ export function CharacterGenerator({
onCancel={() => gen.reset()} onCancel={() => gen.reset()}
/> />
) : ( ) : (
<button <Button
type="button"
onClick={() => void doIngest(false)} onClick={() => void doIngest(false)}
disabled={ingesting || selectedCards.length === 0} 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}`} {ingesting ? "入库中…" : `入库选中 ${selectedCards.length}`}
</button> </Button>
)} )}
</div> </div>
) : null} ) : null}
{gen.ingestStatus === "done" && gen.created.length > 0 ? ( {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} ) : null}
</section> </section>
); );

View File

@@ -1,7 +1,12 @@
"use client"; "use client";
import { useCallback, useState } from "react"; 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 { worldEntityRules } from "@/lib/generation/cards";
import { useWorldGen } from "@/lib/generation/useWorldGen"; import { useWorldGen } from "@/lib/generation/useWorldGen";
@@ -24,25 +29,23 @@ export function WorldGenerator({ projectId }: WorldGeneratorProps) {
return ( return (
<section className="flex flex-col gap-4" aria-label="世界观设计器"> <section className="flex flex-col gap-4" aria-label="世界观设计器">
<div className="rounded border border-line bg-panel p-4"> <div className="rounded border border-line bg-panel p-4">
<h2 className="mb-3 font-serif text-base text-ink"></h2> <SectionHeader title="世界观设计器" className="mb-3" />
<label className="mb-3 block text-sm text-ink-soft"> <Field label="设定方向" className="mb-3">
<TextArea
<textarea
value={brief} value={brief}
onChange={(e) => setBrief(e.target.value)} onChange={(e) => setBrief(e.target.value)}
placeholder="如:东方修真世界,灵气复苏,宗门林立,强者寿元有限" placeholder="如:东方修真世界,灵气复苏,宗门林立,强者寿元有限"
rows={3} rows={3}
className="mt-1 w-full resize-y rounded border border-line bg-bg px-3 py-2 text-sm text-ink"
/> />
</label> </Field>
<button <Button
type="button"
onClick={() => void onGenerate()} onClick={() => void onGenerate()}
disabled={generating || brief.trim().length === 0} 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 ? "生成中…" : "生成世界观"} {generating ? "生成中…" : "生成世界观"}
</button> </Button>
</div> </div>
{gen.status === "done" && gen.entities.length > 0 ? ( {gen.status === "done" && gen.entities.length > 0 ? (

View File

@@ -1,12 +1,15 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import { PenLine } from "lucide-react";
import { Badge } from "@/components/ui/Badge";
import type { OutlineChapterView } from "@/lib/api/types"; import type { OutlineChapterView } from "@/lib/api/types";
import { import {
isCloseWindow, isCloseWindow,
windowBadgeLabel, windowBadgeLabel,
} from "@/lib/outline/outline"; } from "@/lib/outline/outline";
import { buttonClass } from "@/lib/ui/variants";
interface OutlineChapterRowProps { interface OutlineChapterRowProps {
chapter: OutlineChapterView; chapter: OutlineChapterView;
@@ -25,31 +28,28 @@ export function OutlineChapterRow({ chapter, projectId }: OutlineChapterRowProps
</span> </span>
<Link <Link
href={`/projects/${projectId}/write?chapter=${chapter.no}`} 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> </Link>
{windows.map((w) => { {windows.map((w) => {
const close = isCloseWindow(chapter, w); const close = isCloseWindow(chapter, w);
return ( return (
<span <Badge
key={w.code} key={w.code}
variant={close ? "warning" : "accent"}
title={close ? "进入回收窗口,建议本章安排回收" : undefined} 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)} {windowBadgeLabel(w)}
{close ? " · 可回收" : ""} {close ? " · 可回收" : ""}
</span> </Badge>
); );
})} })}
</div> </div>
{chapter.beats && chapter.beats.length > 0 ? ( {chapter.beats && chapter.beats.length > 0 ? (
<p className="mt-1 text-sm text-ink"> <p className="mt-1 text-sm text-ink">
<span className="text-ink-soft">beats: </span> <span className="text-ink-soft"></span>
{chapter.beats.join(" / ")} {chapter.beats.join(" / ")}
</p> </p>
) : ( ) : (

View File

@@ -1,8 +1,14 @@
"use client"; "use client";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { ListTree, Sparkles } from "lucide-react";
import { AppShell } from "@/components/AppShell"; 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 type { OutlineChapterView, ProjectResponse } from "@/lib/api/types";
import { import {
distinctVolumes, distinctVolumes,
@@ -43,20 +49,29 @@ export function OutlineEditor({
activeNav="outline" activeNav="outline"
> >
<div className="flex h-[calc(100vh-var(--chrome,4rem))] flex-col p-6"> <div className="flex h-[calc(100vh-var(--chrome,4rem))] flex-col p-6">
<div className="mb-4 flex items-center gap-3"> <PageHeader
<h1 className="font-serif text-lg text-ink"></h1> title="大纲"
description="按卷组织章节节拍,写作台会把当前章节拍注入生成上下文。"
actions={
<>
{availableVolumes.length > 0 ? ( {availableVolumes.length > 0 ? (
<label htmlFor="view-vol" className="ml-auto text-xs text-ink-soft"> <label
htmlFor="view-vol"
className="ml-auto text-xs text-ink-soft"
>
<select <Select
id="view-vol" id="view-vol"
value={viewVolume === "all" ? "all" : String(viewVolume)} value={viewVolume === "all" ? "all" : String(viewVolume)}
onChange={(e) => onChange={(e) =>
setViewVolume( setViewVolume(
e.target.value === "all" ? "all" : Number(e.target.value), 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" controlSize="sm"
className="ml-1"
> >
<option value="all"></option> <option value="all"></option>
{availableVolumes.map((v) => ( {availableVolumes.map((v) => (
@@ -64,7 +79,7 @@ export function OutlineEditor({
{v} {v}
</option> </option>
))} ))}
</select> </Select>
</label> </label>
) : null} ) : null}
<label <label
@@ -73,22 +88,27 @@ export function OutlineEditor({
> >
</label> </label>
<input <TextInput
id="vol" id="vol"
inputMode="numeric" inputMode="numeric"
value={volume} value={volume}
onChange={(e) => setVolume(Math.max(1, Number(e.target.value) || 1))} onChange={(e) =>
className="w-16 rounded border border-line bg-bg px-2 py-1 text-sm text-ink focus:border-cinnabar focus:outline-none" setVolume(Math.max(1, Number(e.target.value) || 1))
}
controlSize="sm"
className="w-16"
/> />
<button <Button
type="button"
disabled={generating} disabled={generating}
onClick={() => void generate(project.id, volume)} 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" variant="primary"
> >
{generating ? "排大纲中…" : "✦ AI 排大纲"} <Sparkles className="h-4 w-4" aria-hidden="true" />
</button> {generating ? "排大纲中…" : "AI 排大纲"}
</div> </Button>
</>
}
/>
{error ? ( {error ? (
<p className="mb-4 text-sm text-conflict"> <p className="mb-4 text-sm text-conflict">
@@ -109,11 +129,30 @@ export function OutlineEditor({
<div className="min-h-0 flex-1 overflow-auto"> <div className="min-h-0 flex-1 overflow-auto">
{volumes.length === 0 ? ( {volumes.length === 0 ? (
<p className="rounded border border-dashed border-line p-6 text-sm text-ink-soft"> <EmptyState
{chapters.length > 0 && viewVolume !== "all" icon={ListTree}
? `${viewVolume} 暂无大纲。切到「全部」查看其它卷,或点「✦ AI 排大纲」为本卷生成。` title={
: "暂无大纲。点「✦ AI 排大纲」生成逐章节拍与伏笔窗口。"} chapters.length > 0 && viewVolume !== "all"
</p> ? `${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) => ( volumes.map((group) => (
<section key={group.volume} className="mb-6"> <section key={group.volume} className="mb-6">

View 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>
);
}

View File

@@ -1,10 +1,14 @@
"use client"; "use client";
import Link from "next/link"; 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 type { AcceptResponse } from "@/lib/api/types";
import { buildAcceptPreview } from "@/lib/review/accept-preview"; import { buildAcceptPreview } from "@/lib/review/accept-preview";
import { ThinkingIndicator } from "@/components/ThinkingIndicator"; import { ThinkingIndicator } from "@/components/ThinkingIndicator";
import { buttonClass } from "@/lib/ui/variants";
interface AcceptPanelProps { interface AcceptPanelProps {
projectId: string; projectId: string;
@@ -42,40 +46,50 @@ export function AcceptPanel({
if (result) { if (result) {
return ( return (
<div className="rounded border border-pass/50 bg-panel p-4"> <div className="rounded border border-pass/50 bg-panel p-4">
<h3 className="mb-2 text-sm font-semibold text-pass"></h3> <StatusNote variant="success" title="本章已验收">
<ul className="space-y-1 text-xs text-ink-soft"> <p className="text-sm leading-6">
<li> 稿
</p>
<span className="font-mono text-ink">v{result.accepted_version}</span> </StatusNote>
</li> <dl className="mt-3 grid grid-cols-3 gap-2 text-center">
<li> <div className="rounded border border-line bg-bg/60 p-2">
{result.digest_added ? "已新增一行" : "未变更"} <dt className="text-[11px] text-ink-soft"></dt>
</li> <dd className="mt-1 font-mono text-sm text-ink">
<li> v{result.accepted_version}
</dd>
<span className="font-mono text-ink"> </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} {result.decisions_recorded}
</span>{" "} </dd>
</div>
</li> </dl>
{result.review_id ? ( {result.review_id ? (
<li className="truncate"> <p className="mt-2 truncate text-[11px] text-ink-soft">
<span className="font-mono">{result.review_id}</span> <span className="font-mono">{result.review_id}</span>
</li> </p>
) : null} ) : null}
</ul>
{/* 验收闭环 → 写下一章入口(修「验收后无路可走」的断点)。 */} {/* 验收闭环 → 写下一章入口(修「验收后无路可走」的断点)。 */}
<div className="mt-3 flex flex-wrap items-center gap-2"> <div className="mt-3 flex flex-wrap items-center gap-2">
<Link <Link
href={`/projects/${projectId}/write?chapter=${chapterNo + 1}`} 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>
<Link <Link
href={`/projects/${projectId}/outline`} 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> </Link>
</div> </div>
@@ -84,20 +98,24 @@ export function AcceptPanel({
} }
return ( 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 ? ( {blocked ? (
<p className="mb-2 text-xs text-conflict" role="status"> <StatusNote className="mb-3" variant="danger" role="status">
{unresolvedCount} {unresolvedCount}
</p> </StatusNote>
) : ( ) : (
<> <>
<p className="mb-2 text-xs text-ink-soft"> <StatusNote className="mb-3" variant={reviewed ? "success" : "info"}>
{conflictCount === 0 {conflictCount === 0
? reviewed ? reviewed
? "无冲突,可直接验收。" ? "无冲突,可直接验收。"
: "尚未审稿——建议先点「重新审稿」确认本章无冲突,再验收。" : "尚未审稿——建议先点「重新审稿」确认本章无冲突,再验收。"
: "全部冲突已裁决,可验收本章。"} : "全部冲突已裁决,可验收本章。"}
</p> </StatusNote>
{/* R3验收前「本次将更新」清单预期落库口径以验收回执为准。 */} {/* R3验收前「本次将更新」清单预期落库口径以验收回执为准。 */}
<div className="mb-3 rounded border border-line/70 bg-bg/50 p-3"> <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"> <h4 className="mb-1.5 text-xs font-semibold text-ink">
@@ -122,19 +140,22 @@ export function AcceptPanel({
</div> </div>
</> </>
)} )}
<button <Button
type="button"
onClick={onAccept} onClick={onAccept}
disabled={blocked || accepting} disabled={blocked || accepting}
aria-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 ? ( {accepting ? (
<ThinkingIndicator label="验收中" className="justify-center" /> <ThinkingIndicator label="验收中" className="justify-center" />
) : ( ) : (
"全部处理完 → 验收本章" <>
<CheckCircle2 className="h-4 w-4" aria-hidden="true" />
</>
)} )}
</button> </Button>
</div> </div>
); );
} }

View File

@@ -16,8 +16,9 @@ export function BeatMap({ beatMap }: BeatMapProps) {
} }
const spark = toSparkline(beatMap); const spark = toSparkline(beatMap);
return ( return (
<div className="max-w-full overflow-x-auto pb-1">
<div <div
className="flex h-12 items-end gap-0.5" className="flex h-12 min-w-full items-end gap-0.5"
role="img" role="img"
aria-label={`爽点节拍图 ${spark}`} aria-label={`爽点节拍图 ${spark}`}
> >
@@ -30,5 +31,6 @@ export function BeatMap({ beatMap }: BeatMapProps) {
/> />
))} ))}
</div> </div>
</div>
); );
} }

View File

@@ -1,10 +1,16 @@
"use client"; "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 { DecisionDraft, Verdict } from "@/lib/review/decisions";
import type { ReviewConflict } from "@/lib/review/sse";
interface ConflictCardProps { interface ConflictCardProps {
index: number; index: number;
total: number;
conflict: ReviewConflict; conflict: ReviewConflict;
draft: DecisionDraft; draft: DecisionDraft;
missing: boolean; missing: boolean;
@@ -28,15 +34,16 @@ const VERDICT_OPTIONS: { value: Verdict; label: string; primary: boolean }[] = [
// 裁决按钮样式:激活=朱砂填充;未激活的主按钮=朱砂描边强调,次级=低对比描边。 // 裁决按钮样式:激活=朱砂填充;未激活的主按钮=朱砂描边强调,次级=低对比描边。
function verdictClass(active: boolean, primary: boolean): string { function verdictClass(active: boolean, primary: boolean): string {
if (active) return "bg-cinnabar text-panel"; if (active) return buttonClass({ variant: "primary", size: "sm" });
if (primary) return "border border-cinnabar text-cinnabar hover:bg-cinnabar/10"; if (primary) return buttonClass({ variant: "outline", size: "sm" });
return "border border-line text-ink-soft hover:border-cinnabar hover:text-ink"; return buttonClass({ variant: "secondary", size: "sm" });
} }
// 单个冲突报告卡UX §6.4):五类徽标 + where + refs + suggestion + 裁决三态。 // 单个冲突报告卡UX §6.4):五类徽标 + where + refs + suggestion + 裁决三态。
// 冲突=赭红;未决/缺判时加图标+文案不单靠色a11y §10 // 冲突=赭红;未决/缺判时加图标+文案不单靠色a11y §10
export function ConflictCard({ export function ConflictCard({
index, index,
total,
conflict, conflict,
draft, draft,
missing, missing,
@@ -60,13 +67,14 @@ export function ConflictCard({
}`} }`}
> >
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<span <Badge variant="danger" className="shrink-0">
className="shrink-0 rounded bg-[var(--color-conflict)]/10 px-2 py-0.5 text-xs text-conflict" <AlertTriangle className="h-3 w-3" aria-hidden="true" />
aria-hidden="true" {index + 1}/{total}
> </Badge>
{conflict.type} <div className="min-w-0 flex-1">
</span> <p className="text-xs text-conflict">{conflict.type}</p>
<p className="flex-1 text-sm text-ink">{conflict.suggestion}</p> <p className="mt-1 text-sm text-ink">{conflict.suggestion}</p>
</div>
</div> </div>
{conflict.original && conflict.replacement ? ( {conflict.original && conflict.replacement ? (
@@ -95,9 +103,10 @@ export function ConflictCard({
<button <button
type="button" type="button"
onClick={() => onJump(index)} 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> </button>
) : null} ) : null}
{conflict.refs.map((ref) => ( {conflict.refs.map((ref) => (
@@ -121,23 +130,24 @@ export function ConflictCard({
aria-pressed={active} aria-pressed={active}
disabled={rewriting} disabled={rewriting}
onClick={() => onVerdict(index, opt.value)} onClick={() => onVerdict(index, opt.value)}
className={`rounded px-3 py-1 text-xs disabled:cursor-not-allowed disabled:opacity-40 ${verdictClass( className={verdictClass(active, opt.primary)}
active,
opt.primary,
)}`}
> >
{opt.label} {opt.label}
</button> </button>
); );
})} })}
{rewriting ? ( {rewriting ? (
<span className="text-xs text-info"> AI </span> <Badge variant="info">
<Sparkles className="h-3 w-3" aria-hidden="true" />
AI
</Badge>
) : resolved ? ( ) : resolved ? (
<span className="text-xs text-pass" aria-hidden="true"> <Badge variant="success">
<CheckCircle2 className="h-3 w-3" aria-hidden="true" />
</span>
</Badge>
) : ( ) : (
<span className="text-xs text-conflict"></span> <Badge variant="danger"></Badge>
)} )}
</div> </div>
{draft.verdict === "manual" ? ( {draft.verdict === "manual" ? (
@@ -145,14 +155,16 @@ export function ConflictCard({
<label htmlFor={`note-${index}`} className="sr-only"> <label htmlFor={`note-${index}`} className="sr-only">
</label> </label>
<input <TextInput
id={`note-${index}`} id={`note-${index}`}
type="text" type="text"
value={draft.note} value={draft.note}
onChange={(e) => onNote(index, e.target.value)} onChange={(e) => onNote(index, e.target.value)}
placeholder="手改说明(可选)" 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> </div>
) : null} ) : null}
</div> </div>

View File

@@ -1,8 +1,18 @@
"use client"; "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 { useForeshadow } from "@/lib/foreshadow/useForeshadow";
import type { ForeshadowSuggestion } from "@/lib/review/sse"; import type { ForeshadowSuggestion } from "@/lib/review/sse";
@@ -36,11 +46,54 @@ export function ForeshadowSuggestions({
const [registered, setRegistered] = useState<Set<number>>(new Set()); const [registered, setRegistered] = useState<Set<number>>(new Set());
const [resolved, setResolved] = useState<Set<number>>(new Set()); const [resolved, setResolved] = useState<Set<number>>(new Set());
const [pending, setPending] = 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 => { const openRegister = (i: number, s: ForeshadowSuggestion): void => {
setOpenIdx(i); setOpenIdx(i);
setForm({ setForm({
code: suggestForeshadowCode(s.code, i), code: nextFreeForeshadowCode(suggestForeshadowCode(s.code, i), savedCodes),
title: s.title, title: s.title,
plantedAt: chapterNo, plantedAt: chapterNo,
content: s.note ?? s.where ?? "", content: s.note ?? s.where ?? "",
@@ -73,6 +126,9 @@ export function ForeshadowSuggestions({
markPending(i, false); markPending(i, false);
if (ok) { if (ok) {
setRegistered((prev) => new Set(prev).add(i)); 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(); closeForm();
} }
}; };
@@ -84,36 +140,42 @@ export function ForeshadowSuggestions({
if (ok) setResolved((prev) => new Set(prev).add(i)); if (ok) setResolved((prev) => new Set(prev).add(i));
}; };
// 保留原始下标 iregistered/resolved/pending/openIdx 均按 i 索引),仅过滤已保存项。
const visible = suggestions
.map((s, i) => ({ s, i }))
.filter(({ s, i }) => !isAlreadySaved(s, i));
return ( return (
<section className="mb-4"> <section className="mb-4">
<h2 className="text-xs font-semibold uppercase tracking-wide text-ink-soft"> <h2 className="text-xs font-semibold uppercase tracking-wide text-ink-soft">
(foreshadow-analyst) (foreshadow-analyst)
</h2> </h2>
{incomplete ? ( {incomplete ? (
<p className="mt-1 text-xs text-overdue"> </p> <Badge variant="warning" className="mt-1">
) : suggestions.length === 0 ? ( <CircleDashed className="h-3 w-3" aria-hidden="true" />
</Badge>
) : visible.length === 0 ? (
<p className="mt-1 text-xs text-ink-soft"></p> <p className="mt-1 text-xs text-ink-soft"></p>
) : ( ) : (
<ul className="mt-2 space-y-2"> <ul className="mt-2 space-y-2">
{suggestions.map((s, i) => { {visible.map(({ s, i }) => {
const code = s.code; const code = s.code;
const isResolved = s.kind === "resolved"; const isResolved = s.kind === "resolved";
const done = isResolved ? resolved.has(i) : registered.has(i);
const busy = pending.has(i); const busy = pending.has(i);
return ( return (
<li <li
key={i} key={i}
className="rounded border border-line bg-panel p-2 text-xs" className="rounded border border-line bg-panel p-2 text-xs"
> >
<span <Badge variant={isResolved ? "success" : "accent"}>
className={`mr-1 rounded px-1.5 py-0.5 ${ {isResolved ? (
isResolved <Flag className="h-3 w-3" aria-hidden="true" />
? "bg-pass/15 text-pass" ) : (
: "bg-[var(--color-cinnabar-wash)] text-cinnabar" <Plus className="h-3 w-3" aria-hidden="true" />
}`} )}
> {isResolved ? "疑似回收" : "新埋待确认"}
{isResolved ? "⚑ 疑似回收" : " 新埋待确认"} </Badge>
</span>
{code ? ( {code ? (
<span className="font-mono text-ink-soft"> {code}</span> <span className="font-mono text-ink-soft"> {code}</span>
) : null} ) : null}
@@ -123,21 +185,19 @@ export function ForeshadowSuggestions({
) : null} ) : null}
{s.note ? <p className="mt-0.5 text-ink-soft">{s.note}</p> : null} {s.note ? <p className="mt-0.5 text-ink-soft">{s.note}</p> : null}
{/* 作者确认动作区 */} {/* 作者确认动作区(已保存的建议已从列表过滤,故无需「已登记」态) */}
{done ? ( {isResolved ? (
<p className="mt-1.5 text-pass">
{isResolved ? "已标记回收" : "已登记"}
</p>
) : isResolved ? (
code ? ( code ? (
<button <Button
type="button"
disabled={busy} disabled={busy}
onClick={() => void markResolved(i, code)} 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 ? "处理中…" : "✓ 标记回收"} <CheckCircle2 className="h-4 w-4" aria-hidden="true" />
</button> {busy ? "处理中…" : "标记回收"}
</Button>
) : ( ) : (
<p className="mt-1.5 text-ink-soft"> <p className="mt-1.5 text-ink-soft">
@@ -152,13 +212,15 @@ export function ForeshadowSuggestions({
onCancel={closeForm} onCancel={closeForm}
/> />
) : ( ) : (
<button <Button
type="button"
onClick={() => openRegister(i, s)} 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"
> >
<Plus className="h-4 w-4" aria-hidden="true" />
</button>
</Button>
)} )}
</li> </li>
); );
@@ -187,39 +249,28 @@ function RegisterFormInline({
}: RegisterFormInlineProps) { }: RegisterFormInlineProps) {
const canSubmit = const canSubmit =
!busy && form.code.trim().length > 0 && form.title.trim().length > 0; !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 ( return (
<div className="mt-2 space-y-1.5 border-t border-line pt-2"> <div className="mt-2 space-y-1.5 border-t border-line pt-2">
<div> <Field label="编码" htmlFor="fs-reg-code">
<label htmlFor="fs-reg-code" className="text-ink-soft"> <TextInput
</label>
<input
id="fs-reg-code" id="fs-reg-code"
type="text" type="text"
value={form.code} value={form.code}
onChange={(e) => onChange({ ...form, code: e.target.value })} onChange={(e) => onChange({ ...form, code: e.target.value })}
className={field} controlSize="sm"
/> />
</div> </Field>
<div> <Field label="标题" htmlFor="fs-reg-title">
<label htmlFor="fs-reg-title" className="text-ink-soft"> <TextInput
</label>
<input
id="fs-reg-title" id="fs-reg-title"
type="text" type="text"
value={form.title} value={form.title}
onChange={(e) => onChange({ ...form, title: e.target.value })} onChange={(e) => onChange({ ...form, title: e.target.value })}
className={field} controlSize="sm"
/> />
</div> </Field>
<div> <Field label="埋设章号" htmlFor="fs-reg-planted">
<label htmlFor="fs-reg-planted" className="text-ink-soft"> <TextInput
</label>
<input
id="fs-reg-planted" id="fs-reg-planted"
type="number" type="number"
min={1} min={1}
@@ -227,38 +278,36 @@ function RegisterFormInline({
onChange={(e) => onChange={(e) =>
onChange({ ...form, plantedAt: Number(e.target.value) || 1 }) onChange({ ...form, plantedAt: Number(e.target.value) || 1 })
} }
className={field} controlSize="sm"
/> />
</div> </Field>
<div> <Field label="线索/正文(可选)" htmlFor="fs-reg-content">
<label htmlFor="fs-reg-content" className="text-ink-soft"> <TextArea
线/
</label>
<textarea
id="fs-reg-content" id="fs-reg-content"
value={form.content} value={form.content}
onChange={(e) => onChange({ ...form, content: e.target.value })} onChange={(e) => onChange({ ...form, content: e.target.value })}
rows={2} rows={2}
className={`${field} resize-y`} controlSize="sm"
/> />
</div> </Field>
<div className="flex gap-2 pt-0.5"> <div className="flex gap-2 pt-0.5">
<button <Button
type="button"
disabled={!canSubmit} disabled={!canSubmit}
onClick={onSubmit} 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 ? "登记中…" : "确认登记"} {busy ? "登记中…" : "确认登记"}
</button> </Button>
<button <Button
type="button"
disabled={busy} disabled={busy}
onClick={onCancel} 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>
</div> </div>
); );

View File

@@ -1,5 +1,8 @@
"use client"; "use client";
import { AlertTriangle, CheckCircle2, CircleDashed } from "lucide-react";
import { Badge } from "@/components/ui/Badge";
import type { PaceReport } from "@/lib/review/sse"; import type { PaceReport } from "@/lib/review/sse";
import { BeatMap } from "./BeatMap"; import { BeatMap } from "./BeatMap";
@@ -16,7 +19,10 @@ export function PacePanel({ pace, incomplete }: PacePanelProps) {
(pace-checker) (pace-checker)
</h2> </h2>
{incomplete ? ( {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 ? ( ) : pace === null ? (
<p className="mt-1 text-xs text-ink-soft"></p> <p className="mt-1 text-xs text-ink-soft"></p>
) : ( ) : (
@@ -29,15 +35,24 @@ export function PacePanel({ pace, incomplete }: PacePanelProps) {
<p> <p>
{pace.hook ? ( {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> </p>
{pace.water.length > 0 ? ( {pace.water.length > 0 ? (
<div> <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"> <ul className="mt-1 space-y-1">
{pace.water.map((w, i) => ( {pace.water.map((w, i) => (
<li key={i} className="text-ink-soft"> <li key={i} className="text-ink-soft">
@@ -47,7 +62,10 @@ export function PacePanel({ pace, incomplete }: PacePanelProps) {
</ul> </ul>
</div> </div>
) : ( ) : (
<p className="text-pass"> </p> <Badge variant="success">
<CheckCircle2 className="h-3 w-3" aria-hidden="true" />
</Badge>
)} )}
</div> </div>
)} )}

View File

@@ -2,8 +2,16 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import {
AlertTriangle,
CheckCircle2,
RotateCcw,
Square,
} from "lucide-react";
import { AppShell } from "@/components/AppShell"; 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 type { ProjectResponse, ReviewHistoryItem } from "@/lib/api/types";
import { friendlyError } from "@/lib/errors/messages"; import { friendlyError } from "@/lib/errors/messages";
import { import {
@@ -44,6 +52,8 @@ import { AnnotatedText } from "./AnnotatedText";
import { ConflictCard } from "./ConflictCard"; import { ConflictCard } from "./ConflictCard";
import { ForeshadowSuggestions } from "./ForeshadowSuggestions"; import { ForeshadowSuggestions } from "./ForeshadowSuggestions";
import { PacePanel } from "./PacePanel"; import { PacePanel } from "./PacePanel";
import { ReviewSectionPanel } from "./ReviewSectionPanel";
import { ReviewSummaryRail } from "./ReviewSummaryRail";
import { StylePanel } from "@/components/style/StylePanel"; import { StylePanel } from "@/components/style/StylePanel";
import { RefineView } from "@/components/style/RefineView"; import { RefineView } from "@/components/style/RefineView";
@@ -370,6 +380,13 @@ export function ReviewReport({
const resolved = allResolved(drafts); const resolved = allResolved(drafts);
const unresolved = unresolvedIndices(drafts).length; const unresolved = unresolvedIndices(drafts).length;
const reviewing = review.isReviewing; 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 ( return (
<AppShell <AppShell
@@ -378,33 +395,27 @@ export function ReviewReport({
projectId={project.id} projectId={project.id}
activeNav="review" 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"> <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-6 py-3"> <div className="flex items-center gap-3 border-b border-line bg-panel px-4 py-3 sm:px-6">
<h1 className="font-serif text-lg text-ink"> <h1 className="min-w-0 flex-1 truncate font-serif text-lg text-ink">
{chapterNo} 稿 {chapterNo} 稿
</h1> </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} {conflictCount}
</span> </span>
<div className="ml-auto"> <div className="shrink-0">
{reviewing ? ( {reviewing ? (
<button <Button onClick={review.stop} variant="danger" size="sm">
type="button" <Square className="h-4 w-4" aria-hidden="true" />
onClick={review.stop}
className="rounded border border-conflict px-3 py-1.5 text-sm text-conflict"
>
</button> </Button>
) : ( ) : (
<button <Button onClick={onReReview} variant="primary" size="sm">
type="button" <RotateCcw className="h-4 w-4" aria-hidden="true" />
onClick={onReReview} 稿
className="rounded bg-cinnabar px-3 py-1.5 text-sm text-panel hover:opacity-90" </Button>
>
稿
</button>
)} )}
</div> </div>
</div> </div>
@@ -413,7 +424,7 @@ export function ReviewReport({
<ReviewErrorNote error={review.state.error} /> <ReviewErrorNote error={review.state.error} />
) : null} ) : 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"> <div className="mb-2 flex items-start justify-between gap-3">
<p className="text-xs text-ink-soft"> <p className="text-xs text-ink-soft">
稿 稿 稿 稿
@@ -438,67 +449,93 @@ export function ReviewReport({
</section> </section>
{/* 右:审稿分区 + 冲突裁决 + 验收 gate */} {/* 右:审稿分区 + 冲突裁决 + 验收 gate */}
<aside className="flex flex-col overflow-auto border-l border-line bg-panel px-4 py-4"> <aside
<section className="mb-4"> className="flex flex-col border-t border-line bg-panel px-4 py-4 lg:overflow-auto lg:border-l lg:border-t-0"
<h2 className="text-xs font-semibold uppercase tracking-wide text-ink-soft"> aria-label="审稿报告与验收"
(continuity) >
</h2> <ReviewSummaryRail
conflictCount={conflictCount}
unresolvedCount={unresolved}
reviewing={reviewing}
hasReport={hasReport}
onNextUnresolved={jumpToNextUnresolved}
/>
<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 <SectionStatusLine
reviewing={reviewing} reviewing={reviewing}
done={review.state.phase === "done"} done={review.state.phase === "done"}
conflictCount={conflictCount} conflictCount={conflictCount}
sections={review.state.sections} sections={review.state.sections}
/> />
</section>
<section>
{conflicts.length === 0 ? ( {conflicts.length === 0 ? (
<p className="rounded border border-dashed border-line p-4 text-xs text-ink-soft"> <p className="mt-2 rounded border border-dashed border-line p-4 text-xs text-ink-soft">
{review.state.phase === "done" || seededConflicts.length === 0 {review.state.phase === "done" || seededConflicts.length === 0
? "未发现一致性冲突。" ? "未发现一致性冲突。"
: "进页未带审稿留痕,点「重新审稿」开始。"} : "进页未带审稿留痕,点「重新审稿」开始。"}
</p> </p>
) : ( ) : (
<div className="space-y-4"> <div className="mt-3 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"> <p className="rounded border border-dashed border-line/70 bg-bg/40 px-3 py-1.5 text-[11px] text-ink-soft">
稿 稿
</p> </p>
{groups.map((group) => ( {groups.map((group) => {
<div key={group.type}> const unresolvedInGroup = group.items.filter(
<h3 className="mb-2 flex items-center gap-2 text-xs font-semibold text-ink"> ({ 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} {group.type}
<span className="rounded-full bg-[var(--color-conflict)]/10 px-2 py-0.5 font-mono text-conflict"> <Badge
variant={
unresolvedInGroup > 0 ? "danger" : "success"
}
>
{group.items.length} {group.items.length}
</Badge>
</span> </span>
</h3> <span className="font-mono text-[11px] text-ink-soft">
<ul className="space-y-3"> {unresolvedInGroup > 0
? `${unresolvedInGroup} 未裁决`
: "已处理"}
</span>
</summary>
<ul className="mt-3 space-y-3">
{group.items.map(({ conflict: c, index: i }) => ( {group.items.map(({ conflict: c, index: i }) => (
<ConflictCard <ConflictCard
key={i} key={i}
index={i} index={i}
total={conflictCount}
conflict={c} conflict={c}
draft={drafts[i] ?? { verdict: null, note: "" }} draft={drafts[i] ?? { verdict: null, note: "" }}
missing={missing.has(i)} missing={missing.has(i)}
@@ -511,28 +548,88 @@ export function ReviewReport({
/> />
))} ))}
</ul> </ul>
</div> </details>
))} );
})}
</div> </div>
)} )}
</section> </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 <ForeshadowSuggestions
suggestions={foreshadow} suggestions={foreshadow}
incomplete={sectionStatus("foreshadow") === "incomplete"} incomplete={sectionStatus("foreshadow") === "incomplete"}
projectId={project.id} projectId={project.id}
chapterNo={chapterNo} 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 <PacePanel
pace={pace} pace={pace}
incomplete={sectionStatus("pace") === "incomplete"} 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 <StylePanel
style={style} style={style}
incomplete={sectionStatus("style") === "incomplete"} incomplete={sectionStatus("style") === "incomplete"}
onRefine={onRefineSegment} onRefine={onRefineSegment}
/> />
</ReviewSectionPanel>
{refineText !== null ? ( {refineText !== null ? (
<RefineView <RefineView
projectId={project.id} projectId={project.id}
@@ -613,9 +710,15 @@ function SectionStatusLine({
return ( return (
<p className="mt-1 text-xs"> <p className="mt-1 text-xs">
{conflictCount > 0 ? ( {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> </p>
); );

View 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>
);
}

View 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>
);
}

View File

@@ -1,8 +1,15 @@
"use client"; "use client";
import { Plus, Trash2 } from "lucide-react";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { AppShell } from "@/components/AppShell"; 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 type { ProjectResponse, RuleView } from "@/lib/api/types";
import { import {
RULE_LEVELS, RULE_LEVELS,
@@ -38,41 +45,48 @@ export function RulesPage({ project, initialRules }: RulesPageProps) {
activeNav="rules" activeNav="rules"
> >
<div className="mx-auto flex max-w-3xl flex-col gap-6 p-6"> <div className="mx-auto flex max-w-3xl flex-col gap-6 p-6">
<PageHeader
title="规则"
description="维护本作、题材、世界观和章节级硬约束,写作与审稿会共同引用这些规则。"
/>
<form <form
onSubmit={onSubmit} onSubmit={onSubmit}
className="rounded border border-line bg-panel p-4" 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"> <div className="mb-3 flex items-end gap-3">
<label className="text-sm text-ink-soft"> <Field label="级别" htmlFor="rule-level">
<Select
<select id="rule-level"
value={level} value={level}
onChange={(e) => setLevel(e.target.value as RuleLevel)} 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) => ( {RULE_LEVELS.map((lv) => (
<option key={lv} value={lv}> <option key={lv} value={lv}>
{RULE_LEVEL_LABELS[lv]} {RULE_LEVEL_LABELS[lv]}
</option> </option>
))} ))}
</select> </Select>
</label> </Field>
</div> </div>
<textarea <Field label="规则内容" htmlFor="rule-content">
<TextArea
id="rule-content"
value={content} value={content}
onChange={(e) => setContent(e.target.value)} onChange={(e) => setContent(e.target.value)}
placeholder="如:本作禁用现代科技词汇;称呼一律用「道友」" placeholder="如:本作禁用现代科技词汇;称呼一律用「道友」"
rows={3} rows={3}
className="w-full resize-y rounded border border-line bg-bg px-3 py-2 text-sm text-ink"
/> />
<button </Field>
<Button
type="submit" type="submit"
disabled={busy || content.trim().length === 0} 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 ? "保存中…" : "新增规则"} {busy ? "保存中…" : "新增规则"}
</button> </Button>
</form> </form>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
@@ -85,7 +99,22 @@ export function RulesPage({ project, initialRules }: RulesPageProps) {
</span> </span>
</h2> </h2>
{groups[lv].length === 0 ? ( {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"> <ul className="flex flex-col gap-2">
{groups[lv].map((rule) => ( {groups[lv].map((rule) => (
@@ -101,8 +130,9 @@ export function RulesPage({ project, initialRules }: RulesPageProps) {
disabled={busy} disabled={busy}
onClick={() => void remove(project.id, rule.id)} onClick={() => void remove(project.id, rule.id)}
aria-label="删除规则" 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> </button>
</li> </li>

View File

@@ -1,5 +1,10 @@
"use client"; "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 { authOpenUrl, formatExpiresAt } from "@/lib/settings/kimiOauth";
import { useKimiOauth } from "@/lib/settings/useKimiOauth"; import { useKimiOauth } from "@/lib/settings/useKimiOauth";
@@ -19,17 +24,22 @@ export function KimiCodeOauth({
const expiresLabel = formatExpiresAt(expiresAt); const expiresLabel = formatExpiresAt(expiresAt);
return ( return (
<section className="mb-10"> <section>
<h2 className="mb-1 font-serif text-xl text-ink">Kimi CodeOAuth</h2> <SectionHeader
<p className="mb-3 text-sm text-ink-soft"> title="Kimi CodeOAuth"
description={
<>
plan OAuth API Key plan OAuth API Key
<span className="font-mono"> kimi-code · kimi-for-coding</span> <span className="font-mono"> kimi-code · kimi-for-coding</span>
</p> </>
}
className="mb-3"
/>
<div className="rounded border border-line bg-panel p-4"> <div className="rounded border border-line bg-panel p-4">
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<span <span
className={`h-2 w-2 rounded-full ${ className={`h-2 w-2 rounded ${
phase === "connected" ? "bg-pass" : "bg-line" phase === "connected" ? "bg-pass" : "bg-line"
}`} }`}
aria-hidden="true" aria-hidden="true"
@@ -45,27 +55,29 @@ export function KimiCodeOauth({
<div className="ml-auto flex gap-2"> <div className="ml-auto flex gap-2">
{phase === "connected" ? ( {phase === "connected" ? (
<button <Button
type="button"
onClick={() => void oauth.disconnect()} onClick={() => void oauth.disconnect()}
disabled={busy} 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 ? "处理中…" : "断开"} {busy ? "处理中…" : "断开"}
</button> </Button>
) : ( ) : (
<button <Button
type="button"
onClick={() => void oauth.connect()} onClick={() => void oauth.connect()}
disabled={busy} 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 {busy
? "等待授权…" ? "等待授权…"
: phase === "error" : phase === "error"
? "重新连接 Kimi CodeOAuth" ? "重新连接 Kimi CodeOAuth"
: "连接 Kimi CodeOAuth"} : "连接 Kimi CodeOAuth"}
</button> </Button>
)} )}
</div> </div>
</div> </div>
@@ -77,10 +89,16 @@ export function KimiCodeOauth({
/> />
) : null} ) : null}
{phase !== "awaiting" ? (
<StatusNote className="mt-3" variant="info">
OAuth 访
</StatusNote>
) : null}
{phase === "error" && error ? ( {phase === "error" && error ? (
<p className="mt-3 text-sm text-conflict" role="alert"> <StatusNote className="mt-3" variant="danger" role="alert">
{error} {error}
</p> </StatusNote>
) : null} ) : null}
</div> </div>
</section> </section>
@@ -102,23 +120,22 @@ function DeviceInstructions({ userCode, openUrl }: DeviceInstructionsProps) {
return ( return (
<div className="motion-safe:transition-opacity mt-4 rounded border border-dashed border-line bg-bg p-4"> <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"> <StatusNote className="mb-3" variant="info">
</p> </StatusNote>
<div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center">
<output <output
aria-label="设备授权码" aria-label="设备授权码"
tabIndex={0} 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" className="block select-all rounded bg-panel px-4 py-3 text-center font-mono text-2xl tracking-[0.3em] text-ink"
> >
{userCode} {userCode}
</output> </output>
<button <Button onClick={openAuth} variant="outline" size="sm">
type="button" <ExternalLink className="h-4 w-4" aria-hidden="true" />
onClick={openAuth}
className="rounded border border-cinnabar px-3 py-1.5 text-sm text-cinnabar" </Button>
> </div>
</button>
<p className="mt-2 text-xs text-ink-soft"> <p className="mt-2 text-xs text-ink-soft">
</p> </p>

View File

@@ -1,10 +1,26 @@
"use client"; "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 { useToast } from "@/components/Toast";
import { KimiCodeOauth } from "@/components/settings/KimiCodeOauth"; 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 { import {
API_KEY_PROVIDERS, API_KEY_PROVIDERS,
KNOWN_PROVIDERS, KNOWN_PROVIDERS,
@@ -30,12 +46,22 @@ interface TestResult {
capabilities: CapabilitiesView; 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 连接区。 // 设置页主体UX §6.10):档位路由(可编辑)+ API-key 凭据行 + Kimi Code OAuth 连接区。
export function ProvidersSettings({ export function ProvidersSettings({
initial, initial,
kimiOauth, kimiOauth,
}: ProvidersSettingsProps) { }: ProvidersSettingsProps) {
const toast = useToast(); const toast = useToast();
const [activeSection, setActiveSection] =
useState<SettingsSection>("routing");
const [providers, setProviders] = useState(initial.providers ?? []); const [providers, setProviders] = useState(initial.providers ?? []);
const [routing, setRouting] = useState<RoutingDraft[]>( const [routing, setRouting] = useState<RoutingDraft[]>(
toRoutingDrafts(initial.tier_routing ?? []), toRoutingDrafts(initial.tier_routing ?? []),
@@ -116,26 +142,73 @@ export function ProvidersSettings({
}; };
return ( return (
<div> <div className="grid gap-6 lg:grid-cols-[12rem_1fr]">
<section className="mb-10"> <aside className="hidden lg:block">
<h2 className="mb-3 font-serif text-xl text-ink"></h2> <nav
<ul className="divide-y divide-line rounded border border-line bg-panel"> 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>
<div className="min-w-0">
<SegmentedControl
options={SECTION_OPTIONS}
value={activeSection}
onChange={setActiveSection}
ariaLabel="设置分组"
className="mb-4 w-full justify-center lg:hidden"
/>
{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) => ( {routing.map((row) => (
<li <li
key={row.tier} key={row.tier}
className="flex flex-wrap items-center gap-3 px-4 py-3 text-sm" className="grid gap-3 px-4 py-3 text-sm md:grid-cols-[6rem_minmax(10rem,14rem)_1fr]"
> >
<span className="w-20 text-ink"> <span className="self-center text-ink">
{TIER_LABELS[row.tier] ?? row.tier} {TIER_LABELS[row.tier] ?? row.tier}
</span> </span>
<label className="sr-only" htmlFor={`route-provider-${row.tier}`}> <label
className="sr-only"
htmlFor={`route-provider-${row.tier}`}
>
{TIER_LABELS[row.tier] ?? row.tier} {TIER_LABELS[row.tier] ?? row.tier}
</label> </label>
<select <Select
id={`route-provider-${row.tier}`} id={`route-provider-${row.tier}`}
value={row.provider} value={row.provider}
onChange={(e) => updateRouting(row.tier, e.target.value)} 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> <option value=""></option>
{KNOWN_PROVIDERS.map((p) => ( {KNOWN_PROVIDERS.map((p) => (
@@ -143,66 +216,99 @@ export function ProvidersSettings({
{p.label} {p.label}
</option> </option>
))} ))}
</select> </Select>
<label className="sr-only" htmlFor={`route-model-${row.tier}`}> <label className="sr-only" htmlFor={`route-model-${row.tier}`}>
{TIER_LABELS[row.tier] ?? row.tier} {TIER_LABELS[row.tier] ?? row.tier}
</label> </label>
<input <TextInput
id={`route-model-${row.tier}`} id={`route-model-${row.tier}`}
value={row.model} value={row.model}
onChange={(e) => updateRoutingModel(row.tier, e.target.value)} onChange={(e) =>
updateRoutingModel(row.tier, e.target.value)
}
placeholder="model" 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" className="font-mono"
/> />
</li> </li>
))} ))}
</ul> </ul>
<div className="mt-3 flex justify-end"> <div className="mt-3 flex justify-end">
<button <Button
type="button"
onClick={() => void saveRouting()} onClick={() => void saveRouting()}
disabled={savingRouting} disabled={savingRouting}
className="rounded bg-cinnabar px-3 py-1.5 text-sm text-panel disabled:opacity-40" variant="primary"
size="sm"
> >
<Save className="h-4 w-4" aria-hidden="true" />
{savingRouting ? "保存中…" : "保存档位路由"} {savingRouting ? "保存中…" : "保存档位路由"}
</button> </Button>
</div> </div>
</section> </SettingsPanel>
) : null}
{activeSection === "oauth" ? (
<SettingsPanel>
<KimiCodeOauth <KimiCodeOauth
initialConnected={kimiOauth.connected} initialConnected={kimiOauth.connected}
initialExpiresAt={kimiOauth.expiresAt} initialExpiresAt={kimiOauth.expiresAt}
/> />
</SettingsPanel>
<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} ) : null}
<ul className="divide-y divide-line rounded border border-line bg-panel">
{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) => { {API_KEY_PROVIDERS.map((prov) => {
const masked = maskedFor(prov.id); const masked = maskedFor(prov.id);
const result = results[prov.id]; const result = results[prov.id];
return ( return (
<li key={prov.id} className="px-4 py-4"> <li key={prov.id} className="px-4 py-4">
<div className="flex flex-wrap items-center gap-3"> <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 <span
className={`h-2 w-2 rounded-full ${masked ? "bg-pass" : "bg-line"}`} className={`h-2 w-2 rounded ${
masked ? "bg-pass" : "bg-line"
}`}
aria-hidden="true" aria-hidden="true"
/> />
<span className="w-28 text-sm text-ink">{prov.label}</span> {prov.label}
{masked ? ( </span>
<span className="font-mono text-xs text-ink-soft"> <span className="font-mono text-xs text-ink-soft">
{masked} {masked ?? "未配置"}
</span> </span>
) : null}
<label className="sr-only" htmlFor={`key-${prov.id}`}> <label className="sr-only" htmlFor={`key-${prov.id}`}>
{prov.label} API Key {prov.label} API Key
</label> </label>
<input <TextInput
id={`key-${prov.id}`} id={`key-${prov.id}`}
type="password" type="password"
autoComplete="off" autoComplete="off"
@@ -213,37 +319,54 @@ export function ProvidersSettings({
[prov.id]: e.target.value, [prov.id]: e.target.value,
})) }))
} }
placeholder={masked ? "输入新 Key 以更新" : "输入 API Key"} placeholder={
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" masked ? "输入新 Key 以更新" : "输入 API Key"
}
/> />
<button <Button
type="button"
onClick={() => saveCredential(prov.id)} onClick={() => saveCredential(prov.id)}
disabled={savingId === prov.id} disabled={
className="rounded bg-cinnabar px-3 py-1.5 text-sm text-panel disabled:opacity-40" 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 {savingId === prov.id
? "保存中…" ? "保存中…"
: masked : masked
? "更新凭据" ? "更新"
: "添加凭据"} : "添加"}
</button> </Button>
<button <Button
type="button"
onClick={() => testConnection(prov.id)} onClick={() => testConnection(prov.id)}
disabled={testingId === prov.id} disabled={testingId === prov.id}
className="rounded border border-line px-3 py-1.5 text-sm text-ink disabled:opacity-40" variant="secondary"
size="sm"
> >
{testingId === prov.id ? "测试中…" : "测试连接"} <PlugZap className="h-4 w-4" aria-hidden="true" />
</button> {testingId === prov.id ? "测试中…" : "测试"}
</Button>
</div> </div>
<p className="mt-2 text-xs leading-5 text-ink-soft lg:pl-[10.5rem]">
{masked
? "已保存脱敏凭据;输入新 Key 可覆盖更新。"
: "保存后再测试连接,成功后即可在档位路由中使用。"}
</p>
{result ? ( {result ? (
<div className="mt-2 flex items-center gap-2 pl-5"> <div className="mt-2 flex flex-wrap items-center gap-2 lg:pl-[10.5rem]">
<span <Badge variant={result.ok ? "success" : "danger"}>
className={`text-xs ${result.ok ? "text-pass" : "text-conflict"}`} {result.ok ? (
> <CheckCircle2
{result.ok ? "✓ 已连接" : "✗ 未连接"} className="h-3 w-3"
</span> aria-hidden="true"
/>
) : (
<XCircle className="h-3 w-3" aria-hidden="true" />
)}
{result.ok ? "已连接" : "未连接"}
</Badge>
<CapabilityBadges caps={result.capabilities} /> <CapabilityBadges caps={result.capabilities} />
</div> </div>
) : null} ) : null}
@@ -251,8 +374,49 @@ export function ProvidersSettings({
); );
})} })}
</ul> </ul>
</section> </SettingsPanel>
) : null}
</div> </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>
); );
} }
@@ -267,10 +431,10 @@ function CapabilityBadges({ caps }: { caps: CapabilitiesView }) {
{badges.map((b) => ( {badges.map((b) => (
<span <span
key={b.label} key={b.label}
className={`rounded px-2 py-0.5 text-[11px] ${ className={`rounded border px-2 py-0.5 text-[11px] ${
b.on b.on
? "bg-[var(--color-cinnabar-wash)] text-cinnabar" ? "border-cinnabar/20 bg-[var(--color-cinnabar-wash)] text-cinnabar"
: "bg-bg text-ink-soft/50" : "border-line bg-bg text-ink-soft/50"
}`} }`}
> >
{b.label} {b.label}

View File

@@ -1,6 +1,20 @@
import type { LucideIcon } from "lucide-react";
import { Blocks, Database, Pencil, ShieldCheck } from "lucide-react";
import { AppShell } from "@/components/AppShell"; 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 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 { interface SkillsPageProps {
project: ProjectResponse; project: ProjectResponse;
@@ -10,6 +24,7 @@ interface SkillsPageProps {
// 技能库UX §7只读注册表视图name/scope/tier/reads/writes。Server Component纯读 // 技能库UX §7只读注册表视图name/scope/tier/reads/writes。Server Component纯读
export function SkillsPage({ project, skills }: SkillsPageProps) { export function SkillsPage({ project, skills }: SkillsPageProps) {
const groups = groupByScope(skills); const groups = groupByScope(skills);
const summary = summarizeSkills(skills);
return ( return (
<AppShell <AppShell
@@ -18,60 +33,161 @@ export function SkillsPage({ project, skills }: SkillsPageProps) {
projectId={project.id} projectId={project.id}
activeNav="skills" activeNav="skills"
> >
<div className="mx-auto flex max-w-4xl flex-col gap-6 p-6"> <div className="mx-auto flex max-w-6xl flex-col gap-6 p-6">
<h1 className="font-serif text-lg text-ink"></h1> <PageHeader
<p className="text-xs text-ink-soft"> title="技能库"
Skill / description="声明式 Skill 注册表:每个技能声明能力档位与可读/写的表,越权产出会被丢弃并审计。"
</p> />
{groups.length === 0 ? ( <div className="grid gap-6 lg:grid-cols-[18rem_minmax(0,1fr)]">
<p className="text-sm text-ink-soft"></p> <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>
))
) : ( ) : (
groups.map((group) => ( <span className="text-xs text-ink-soft"></span>
<section key={group.scope}> )}
<h2 className="mb-2 font-serif text-sm text-ink"> </div>
{scopeLabel(group.scope)} </Card>
<span className="ml-2 text-xs text-ink-soft">
{group.skills.length} <Card as="section" className="p-4">
</span> <SectionHeader title="能力档位" />
</h2> {summary.tiers.length > 0 ? (
<ul className="flex flex-col gap-2"> <ul className="mt-3 flex flex-col gap-2 text-sm">
{group.skills.map((skill) => ( {summary.tiers.map((item) => (
<li <li
key={skill.name} key={item.key}
className="rounded border border-line bg-panel p-3 text-sm" className="flex items-center justify-between border-b border-line/70 py-2 last:border-b-0"
> >
<div className="mb-1 flex flex-wrap items-center gap-2"> <span className="text-ink">{tierLabel(item.key)}</span>
<span className="font-mono text-ink">{skill.name}</span> <span className="font-mono text-xs text-ink-soft">
<span className="rounded bg-bg px-2 py-0.5 text-xs text-ink-soft"> {item.count}
{tierLabel(skill.tier)}
</span> </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> </ul>
</section> ) : (
)) <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>
))}
</>
)}
</div>
</div>
</div> </div>
</AppShell> </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("、") : "—";
}

View File

@@ -1,9 +1,12 @@
"use client"; "use client";
import { useRefine } from "@/lib/style/useRefine"; import { CheckCircle2, X } from "lucide-react";
import { useEffect } from "react"; import { useEffect } from "react";
import { ThinkingIndicator } from "@/components/ThinkingIndicator"; 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 { interface RefineViewProps {
projectId: string; projectId: string;
@@ -46,15 +49,17 @@ export function RefineView({
<button <button
type="button" type="button"
onClick={onClose} 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="关闭回炉对比" aria-label="关闭回炉对比"
> >
<X className="h-4 w-4" aria-hidden="true" />
</button> </button>
</div> </div>
{segment.trim().length === 0 ? ( {segment.trim().length === 0 ? (
<p className="text-overdue">稿稿</p> <Badge variant="warning">
稿稿
</Badge>
) : refiner.status === "refining" ? ( ) : refiner.status === "refining" ? (
<p className="text-info"> <p className="text-info">
<ThinkingIndicator label="回炉中" /> <ThinkingIndicator label="回炉中" />
@@ -76,24 +81,21 @@ export function RefineView({
</p> </p>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<button <Button
type="button"
onClick={() => { onClick={() => {
if (refiner.result) { if (refiner.result) {
onAdopt(refiner.result.original, refiner.result.refined); 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>
<button <Button onClick={onClose} variant="secondary" size="sm">
type="button"
onClick={onClose}
className="rounded border border-line px-3 py-1.5 text-ink hover:border-cinnabar"
>
</button> </Button>
</div> </div>
</div> </div>
) : null} ) : null}

View File

@@ -1,5 +1,9 @@
"use client"; "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"; import type { StyleDriftReport, StyleDriftSegment } from "@/lib/review/sse";
interface StylePanelProps { interface StylePanelProps {
@@ -26,7 +30,10 @@ export function StylePanel({ style, incomplete, onRefine }: StylePanelProps) {
(style-auditor) (style-auditor)
</h2> </h2>
{incomplete ? ( {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 ? ( ) : style === null ? (
<p className="mt-1 text-xs text-ink-soft"> <p className="mt-1 text-xs text-ink-soft">
@@ -47,7 +54,10 @@ export function StylePanel({ style, incomplete, onRefine }: StylePanelProps) {
</p> </p>
{style.segments.length === 0 ? ( {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"> <ul className="space-y-2">
{style.segments.map((seg, i) => ( {style.segments.map((seg, i) => (
@@ -57,9 +67,9 @@ export function StylePanel({ style, incomplete, onRefine }: StylePanelProps) {
data-testid="drift-segment" data-testid="drift-segment"
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="font-mono text-overdue"> <Badge variant="warning" className="font-mono">
{seg.idx} {seg.idx}
</span> </Badge>
<span className="font-mono text-ink-soft"> <span className="font-mono text-ink-soft">
{seg.score}% {seg.score}%
</span> </span>
@@ -68,13 +78,14 @@ export function StylePanel({ style, incomplete, onRefine }: StylePanelProps) {
) : null} ) : null}
</div> </div>
<div className="mt-1.5 flex gap-2"> <div className="mt-1.5 flex gap-2">
<button <Button
type="button"
onClick={() => onRefine(seg)} 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> </div>
</li> </li>
))} ))}

View File

@@ -4,6 +4,10 @@ import { useCallback, useState, type ChangeEvent } from "react";
import { hasUsableSamples, type StyleLearnMode } from "@/lib/style/style"; import { hasUsableSamples, type StyleLearnMode } from "@/lib/style/style";
import { useToast } from "@/components/Toast"; 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 { interface StyleUploadProps {
busy: boolean; busy: boolean;
@@ -61,21 +65,15 @@ export function StyleUpload({
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<div> <Field label="样本正文(空行分段;可粘贴多段)" htmlFor="style-samples">
<label <TextArea
htmlFor="style-samples"
className="mb-1 block text-sm font-semibold text-ink"
>
</label>
<textarea
id="style-samples" id="style-samples"
value={text} value={text}
onChange={(e) => setText(e.target.value)} onChange={(e) => setText(e.target.value)}
placeholder="粘贴你想学习文风的章节正文…" 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"> <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"> <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" className="sr-only"
/> />
</label> </label>
<button <Button type="button" onClick={submit} disabled={busy} variant="primary">
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"
>
{hasFingerprint ? "重新学习文风" : "学习文风"} {hasFingerprint ? "重新学习文风" : "学习文风"}
</button> </Button>
</div> </div>
{pollStatus === "polling" ? ( {pollStatus === "polling" ? (
<p className="text-xs text-info" aria-live="polite"> <StatusNote variant="info" aria-live="polite">
{progress}% {progress}%
</p> </StatusNote>
) : pollStatus === "error" ? ( ) : pollStatus === "error" ? (
<p className="text-xs text-conflict"></p> <StatusNote variant="danger"></StatusNote>
) : null} ) : null}
</div> </div>
); );

View File

@@ -3,6 +3,11 @@
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { useToast } from "@/components/Toast"; 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 { api } from "@/lib/api/client";
import type { TemplateResponse } from "@/lib/api/types"; import type { TemplateResponse } from "@/lib/api/types";
import { import {
@@ -89,56 +94,49 @@ export function TemplatesManager({ initial }: TemplatesManagerProps) {
void onCreate(); void onCreate();
}} }}
> >
<h2 className="font-serif text-lg text-ink"></h2> <SectionHeader title="新建模板" />
<label className="block text-sm text-ink-soft"> <Field label="标题" required>
* <TextInput
<input
type="text" type="text"
value={draft.title} value={draft.title}
onChange={(e) => setField("title", e.target.value)} 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="标题" aria-label="标题"
/> />
</label> </Field>
<label className="block text-sm text-ink-soft"> <Field label="正文" required help="一键填入生成器的 brief/原文。">
* brief/ <TextArea
<textarea
value={draft.body} value={draft.body}
onChange={(e) => setField("body", e.target.value)} onChange={(e) => setField("body", e.target.value)}
rows={4} 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="正文" aria-label="正文"
/> />
</label> </Field>
<div className="flex flex-wrap gap-4"> <div className="flex flex-wrap gap-4">
<label className="block text-sm text-ink-soft"> <Field label="分类(可选)" className="w-full sm:w-48">
<TextInput
<input
type="text" type="text"
value={draft.category} value={draft.category}
onChange={(e) => setField("category", e.target.value)} 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="分类" aria-label="分类"
/> />
</label> </Field>
<label className="block text-sm text-ink-soft"> <Field label="关联生成器 key可选" className="w-full sm:w-48">
key <TextInput
<input
type="text" type="text"
value={draft.toolKey} value={draft.toolKey}
onChange={(e) => setField("toolKey", e.target.value)} 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" aria-label="关联生成器 key"
/> />
</label> </Field>
</div> </div>
<button <Button
type="submit" type="submit"
disabled={creating || !isTemplateDraftValid(draft)} 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 ? "新建中…" : "新建模板"} {creating ? "新建中…" : "新建模板"}
</button> </Button>
</form> </form>
<section className="flex flex-col gap-3" aria-label="模板列表"> <section className="flex flex-col gap-3" aria-label="模板列表">
@@ -163,14 +161,16 @@ export function TemplatesManager({ initial }: TemplatesManagerProps) {
{t.body} {t.body}
</p> </p>
</div> </div>
<button <Button
type="button" type="button"
onClick={() => void onDelete(t.id)} 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}`} aria-label={`删除模板 ${t.title}`}
> >
</button> </Button>
</li> </li>
))} ))}
</ul> </ul>

View File

@@ -1,9 +1,15 @@
"use client"; "use client";
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { ArrowLeft, Database, Sparkles } from "lucide-react";
import { useToast } from "@/components/Toast"; import { useToast } from "@/components/Toast";
import { ConflictAdjudication } from "@/components/generation/ConflictAdjudication"; 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 type { ToolDescriptorView, ToolInputFieldView } from "@/lib/api/types";
import { import {
buildGenerateRequest, buildGenerateRequest,
@@ -115,25 +121,30 @@ export function GeneratorRunner({
return ( return (
<section <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} aria-label={tool.title}
> >
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div> <div>
<div className="mb-2 flex flex-wrap items-center gap-2">
<h2 className="font-serif text-lg text-ink">{tool.title}</h2> <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> <p className="mt-1 text-sm text-ink-soft">{tool.subtitle}</p>
</div> </div>
<button <Button onClick={onClose} variant="secondary" size="sm">
type="button" <ArrowLeft className="h-4 w-4" aria-hidden="true" />
onClick={onClose}
className="rounded border border-line px-3 py-1 text-sm text-ink-soft hover:text-ink"
>
</button> </Button>
</div> </div>
<form <form
className="flex flex-col gap-3" className="grid gap-3 rounded border border-line bg-bg/50 p-4"
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
void onGenerate(); void onGenerate();
@@ -148,13 +159,10 @@ export function GeneratorRunner({
onChange={(v) => setField(field.name, v)} onChange={(v) => setField(field.name, v)}
/> />
))} ))}
<button <Button type="submit" disabled={generating} variant="primary">
type="submit" <Sparkles className="h-4 w-4" aria-hidden="true" />
disabled={generating}
className="self-start rounded bg-cinnabar px-4 py-2 text-sm text-white disabled:opacity-50"
>
{generating ? "生成中…" : "生成"} {generating ? "生成中…" : "生成"}
</button> </Button>
</form> </form>
{gen.genStatus === "preview" && gen.preview ? ( {gen.genStatus === "preview" && gen.preview ? (
@@ -181,18 +189,18 @@ export function GeneratorRunner({
</ul> </ul>
{canIngest && gen.ingestStatus !== "conflict" ? ( {canIngest && gen.ingestStatus !== "conflict" ? (
<button <Button
type="button"
onClick={() => void runIngest(false)} onClick={() => void runIngest(false)}
disabled={ingesting || (!singleObject && selected.size === 0)} 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 {ingesting
? "入库中…" ? "入库中…"
: singleObject : singleObject
? `入库为规则至 ${table}` ? `入库为规则至 ${table}`
: `入库选中(${selected.size})至 ${table}`} : `入库选中(${selected.size})至 ${table}`}
</button> </Button>
) : null} ) : null}
{gen.ingestStatus === "conflict" && gen.conflicts ? ( {gen.ingestStatus === "conflict" && gen.conflicts ? (
@@ -223,33 +231,24 @@ interface FormFieldProps {
// 声明式控件映射type → text / textarea / numberselect 暂同 text后端未给 options // 声明式控件映射type → text / textarea / numberselect 暂同 text后端未给 options
function FormField({ field, value, onChange }: FormFieldProps) { 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 ( return (
<label className="block text-sm text-ink-soft"> <Field label={field.label} help={field.help} required={field.required}>
{labelText}
{field.type === "textarea" ? ( {field.type === "textarea" ? (
<textarea <TextArea
value={value} value={value}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
rows={3} rows={3}
className={`${common} resize-y`}
aria-label={field.label} aria-label={field.label}
/> />
) : ( ) : (
<input <TextInput
type={field.type === "number" ? "number" : "text"} type={field.type === "number" ? "number" : "text"}
value={value} value={value}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
className={common}
aria-label={field.label} aria-label={field.label}
/> />
)} )}
{field.help ? ( </Field>
<span className="mt-1 block text-xs text-ink-soft/80">{field.help}</span>
) : null}
</label>
); );
} }

View File

@@ -1,6 +1,10 @@
"use client"; "use client";
import { ArrowRight, Database, Sparkles } from "lucide-react";
import { Badge } from "@/components/ui/Badge";
import type { ToolDescriptorView } from "@/lib/api/types"; import type { ToolDescriptorView } from "@/lib/api/types";
import { cardClass } from "@/lib/ui/variants";
interface ToolCardProps { interface ToolCardProps {
tool: ToolDescriptorView; tool: ToolDescriptorView;
@@ -15,27 +19,32 @@ export function ToolCard({ tool, onOpen }: ToolCardProps) {
<button <button
type="button" type="button"
onClick={() => onOpen(tool)} 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"> <div className="mb-2 flex flex-wrap items-center gap-2">
<h2 className="font-serif text-xl text-ink">{tool.title}</h2> <h2 className="font-serif text-xl text-ink">{tool.title}</h2>
{tool.genre ? ( {tool.genre ? <Badge>{tool.genre}</Badge> : null}
<span className="rounded bg-bg px-2 py-0.5 text-xs text-ink-soft"> {tool.is_legacy ? null : <Badge variant="accent"></Badge>}
{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.ingestable ? ( {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} ) : null}
</div> </div>
<p className="line-clamp-2 text-sm text-ink-soft">{tool.subtitle}</p> <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> </button>
); );
} }

View File

@@ -2,8 +2,11 @@
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Sparkles } from "lucide-react";
import { AppShell } from "@/components/AppShell"; 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 type { ProjectResponse, ToolDescriptorView } from "@/lib/api/types";
import { isLegacyTool, resolveLegacyRoute } from "@/lib/toolbox/toolbox"; import { isLegacyTool, resolveLegacyRoute } from "@/lib/toolbox/toolbox";
import { ToolCard } from "./ToolCard"; import { ToolCard } from "./ToolCard";
@@ -63,13 +66,11 @@ export function ToolboxPage({
activeNav="toolbox" activeNav="toolbox"
> >
<div className="mx-auto flex max-w-5xl flex-col gap-6 p-6"> <div className="mx-auto flex max-w-5xl flex-col gap-6 p-6">
<header> <PageHeader
<h1 className="font-serif text-lg text-ink"></h1> title="创作工具箱"
<p className="mt-1 text-xs text-ink-soft"> eyebrow="generator toolbox"
/ / / / / / / description="脑洞、书名、简介、名字、金手指、词条、黄金开篇与细纲。每个工具都先生成结构化预览,可入库内容会经过一致性预检。"
/>
</p>
</header>
{active ? ( {active ? (
<GeneratorRunner <GeneratorRunner
@@ -78,7 +79,11 @@ export function ToolboxPage({
onClose={() => setActive(null)} onClose={() => setActive(null)}
/> />
) : sorted.length === 0 ? ( ) : 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"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{sorted.map((tool) => ( {sorted.map((tool) => (

View 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>
);
}

View 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>
);
});

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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}
/>
);
}

View 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;
}

View 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}
/>
);
}

View 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}
/>
);
}

View File

@@ -1,10 +1,24 @@
"use client"; "use client";
import Link from "next/link"; 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 { AppShell } from "@/components/AppShell";
import { Drawer } from "@/components/Drawer"; 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 type { ProjectResponse } from "@/lib/api/types";
import { friendlyError } from "@/lib/errors/messages"; import { friendlyError } from "@/lib/errors/messages";
import { useAutosave } from "@/lib/autosave/useAutosave"; import { useAutosave } from "@/lib/autosave/useAutosave";
@@ -14,6 +28,7 @@ import {
type ChapterEntry, type ChapterEntry,
} from "@/lib/workbench/chapter"; } from "@/lib/workbench/chapter";
import { composeDirective, STYLE_PRESETS } from "@/lib/workbench/directive"; import { composeDirective, STYLE_PRESETS } from "@/lib/workbench/directive";
import { buttonClass } from "@/lib/ui/variants";
import { ChapterList, ChapterListContent } from "./ChapterList"; import { ChapterList, ChapterListContent } from "./ChapterList";
import { ChapterAssistant, AssistantContent } from "./ChapterAssistant"; import { ChapterAssistant, AssistantContent } from "./ChapterAssistant";
import { Editor } from "./Editor"; import { Editor } from "./Editor";
@@ -50,6 +65,8 @@ export function Workbench({
const autosave = useAutosave(project.id, chapterNo, initialText); const autosave = useAutosave(project.id, chapterNo, initialText);
const stream = useDraftStream(); const stream = useDraftStream();
const lastStreamText = useRef(""); const lastStreamText = useRef("");
const chapterTriggerRef = useRef<HTMLButtonElement>(null);
const assistantTriggerRef = useRef<HTMLButtonElement>(null);
// 流式 token 累积进编辑器(打字机);停止/结束后已生成部分留在草稿。 // 流式 token 累积进编辑器(打字机);停止/结束后已生成部分留在草稿。
useEffect(() => { useEffect(() => {
@@ -95,7 +112,7 @@ export function Workbench({
projectId={project.id} projectId={project.id}
activeNav="write" 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 <ChapterList
projectId={project.id} projectId={project.id}
chapters={chapters} chapters={chapters}
@@ -103,6 +120,13 @@ export function Workbench({
/> />
<section className="flex min-w-0 flex-col bg-bg"> <section className="flex min-w-0 flex-col bg-bg">
<MobileContextBar
chapterNo={chapterNo}
onOpenChapters={() => setMobilePanel("chapters")}
onOpenAssistant={() => setMobilePanel("assistant")}
chapterTriggerRef={chapterTriggerRef}
assistantTriggerRef={assistantTriggerRef}
/>
<DirectivePanel <DirectivePanel
directive={directive} directive={directive}
onDirectiveChange={setDirective} onDirectiveChange={setDirective}
@@ -126,8 +150,6 @@ export function Workbench({
streamError={stream.state.error} streamError={stream.state.error}
onWrite={onWrite} onWrite={onWrite}
onStop={stream.stop} onStop={stream.stop}
onOpenChapters={() => setMobilePanel("chapters")}
onOpenAssistant={() => setMobilePanel("assistant")}
/> />
</section> </section>
@@ -140,6 +162,7 @@ export function Workbench({
onClose={closePanel} onClose={closePanel}
side="left" side="left"
label="目录" label="目录"
triggerRef={chapterTriggerRef}
> >
<ChapterListContent <ChapterListContent
projectId={project.id} projectId={project.id}
@@ -152,6 +175,7 @@ export function Workbench({
onClose={closePanel} onClose={closePanel}
side="right" side="right"
label="本章助手" label="本章助手"
triggerRef={assistantTriggerRef}
> >
<AssistantContent projectId={project.id} chapterNo={chapterNo} /> <AssistantContent projectId={project.id} chapterNo={chapterNo} />
</Drawer> </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 { interface DirectivePanelProps {
directive: string; directive: string;
onDirectiveChange: (value: string) => void; onDirectiveChange: (value: string) => void;
@@ -174,42 +240,55 @@ function DirectivePanel({
presetIds, presetIds,
onTogglePreset, onTogglePreset,
}: DirectivePanelProps) { }: DirectivePanelProps) {
const panelId = useId();
const activeCount = presetIds.length + (directive.trim().length > 0 ? 1 : 0);
return ( return (
<details className="border-b border-line bg-panel px-6 py-2"> <details className="border-b border-line bg-panel px-4 py-3 sm:px-6">
<summary className="cursor-pointer text-sm text-ink-soft hover:text-cinnabar"> <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> </summary>
<div className="mt-2 space-y-2"> <div id={panelId} className="mt-3 space-y-3">
<SectionHeader
title="写作指令"
description="用于补充或覆盖本章大纲节拍,只影响本次生成。"
/>
<label className="block"> <label className="block">
<span className="sr-only"></span> <span className="sr-only"></span>
<textarea <TextArea
value={directive} value={directive}
onChange={(e) => onDirectiveChange(e.target.value)} onChange={(e) => onDirectiveChange(e.target.value)}
rows={2} rows={2}
placeholder="本章想怎么写?(可选,覆盖/补充大纲节拍)" 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> </label>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2" role="group" aria-label="风格预设">
{STYLE_PRESETS.map((preset) => { {STYLE_PRESETS.map((preset) => {
const active = presetIds.includes(preset.id); const active = presetIds.includes(preset.id);
return ( return (
<button <Button
key={preset.id} key={preset.id}
type="button" type="button"
aria-pressed={active} aria-pressed={active}
onClick={() => onTogglePreset(preset.id)} onClick={() => onTogglePreset(preset.id)}
className={ variant={active ? "outline" : "secondary"}
active size="sm"
? "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"
}
> >
{preset.label} {preset.label}
</button> </Button>
); );
})} })}
</div> </div>
{activeCount > 0 ? (
<StatusNote variant="info">
</StatusNote>
) : null}
</div> </div>
</details> </details>
); );
@@ -250,8 +329,6 @@ interface ToolbarProps {
streamError: { code: string; message: string } | null; streamError: { code: string; message: string } | null;
onWrite: () => void; onWrite: () => void;
onStop: () => void; onStop: () => void;
onOpenChapters: () => void;
onOpenAssistant: () => void;
} }
function Toolbar({ function Toolbar({
@@ -264,36 +341,22 @@ function Toolbar({
streamError, streamError,
onWrite, onWrite,
onStop, onStop,
onOpenChapters,
onOpenAssistant,
}: ToolbarProps) { }: ToolbarProps) {
return ( 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} {streamError ? <StreamErrorNote streamError={streamError} /> : null}
<div className="flex flex-wrap items-center gap-2 sm:gap-3"> <div className="grid gap-2 sm:flex sm:flex-wrap sm:items-center sm:gap-3">
<button <div className="flex min-w-0 flex-wrap items-center gap-2">
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>
{streaming ? ( {streaming ? (
<button <Button onClick={onStop} variant="danger" size="sm">
type="button" <Square className="h-4 w-4" aria-hidden="true" />
onClick={onStop}
className="rounded border border-conflict px-4 py-2 text-sm text-conflict"
>
</button> </Button>
) : ( ) : (
<button <Button onClick={onWrite} variant="primary" size="sm">
type="button" <PenLine className="h-4 w-4" aria-hidden="true" />
onClick={onWrite}
className="rounded bg-cinnabar px-4 py-2 text-sm text-panel hover:opacity-90" </Button>
>
</button>
)} )}
{streaming ? ( {streaming ? (
<span className="font-mono text-xs text-cinnabar motion-safe:animate-pulse"> <span className="font-mono text-xs text-cinnabar motion-safe:animate-pulse">
@@ -306,30 +369,32 @@ function Toolbar({
)} )}
<Link <Link
href={`/projects/${projectId}/outline`} 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>
<Link <Link
href={`/projects/${projectId}/foreshadow`} 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> </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 <Link
href={`/projects/${projectId}/review?chapter=${chapterNo}`} 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> </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 === "saving"
? "保存中…" ? "保存中…"
: saveStatus === "error" : saveStatus === "error"

View File

@@ -1539,6 +1539,17 @@ export interface components {
selling_points?: string[]; selling_points?: string[];
/** Structure */ /** Structure */
structure?: string | null; structure?: string | null;
/**
* Updated At
* @description 项目最近更新时间
*/
updated_at?: string | null;
/**
* Pending Review Count
* @description 待审稿章节数
* @default 0
*/
pending_review_count: number;
}; };
/** /**
* ProviderCredentialInput * ProviderCredentialInput

View 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();
});
});

View File

@@ -4,9 +4,12 @@ import type { ForeshadowView } from "@/lib/api/types";
import { import {
buildRegisterRequest, buildRegisterRequest,
buildTransitionRequest, buildTransitionRequest,
countByStatus,
extractReason, extractReason,
groupByStatus, groupByStatus,
isWindowApproaching, isWindowApproaching,
LANE_LABELS,
nextFreeForeshadowCode,
suggestForeshadowCode, suggestForeshadowCode,
validationReasonMessage, validationReasonMessage,
} from "./board"; } 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 => ({ const view = (over: Partial<ForeshadowView>): ForeshadowView => ({
code: "F-001", code: "F-001",
title: "线索", 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", () => { describe("isWindowApproaching", () => {
it("is true when current chapter is inside [from,to] for OPEN/PARTIAL", () => { it("is true when current chapter is inside [from,to] for OPEN/PARTIAL", () => {
const f = view({ const f = view({

View File

@@ -18,13 +18,14 @@ export const LANES: readonly ForeshadowStatus[] = [
] as const; ] as const;
export const LANE_LABELS: Record<ForeshadowStatus, string> = { export const LANE_LABELS: Record<ForeshadowStatus, string> = {
OPEN: "OPEN", OPEN: "待推进",
PARTIAL: "PARTIAL", PARTIAL: "推进中",
CLOSED: "CLOSED", CLOSED: "已回收",
OVERDUE: "OVERDUE", OVERDUE: "已逾期",
}; };
export type ForeshadowLanes = Record<ForeshadowStatus, ForeshadowView[]>; export type ForeshadowLanes = Record<ForeshadowStatus, ForeshadowView[]>;
export type ForeshadowLaneCounts = Record<ForeshadowStatus, number>;
function emptyLanes(): ForeshadowLanes { function emptyLanes(): ForeshadowLanes {
return { OPEN: [], PARTIAL: [], CLOSED: [], OVERDUE: [] }; return { OPEN: [], PARTIAL: [], CLOSED: [], OVERDUE: [] };
@@ -46,6 +47,18 @@ export function groupByStatus(
return lanes; 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提示安排回收 // 接近回收窗口判定current 在 [from, to] 内,或已超 from 但尚未 CLOSED提示安排回收
// 仅对 OPEN/PARTIAL 提示CLOSED 已回收、OVERDUE 已逾期另有强调。 // 仅对 OPEN/PARTIAL 提示CLOSED 已回收、OVERDUE 已逾期另有强调。
export function isWindowApproaching( export function isWindowApproaching(
@@ -70,6 +83,21 @@ export function suggestForeshadowCode(
return `FS-${String(index + 1).padStart(2, "0")}`; 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 { export interface RegisterInput {
projectId: string; projectId: string;
code: string; code: string;

View 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);
});
});

View 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("生成后端返回 errorgenStatus=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("入库非冲突 erroringestStatus=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([]);
});
});

View 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("后端返回 errorstatus=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([]);
});
});

View 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);
});
// Assertdone、进度 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);
});
});

View File

@@ -1,6 +1,10 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { aiToolItems } from "./ai-tools"; import {
aiToolItems,
primaryAiToolItems,
secondaryAiToolItems,
} from "./ai-tools";
describe("aiToolItems", () => { describe("aiToolItems", () => {
it("returns 5 items", () => { it("returns 5 items", () => {
@@ -25,4 +29,16 @@ describe("aiToolItems", () => {
expect(review?.href).toBe("/projects/p1/review"); expect(review?.href).toBe("/projects/p1/review");
expect(review?.key).toBe("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",
]);
});
}); });

View File

@@ -8,8 +8,6 @@ import type { ActiveNav } from "./items";
export interface AiToolItem { export interface AiToolItem {
href: string; href: string;
label: string; label: string;
// 行首字形纸感非语义aria-hidden 渲染)。
glyph: string;
// 高亮键(与 AppShell activeNav 比对)。 // 高亮键(与 AppShell activeNav 比对)。
key: ActiveNav; key: ActiveNav;
// 主动作(写本章)朱砂强调,其余次级。 // 主动作(写本章)朱砂强调,其余次级。
@@ -20,10 +18,18 @@ export interface AiToolItem {
export function aiToolItems(projectId: string): AiToolItem[] { export function aiToolItems(projectId: string): AiToolItem[] {
const base = `/projects/${projectId}`; const base = `/projects/${projectId}`;
return [ return [
{ href: `${base}/write`, label: "写本章", glyph: "✍", key: "write", primary: true }, { href: `${base}/write`, label: "写本章", key: "write", primary: true },
{ href: `${base}/review`, label: "审稿", glyph: "✦", key: "review" }, { href: `${base}/review`, label: "审稿", key: "review" },
{ href: `${base}/outline`, label: "大纲", glyph: "❡", key: "outline" }, { href: `${base}/outline`, label: "大纲", key: "outline" },
{ href: `${base}/codex`, label: "设定库", glyph: "❖", key: "codex" }, { href: `${base}/codex`, label: "设定库", key: "codex" },
{ href: `${base}/toolbox`, label: "工具箱", glyph: "✧", key: "toolbox" }, { 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);
}

View 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");
});
});

View 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(
"暂无编辑记录",
);
});
});

View 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;
}

View 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");
});
});

View 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("网络抛异常(非 Abortphase=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 被吞掉:不进入 errorphase 维持 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");
});
});

View 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");
});
});

View 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 () => {
// Arrangestart 拿到 device + job_idjob 首拍即 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 failedphase=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",
);
});
});

View File

@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { SkillView } from "@/lib/api/types"; 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 => ({ const skill = (over: Partial<SkillView>): SkillView => ({
name: "x", name: "x",
@@ -36,3 +36,61 @@ describe("groupByScope", () => {
expect(groupByScope(undefined)).toEqual([]); 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: [],
});
});
});

View File

@@ -27,6 +27,21 @@ export function tierLabel(tier: string): string {
export type SkillGroups = { scope: string; skills: SkillView[] }[]; 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 顺序按首次出现。 // 按 scope 分组(保持服务端 name 升序scope 顺序按首次出现。
export function groupByScope( export function groupByScope(
skills: readonly SkillView[] | undefined, skills: readonly SkillView[] | undefined,
@@ -42,3 +57,41 @@ export function groupByScope(
} }
return order.map((scope) => ({ scope, skills: map.get(scope) ?? [] })); 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 }));
}

View 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";
// fetchSSE 流)是 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("网络抛异常(非 Abortphase=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 被吞掉:不进入 errorphase 维持 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("已落草稿");
});
});

View 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_UNAVAILABLEstatus=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();
});
});

View File

@@ -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 传入的 // P1-6 回归守卫学文风轮询完成done 边沿effect 必须用「最近一次 learn 传入的
// projectId」去拉指纹而非闭包捕获的旧值。useStyleLearn 用 ref 追踪 projectId 来保证这点。 // projectId」去拉指纹而非闭包捕获的旧值。useStyleLearn 用 ref 追踪 projectId 来保证这点。
@@ -39,3 +45,239 @@ describe("useStyleLearn projectId 追踪P1-6 stale-closure 守卫)", () =>
expect(t.onDone()).toBeNull(); 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();
});
});

View 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("生成后端返回 errorgenStatus=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("入库非冲突 erroringestStatus=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([]);
});
});

View 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
View 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();
}

View 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
View 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);
}

View 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("挂载成功:拉到确定性结果并落到 dataloading 归位", 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);
});
});

View File

@@ -45,6 +45,7 @@ export function useInjection(projectId: string, chapterNo: number): UseInjection
setError(null); setError(null);
void (async () => { void (async () => {
try {
const { data: body, error: err } = await api.GET(INJECTION_PATH, { const { data: body, error: err } = await api.GET(INJECTION_PATH, {
params: { path: { project_id: projectId, chapter_no: chapterNo } }, params: { path: { project_id: projectId, chapter_no: chapterNo } },
}); });
@@ -55,7 +56,14 @@ export function useInjection(projectId: string, chapterNo: number): UseInjection
} else { } else {
setData(body); setData(body);
} }
setLoading(false); } catch {
// 网络层失败(后端不可达/CORSopenapi-fetch 直接抛而非返回 error 信封。
if (cancelled) return;
setError("注入信息暂不可用");
setData(null);
} finally {
if (!cancelled) setLoading(false);
}
})(); })();
return () => { return () => {
@@ -80,6 +88,9 @@ export function useInjection(projectId: string, chapterNo: number): UseInjection
} else { } else {
setData(body); // 以服务端确定结果为准(不变量 #6 setData(body); // 以服务端确定结果为准(不变量 #6
} }
} catch {
// 网络层失败:本地状态不动(天然回滚),给可读文案、不抛。
setError("保存注入设置失败,请重试");
} finally { } finally {
savingRef.current = false; savingRef.current = false;
setSaving(false); setSaving(false);

View File

@@ -9,22 +9,29 @@
"lint": "next lint", "lint": "next lint",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "vitest run", "test": "vitest run",
"test:coverage": "vitest run --coverage",
"gen:api": "node scripts/gen-api.mjs" "gen:api": "node scripts/gen-api.mjs"
}, },
"dependencies": { "dependencies": {
"lucide-react": "^1.21.0",
"next": "15.1.3", "next": "15.1.3",
"openapi-fetch": "^0.13.0", "openapi-fetch": "^0.13.0",
"react": "19.0.0", "react": "19.0.0",
"react-dom": "19.0.0" "react-dom": "19.0.0"
}, },
"devDependencies": { "devDependencies": {
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.2",
"@types/node": "^22.10.0", "@types/node": "^22.10.0",
"@types/react": "19.0.0", "@types/react": "19.0.0",
"@types/react-dom": "19.0.0", "@types/react-dom": "19.0.0",
"@vitest/coverage-v8": "^2.1.9",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"eslint": "^9.17.0", "eslint": "^9.17.0",
"eslint-config-next": "15.1.3", "eslint-config-next": "15.1.3",
"jsdom": "^29.1.1",
"openapi-typescript": "^7.5.0", "openapi-typescript": "^7.5.0",
"playwright": "^1.61.1",
"postcss": "^8.4.49", "postcss": "^8.4.49",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"typescript": "^5.7.2", "typescript": "^5.7.2",

900
apps/web/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,9 @@
packages: packages:
- . - .
allowBuilds: allowBuilds:
esbuild: set this to true or false esbuild: true
sharp: set this to true or false sharp: true
unrs-resolver: set this to true or false unrs-resolver: true
onlyBuiltDependencies: onlyBuiltDependencies:
- esbuild - esbuild
- sharp - sharp

View File

@@ -2,7 +2,11 @@ import type { Config } from "tailwindcss";
// 纸感·文学温暖主题UX_SPEC §2。颜色经 CSS 变量落地,便于后续夜读模式切换。 // 纸感·文学温暖主题UX_SPEC §2。颜色经 CSS 变量落地,便于后续夜读模式切换。
const config: Config = { const config: Config = {
content: ["./app/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}"], content: [
"./app/**/*.{ts,tsx}",
"./components/**/*.{ts,tsx}",
"./lib/**/*.{ts,tsx}",
],
theme: { theme: {
extend: { extend: {
colors: { colors: {
@@ -23,7 +27,7 @@ const config: Config = {
mono: ['"JetBrains Mono"', "ui-monospace"], mono: ['"JetBrains Mono"', "ui-monospace"],
}, },
borderRadius: { DEFAULT: "6px" }, borderRadius: { DEFAULT: "6px" },
boxShadow: { paper: "0 1px 3px #2B26200F" }, boxShadow: { paper: "0 1px 3px var(--shadow-paper)" },
maxWidth: { prose: "720px" }, maxWidth: { prose: "720px" },
}, },
}, },

File diff suppressed because one or more lines are too long

View File

@@ -10,5 +10,21 @@ export default defineConfig({
test: { test: {
environment: "node", environment: "node",
include: ["lib/**/*.test.ts"], 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