[ { "area": "projects-list+create", "severity": "HIGH", "component": "apps/web/components/ProjectWizard.tsx (StepProtagonist + StepPremise)", "symptom": "Step 4 (主角/金手指) and Step 3 (立意/premise) edit the SAME form field form.premise. Whatever the user types in step 4 overwrites their step-3 立意 input (and vice-versa if they go back). The '主角/金手指概要' and '立意' cannot coexist; only the last-edited survives into POST /projects.", "evidence": "StepPremise: value={form.premise} onChange={(e)=>update({premise:e.target.value})}. StepProtagonist: value={form.premise} onChange={(e)=>update({premise:e.target.value})} — identical binding. WizardForm has no separate protagonist field.", "repro": "Open /projects/new → fill title (step1) → next to step3, type 立意 text → next to step4, type 主角设定 text → submit. Resulting project.premise contains only the step-4 text; the step-3 立意 is lost. (Confirmed by code; needs-browser to screenshot.)" }, { "area": "projects-list+create", "severity": "LOW", "component": "apps/web/components/ProjectWizard.tsx submit (error toast)", "symptom": "On create failure the user always sees the same generic toast '创建作品失败,请重试' regardless of cause (422 validation vs 503 vs 5xx). Validation detail from backend ({detail:[...]}) is discarded.", "evidence": "if (error || !data) { toast('创建作品失败,请重试','error') } — no inspection of error.detail/error.error.message.", "repro": "Force a 422 (only reachable if title bypasses client guard) → toast shows generic message; backend's specific reason not shown." }, { "area": "projects-list+create", "severity": "LOW", "component": "backend POST /projects validation", "symptom": "Whitespace-only title accepted (HTTP 201). min_length=1 does not strip whitespace, so ' ' creates a blank-looking project.", "evidence": "curl POST {\"title\":\" \"} → HTTP 201, title:' '. Frontend trims so not reachable via wizard, but API contract is permissive.", "repro": "curl -X POST /projects -d '{\"title\":\" \"}' → 201." }, { "area": "outline-editor", "severity": "MEDIUM", "component": "apps/web/components/outline/OutlineEditor.tsx (volume input) + lib/api/server.ts fetchOutline", "symptom": "Volume selector is misleading: it only parameterizes POST generate, but GET /outline ignores the volume filter and the editor always renders ALL volumes. After generating into volume N, the page still shows every volume; user has no way to view a single volume despite the picker.", "evidence": "curl 'GET /projects/71b3.../outline?volume=2' returns count: 30 (filter ignored). OutlineEditor.tsx:24 groupByVolume(chapters) renders every group; volume state (line 23) is used only at generate() line 50.", "repro": "Open outline page; change 卷号 to 2; observe all volumes still listed; GET endpoint takes no volume query." }, { "area": "outline-editor", "severity": "MEDIUM", "component": "apps/web/lib/outline/useOutline.ts:44-49", "symptom": "422 validation errors from POST /outline are not surfaced with their detail — the hook reads env.error.code but FastAPI 422 uses {detail:[...]} (no .error), so it always falls back to generic 'OUTLINE_FAILED' / '大纲生成失败,请重试。'", "evidence": "POST {volume:0} -> 422 {detail:[{type:greater_than_equal,...}]}; hook code: const env = apiError as {error?:{code?,message?}}; code = env?.error?.code ?? 'OUTLINE_FAILED'. openapi: 422 schema is HTTPValidationError (detail[]), business errors use ErrorEnvelope (error{code}).", "repro": "Trigger POST /outline with volume=0 (UI clamps to >=1 so only reachable via direct API or a non-clamped path); error banner shows generic message, loses validation cause." }, { "area": "outline-editor", "severity": "LOW", "component": "apps/web/lib/outline/useOutline.ts:55-57 + OutlineEditor empty state", "symptom": "Generation that yields zero chapters still reports success: status=ready, toast '大纲已生成', but the page renders the '暂无大纲' empty placeholder. Misleading success for an effectively no-op generate (e.g. project lacking premise/structure).", "evidence": "POST {volume:1} on a content-less throwaway project returned HTTP 200 {chapters:[]}; nothing persisted (subsequent GET count: 0). Hook setChapters([]) + success toast; volumes.length===0 -> placeholder shown.", "repro": "Create project with no premise/theme/structure, POST /outline; observe 200 + empty chapters; UI would toast success yet show empty state." }, { "area": "outline-editor", "severity": "LOW", "component": "outline area (task scope vs implementation)", "symptom": "QA scope expects PUT edits (add/remove/reorder chapter, beats, foreshadow_windows) and optimistic-save/rollback, but none exist — outline is generate-and-display only, fully read-only after generation.", "evidence": "openapi /projects/{id}/outline exposes only get+post; schema.d.ts line 295 put?: never. OutlineChapterRow.tsx renders static beats/badges + a '写此章' Link; no inputs, no save handler.", "repro": "Inspect components — no editable fields, no PUT call anywhere; openapi has no PUT/PATCH/DELETE for outline." }, { "area": "rules", "severity": "HIGH", "component": "apps/api/ww_api/routers/rules.py + packages/core/ww_core/domain/rule_repo.py", "symptom": "POST a rule to a non-existent project_id returns HTTP 500 (generic INTERNAL) instead of 404.", "evidence": "curl -X POST http://localhost:8000/projects/00000000-0000-0000-0000-000000000000/rules -d '{\"level\":\"project\",\"content\":\"x\"}' -> 500 {\"error\":{\"code\":\"INTERNAL\",\"message\":\"服务器内部错误\",...}}. SqlRuleWriteRepo.create (rule_repo.py:44-49) inserts+flushes with no existence check; the FK violation escapes as 500.", "repro": "POST /projects/{random-uuid}/rules with a valid body -> observe 500. Expected 404 (project not found)." }, { "area": "rules", "severity": "MEDIUM", "component": "apps/api/ww_api/schemas/rules.py + routers/rules.py", "symptom": "Whitespace-only rule content is accepted and persisted (201). Server min_length:1 validates the raw string before trimming; no strip on the server.", "evidence": "curl -X POST .../rules -d '{\"level\":\"project\",\"content\":\" \"}' -> 201; subsequent GET returns {\"level\":\"project\",\"content\":\" \"}. schemas/rules.py:21 content: str = Field(min_length=1) with no strip/validator.", "repro": "POST a rule whose content is only spaces -> 201, junk row stored. (Frontend trims client-side so its own UI won't send this, but the API contract is permissive and the row is unremovable — see DELETE gap.)" }, { "area": "rules", "severity": "MEDIUM", "component": "apps/api/ww_api/routers/generation.py (list_rules)", "symptom": "GET rules for a non-existent project returns 200 {\"rules\":[]} instead of 404, masking bad project ids.", "evidence": "curl http://localhost:8000/projects/00000000-0000-0000-0000-000000000000/rules -> 200 {\"rules\":[]}. generation.py:377-384 queries rules by project_id with no project-existence check. Note the rules ROUTE page (app/.../rules/page.tsx) calls fetchProject first and would 404 in the UI, but the API itself does not.", "repro": "GET /projects/{random-uuid}/rules -> 200 empty list." }, { "area": "rules", "severity": "MEDIUM", "component": "rules API surface (no router)", "symptom": "No DELETE (or edit) endpoint for rules — CRUD is create+read only. A mistakenly-added rule (e.g. the whitespace row above) cannot be removed via API or UI.", "evidence": "grep for delete in routers/rules.py + rule_repo.py = none; openapi.json exposes only POST+GET on /projects/{project_id}/rules. RulesPage.tsx renders rules as plain
  • with no delete control.", "repro": "Add any rule, then attempt to remove it: no endpoint/UI exists." }, { "area": "rules", "severity": "LOW", "component": "apps/web/components/rules/RulesPage.tsx", "symptom": "Rule-content textarea has no accessible label (only a placeholder), failing the label/role a11y bar; screen readers announce no field name.", "evidence": "RulesPage.tsx:62-68