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

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`.
- **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 已落地)
工具**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`)。
- **本地起服务**`docker compose up`pg + api + web)。仅起库`docker compose up -d pg`裸跑 API`uv run uvicorn ww_api.main:app --reload`
- **迁移**`uv run alembic upgrade head`改模型后 `uv run alembic revision --autogenerate -m "..."`漂移校验 `uv run alembic check` pg 在跑)。
- **后端门禁**`uv run ruff check .` · `uv run ruff format .` · `uv run mypy packages apps` · `uv run pytest -q`。单测:`uv run pytest path::test -q`
- **前端门禁**`cd apps/web``pnpm lint` · `pnpm typecheck` · `pnpm test`vitest· `pnpm build`
- **后端门禁**`uv run ruff check .` · `uv run ruff format .` · `uv run mypy packages apps` · `uv run pytest -q`覆盖率门禁`uv run pytest -q --cov --cov-report=term-missing --cov-fail-under=80`单测`uv run pytest path::test -q`单测不带 `--cov`避免 fail-under 误报)。
- **前端门禁**`cd apps/web``pnpm lint` · `pnpm typecheck` · `pnpm test`vitest)· `pnpm build`覆盖率门禁`pnpm test:coverage`阈值 80%范围 `lib/**`)。
- **重生成 TS 客户端**改后端 schema `cd apps/web && pnpm gen:api`离线`uv run python -m ww_api.export_openapi` `openapi-typescript` 生成 `lib/api/schema.d.ts`)。
- **pnpm 配置** `apps/web/pnpm-workspace.yaml`pnpm 11 不再读 package.json/.npmrc)—— `onlyBuiltDependencies` 白名单 + `verifyDepsBeforeRun: false` `memory/gotchas.md`)。
- **CI**`.github/workflows/ci.yml`backend job pg service ruff/mypy/alembic/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`。
> **P1 目标DoD**`GeneratorTool` 描述符 + `TOOLBOX` 注册表 + 通用 `GET /skills/toolbox` & `POST /projects/{id}/skills/{tool_key}/generate` 端点 + **1 个最简生成器(脑洞)端到端打通**mock 网关 E2E 零 token。证实「加生成器 = 加一份声明」这条路。
@@ -126,6 +161,7 @@ T0.1 monorepo 骨架 ✅ @devops · T0.2 16 MVP 表迁移无漂移users st
> 格式:`- [YYYY-MM-DD] @skill 完成/进展 Txx — 一句话结果 + 影响的契约/文件`
- [2026-06-26] @orchestrator**全项目 Code Review七维度完成 → `QA-CR` 整改批立项**。6 个专科 reviewer 并行(后端 FastAPI 架构 / Python 规范 / LangGraph 最佳实践 / 前端 React 架构 / 前端 TS 类型安全 / 应用安全)+ 依赖时效自查212 py + 168 tsx 全覆盖。绿门禁下仍揪出 **2 CRITICAL + 13 HIGH + 一批 MEDIUM/LOW****C1** Next.js 15.1.3 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] @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 竖井。
@@ -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-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-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
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from datetime import UTC, datetime
from typing import Any
from pydantic import BaseModel
@@ -41,6 +42,7 @@ class FakeProjectRepo:
def __init__(self) -> None:
self.rows: dict[uuid.UUID, tuple[uuid.UUID, ProjectView]] = {}
self.now = datetime(2026, 6, 28, tzinfo=UTC)
async def create(self, owner_id: uuid.UUID, data: ProjectCreate) -> ProjectView:
pid = uuid.uuid4()
@@ -53,6 +55,7 @@ class FakeProjectRepo:
theme=data.theme,
selling_points=list(data.selling_points),
structure=data.structure,
updated_at=self.now,
)
self.rows[pid] = (owner_id, view)
return view

View File

@@ -158,8 +158,11 @@ async def test_list_projects() -> None:
await client.post("/projects", json={"title": ""})
resp = await client.get("/projects")
assert resp.status_code == 200
titles = {p["title"] for p in resp.json()["projects"]}
projects = resp.json()["projects"]
titles = {p["title"] for p in projects}
assert titles == {"", ""}
assert all("updated_at" in p for p in projects)
assert all(p["pending_review_count"] == 0 for p in projects)
@pytest.mark.asyncio
@@ -169,7 +172,10 @@ async def test_get_project_detail() -> None:
created = (await client.post("/projects", json={"title": "详情"})).json()
resp = await client.get(f"/projects/{created['id']}")
assert resp.status_code == 200
assert resp.json()["title"] == "详情"
body = resp.json()
assert body["title"] == "详情"
assert "updated_at" in body
assert body["pending_review_count"] == 0
@pytest.mark.asyncio

View File

@@ -6,6 +6,7 @@ snake_case前端经 OpenAPI 生成 TS 类型消费。改字段 → 前端必
from __future__ import annotations
import uuid
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, Field
@@ -34,6 +35,8 @@ class ProjectResponse(BaseModel):
theme: str | None = None
selling_points: list[str] = Field(default_factory=list)
structure: str | None = None
updated_at: datetime | None = Field(default=None, description="项目最近更新时间")
pending_review_count: int = Field(default=0, ge=0, description="待审稿章节数")
class ProjectListResponse(BaseModel):

View File

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

View File

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

View File

@@ -1,9 +1,13 @@
import Link from "next/link";
import { BookOpen, Plus } from "lucide-react";
import { AppShell } from "@/components/AppShell";
import { ProjectCard } from "@/components/ProjectCard";
import { ProjectLibrary } from "@/components/projects/ProjectLibrary";
import { EmptyState } from "@/components/ui/EmptyState";
import { PageHeader } from "@/components/ui/PageHeader";
import { fetchProjects } from "@/lib/api/server";
import type { ProjectResponse } from "@/lib/api/types";
import { buttonClass } from "@/lib/ui/variants";
// 作品库Dashboard 入口UX §6.1。Server Component 读取。
export default async function DashboardPage() {
@@ -18,63 +22,44 @@ export default async function DashboardPage() {
return (
<AppShell>
<div className="mx-auto max-w-5xl px-8 py-10">
<div className="mb-8 flex items-center justify-between">
<h1 className="font-serif text-3xl text-ink"></h1>
<div className="mx-auto max-w-5xl px-6 py-10 sm:px-8">
<PageHeader
title="我的作品"
description="从一个灵感进入正文、设定、审稿与验收闭环。"
actions={
<Link
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>
</div>
}
/>
{loadError ? (
<p className="rounded border border-conflict bg-panel p-6 text-conflict">
API
</p>
) : projects.length === 0 ? (
<EmptyState />
<EmptyState
icon={BookOpen}
title="还没有作品"
description="从一句灵感开始,后续的设定库、大纲、写章和审稿都会围绕这本书展开。"
action={
<Link
href="/projects/new"
className={buttonClass({ variant: "primary" })}
>
<Plus className="h-4 w-4" aria-hidden="true" />
</Link>
}
/>
) : (
<ul className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
{projects.map((p) => (
<li key={p.id}>
<ProjectCard project={p} />
</li>
))}
<li>
<NewProjectCard />
</li>
</ul>
<ProjectLibrary projects={projects} />
)}
</div>
</AppShell>
);
}
function EmptyState() {
return (
<div className="flex flex-col items-center gap-4 rounded border border-line bg-panel py-20 text-center">
<p className="font-serif text-2xl text-ink"></p>
<p className="text-ink-soft"></p>
<Link
href="/projects/new"
className="rounded bg-cinnabar px-5 py-2 text-sm text-panel hover:opacity-90"
>
</Link>
</div>
);
}
function NewProjectCard() {
return (
<Link
href="/projects/new"
className="flex h-full min-h-[160px] flex-col items-center justify-center rounded border border-dashed border-line bg-panel text-center hover:border-cinnabar"
>
<span className="font-serif text-xl text-cinnabar"> </span>
<span className="mt-2 text-sm text-ink-soft"></span>
</Link>
);
}

View File

@@ -1,5 +1,6 @@
import { AppShell } from "@/components/AppShell";
import { ProvidersSettings } from "@/components/settings/ProvidersSettings";
import { PageHeader } from "@/components/ui/PageHeader";
import { fetchKimiOauthStatus, fetchProviders } from "@/lib/api/server";
import type { ProvidersResponse } from "@/lib/api/types";
@@ -27,7 +28,11 @@ export default async function ProvidersSettingsPage() {
return (
<AppShell title="设置 模型与提供商">
<div className="mx-auto max-w-3xl px-8 py-10">
<div className="mx-auto max-w-6xl px-4 py-8 sm:px-8 sm:py-10">
<PageHeader
title="模型与提供商"
description="配置写手、分析、轻量三类能力档位的路由,并管理 API Key 与 Kimi Code OAuth。"
/>
{loadError ? (
<p className="rounded border border-conflict bg-panel p-6 text-conflict">
API

View File

@@ -1,7 +1,21 @@
import Link from "next/link";
import {
BookOpen,
ClipboardCheck,
ListTree,
PenLine,
Sparkles,
type LucideIcon,
} from "lucide-react";
import { aiToolItems } from "@/lib/nav/ai-tools";
import { AiToolbarMoreMenu } from "@/components/AiToolbarMoreMenu";
import {
aiToolItems,
primaryAiToolItems,
secondaryAiToolItems,
} from "@/lib/nav/ai-tools";
import type { ActiveNav } from "@/lib/nav/items";
import { buttonClass } from "@/lib/ui/variants";
interface AiToolbarProps {
projectId: string;
@@ -9,16 +23,20 @@ interface AiToolbarProps {
}
// T4-a · 项目内页常驻 AI 工具条顶栏下UX §3/§5
// 纯链接服务端组件:写本章=朱砂主动作,其余次级;激活项朱砂高亮。窄屏横向滚动
// 桌面展示完整工具条;窄屏只保留写本章/审稿,把低频入口收进更多菜单
export function AiToolbar({ projectId, activeNav }: AiToolbarProps) {
const primaryItems = primaryAiToolItems(projectId);
const secondaryItems = secondaryAiToolItems(projectId);
return (
<nav
aria-label="AI 工具条"
className="flex h-12 items-center gap-2 overflow-x-auto whitespace-nowrap border-b border-line bg-panel px-4 sm:px-6"
className="flex h-12 items-center gap-2 border-b border-line bg-panel px-4 sm:px-6"
>
{aiToolItems(projectId).map((item) => {
<div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden lg:hidden">
{primaryItems.map((item) => {
const isActive = item.key === activeNav;
const className = toolClassName(item.primary === true, isActive);
const Icon = toolIcon(item.key);
return (
<Link
key={item.href}
@@ -26,23 +44,50 @@ export function AiToolbar({ projectId, activeNav }: AiToolbarProps) {
aria-current={isActive ? "page" : undefined}
className={className}
>
<span aria-hidden="true">{item.glyph}</span>
<Icon className="h-4 w-4" aria-hidden="true" />
<span>{item.label}</span>
</Link>
);
})}
<AiToolbarMoreMenu items={secondaryItems} activeNav={activeNav} />
</div>
<div className="hidden items-center gap-2 overflow-x-auto whitespace-nowrap lg:flex">
{aiToolItems(projectId).map((item) => {
const isActive = item.key === activeNav;
const className = toolClassName(item.primary === true, isActive);
const Icon = toolIcon(item.key);
return (
<Link
key={item.href}
href={item.href}
aria-current={isActive ? "page" : undefined}
className={className}
>
<Icon className="h-4 w-4" aria-hidden="true" />
<span>{item.label}</span>
</Link>
);
})}
</div>
</nav>
);
}
function toolIcon(key: ActiveNav): LucideIcon {
if (key === "write") return PenLine;
if (key === "review") return ClipboardCheck;
if (key === "outline") return ListTree;
if (key === "codex") return BookOpen;
return Sparkles;
}
function toolClassName(isPrimary: boolean, isActive: boolean): string {
const base =
"flex items-center gap-1.5 rounded border px-3 py-1.5 text-sm transition-colors";
if (isActive) {
return `${base} border-cinnabar text-cinnabar`;
return buttonClass({ variant: "outline", size: "sm" });
}
if (isPrimary) {
return `${base} border-cinnabar bg-cinnabar text-panel hover:border-cinnabar`;
return buttonClass({ variant: "primary", size: "sm" });
}
return `${base} border-line text-ink-soft hover:border-cinnabar hover:text-cinnabar`;
return buttonClass({ variant: "secondary", size: "sm" });
}

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 type { ReactNode } from "react";
import { Settings } from "lucide-react";
import type { ActiveNav } from "@/lib/nav/items";
import { buttonClass } from "@/lib/ui/variants";
import { AiToolbar } from "./AiToolbar";
import { LeftNav } from "./LeftNav";
import { NavDrawer } from "./NavDrawer";
import { ThemeToggle } from "./ThemeToggle";
interface AppShellProps {
children: ReactNode;
@@ -34,23 +37,33 @@ export function AppShell({
<NavDrawer projectId={projectId} activeNav={activeNav} />
<Link
href="/"
className="font-serif text-xl text-cinnabar"
className="shrink-0 whitespace-nowrap font-serif text-xl text-cinnabar"
aria-label="返回作品库"
>
</Link>
{title ? (
<span className="font-serif text-lg text-ink">{title}</span>
<span className="min-w-0 truncate font-serif text-lg text-ink">
{title}
</span>
) : null}
{subtitle ? (
<span className="font-mono text-xs text-ink-soft">{subtitle}</span>
<span className="hidden shrink-0 font-mono text-xs text-ink-soft sm:inline">
{subtitle}
</span>
) : null}
<nav className="ml-auto flex items-center gap-4 text-sm">
<nav className="ml-auto flex shrink-0 items-center gap-2 text-sm">
<ThemeToggle />
<Link
href="/settings/providers"
className="text-ink-soft hover:text-cinnabar"
className={buttonClass({
variant: "ghost",
size: "sm",
className: "border-transparent",
})}
>
<Settings className="h-4 w-4" aria-hidden="true" />
<span className="hidden sm:inline"></span>
</Link>
</nav>
</header>
@@ -59,7 +72,7 @@ export function AppShell({
) : null}
<div className="flex">
<LeftNav projectId={projectId} activeNav={activeNav} />
<main className="min-h-[calc(100vh-var(--chrome,4rem))] flex-1 bg-bg">
<main className="min-h-[calc(100vh-var(--chrome,4rem))] min-w-0 flex-1 bg-bg">
{children}
</main>
</div>

View File

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

View File

@@ -1,7 +1,9 @@
"use client";
import { useState } from "react";
import { Menu } from "lucide-react";
import { Button } from "@/components/ui/Button";
import type { ActiveNav } from "@/lib/nav/items";
import { Drawer } from "./Drawer";
import { NavItems } from "./NavItems";
@@ -17,17 +19,16 @@ export function NavDrawer({ projectId, activeNav }: NavDrawerProps) {
return (
<>
<button
type="button"
<Button
onClick={() => setOpen(true)}
aria-label="打开导航"
aria-expanded={open}
className="-ml-1 rounded p-2 text-ink hover:text-cinnabar lg:hidden"
variant="ghost"
size="icon"
className="-ml-2 lg:hidden"
>
<span aria-hidden="true" className="block text-lg leading-none">
</span>
</button>
<Menu className="h-5 w-5" aria-hidden="true" />
</Button>
<Drawer
open={open}
onClose={() => setOpen(false)}

View File

@@ -1,6 +1,21 @@
"use client";
import Link from "next/link";
import {
Blocks,
BookOpen,
Boxes,
ClipboardCheck,
Flag,
LayoutTemplate,
ListTree,
Palette,
PenLine,
Settings,
Sparkles,
Workflow,
type LucideIcon,
} from "lucide-react";
import { usePathname } from "next/navigation";
import {
@@ -54,6 +69,22 @@ export function NavItems({ projectId, activeNav, onNavigate }: NavItemsProps) {
);
}
function navIcon(item: NavItem): LucideIcon {
if (item.key === "write") return PenLine;
if (item.key === "outline") return ListTree;
if (item.key === "foreshadow") return Flag;
if (item.key === "review") return ClipboardCheck;
if (item.key === "chains") return Workflow;
if (item.key === "style") return Palette;
if (item.key === "codex") return BookOpen;
if (item.key === "toolbox") return Sparkles;
if (item.key === "rules") return Boxes;
if (item.key === "skills") return Blocks;
if (item.href === "/templates") return LayoutTemplate;
if (item.href === "/settings/providers") return Settings;
return BookOpen;
}
function NavLink({
item,
active,
@@ -63,18 +94,20 @@ function NavLink({
active: boolean;
onNavigate?: () => void;
}) {
const Icon = navIcon(item);
return (
<li>
<Link
href={item.href}
onClick={onNavigate}
aria-current={active ? "page" : undefined}
className={`flex items-center gap-2 rounded px-3 py-2 text-sm ${
className={`flex items-center gap-2 rounded px-3 py-2 text-sm transition-colors ${
active
? "border-l-2 border-cinnabar bg-[var(--color-cinnabar-wash)] text-cinnabar"
: "text-ink hover:bg-[var(--color-cinnabar-wash)]"
: "border-l-2 border-transparent text-ink hover:bg-[var(--color-cinnabar-wash)] hover:text-cinnabar"
}`}
>
<Icon className="h-4 w-4 shrink-0" aria-hidden="true" />
{item.label}
</Link>
</li>

View File

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

View File

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

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
key={i}
index={i}
total={conflicts.length}
conflict={conflict}
draft={draft}
missing={false}

View File

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

View File

@@ -1,10 +1,16 @@
"use client";
import { useMemo, useState } from "react";
import { Clock3, Globe2, Sparkles, UserRound } from "lucide-react";
import { AppShell } from "@/components/AppShell";
import { CharacterGenerator } from "@/components/generation/CharacterGenerator";
import { WorldGenerator } from "@/components/generation/WorldGenerator";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { EmptyState } from "@/components/ui/EmptyState";
import { PageHeader } from "@/components/ui/PageHeader";
import type {
CharacterCardView,
ProjectResponse,
@@ -60,6 +66,10 @@ export function CodexPage({
activeNav="codex"
>
<div className="flex h-[calc(100vh-var(--chrome,4rem))] flex-col p-4">
<PageHeader
title="设定库"
description="人物、世界观与时间线共同构成写作时的真相源。AI 生成内容也要先预览、再确认入库。"
/>
<div className="mb-4 flex items-center gap-2" role="tablist">
{TABS.map((t) => (
<button
@@ -82,20 +92,28 @@ export function CodexPage({
<div className="min-h-0 flex-1 overflow-auto">
{tab === "characters" ? (
<div className="flex flex-col gap-4">
<section className="rounded border border-line bg-panel p-3">
<h3 className="mb-2 font-serif text-sm text-ink">
<Card as="section" className="p-4">
<h3 className="mb-3 font-serif text-sm text-ink">
{characters.length}
</h3>
{characters.length > 0 ? (
<ul className="flex flex-col gap-2">
<ul className="grid grid-cols-1 gap-3 lg:grid-cols-2">
{characters.map((c, i) => (
<li
key={`${c.name}-${i}`}
className="rounded bg-bg px-2 py-1.5 text-xs text-ink-soft"
className="rounded border border-line bg-bg p-3 text-sm"
>
<span className="text-ink">
{c.name}{c.role}
<div className="mb-2 flex items-center gap-2">
<span className="flex h-8 w-8 items-center justify-center rounded bg-[var(--color-cinnabar-wash)] text-cinnabar">
<UserRound className="h-4 w-4" aria-hidden="true" />
</span>
<div className="min-w-0">
<p className="truncate font-serif text-base text-ink">
{c.name}
</p>
<p className="text-xs text-ink-soft">{c.role}</p>
</div>
</div>
{c.relations && c.relations.length > 0 ? (
<ul className="mt-1 flex flex-wrap gap-1">
{c.relations.map((r, j) => (
@@ -113,11 +131,29 @@ export function CodexPage({
))}
</ul>
) : (
<p className="text-xs text-ink-soft">
</p>
<EmptyState
icon={UserRound}
title="暂无入库人物"
description="先生成或手动整理主角、配角与关系,再让写作上下文稳定引用。"
action={
<Button
onClick={() =>
document
.getElementById("character-generator")
?.scrollIntoView({ block: "center" })
}
variant="primary"
size="sm"
>
<Sparkles className="h-4 w-4" aria-hidden="true" />
</Button>
}
className="bg-bg/50"
/>
)}
</section>
</Card>
<div id="character-generator">
<CharacterGenerator
projectId={project.id}
onIngested={(_, cards) =>
@@ -125,12 +161,13 @@ export function CodexPage({
}
/>
</div>
</div>
) : null}
{tab === "world" ? (
<div className="flex flex-col gap-4">
<section className="rounded border border-line bg-panel p-3">
<h3 className="mb-2 font-serif text-sm text-ink">
<Card as="section" className="p-4">
<h3 className="mb-3 font-serif text-sm text-ink">
{initialWorldEntities.length}
</h3>
{initialWorldEntities.length > 0 ? (
@@ -141,12 +178,16 @@ export function CodexPage({
className="rounded border border-line bg-bg p-3 text-sm"
>
<header className="mb-2 flex items-center gap-2">
<Globe2
className="h-4 w-4 text-cinnabar"
aria-hidden="true"
/>
<span className="font-serif text-base text-ink">
{entity.name}
</span>
<span className="rounded bg-panel px-2 py-0.5 text-xs text-ink-soft">
<Badge>
{entity.type}
</span>
</Badge>
</header>
{worldEntityRules(entity).length > 0 ? (
<ul className="flex list-disc flex-col gap-1 pl-5 text-ink-soft">
@@ -161,19 +202,40 @@ export function CodexPage({
))}
</div>
) : (
<p className="text-xs text-ink-soft">
</p>
<EmptyState
icon={Globe2}
title="暂无入库世界观"
description="先沉淀硬规则、地理、势力与能力边界,减少后续章节设定漂移。"
action={
<Button
onClick={() =>
document
.getElementById("world-generator")
?.scrollIntoView({ block: "center" })
}
variant="primary"
size="sm"
>
<Sparkles className="h-4 w-4" aria-hidden="true" />
</Button>
}
className="bg-bg/50"
/>
)}
</section>
</Card>
<div id="world-generator">
<WorldGenerator projectId={project.id} />
</div>
</div>
) : null}
{tab === "timeline" ? (
<div className="rounded border border-dashed border-line bg-panel p-6 text-center text-sm text-ink-soft">
线P2 +
</div>
<EmptyState
icon={Clock3}
title="时间线尚未启用"
description="时间线会从章节摘要、伏笔窗口和验收记录派生,后续可用于全书一致性扫描。"
/>
) : null}
</div>
</div>

View File

@@ -1,14 +1,22 @@
"use client";
import { useMemo } from "react";
import { useMemo, useState } from "react";
import { Flag, Plus } from "lucide-react";
import { AppShell } from "@/components/AppShell";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { EmptyState } from "@/components/ui/EmptyState";
import { PageHeader } from "@/components/ui/PageHeader";
import type { ForeshadowView, ProjectResponse } from "@/lib/api/types";
import {
LANES,
LANE_LABELS,
countByStatus,
groupByStatus,
type ForeshadowStatus,
} from "@/lib/foreshadow/board";
import type { BadgeVariant } from "@/lib/ui/variants";
import { useForeshadow } from "@/lib/foreshadow/useForeshadow";
import { KanbanColumn } from "./KanbanColumn";
import { RegisterForm } from "./RegisterForm";
@@ -25,7 +33,9 @@ export function ForeshadowBoard({
initialItems,
}: ForeshadowBoardProps) {
const { items, busy, register, transition } = useForeshadow(initialItems);
const [registerOpen, setRegisterOpen] = useState(false);
const lanes = useMemo(() => groupByStatus(items), [items]);
const counts = useMemo(() => countByStatus(items), [items]);
const onTransition = (
code: string,
@@ -46,16 +56,51 @@ export function ForeshadowBoard({
projectId={project.id}
activeNav="foreshadow"
>
<div className="flex h-[calc(100vh-var(--chrome,4rem))] flex-col p-4">
<div className="mb-4 flex items-start justify-between gap-4">
<h1 className="font-serif text-lg text-ink"></h1>
<div className="flex min-h-[calc(100vh-var(--chrome,4rem))] flex-col p-4 xl:h-[calc(100vh-var(--chrome,4rem))] xl:min-h-0">
<PageHeader
title="伏笔看板"
description="跟踪每条伏笔从埋设、推进到回收的状态,逾期项会单独提醒。"
actions={
<Button
onClick={() => setRegisterOpen(true)}
disabled={registerOpen}
variant="primary"
size="sm"
>
<Plus className="h-4 w-4" aria-hidden="true" />
</Button>
}
/>
{registerOpen ? (
<div className="mb-4">
<RegisterForm
projectId={project.id}
busy={busy}
onRegister={register}
open={registerOpen}
onOpenChange={setRegisterOpen}
/>
</div>
) : null}
{items.length === 0 ? (
<EmptyState
icon={Flag}
title="还没有伏笔"
description="登记第一条伏笔后,这里会按待推进、推进中、已回收、已逾期四种状态自动分栏。"
action={
<RegisterForm
projectId={project.id}
busy={busy}
onRegister={register}
/>
</div>
<div className="grid min-h-0 flex-1 grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-4">
}
className="mt-6"
/>
) : (
<>
<ForeshadowBoardSummary counts={counts} />
<div className="grid flex-none grid-cols-1 gap-3 md:grid-cols-2 xl:min-h-0 xl:flex-1 xl:grid-cols-4">
{LANES.map((status) => (
<KanbanColumn
key={status}
@@ -66,7 +111,49 @@ export function ForeshadowBoard({
/>
))}
</div>
</>
)}
</div>
</AppShell>
);
}
const SUMMARY_VARIANTS: Record<ForeshadowStatus, BadgeVariant> = {
OPEN: "accent",
PARTIAL: "info",
CLOSED: "success",
OVERDUE: "warning",
};
function ForeshadowBoardSummary({
counts,
}: {
counts: Record<ForeshadowStatus, number>;
}) {
const total = LANES.reduce((sum, lane) => sum + counts[lane], 0);
return (
<section
aria-label="伏笔状态概览"
className="mb-3 grid grid-cols-2 gap-2 sm:grid-cols-4"
>
{LANES.map((status) => (
<div
key={status}
className="rounded border border-line bg-panel px-3 py-2"
>
<div className="flex items-center justify-between gap-2">
<Badge variant={SUMMARY_VARIANTS[status]}>
{LANE_LABELS[status]}
</Badge>
<span className="font-mono text-sm text-ink">{counts[status]}</span>
</div>
<p className="mt-1 text-[11px] text-ink-soft">
<span className="font-mono">{status}</span>
{" · "}
{total > 0 ? `${Math.round((counts[status] / total) * 100)}%` : "0%"}
</p>
</div>
))}
</section>
);
}

View File

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

View File

@@ -1,7 +1,8 @@
"use client";
import type { ForeshadowView } from "@/lib/api/types";
import type { ForeshadowStatus } from "@/lib/foreshadow/board";
import { LANE_LABELS, type ForeshadowStatus } from "@/lib/foreshadow/board";
import { Badge } from "@/components/ui/Badge";
import { ForeshadowCard } from "./ForeshadowCard";
interface KanbanColumnProps {
@@ -31,17 +32,23 @@ export function KanbanColumn({
<header
className={`flex items-center justify-between rounded-t border-b px-3 py-2 ${
overdue
? "border-overdue bg-overdue/10 text-overdue"
? "border-overdue bg-overdue/10 text-ink"
: "border-line bg-panel text-ink-soft"
}`}
>
<span className="font-mono text-xs font-semibold">
{overdue ? "⚠ " : ""}
{status}
<span className="flex items-center gap-2">
{overdue ? (
<Badge variant="warning">{LANE_LABELS[status]}</Badge>
) : (
<span className="text-sm font-medium text-ink">
{LANE_LABELS[status]}
</span>
)}
<span className="font-mono text-[10px] text-ink-soft">{status}</span>
</span>
<span className="font-mono text-xs">{items.length}</span>
</header>
<ul className="flex-1 space-y-2 overflow-auto p-2">
<ul className="space-y-2 p-2 xl:flex-1 xl:overflow-auto">
{items.length === 0 ? (
<li className="px-1 py-3 text-xs text-ink-soft/60"></li>
) : (

View File

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

View File

@@ -1,5 +1,6 @@
"use client";
import { TextInput } from "@/components/ui/TextInput";
import type { CharacterCardView } from "@/lib/api/types";
interface CharacterCardItemProps {
@@ -32,11 +33,12 @@ export function CharacterCardItem({
className="size-4 accent-cinnabar"
/>
{onEdit ? (
<input
<TextInput
value={card.name}
onChange={(e) => onEdit({ name: e.target.value })}
aria-label="角色名"
className="flex-1 rounded border border-line bg-bg px-2 py-1 font-serif text-base text-ink"
controlSize="sm"
className="min-w-0 flex-1 font-serif text-base"
/>
) : (
<span className="flex-1 font-serif text-base text-ink">{card.name}</span>
@@ -61,11 +63,12 @@ export function CharacterCardItem({
<div className="mb-1 flex items-start gap-1 text-ink-soft">
<span className="shrink-0 text-ink"></span>
{onEdit ? (
<input
<TextInput
value={card.arc}
onChange={(e) => onEdit({ arc: e.target.value })}
aria-label="人物弧光"
className="flex-1 rounded border border-line bg-bg px-2 py-1 text-ink"
controlSize="sm"
className="min-w-0 flex-1"
/>
) : (
<span>{card.arc}</span>

View File

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

View File

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

View File

@@ -1,12 +1,15 @@
"use client";
import Link from "next/link";
import { PenLine } from "lucide-react";
import { Badge } from "@/components/ui/Badge";
import type { OutlineChapterView } from "@/lib/api/types";
import {
isCloseWindow,
windowBadgeLabel,
} from "@/lib/outline/outline";
import { buttonClass } from "@/lib/ui/variants";
interface OutlineChapterRowProps {
chapter: OutlineChapterView;
@@ -25,31 +28,28 @@ export function OutlineChapterRow({ chapter, projectId }: OutlineChapterRowProps
</span>
<Link
href={`/projects/${projectId}/write?chapter=${chapter.no}`}
className="rounded border border-line px-1.5 py-0.5 text-xs text-ink-soft hover:border-cinnabar hover:text-cinnabar"
className={buttonClass({ variant: "secondary", size: "sm" })}
>
<PenLine className="h-3.5 w-3.5" aria-hidden="true" />
</Link>
{windows.map((w) => {
const close = isCloseWindow(chapter, w);
return (
<span
<Badge
key={w.code}
variant={close ? "warning" : "accent"}
title={close ? "进入回收窗口,建议本章安排回收" : undefined}
className={`rounded px-1.5 py-0.5 text-xs ${
close
? "bg-overdue/15 text-overdue"
: "bg-[var(--color-cinnabar-wash)] text-cinnabar"
}`}
>
{windowBadgeLabel(w)}
{close ? " · 可回收" : ""}
</span>
</Badge>
);
})}
</div>
{chapter.beats && chapter.beats.length > 0 ? (
<p className="mt-1 text-sm text-ink">
<span className="text-ink-soft">beats: </span>
<span className="text-ink-soft"></span>
{chapter.beats.join(" / ")}
</p>
) : (

View File

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

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

View File

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

View File

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

View File

@@ -1,8 +1,18 @@
"use client";
import { useState } from "react";
import { CheckCircle2, CircleDashed, Flag, Plus } from "lucide-react";
import { useEffect, useState } from "react";
import { suggestForeshadowCode } from "@/lib/foreshadow/board";
import { api } from "@/lib/api/client";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { Field } from "@/components/ui/Field";
import { TextArea } from "@/components/ui/TextArea";
import { TextInput } from "@/components/ui/TextInput";
import {
nextFreeForeshadowCode,
suggestForeshadowCode,
} from "@/lib/foreshadow/board";
import { useForeshadow } from "@/lib/foreshadow/useForeshadow";
import type { ForeshadowSuggestion } from "@/lib/review/sse";
@@ -36,11 +46,54 @@ export function ForeshadowSuggestions({
const [registered, setRegistered] = useState<Set<number>>(new Set());
const [resolved, setResolved] = useState<Set<number>>(new Set());
const [pending, setPending] = useState<Set<number>>(new Set());
// 项目已存在的伏笔(用于隐藏「之前会话就已保存过」的建议):
// savedTitles = 已登记伏笔的标题savedClosedCodes = 已 CLOSED已回收的 code。
const [savedTitles, setSavedTitles] = useState<Set<string>>(new Set());
const [savedClosedCodes, setSavedClosedCodes] = useState<Set<string>>(
new Set(),
);
// 看板已占用的全部代号——预填登记代号时跳过它们,避免撞码 422。
const [savedCodes, setSavedCodes] = useState<Set<string>>(new Set());
useEffect(() => {
let active = true;
void (async () => {
const { data } = await api.GET("/projects/{project_id}/foreshadow", {
params: { path: { project_id: projectId } },
});
if (!active || !data?.foreshadow) return;
setSavedTitles(new Set(data.foreshadow.map((f) => f.title.trim())));
setSavedClosedCodes(
new Set(
data.foreshadow
.filter((f) => f.status === "CLOSED")
.map((f) => f.code),
),
);
setSavedCodes(
new Set(data.foreshadow.map((f) => f.code).filter((c) => c)),
);
})();
return () => {
active = false;
};
}, [projectId]);
// 该建议是否「已保存过」→ 从列表隐藏:
// - 本次会话已登记/回收registered/resolved
// - 新埋建议:标题已存在于看板(之前已登记)
// - 疑似回收建议:对应 code 已 CLOSED之前已回收
const isAlreadySaved = (s: ForeshadowSuggestion, i: number): boolean => {
if (s.kind === "resolved") {
return resolved.has(i) || (s.code ? savedClosedCodes.has(s.code) : false);
}
return registered.has(i) || savedTitles.has(s.title.trim());
};
const openRegister = (i: number, s: ForeshadowSuggestion): void => {
setOpenIdx(i);
setForm({
code: suggestForeshadowCode(s.code, i),
code: nextFreeForeshadowCode(suggestForeshadowCode(s.code, i), savedCodes),
title: s.title,
plantedAt: chapterNo,
content: s.note ?? s.where ?? "",
@@ -73,6 +126,9 @@ export function ForeshadowSuggestions({
markPending(i, false);
if (ok) {
setRegistered((prev) => new Set(prev).add(i));
// 并入已存集合:连续登记时下一条不再取到同一空闲码,且该标题立即隐藏。
setSavedCodes((prev) => new Set(prev).add(form.code));
setSavedTitles((prev) => new Set(prev).add(form.title.trim()));
closeForm();
}
};
@@ -84,36 +140,42 @@ export function ForeshadowSuggestions({
if (ok) setResolved((prev) => new Set(prev).add(i));
};
// 保留原始下标 iregistered/resolved/pending/openIdx 均按 i 索引),仅过滤已保存项。
const visible = suggestions
.map((s, i) => ({ s, i }))
.filter(({ s, i }) => !isAlreadySaved(s, i));
return (
<section className="mb-4">
<h2 className="text-xs font-semibold uppercase tracking-wide text-ink-soft">
(foreshadow-analyst)
</h2>
{incomplete ? (
<p className="mt-1 text-xs text-overdue"> </p>
) : suggestions.length === 0 ? (
<Badge variant="warning" className="mt-1">
<CircleDashed className="h-3 w-3" aria-hidden="true" />
</Badge>
) : visible.length === 0 ? (
<p className="mt-1 text-xs text-ink-soft"></p>
) : (
<ul className="mt-2 space-y-2">
{suggestions.map((s, i) => {
{visible.map(({ s, i }) => {
const code = s.code;
const isResolved = s.kind === "resolved";
const done = isResolved ? resolved.has(i) : registered.has(i);
const busy = pending.has(i);
return (
<li
key={i}
className="rounded border border-line bg-panel p-2 text-xs"
>
<span
className={`mr-1 rounded px-1.5 py-0.5 ${
isResolved
? "bg-pass/15 text-pass"
: "bg-[var(--color-cinnabar-wash)] text-cinnabar"
}`}
>
{isResolved ? "⚑ 疑似回收" : " 新埋待确认"}
</span>
<Badge variant={isResolved ? "success" : "accent"}>
{isResolved ? (
<Flag className="h-3 w-3" aria-hidden="true" />
) : (
<Plus className="h-3 w-3" aria-hidden="true" />
)}
{isResolved ? "疑似回收" : "新埋待确认"}
</Badge>
{code ? (
<span className="font-mono text-ink-soft"> {code}</span>
) : null}
@@ -123,21 +185,19 @@ export function ForeshadowSuggestions({
) : null}
{s.note ? <p className="mt-0.5 text-ink-soft">{s.note}</p> : null}
{/* 作者确认动作区 */}
{done ? (
<p className="mt-1.5 text-pass">
{isResolved ? "已标记回收" : "已登记"}
</p>
) : isResolved ? (
{/* 作者确认动作区(已保存的建议已从列表过滤,故无需「已登记」态) */}
{isResolved ? (
code ? (
<button
type="button"
<Button
disabled={busy}
onClick={() => void markResolved(i, code)}
className="mt-1.5 rounded border border-pass px-2 py-0.5 text-pass hover:bg-pass/10 disabled:cursor-not-allowed disabled:opacity-40"
className="mt-1.5"
variant="secondary"
size="sm"
>
{busy ? "处理中…" : "✓ 标记回收"}
</button>
<CheckCircle2 className="h-4 w-4" aria-hidden="true" />
{busy ? "处理中…" : "标记回收"}
</Button>
) : (
<p className="mt-1.5 text-ink-soft">
@@ -152,13 +212,15 @@ export function ForeshadowSuggestions({
onCancel={closeForm}
/>
) : (
<button
type="button"
<Button
onClick={() => openRegister(i, s)}
className="mt-1.5 rounded border border-cinnabar px-2 py-0.5 text-cinnabar hover:bg-cinnabar/10"
className="mt-1.5"
variant="outline"
size="sm"
>
</button>
<Plus className="h-4 w-4" aria-hidden="true" />
</Button>
)}
</li>
);
@@ -187,39 +249,28 @@ function RegisterFormInline({
}: RegisterFormInlineProps) {
const canSubmit =
!busy && form.code.trim().length > 0 && form.title.trim().length > 0;
const field =
"w-full rounded border border-line bg-bg px-2 py-1 text-xs text-ink focus:border-cinnabar focus:outline-none";
return (
<div className="mt-2 space-y-1.5 border-t border-line pt-2">
<div>
<label htmlFor="fs-reg-code" className="text-ink-soft">
</label>
<input
<Field label="编码" htmlFor="fs-reg-code">
<TextInput
id="fs-reg-code"
type="text"
value={form.code}
onChange={(e) => onChange({ ...form, code: e.target.value })}
className={field}
controlSize="sm"
/>
</div>
<div>
<label htmlFor="fs-reg-title" className="text-ink-soft">
</label>
<input
</Field>
<Field label="标题" htmlFor="fs-reg-title">
<TextInput
id="fs-reg-title"
type="text"
value={form.title}
onChange={(e) => onChange({ ...form, title: e.target.value })}
className={field}
controlSize="sm"
/>
</div>
<div>
<label htmlFor="fs-reg-planted" className="text-ink-soft">
</label>
<input
</Field>
<Field label="埋设章号" htmlFor="fs-reg-planted">
<TextInput
id="fs-reg-planted"
type="number"
min={1}
@@ -227,38 +278,36 @@ function RegisterFormInline({
onChange={(e) =>
onChange({ ...form, plantedAt: Number(e.target.value) || 1 })
}
className={field}
controlSize="sm"
/>
</div>
<div>
<label htmlFor="fs-reg-content" className="text-ink-soft">
线/
</label>
<textarea
</Field>
<Field label="线索/正文(可选)" htmlFor="fs-reg-content">
<TextArea
id="fs-reg-content"
value={form.content}
onChange={(e) => onChange({ ...form, content: e.target.value })}
rows={2}
className={`${field} resize-y`}
controlSize="sm"
/>
</div>
</Field>
<div className="flex gap-2 pt-0.5">
<button
type="button"
<Button
disabled={!canSubmit}
onClick={onSubmit}
className="rounded bg-cinnabar px-2 py-0.5 text-panel hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-40"
variant="primary"
size="sm"
>
<CheckCircle2 className="h-4 w-4" aria-hidden="true" />
{busy ? "登记中…" : "确认登记"}
</button>
<button
type="button"
</Button>
<Button
disabled={busy}
onClick={onCancel}
className="rounded border border-line px-2 py-0.5 text-ink-soft hover:border-cinnabar hover:text-ink disabled:opacity-40"
variant="secondary"
size="sm"
>
</button>
</Button>
</div>
</div>
);

View File

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

View File

@@ -2,8 +2,16 @@
import { useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
import {
AlertTriangle,
CheckCircle2,
RotateCcw,
Square,
} from "lucide-react";
import { AppShell } from "@/components/AppShell";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import type { ProjectResponse, ReviewHistoryItem } from "@/lib/api/types";
import { friendlyError } from "@/lib/errors/messages";
import {
@@ -44,6 +52,8 @@ import { AnnotatedText } from "./AnnotatedText";
import { ConflictCard } from "./ConflictCard";
import { ForeshadowSuggestions } from "./ForeshadowSuggestions";
import { PacePanel } from "./PacePanel";
import { ReviewSectionPanel } from "./ReviewSectionPanel";
import { ReviewSummaryRail } from "./ReviewSummaryRail";
import { StylePanel } from "@/components/style/StylePanel";
import { RefineView } from "@/components/style/RefineView";
@@ -370,6 +380,13 @@ export function ReviewReport({
const resolved = allResolved(drafts);
const unresolved = unresolvedIndices(drafts).length;
const reviewing = review.isReviewing;
const hasReport =
review.state.phase === "done" ||
review.state.sections.length > 0 ||
seededConflicts.length > 0 ||
foreshadow.length > 0 ||
pace !== null ||
style !== null;
return (
<AppShell
@@ -378,33 +395,27 @@ export function ReviewReport({
projectId={project.id}
activeNav="review"
>
<div className="grid h-[calc(100vh-var(--chrome,4rem))] grid-cols-1 lg:grid-cols-[1fr_24rem]">
<div className="flex min-h-[calc(100vh-var(--chrome,4rem))] flex-col lg:grid lg:h-[calc(100vh-var(--chrome,4rem))] lg:min-h-0 lg:grid-cols-[minmax(0,1fr)_24rem]">
{/* 左:终稿正文 + 就地标注 */}
<section className="flex min-w-0 flex-col bg-bg">
<div className="flex items-center gap-3 border-b border-line bg-panel px-6 py-3">
<h1 className="font-serif text-lg text-ink">
<section className="flex min-h-[70vh] min-w-0 flex-col bg-bg lg:min-h-0">
<div className="flex items-center gap-3 border-b border-line bg-panel px-4 py-3 sm:px-6">
<h1 className="min-w-0 flex-1 truncate font-serif text-lg text-ink">
{chapterNo} 稿
</h1>
<span className="font-mono text-xs text-ink-soft">
<span className="hidden shrink-0 font-mono text-xs text-ink-soft sm:inline">
{conflictCount}
</span>
<div className="ml-auto">
<div className="shrink-0">
{reviewing ? (
<button
type="button"
onClick={review.stop}
className="rounded border border-conflict px-3 py-1.5 text-sm text-conflict"
>
<Button onClick={review.stop} variant="danger" size="sm">
<Square className="h-4 w-4" aria-hidden="true" />
</button>
</Button>
) : (
<button
type="button"
onClick={onReReview}
className="rounded bg-cinnabar px-3 py-1.5 text-sm text-panel hover:opacity-90"
>
稿
</button>
<Button onClick={onReReview} variant="primary" size="sm">
<RotateCcw className="h-4 w-4" aria-hidden="true" />
稿
</Button>
)}
</div>
</div>
@@ -413,7 +424,7 @@ export function ReviewReport({
<ReviewErrorNote error={review.state.error} />
) : null}
<div className="flex-1 overflow-auto px-6 py-6">
<div className="flex-1 overflow-auto px-4 py-4 sm:px-6 sm:py-6">
<div className="mb-2 flex items-start justify-between gap-3">
<p className="text-xs text-ink-soft">
稿 稿
@@ -438,67 +449,93 @@ export function ReviewReport({
</section>
{/* 右:审稿分区 + 冲突裁决 + 验收 gate */}
<aside className="flex flex-col overflow-auto border-l border-line bg-panel px-4 py-4">
<section className="mb-4">
<h2 className="text-xs font-semibold uppercase tracking-wide text-ink-soft">
(continuity)
</h2>
<aside
className="flex flex-col border-t border-line bg-panel px-4 py-4 lg:overflow-auto lg:border-l lg:border-t-0"
aria-label="审稿报告与验收"
>
<ReviewSummaryRail
conflictCount={conflictCount}
unresolvedCount={unresolved}
reviewing={reviewing}
hasReport={hasReport}
onNextUnresolved={jumpToNextUnresolved}
/>
<div className="space-y-3">
<ReviewSectionPanel
title="一致性"
subtitle="continuity"
statusLabel={
reviewing
? "审稿中"
: conflictCount > 0
? `${conflictCount} 冲突`
: hasReport
? "通过"
: "待审"
}
statusVariant={
reviewing
? "info"
: conflictCount > 0
? "danger"
: hasReport
? "success"
: "neutral"
}
defaultOpen={conflictCount > 0 || !hasReport}
>
<SectionStatusLine
reviewing={reviewing}
done={review.state.phase === "done"}
conflictCount={conflictCount}
sections={review.state.sections}
/>
</section>
<section>
{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
? "未发现一致性冲突。"
: "进页未带审稿留痕,点「重新审稿」开始。"}
</p>
) : (
<div className="space-y-4">
{/* R2未裁决进度 + 跳到下一条未裁决 */}
<div className="flex items-center justify-between text-xs text-ink-soft">
<span>
{unresolved > 0 ? (
<span className="text-conflict">
{unresolved}/{conflictCount}
</span>
) : (
<span className="text-pass"> </span>
)}
</span>
<button
type="button"
onClick={jumpToNextUnresolved}
disabled={unresolved === 0}
className="rounded border border-line px-2 py-0.5 hover:border-cinnabar hover:text-cinnabar disabled:cursor-not-allowed disabled:opacity-40"
>
</button>
</div>
{/* B讲清裁决保存模型——「采纳/裁决」仅本地暂存,验收时一并落库 */}
<div className="mt-3 space-y-4">
<p className="rounded border border-dashed border-line/70 bg-bg/40 px-3 py-1.5 text-[11px] text-ink-soft">
稿
稿
</p>
{groups.map((group) => (
<div key={group.type}>
<h3 className="mb-2 flex items-center gap-2 text-xs font-semibold text-ink">
{groups.map((group) => {
const unresolvedInGroup = group.items.filter(
({ index }) => !drafts[index]?.verdict,
).length;
return (
<details
key={group.type}
open={unresolvedInGroup > 0}
className="rounded border border-line/70 bg-bg/35 p-2"
>
<summary className="flex cursor-pointer list-none items-center justify-between gap-2 text-xs font-semibold text-ink">
<span className="flex items-center gap-2">
{group.type}
<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}
</Badge>
</span>
</h3>
<ul className="space-y-3">
<span className="font-mono text-[11px] text-ink-soft">
{unresolvedInGroup > 0
? `${unresolvedInGroup} 未裁决`
: "已处理"}
</span>
</summary>
<ul className="mt-3 space-y-3">
{group.items.map(({ conflict: c, index: i }) => (
<ConflictCard
key={i}
index={i}
total={conflictCount}
conflict={c}
draft={drafts[i] ?? { verdict: null, note: "" }}
missing={missing.has(i)}
@@ -511,28 +548,88 @@ export function ReviewReport({
/>
))}
</ul>
</div>
))}
</details>
);
})}
</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
suggestions={foreshadow}
incomplete={sectionStatus("foreshadow") === "incomplete"}
projectId={project.id}
chapterNo={chapterNo}
/>
</ReviewSectionPanel>
<ReviewSectionPanel
title="节奏"
subtitle="pace-checker"
statusLabel={
sectionStatus("pace") === "incomplete"
? "未完成"
: pace
? "已完成"
: "无报告"
}
statusVariant={
sectionStatus("pace") === "incomplete"
? "warning"
: pace
? "info"
: "neutral"
}
defaultOpen={pace !== null}
>
<PacePanel
pace={pace}
incomplete={sectionStatus("pace") === "incomplete"}
/>
</ReviewSectionPanel>
<ReviewSectionPanel
title="文风"
subtitle="style"
statusLabel={
sectionStatus("style") === "incomplete"
? "未完成"
: style
? "已完成"
: "无报告"
}
statusVariant={
sectionStatus("style") === "incomplete"
? "warning"
: style
? "info"
: "neutral"
}
defaultOpen={style !== null}
>
<StylePanel
style={style}
incomplete={sectionStatus("style") === "incomplete"}
onRefine={onRefineSegment}
/>
</ReviewSectionPanel>
{refineText !== null ? (
<RefineView
projectId={project.id}
@@ -613,9 +710,15 @@ function SectionStatusLine({
return (
<p className="mt-1 text-xs">
{conflictCount > 0 ? (
<span className="text-conflict"> {conflictCount} </span>
<Badge variant="danger">
<AlertTriangle className="h-3 w-3" aria-hidden="true" />
{conflictCount}
</Badge>
) : (
<span className="text-pass"> </span>
<Badge variant="success">
<CheckCircle2 className="h-3 w-3" aria-hidden="true" />
</Badge>
)}
</p>
);

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

View File

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

View File

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

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 { Badge } from "@/components/ui/Badge";
import { Card } from "@/components/ui/Card";
import { EmptyState } from "@/components/ui/EmptyState";
import { PageHeader } from "@/components/ui/PageHeader";
import { SectionHeader } from "@/components/ui/SectionHeader";
import { StatusNote } from "@/components/ui/StatusNote";
import type { ProjectResponse, SkillView } from "@/lib/api/types";
import { groupByScope, scopeLabel, tierLabel } from "@/lib/skills/skills";
import {
groupByScope,
scopeLabel,
summarizeSkills,
tierLabel,
} from "@/lib/skills/skills";
interface SkillsPageProps {
project: ProjectResponse;
@@ -10,6 +24,7 @@ interface SkillsPageProps {
// 技能库UX §7只读注册表视图name/scope/tier/reads/writes。Server Component纯读
export function SkillsPage({ project, skills }: SkillsPageProps) {
const groups = groupByScope(skills);
const summary = summarizeSkills(skills);
return (
<AppShell
@@ -18,60 +33,161 @@ export function SkillsPage({ project, skills }: SkillsPageProps) {
projectId={project.id}
activeNav="skills"
>
<div className="mx-auto flex max-w-4xl flex-col gap-6 p-6">
<h1 className="font-serif text-lg text-ink"></h1>
<p className="text-xs text-ink-soft">
Skill /
</p>
{groups.length === 0 ? (
<p className="text-sm text-ink-soft"></p>
<div className="mx-auto flex max-w-6xl flex-col gap-6 p-6">
<PageHeader
title="技能库"
description="声明式 Skill 注册表:每个技能声明能力档位与可读/写的表,越权产出会被丢弃并审计。"
/>
<div className="grid gap-6 lg:grid-cols-[18rem_minmax(0,1fr)]">
<aside className="flex flex-col gap-4 lg:sticky lg:top-[8rem] lg:self-start">
<Card as="section" className="p-4">
<SectionHeader
title="注册表概览"
description="按来源、档位和表权限汇总当前可用技能。"
/>
<dl className="mt-4 grid grid-cols-3 gap-2 text-center">
<div className="rounded border border-line bg-paper p-3">
<dt className="text-xs text-ink-soft"></dt>
<dd className="mt-1 font-serif text-2xl text-ink">
{summary.total}
</dd>
</div>
<div className="rounded border border-line bg-paper p-3">
<dt className="text-xs text-ink-soft"></dt>
<dd className="mt-1 font-serif text-2xl text-ink">
{summary.writableCount}
</dd>
</div>
<div className="rounded border border-line bg-paper p-3">
<dt className="text-xs text-ink-soft"></dt>
<dd className="mt-1 font-serif text-2xl text-ink">
{summary.readonlyCount}
</dd>
</div>
</dl>
<div className="mt-4 flex flex-wrap gap-2">
{summary.scopes.length > 0 ? (
summary.scopes.map((item) => (
<Badge key={item.key} variant="info">
{scopeLabel(item.key)} {item.count}
</Badge>
))
) : (
groups.map((group) => (
<section key={group.scope}>
<h2 className="mb-2 font-serif text-sm text-ink">
{scopeLabel(group.scope)}
<span className="ml-2 text-xs text-ink-soft">
{group.skills.length}
</span>
</h2>
<ul className="flex flex-col gap-2">
{group.skills.map((skill) => (
<span className="text-xs text-ink-soft"></span>
)}
</div>
</Card>
<Card as="section" className="p-4">
<SectionHeader title="能力档位" />
{summary.tiers.length > 0 ? (
<ul className="mt-3 flex flex-col gap-2 text-sm">
{summary.tiers.map((item) => (
<li
key={skill.name}
className="rounded border border-line bg-panel p-3 text-sm"
key={item.key}
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="font-mono text-ink">{skill.name}</span>
<span className="rounded bg-bg px-2 py-0.5 text-xs text-ink-soft">
{tierLabel(skill.tier)}
<span className="text-ink">{tierLabel(item.key)}</span>
<span className="font-mono text-xs text-ink-soft">
{item.count}
</span>
{skill.genre ? (
<span className="rounded bg-bg px-2 py-0.5 text-xs text-ink-soft">
{skill.genre}
</span>
) : null}
</div>
<div className="flex flex-wrap gap-4 text-xs text-ink-soft">
<span>
{skill.reads && skill.reads.length > 0
? skill.reads.join("、")
: "—"}
</span>
<span>
{skill.writes && skill.writes.length > 0
? skill.writes.join("、")
: "—"}
</span>
</div>
</li>
))}
</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>
</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";
import { useRefine } from "@/lib/style/useRefine";
import { CheckCircle2, X } from "lucide-react";
import { useEffect } from "react";
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
import { Button } from "@/components/ui/Button";
import { Badge } from "@/components/ui/Badge";
import { useRefine } from "@/lib/style/useRefine";
interface RefineViewProps {
projectId: string;
@@ -46,15 +49,17 @@ export function RefineView({
<button
type="button"
onClick={onClose}
className="text-ink-soft hover:text-cinnabar"
className="rounded border border-transparent p-1 text-ink-soft hover:border-line hover:text-cinnabar"
aria-label="关闭回炉对比"
>
<X className="h-4 w-4" aria-hidden="true" />
</button>
</div>
{segment.trim().length === 0 ? (
<p className="text-overdue">稿稿</p>
<Badge variant="warning">
稿稿
</Badge>
) : refiner.status === "refining" ? (
<p className="text-info">
<ThinkingIndicator label="回炉中" />
@@ -76,24 +81,21 @@ export function RefineView({
</p>
</div>
<div className="flex gap-2">
<button
type="button"
<Button
onClick={() => {
if (refiner.result) {
onAdopt(refiner.result.original, refiner.result.refined);
}
}}
className="rounded bg-cinnabar px-3 py-1.5 text-panel hover:opacity-90"
variant="primary"
size="sm"
>
<CheckCircle2 className="h-4 w-4" aria-hidden="true" />
</button>
<button
type="button"
onClick={onClose}
className="rounded border border-line px-3 py-1.5 text-ink hover:border-cinnabar"
>
</Button>
<Button onClick={onClose} variant="secondary" size="sm">
</button>
</Button>
</div>
</div>
) : null}

View File

@@ -1,5 +1,9 @@
"use client";
import { CheckCircle2, CircleDashed, RotateCcw } from "lucide-react";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import type { StyleDriftReport, StyleDriftSegment } from "@/lib/review/sse";
interface StylePanelProps {
@@ -26,7 +30,10 @@ export function StylePanel({ style, incomplete, onRefine }: StylePanelProps) {
(style-auditor)
</h2>
{incomplete ? (
<p className="mt-1 text-xs text-overdue"> </p>
<Badge variant="warning" className="mt-1">
<CircleDashed className="h-3 w-3" aria-hidden="true" />
</Badge>
) : style === null ? (
<p className="mt-1 text-xs text-ink-soft">
@@ -47,7 +54,10 @@ export function StylePanel({ style, incomplete, onRefine }: StylePanelProps) {
</p>
{style.segments.length === 0 ? (
<p className="text-pass"> </p>
<Badge variant="success">
<CheckCircle2 className="h-3 w-3" aria-hidden="true" />
</Badge>
) : (
<ul className="space-y-2">
{style.segments.map((seg, i) => (
@@ -57,9 +67,9 @@ export function StylePanel({ style, incomplete, onRefine }: StylePanelProps) {
data-testid="drift-segment"
>
<div className="flex items-center gap-2">
<span className="font-mono text-overdue">
<Badge variant="warning" className="font-mono">
{seg.idx}
</span>
</Badge>
<span className="font-mono text-ink-soft">
{seg.score}%
</span>
@@ -68,13 +78,14 @@ export function StylePanel({ style, incomplete, onRefine }: StylePanelProps) {
) : null}
</div>
<div className="mt-1.5 flex gap-2">
<button
type="button"
<Button
onClick={() => onRefine(seg)}
className="rounded bg-cinnabar px-2 py-1 text-panel hover:opacity-90"
variant="primary"
size="sm"
>
<RotateCcw className="h-4 w-4" aria-hidden="true" />
</button>
</Button>
</div>
</li>
))}

View File

@@ -4,6 +4,10 @@ import { useCallback, useState, type ChangeEvent } from "react";
import { hasUsableSamples, type StyleLearnMode } from "@/lib/style/style";
import { useToast } from "@/components/Toast";
import { Button } from "@/components/ui/Button";
import { Field } from "@/components/ui/Field";
import { StatusNote } from "@/components/ui/StatusNote";
import { TextArea } from "@/components/ui/TextArea";
interface StyleUploadProps {
busy: boolean;
@@ -61,21 +65,15 @@ export function StyleUpload({
return (
<div className="space-y-3">
<div>
<label
htmlFor="style-samples"
className="mb-1 block text-sm font-semibold text-ink"
>
</label>
<textarea
<Field label="样本正文(空行分段;可粘贴多段)" htmlFor="style-samples">
<TextArea
id="style-samples"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="粘贴你想学习文风的章节正文…"
className="min-h-[30vh] w-full resize-y rounded border border-line bg-panel p-3 font-serif text-[15px] leading-[1.9] text-ink focus:border-cinnabar focus:outline-none"
className="min-h-[30vh] bg-panel font-serif text-[15px] leading-[1.9]"
/>
</div>
</Field>
<div className="flex items-center gap-3">
<label className="cursor-pointer rounded border border-line px-3 py-1.5 text-sm text-ink hover:border-cinnabar hover:text-cinnabar">
@@ -88,22 +86,17 @@ export function StyleUpload({
className="sr-only"
/>
</label>
<button
type="button"
onClick={submit}
disabled={busy}
className="rounded bg-cinnabar px-4 py-1.5 text-sm text-panel hover:opacity-90 disabled:opacity-50"
>
<Button type="button" onClick={submit} disabled={busy} variant="primary">
{hasFingerprint ? "重新学习文风" : "学习文风"}
</button>
</Button>
</div>
{pollStatus === "polling" ? (
<p className="text-xs text-info" aria-live="polite">
<StatusNote variant="info" aria-live="polite">
{progress}%
</p>
</StatusNote>
) : pollStatus === "error" ? (
<p className="text-xs text-conflict"></p>
<StatusNote variant="danger"></StatusNote>
) : null}
</div>
);

View File

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

View File

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

View File

@@ -1,6 +1,10 @@
"use client";
import { ArrowRight, Database, Sparkles } from "lucide-react";
import { Badge } from "@/components/ui/Badge";
import type { ToolDescriptorView } from "@/lib/api/types";
import { cardClass } from "@/lib/ui/variants";
interface ToolCardProps {
tool: ToolDescriptorView;
@@ -15,27 +19,32 @@ export function ToolCard({ tool, onOpen }: ToolCardProps) {
<button
type="button"
onClick={() => onOpen(tool)}
className="flex h-full min-h-[140px] flex-col rounded border border-line bg-panel p-5 text-left shadow-paper transition-colors hover:border-cinnabar focus-visible:border-cinnabar focus-visible:outline-none"
className={cardClass(
"group flex h-full min-h-[164px] flex-col p-5 text-left transition-colors hover:border-cinnabar focus-visible:border-cinnabar focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cinnabar/30",
)}
>
<div className="mb-2 flex flex-wrap items-center gap-2">
<h2 className="font-serif text-xl text-ink">{tool.title}</h2>
{tool.genre ? (
<span className="rounded bg-bg px-2 py-0.5 text-xs text-ink-soft">
{tool.genre}
</span>
) : null}
{tool.is_legacy ? null : (
<span className="rounded bg-[var(--color-cinnabar-wash)] px-2 py-0.5 text-xs text-cinnabar">
NEW
</span>
)}
{tool.genre ? <Badge>{tool.genre}</Badge> : null}
{tool.is_legacy ? null : <Badge variant="accent"></Badge>}
{tool.ingestable ? (
<span className="rounded bg-bg px-2 py-0.5 text-xs text-ink-soft">
<Badge variant="info">
<Database className="h-3 w-3" aria-hidden="true" />
</span>
</Badge>
) : null}
</div>
<p className="line-clamp-2 text-sm text-ink-soft">{tool.subtitle}</p>
<div className="mt-auto flex items-center gap-2 pt-4 text-xs text-ink-soft">
<Sparkles className="h-3.5 w-3.5 text-cinnabar" aria-hidden="true" />
<span className="truncate">
{tool.input_fields?.[0]?.label ?? "现有页面"}
</span>
<ArrowRight
className="ml-auto h-3.5 w-3.5 text-cinnabar opacity-0 transition-opacity group-hover:opacity-100"
aria-hidden="true"
/>
</div>
</button>
);
}

View File

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

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

View File

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

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 {
buildRegisterRequest,
buildTransitionRequest,
countByStatus,
extractReason,
groupByStatus,
isWindowApproaching,
LANE_LABELS,
nextFreeForeshadowCode,
suggestForeshadowCode,
validationReasonMessage,
} from "./board";
@@ -24,6 +27,24 @@ describe("suggestForeshadowCode", () => {
});
});
describe("nextFreeForeshadowCode", () => {
it("keeps the preferred code when it is free", () => {
expect(nextFreeForeshadowCode("FS-04", new Set(["FS-01", "FS-02"]))).toBe(
"FS-04",
);
});
it("skips taken codes and returns the first free FS-NN", () => {
const taken = new Set(["FS-01", "FS-02", "FS-03", "FS-05", "FS-OLD"]);
// preferred FS-01 已占 → 取首个空闲 FS-04
expect(nextFreeForeshadowCode("FS-01", taken)).toBe("FS-04");
});
it("returns FS-01 when nothing is taken", () => {
expect(nextFreeForeshadowCode("FS-01", new Set())).toBe("FS-01");
});
});
const view = (over: Partial<ForeshadowView>): ForeshadowView => ({
code: "F-001",
title: "线索",
@@ -53,6 +74,32 @@ describe("groupByStatus", () => {
});
});
describe("countByStatus", () => {
it("counts items in the same lane model used by the board", () => {
expect(
countByStatus([
view({ status: "OPEN" }),
view({ status: "OPEN" }),
view({ status: "PARTIAL" }),
view({ status: "CLOSED" }),
view({ status: "OVERDUE" }),
view({ status: "WEIRD" }),
]),
).toEqual({ OPEN: 3, PARTIAL: 1, CLOSED: 1, OVERDUE: 1 });
});
});
describe("LANE_LABELS", () => {
it("keeps author-facing labels distinct from status codes", () => {
expect(LANE_LABELS).toEqual({
OPEN: "待推进",
PARTIAL: "推进中",
CLOSED: "已回收",
OVERDUE: "已逾期",
});
});
});
describe("isWindowApproaching", () => {
it("is true when current chapter is inside [from,to] for OPEN/PARTIAL", () => {
const f = view({

View File

@@ -18,13 +18,14 @@ export const LANES: readonly ForeshadowStatus[] = [
] as const;
export const LANE_LABELS: Record<ForeshadowStatus, string> = {
OPEN: "OPEN",
PARTIAL: "PARTIAL",
CLOSED: "CLOSED",
OVERDUE: "OVERDUE",
OPEN: "待推进",
PARTIAL: "推进中",
CLOSED: "已回收",
OVERDUE: "已逾期",
};
export type ForeshadowLanes = Record<ForeshadowStatus, ForeshadowView[]>;
export type ForeshadowLaneCounts = Record<ForeshadowStatus, number>;
function emptyLanes(): ForeshadowLanes {
return { OPEN: [], PARTIAL: [], CLOSED: [], OVERDUE: [] };
@@ -46,6 +47,18 @@ export function groupByStatus(
return lanes;
}
export function countByStatus(
items: readonly ForeshadowView[] | undefined,
): ForeshadowLaneCounts {
const lanes = groupByStatus(items);
return {
OPEN: lanes.OPEN.length,
PARTIAL: lanes.PARTIAL.length,
CLOSED: lanes.CLOSED.length,
OVERDUE: lanes.OVERDUE.length,
};
}
// 接近回收窗口判定current 在 [from, to] 内,或已超 from 但尚未 CLOSED提示安排回收
// 仅对 OPEN/PARTIAL 提示CLOSED 已回收、OVERDUE 已逾期另有强调。
export function isWindowApproaching(
@@ -70,6 +83,21 @@ export function suggestForeshadowCode(
return `FS-${String(index + 1).padStart(2, "0")}`;
}
// 在已占用代号taken区分大小写按原样为预填代号挑一个不冲突的值
// preferred 未被占用就用它;否则从 FS-01 起取首个空闲的 FS-<两位序号>。
// 纯函数(可单测)。避免登记时与库里已存代号撞码 → 422 duplicate。
export function nextFreeForeshadowCode(
preferred: string,
taken: ReadonlySet<string>,
): string {
if (preferred && !taken.has(preferred)) return preferred;
for (let n = 1; n < 1000; n++) {
const candidate = `FS-${String(n).padStart(2, "0")}`;
if (!taken.has(candidate)) return candidate;
}
return preferred; // 理论不可达1000 个代号全占用时退回原值
}
export interface RegisterInput {
projectId: string;
code: string;

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 { aiToolItems } from "./ai-tools";
import {
aiToolItems,
primaryAiToolItems,
secondaryAiToolItems,
} from "./ai-tools";
describe("aiToolItems", () => {
it("returns 5 items", () => {
@@ -25,4 +29,16 @@ describe("aiToolItems", () => {
expect(review?.href).toBe("/projects/p1/review");
expect(review?.key).toBe("review");
});
it("keeps high-frequency tools outside the mobile more menu", () => {
expect(primaryAiToolItems("p1").map((item) => item.key)).toEqual([
"write",
"review",
]);
expect(secondaryAiToolItems("p1").map((item) => item.key)).toEqual([
"outline",
"codex",
"toolbox",
]);
});
});

View File

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

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 type { SkillView } from "@/lib/api/types";
import { groupByScope, scopeLabel, tierLabel } from "./skills";
import { groupByScope, scopeLabel, summarizeSkills, tierLabel } from "./skills";
const skill = (over: Partial<SkillView>): SkillView => ({
name: "x",
@@ -36,3 +36,61 @@ describe("groupByScope", () => {
expect(groupByScope(undefined)).toEqual([]);
});
});
describe("summarizeSkills", () => {
it("counts scopes, tiers, readonly skills and unique tables", () => {
const summary = summarizeSkills([
skill({
name: "a",
scope: "builtin",
tier: "analyst",
reads: ["projects", "outline"],
writes: [],
}),
skill({
name: "b",
scope: "custom",
tier: "writer",
reads: ["projects"],
writes: ["chapters"],
}),
skill({
name: "c",
scope: "builtin",
tier: "analyst",
reads: ["world_entities"],
writes: ["outline", "chapters"],
}),
]);
expect(summary.total).toBe(3);
expect(summary.readonlyCount).toBe(1);
expect(summary.writableCount).toBe(2);
expect(summary.scopes).toEqual([
{ key: "builtin", count: 2 },
{ key: "custom", count: 1 },
]);
expect(summary.tiers).toEqual([
{ key: "analyst", count: 2 },
{ key: "writer", count: 1 },
]);
expect(summary.readableTables).toEqual([
"outline",
"projects",
"world_entities",
]);
expect(summary.writableTables).toEqual(["chapters", "outline"]);
});
it("handles undefined", () => {
expect(summarizeSkills(undefined)).toEqual({
total: 0,
readonlyCount: 0,
writableCount: 0,
scopes: [],
tiers: [],
readableTables: [],
writableTables: [],
});
});
});

View File

@@ -27,6 +27,21 @@ export function tierLabel(tier: string): string {
export type SkillGroups = { scope: string; skills: SkillView[] }[];
export interface SkillCount {
key: string;
count: number;
}
export interface SkillsSummary {
total: number;
readonlyCount: number;
writableCount: number;
scopes: SkillCount[];
tiers: SkillCount[];
readableTables: string[];
writableTables: string[];
}
// 按 scope 分组(保持服务端 name 升序scope 顺序按首次出现。
export function groupByScope(
skills: readonly SkillView[] | undefined,
@@ -42,3 +57,41 @@ export function groupByScope(
}
return order.map((scope) => ({ scope, skills: map.get(scope) ?? [] }));
}
export function summarizeSkills(
skills: readonly SkillView[] | undefined,
): SkillsSummary {
const scopeCounts = new Map<string, number>();
const tierCounts = new Map<string, number>();
const readableTables = new Set<string>();
const writableTables = new Set<string>();
let writableCount = 0;
for (const skill of skills ?? []) {
scopeCounts.set(skill.scope, (scopeCounts.get(skill.scope) ?? 0) + 1);
tierCounts.set(skill.tier, (tierCounts.get(skill.tier) ?? 0) + 1);
for (const table of skill.reads ?? []) {
readableTables.add(table);
}
if (skill.writes && skill.writes.length > 0) {
writableCount += 1;
for (const table of skill.writes) {
writableTables.add(table);
}
}
}
return {
total: skills?.length ?? 0,
readonlyCount: (skills?.length ?? 0) - writableCount,
writableCount,
scopes: countsFromMap(scopeCounts),
tiers: countsFromMap(tierCounts),
readableTables: [...readableTables].sort(),
writableTables: [...writableTables].sort(),
};
}
function countsFromMap(map: Map<string, number>): SkillCount[] {
return [...map.entries()].map(([key, count]) => ({ key, count }));
}

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 传入的
// projectId」去拉指纹而非闭包捕获的旧值。useStyleLearn 用 ref 追踪 projectId 来保证这点。
@@ -39,3 +45,239 @@ describe("useStyleLearn projectId 追踪P1-6 stale-closure 守卫)", () =>
expect(t.onDone()).toBeNull();
});
});
// ── 真实 hook 覆盖renderHook 驱动编排POST 受理 → 轮询 → done 拉指纹)─────────
const post = vi.fn();
const get = vi.fn();
const toast = vi.fn();
vi.mock("@/lib/api/client", () => ({
api: {
POST: (...a: unknown[]) => post(...a),
GET: (...a: unknown[]) => get(...a),
},
}));
vi.mock("@/components/Toast", () => ({ useToast: () => toast }));
const STYLE_PATH = "/projects/{project_id}/style";
const JOBS_PATH = "/jobs/{job_id}";
interface GetOpts {
params: { path: { project_id?: string; job_id?: string } };
}
interface PostOpts {
params: { path: { project_id: string } };
}
// 微任务 + 0ms 定时器全部冲洗(轮询 tick→dispatch→effect→refetch 链需多跳)。
async function flush(): Promise<void> {
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
}
const FINGERPRINT_BODY = {
dimensions: [{ name: "节奏", value: "快", evidence: ["短句"] }],
version: 2,
};
describe("useStyleLearn 真实 hook 编排", () => {
beforeEach(() => {
vi.useFakeTimers();
post.mockReset();
get.mockReset();
toast.mockReset();
});
afterEach(() => {
vi.clearAllMocks();
vi.useRealTimers();
});
it("初始:未提交则 busy=false、pollStatus=idle、指纹为传入初值", () => {
const initial = { dimensions: [], version: 1 };
const { result } = renderHook(() => useStyleLearn(initial));
expect(result.current.busy).toBe(false);
expect(result.current.pollStatus).toBe("idle");
expect(result.current.progress).toBe(0);
expect(result.current.fingerprint).toBe(initial);
});
it("受理失败(通用错误):返回 false、弹通用 toast、busy 归位", async () => {
post.mockResolvedValue({ data: null, error: { error: { code: "INTERNAL" } } });
const { result } = renderHook(() => useStyleLearn(null));
let ok: boolean | undefined;
await act(async () => {
ok = await result.current.learn("p1", ["样本"], "create");
});
expect(ok).toBe(false);
expect(toast).toHaveBeenCalledWith("学文风受理失败,请稍后重试。", "error");
expect(result.current.busy).toBe(false);
expect(get).not.toHaveBeenCalled();
});
it("受理失败LLM_UNAVAILABLE提示去设置页连一家", async () => {
post.mockResolvedValue({
data: null,
error: { error: { code: "LLM_UNAVAILABLE" } },
});
const { result } = renderHook(() => useStyleLearn(null));
let ok: boolean | undefined;
await act(async () => {
ok = await result.current.learn("p1", ["样本"], "create");
});
expect(ok).toBe(false);
expect(toast).toHaveBeenCalledWith(
"未配置提供商,请先去设置页连一家。",
"error",
);
});
it("受理成功 → 轮询 done → 拉最新指纹并弹成功 toast", async () => {
post.mockResolvedValue({ data: { job_id: "job-1" }, error: null });
get.mockImplementation((path: string, opts: GetOpts) => {
if (path === JOBS_PATH) {
return Promise.resolve({
data: { id: "job-1", status: "done", progress: 100 },
error: null,
});
}
return Promise.resolve({ data: FINGERPRINT_BODY, error: null });
});
const { result } = renderHook(() => useStyleLearn(null));
let ok: boolean | undefined;
await act(async () => {
ok = await result.current.learn("p1", ["样本"], "create");
});
await flush();
expect(ok).toBe(true);
expect(result.current.pollStatus).toBe("done");
expect(result.current.fingerprint).toEqual({
dimensions: [{ name: "节奏", value: "快", evidence: ["短句"] }],
version: 2,
});
expect(toast).toHaveBeenCalledWith("文风指纹已更新。", "success");
});
it("done 但拉指纹失败GET /style error指纹不变、仍弹成功 toast", async () => {
post.mockResolvedValue({ data: { job_id: "job-1" }, error: null });
get.mockImplementation((path: string) => {
if (path === JOBS_PATH) {
return Promise.resolve({
data: { id: "job-1", status: "done", progress: 100 },
error: null,
});
}
return Promise.resolve({ data: null, error: { detail: "404" } });
});
const initial = { dimensions: [], version: 9 };
const { result } = renderHook(() => useStyleLearn(initial));
await act(async () => {
await result.current.learn("p1", ["样本"], "create");
});
await flush();
expect(result.current.fingerprint).toBe(initial);
expect(toast).toHaveBeenCalledWith("文风指纹已更新。", "success");
});
it("轮询失败job failed弹学文风失败 toast 带原因", async () => {
post.mockResolvedValue({ data: { job_id: "job-1" }, error: null });
get.mockImplementation((path: string) => {
if (path === JOBS_PATH) {
return Promise.resolve({
data: { id: "job-1", status: "failed", progress: 40, error: "模型超时" },
error: null,
});
}
return Promise.resolve({ data: FINGERPRINT_BODY, error: null });
});
const { result } = renderHook(() => useStyleLearn(null));
await act(async () => {
await result.current.learn("p1", ["样本"], "create");
});
await flush();
expect(result.current.pollStatus).toBe("error");
expect(toast).toHaveBeenCalledWith("学文风失败:模型超时", "error");
});
it("切项目done 边沿用最近一次 learn 的 projectId 拉指纹stale-closure 守卫)", async () => {
const styleProjectIds: string[] = [];
post.mockImplementation((_path: string, opts: PostOpts) => {
const pid = opts.params.path.project_id;
return Promise.resolve({
data: { job_id: pid === "project-A" ? "job-A" : "job-B" },
error: null,
});
});
get.mockImplementation((path: string, opts: GetOpts) => {
if (path === JOBS_PATH) {
const jobId = opts.params.path.job_id;
// A 仍在跑(排队 setTimeout 下一拍B 直接 done。
const status = jobId === "job-A" ? "running" : "done";
return Promise.resolve({
data: { id: jobId, status, progress: status === "done" ? 100 : 30 },
error: null,
});
}
styleProjectIds.push(opts.params.path.project_id ?? "?");
return Promise.resolve({ data: FINGERPRINT_BODY, error: null });
});
const { result } = renderHook(() => useStyleLearn(null));
await act(async () => {
await result.current.learn("project-A", ["a"], "create");
});
await act(async () => {
await result.current.learn("project-B", ["b"], "update");
});
await flush();
// done 边沿拉指纹用的应是后切的 project-B而非陈旧的 project-A。
expect(styleProjectIds).toContain("project-B");
expect(styleProjectIds).not.toContain("project-A");
expect(toast).toHaveBeenCalledWith("文风指纹已更新。", "success");
});
});
// learnSummary 是导出的纯函数done 且有 job 时回显版本/维度数,否则 null。
describe("learnSummary", () => {
const baseJob: JobView = {
id: "j",
kind: "style_learn",
status: "done",
progress: 100,
result: { version: 3, dims_count: 16 },
error: null,
};
const pollOf = (over: Partial<UseJobPoll>): UseJobPoll =>
({
status: "done",
progress: 100,
job: baseJob,
error: null,
poll: vi.fn(),
reset: vi.fn(),
...over,
}) as UseJobPoll;
it("done 且有 job回显 version 与 dimsCount", () => {
expect(learnSummary(pollOf({}))).toEqual({ version: 3, dimsCount: 16 });
});
it("非 done返回 null", () => {
expect(learnSummary(pollOf({ status: "polling" }))).toBeNull();
});
it("done 但无 job返回 null", () => {
expect(learnSummary(pollOf({ job: null }))).toBeNull();
});
});

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

View File

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

900
apps/web/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

@@ -10,5 +10,21 @@ export default defineConfig({
test: {
environment: "node",
include: ["lib/**/*.test.ts"],
coverage: {
provider: "v8",
// 覆盖范围 = lib/** 全部逻辑层(纯逻辑 + React hooks
// hooks(use*.ts) 用 jsdom + @testing-library/react 的 renderHook 单测(见各 *.test.ts 的
// `// @vitest-environment jsdom` 头)。组件(.tsx)仍由 E2E/人工验收覆盖,不在 vitest 范围。
// 排除项测试文件本身、OpenAPI codegen 生成物。
include: ["lib/**/*.ts"],
exclude: ["lib/**/*.test.ts", "lib/api/schema.d.ts"],
reporter: ["text", "text-summary"],
thresholds: {
lines: 80,
functions: 80,
branches: 80,
statements: 80,
},
},
},
});

Some files were not shown because too many files have changed in this diff Show More