Compare commits

...

20 Commits

Author SHA1 Message Date
Yaojia Wang
64732b0d1b Merge branch 'feat/review-chapter-select-and-foreshadow' into develop
审稿选章(列章端点 + 全章下拉 + 默认最近章)
+ 伏笔主动提醒(写作台本章伏笔)与 AI 写作时自动应用(写章上下文注入到期/逾期伏笔 + 提示词指令)
+ 宽屏留白优化(作品库 max-w-7xl 四列 / 正文 768px)
+ 主题切换水合报错修复
+ AI 工作中动效强化(构思占位 / 流式进度条 / 波动三点 / 转圈)
门禁:前端 vitest 749 · lint/typecheck/build ✓;后端 pytest 1032 · ruff/mypy ✓。
2026-07-12 19:47:12 +02:00
Yaojia Wang
1afeb2cdb5 feat(ui): 强化 AI 工作中动效——构思占位/流式进度条/波动三点/转圈
- 动效基元:ai-dot 波动(三点竖跳)、ai-stream 不确定进度条、ai-breathe 呼吸微光(均带 reduced-motion 回退)
- ThinkingIndicator 升级为明显波动;新增 Spinner / StreamingBar / GenerationSkeleton 原子件;Button 加 loading 态
- 写作台:首 token 前正文区『AI 正在构思本章…』占位(补最大缺口,不再空白像卡死)+ 流式顶部进度条
- 整章重写流式进度条;续写/润色忙碌骨架 + 触发键转圈;生成器/角色/世界观改用 Button loading
2026-07-12 19:32:18 +02:00
Yaojia Wang
9bc0e1f2e6 fix(ui): 修复主题切换按钮的水合不一致(夜读模式刷新报错)
- 首帧 mode 用默认值(与服务端一致)→ 消除 SSR/客户端 aria-label/图标不一致
- 图标改由 CSS 按 <html data-theme> 显隐(两个图标始终在 DOM)→ 无首帧闪烁、无结构差异
- 挂载后 useEffect 以 localStorage 校正 mode → label 恢复正确文案
- 去掉会锁死服务端陈旧 label 的 suppressHydrationWarning
2026-07-12 19:02:27 +02:00
Yaojia Wang
40cc3fc9e3 style(ui): 收窄宽屏留白——作品库加宽到 max-w-7xl + xl 四列,写作正文 720→768px
- PageContainer wide 档 max-w-6xl→max-w-7xl;作品库改用 wide 档
- 作品库卡片网格 lg 三列基础上加 xl:grid-cols-4,宽屏铺满少留白
- 写作台正文测量 720→768px(约 42 中文字/行,仍舒适),收窄两侧空档
2026-07-12 18:39:01 +02:00
Yaojia Wang
b46afd4f8c feat(foreshadow): 写章上下文注入到期/逾期伏笔 + AI 顺势回收指令
- assemble 富渲染伏笔行:补『期望回收 X-Y章』+『(已逾期!)』标记(仍在 volatile 缓存段,守不变量 #9)
- 新增 foreshadow_inject 纯函数:除大纲显式窗口外,按当前章自动纳入
  『回收窗口内 / 已逾期且未 CLOSED』的伏笔(确定性、无向量,守不变量 #6)
- write_craft.md 增克制指令『到点顺势收,别为收而收』;foreshadow.md 描述对齐新注入格式
- MemoryRepos 加可选 foreshadow_ledger(照 review 先例,None 优雅降级)
- 金标准仅 foreshadow spec hash 变更;后端全绿 pytest 1032 passed
2026-07-12 18:13:14 +02:00
Yaojia Wang
b810a3fa3c feat(foreshadow): 写作台本章伏笔主动提醒(前端)
- 新增 chapterForeshadow 纯分类:按当前章分 可回收/待回收/已逾期(启用闲置的 isWindowApproaching)
- useChapterForeshadow hook(复用 GET /foreshadow,切章仅重分类不重拉)
- 右栏「本章参考」+ ContextDrawer 伏笔页新增「本章伏笔」实时清单,
  分色展示 + 跳看板链接 + 空态;标注『按已验收进度』
2026-07-12 17:53:00 +02:00
Yaojia Wang
9aa0cddeaf feat(review): 审稿选章可用——列章端点 + 选章下拉全章可见 + 默认最近章
- 后端新增 GET /projects/{id}/chapters(列真实存在的章 + has_draft/accepted/reviewed_at),TDD 10 测
- gen:api 重生成 TS 客户端(ChapterListItem)
- 选章下拉去掉 hidden(手机/窄屏也可切章);选项以真实章为准 + 大纲结构,
  无草稿章置灰(仅当前章例外),覆盖『写过但不在大纲』的章
- 无 ?chapter 时默认落到最近一个有草稿的章,而非硬编码第 1 章
2026-07-12 17:52:40 +02:00
Yaojia Wang
5f28491ad6 Merge branch 'feat/ui-uplift-claude-designmd' into develop
UI 提升对标 Claude DESIGN.md(P1 地基 / P2 组件 / P3 页面 / P4 无障碍,共 33 项)
+ 应用内帮助页 /help(功能介绍 + 新手上手,已按代码实现核验)
+ README 更新。前端门禁全绿(vitest 732 · lint/typecheck/build ✓)。
2026-07-12 04:59:14 +02:00
Yaojia Wang
4a334f79cc docs(readme): 补沉浸式写作体验(双主题/专注/⌘K/帮助页)+ 校正角色卡字段
- 新增功能总览一条:纸感+夜读双主题、专注模式、⌘K 命令面板、应用内帮助页 /help
- 角色卡去掉不存在的『目标』字段,改为按 Character 模型实有的 性格/背景(motive/traits/backstory/arc/speech_tics)
2026-07-12 04:55:16 +02:00
Yaojia Wang
54d3a532af fix(help): 按代码实现校正帮助页描述(多 agent 逐条核验)
- 大纲:卷→章→场景 改为 卷→章→节拍;场景仅细纲生成器产物并入 beats
- 设定库人物卡:动机/目标 改为 动机+人物弧光(无独立 goal 字段)
- 多章链:批量推进+联审 改为 循环写章→四审→验收、遇冲突暂停裁决(去夸大)
- 工具箱:改为十多个生成器实名列举 + 部分入库部分预览(世界观/人设/大纲实为跳转入口)
- 模板库:改为提示词模板填入生成器(原描述『立项一键套用』有误,与立项流程无关)
- 规则:补『全局/题材/文风/本作』四级具名
- 立项步骤:措辞收敛(立项立卖点,设定/大纲为后续步骤)
2026-07-11 18:51:08 +02:00
Yaojia Wang
ac40f48e05 feat(help): 新增应用内帮助页 /help(功能介绍 + 新手上手)
- 暖深色英雄 + 快速上手三步 + 三个核心理念 + 功能一览(写作/资料库/审阅/工具全局)
  + 顺手小技巧 + 常见问题,全部用设计系统组件与语义 token
- 内容对齐 PRODUCT_SPEC:立项→设定→大纲→写章→四审→验收、四审四维、多提供商网关、单一真相源
- 顶栏新增「帮助」入口(FileQuestion → /help)
2026-07-11 18:40:35 +02:00
Yaojia Wang
f4bea7f26d feat(ui): 弹层进出场过渡 + 首屏暖深色英雄(P2-6/P4-6)
- useMountTransition hook(延迟卸载播放退场,尊重 reduced-motion)+ vitest
- Drawer 淡入+滑入进出场;focus/scroll-lock 仍由 open 驱动、focus 键 shouldRender 防丢首焦
- Toast 入场 toast-in 动画(motion-safe)
- P4-6 首屏零作品用 callout 暖深色编辑部英雄(对标 cta-band-dark,不干扰工作库)
  新增 callout/on-callout/on-callout-soft 双主题成对 token
2026-07-11 14:04:14 +02:00
Yaojia Wang
355a2d11cd feat(ui): Checkbox/Radio 原子件 + 3 处原生控件采用(P2-5)
- checkboxClass/radioClass(accent-cinnabar + 统一焦点环)+ 同名测试
- Checkbox/Radio 原子件:无 label 只渲染受控原生 input 便于就地替换
- 采用:ChainStarter 链类型单选、GeneratorRunner 入库勾选、CharacterCardItem 角色勾选
2026-07-11 14:04:01 +02:00
Yaojia Wang
45025c36bf feat(ui): P4 无障碍与打磨收尾
- P4-1 对比度:muted-soft 提到 WCAG AA 小字(≥4.5:1)于两主题各面
- P4-3 ConflictCard 原文/建议加 Minus/Plus 图标 + sr-only 标签(不靠颜色)
- P4-4 动效:3 处 chevron transition-transform 加 motion-safe 门控
- P4-5 命令面板 ⌘K 键帽提示 + 键盘操作读数 + 空态引导 + mono 快捷键
- P2-8 SegmentedControl 升级 radiogroup(role=radio/aria-checked + 方向键 roving)
2026-07-11 08:12:36 +02:00
Yaojia Wang
5674158707 feat(ui): 工作台精修 + 拆分(P3-3..7)
- Workbench.tsx 937→467 行,抽出 AiToolbar/WorkbenchToolbar/MobileContextBar
- P3-3 AI 工具条分组留白 + 眉题 + 主 CTA 凸显
- P3-4 专注写作模式(useFocusMode hook+vitest,隐藏侧栏加宽正文)+ 左双栏面色区分
- P3-5 右栏本章参考小标题去衬线(font-sans) + 空态引导(无标题保 h2→h3 大纲)
- P3-6 底栏信息/操作/状态三区 + TOC 当前章高亮;TOC 状态点因无每章状态数据跳过
- P3-7 展示层流式跟随滚动(不触碰 SSE 逻辑,尊重 reduced-motion)
- AppShell 加可选 hideNav(加法式,默认 false)
2026-07-11 08:04:56 +02:00
Yaojia Wang
67e30a6863 feat(ui): P3 页面级应用(作品库卡片/空态/加载/设置/次级页)
- 作品库卡片重设计:书脊封面字块 + 衬线书名 + 一句话故事(缺失给占位) +
  元信息行(题材徽章/StatusDot 待审/mono 时间),Card tone=card 浮起消塌陷
- EmptyState 中性化 + inline/card/bare 变体 + eyebrow(向后兼容)
- loading 骨架化(Skeleton 拼作品库结构,motion-safe)
- 设置页拆分 ProvidersSettings 489→198 + 5 个子组件,凭据点用 StatusDot
- 次级页(向导/模板/codex/大纲)排版字阶与圆角 token 收敛
2026-07-11 07:36:32 +02:00
Yaojia Wang
d3e9ae972f feat(ui): 新增 PageContainer 统一页容器(P3-1)+ 作品库采用 2026-07-11 07:26:54 +02:00
Yaojia Wang
83ba47ab8f feat(ui): 共享组件补齐——Card 面色分层 + Button link/lg + 4 原子件
- Card 新增 tone(panel/card/soft)/flat/interactive;cardClass 兼容旧字符串签名
- Button 新增 link(coral 文字链) 变体与 lg 尺寸
- 新增原子件 Eyebrow(统一眉题) / StatusDot(状态点·读屏可见) /
  Skeleton(motion-safe 占位) / Meter(进度条·progressbar)
- PageHeader/SectionHeader 用 display/title 字阶 + Eyebrow 眉题
2026-07-11 07:24:08 +02:00
Yaojia Wang
fd6551b0ba feat(ui): 稳健跨 OS 衬线兜底栈 + 双主题 token 奇偶校验
- 决策:不自托管 CJK webfont(数 MB + 构建期外拉,违反 CSP 友好/首屏)
  改用跨 OS 系统衬线栈,保证中文标题可靠命中衬线,零 webfont 字节
- 新增 themeParity 测试:paper/night 两块 --color-* 必须成对,防漏配破面
2026-07-11 07:18:23 +02:00
Yaojia Wang
b9f9134748 feat(ui): 补齐分层设计 token 并迁移共享变体(对标 Claude DESIGN.md)
- 面色阶 surface-soft/card/strong + 文字 5 级 body-strong/body/muted-soft
- 边框 line-soft、coral 状态 cinnabar-active/disabled(按压变深 + 奶油化禁用)
- 圆角刻度 xs..xl/full:按钮 rounded-md、卡片 rounded-lg、徽章 pill
- 排版档 display/title/caption/eyebrow/prose(衬线负字距)
- 动效 token duration-fast/base + ease-standard,transitionUi 常量统一过渡
- 焦点环统一 cinnabar/35;双主题成对定义
2026-07-11 07:16:00 +02:00
92 changed files with 4547 additions and 1097 deletions

View File

@@ -10,7 +10,7 @@
- **引导式立项** — 一句灵感走向连载:选题材 / 基调 / 结局走向 / 叙事视角AI 起草世界观、主角、金手指、总纲方案,一键建库。 - **引导式立项** — 一句灵感走向连载:选题材 / 基调 / 结局走向 / 叙事视角AI 起草世界观、主角、金手指、总纲方案,一键建库。
- **设定库Codex** — 结构化的单一真相源: - **设定库Codex** — 结构化的单一真相源:
- 角色卡(外貌 / 动机 / 目标 / 口癖 / 性格弧光 / 人物关系图) - 角色卡(外貌 / 动机 / 性格 / 背景 / 口癖 / 性格弧光 / 人物关系图)
- 世界观(力量体系 / 势力 / 地理 / 词条) - 世界观(力量体系 / 势力 / 地理 / 词条)
- **大纲 / 细纲** — AI 排分卷分章骨架,逐层细化到场景清单,并提示伏笔回收窗口。 - **大纲 / 细纲** — AI 排分卷分章骨架,逐层细化到场景清单,并提示伏笔回收窗口。
- **写章SSE 流式)** — 注入设定 + 文风指纹,按 genre-aware 的网文写作教条(黄金三章、章末钩子、爽点密度)流式产出本章草稿。 - **写章SSE 流式)** — 注入设定 + 文风指纹,按 genre-aware 的网文写作教条(黄金三章、章末钩子、爽点密度)流式产出本章草稿。
@@ -19,6 +19,7 @@
- **创作工具箱15 个生成器)** — 声明式 skill 框架,「加生成器 = 加一份声明」:脑洞 / 书名 / 简介 / 取名 / 金手指 / 词条 / 黄金开篇 / 细纲 / 续写 / 扩写 / 降 AI / 拆书,外加世界观 / 角色 / 大纲。 - **创作工具箱15 个生成器)** — 声明式 skill 框架,「加生成器 = 加一份声明」:脑洞 / 书名 / 简介 / 取名 / 金手指 / 词条 / 黄金开篇 / 细纲 / 续写 / 扩写 / 降 AI / 拆书,外加世界观 / 角色 / 大纲。
- **模板库** — 复用与沉淀常用创作模板。 - **模板库** — 复用与沉淀常用创作模板。
- **多 Provider LLM 网关** — 路由 / 回退 / 熔断的自建薄网关Agent 只声明能力 tier网关按配置映射到 provider+model支持 Kimi Code 订阅 OAuthdevice flow - **多 Provider LLM 网关** — 路由 / 回退 / 熔断的自建薄网关Agent 只声明能力 tier网关按配置映射到 provider+model支持 Kimi Code 订阅 OAuthdevice flow
- **沉浸式写作体验** — 纸感暖色 + 夜读双主题(`data-theme` 切换)、写作台专注模式(隐藏侧栏加宽正文)、`⌘K` 命令面板(跨页跳转 / 执行命令)、应用内帮助页 [`/help`](http://localhost:4000/help)(功能介绍 + 新手上手。UI 对标编辑部式暖奶油设计语言,全程尊重 `prefers-reduced-motion` 与 WCAG AA。
--- ---

View File

@@ -0,0 +1,213 @@
"""GET /projects/:id/chapters 列章端点 + 纯聚合逻辑测试(审稿选章枚举)。
分两层,与本仓「真 PG 行为交 tests/ E2E、单测保持确定性」纪律一致
- **纯聚合** `build_chapter_list`:喂造好的 chapter/review 行,断言 has_draft/accepted/
reviewed_at、去重、按章号升序、空正文草稿不计这是端点真正的业务逻辑脱库直测
- **端点集成**FastAPI client + 覆盖 `get_chapter_lister` 为 Fake同 test_projects.py 风格),
断言 404、空项目 []、字段透传、升序;网关无关(本端点不触 LLM
"""
from __future__ import annotations
import uuid
from datetime import UTC, datetime
import httpx
import pytest
from cryptography.fernet import Fernet
from fakes_projects import FakeProjectRepo
from ww_api.services.chapter_list import (
ChapterRow,
ReviewRow,
build_chapter_list,
)
from ww_api.services.credentials import STUB_OWNER_ID
from ww_core.domain.project_repo import ProjectView
from ww_shared import ErrorCode
# ---- 纯聚合逻辑build_chapter_list----
def _dt(day: int) -> datetime:
return datetime(2026, 7, day, tzinfo=UTC)
def test_build_chapter_list_marks_draft_accepted_and_reviewed() -> None:
# 三态各出现:草稿章(1) / 已验收章(2) / 已审章(3, 有 review 时间)。
chapter_rows = [
ChapterRow(chapter_no=1, status="draft", content="草稿正文"),
ChapterRow(chapter_no=2, status="draft", content="待验收"),
ChapterRow(chapter_no=2, status="accepted", content="终稿"),
ChapterRow(chapter_no=3, status="draft", content="被审的草稿"),
]
review_rows = [ReviewRow(chapter_no=3, created_at=_dt(5))]
items = build_chapter_list(chapter_rows, review_rows)
by_no = {it.chapter_no: it for it in items}
assert by_no[1].has_draft is True
assert by_no[1].accepted is False
assert by_no[1].reviewed_at is None
assert by_no[2].accepted is True
assert by_no[2].has_draft is True
assert by_no[3].reviewed_at == _dt(5)
def test_build_chapter_list_empty_returns_empty() -> None:
assert build_chapter_list([], []) == []
def test_build_chapter_list_sorted_ascending_and_deduped() -> None:
# 乱序 + 同章多版本行 → 去重 + 章号升序。
chapter_rows = [
ChapterRow(chapter_no=3, status="draft", content="c3"),
ChapterRow(chapter_no=1, status="draft", content="c1"),
ChapterRow(chapter_no=1, status="accepted", content="c1a"),
ChapterRow(chapter_no=2, status="accepted", content="c2"),
]
items = build_chapter_list(chapter_rows, [])
assert [it.chapter_no for it in items] == [1, 2, 3]
def test_build_chapter_list_reviewed_at_is_latest() -> None:
# 同章多次审稿 → reviewed_at 取最近一次。
chapter_rows = [ChapterRow(chapter_no=1, status="draft", content="x")]
review_rows = [
ReviewRow(chapter_no=1, created_at=_dt(3)),
ReviewRow(chapter_no=1, created_at=_dt(9)),
ReviewRow(chapter_no=1, created_at=_dt(6)),
]
items = build_chapter_list(chapter_rows, review_rows)
assert items[0].reviewed_at == _dt(9)
def test_build_chapter_list_empty_content_draft_not_marked() -> None:
# 空/纯空白正文的草稿行不算「有草稿正文」(与 GET .../draft 语义一致)。
chapter_rows = [
ChapterRow(chapter_no=1, status="draft", content=" "),
ChapterRow(chapter_no=1, status="draft", content=None),
]
items = build_chapter_list(chapter_rows, [])
assert items[0].chapter_no == 1
assert items[0].has_draft is False
def test_build_chapter_list_review_only_chapter_appears() -> None:
# 有审稿但无 chapters 行(作者以 body.draft 送审未存草稿)→ 该章仍枚举、标已审。
items = build_chapter_list([], [ReviewRow(chapter_no=7, created_at=_dt(2))])
assert len(items) == 1
assert items[0].chapter_no == 7
assert items[0].has_draft is False
assert items[0].accepted is False
assert items[0].reviewed_at == _dt(2)
# ---- 端点集成FastAPI client + Fake lister----
class _FakeChapterLister:
"""实现 ChapterLister Protocol 的内存版:回放预置的列章结果。"""
def __init__(self, items_by_project: dict[uuid.UUID, list[object]] | None = None) -> None:
self.items_by_project = items_by_project or {}
async def list_chapters(self, project_id: uuid.UUID) -> list[object]:
return self.items_by_project.get(project_id, [])
def _make_client(
*,
project_repo: FakeProjectRepo,
lister: _FakeChapterLister,
) -> httpx.AsyncClient:
import os
os.environ.setdefault("CREDENTIAL_ENC_KEY", Fernet.generate_key().decode())
from ww_api.main import create_app
from ww_api.services.project_deps import get_chapter_lister, get_project_repo
app = create_app()
app.dependency_overrides[get_project_repo] = lambda: project_repo
app.dependency_overrides[get_chapter_lister] = lambda: lister
transport = httpx.ASGITransport(app=app)
return httpx.AsyncClient(transport=transport, base_url="http://test")
def _seed_project(project_repo: FakeProjectRepo) -> uuid.UUID:
pid = uuid.uuid4()
project_repo.rows[pid] = (STUB_OWNER_ID, ProjectView(id=pid, title="测试"))
return pid
@pytest.mark.asyncio
async def test_list_chapters_returns_items_with_fields() -> None:
from ww_api.schemas.projects import ChapterListItem
project_repo = FakeProjectRepo()
pid = _seed_project(project_repo)
lister = _FakeChapterLister(
{
pid: [
ChapterListItem(chapter_no=1, has_draft=True, accepted=False, reviewed_at=None),
ChapterListItem(chapter_no=2, has_draft=True, accepted=True, reviewed_at=_dt(4)),
]
}
)
client = _make_client(project_repo=project_repo, lister=lister)
async with client:
resp = await client.get(f"/projects/{pid}/chapters")
assert resp.status_code == 200
body = resp.json()
assert isinstance(body, list)
assert body[0] == {
"chapter_no": 1,
"has_draft": True,
"accepted": False,
"reviewed_at": None,
}
assert body[1]["chapter_no"] == 2
assert body[1]["accepted"] is True
assert body[1]["reviewed_at"].startswith("2026-07-04")
@pytest.mark.asyncio
async def test_list_chapters_empty_project_returns_empty_list() -> None:
project_repo = FakeProjectRepo()
pid = _seed_project(project_repo)
client = _make_client(project_repo=project_repo, lister=_FakeChapterLister())
async with client:
resp = await client.get(f"/projects/{pid}/chapters")
assert resp.status_code == 200
assert resp.json() == []
@pytest.mark.asyncio
async def test_list_chapters_unknown_project_404() -> None:
client = _make_client(project_repo=FakeProjectRepo(), lister=_FakeChapterLister())
async with client:
resp = await client.get(f"/projects/{uuid.uuid4()}/chapters")
assert resp.status_code == 404
assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND
@pytest.mark.asyncio
async def test_list_chapters_ascending_order() -> None:
from ww_api.schemas.projects import ChapterListItem
project_repo = FakeProjectRepo()
pid = _seed_project(project_repo)
lister = _FakeChapterLister(
{
pid: [
ChapterListItem(chapter_no=1, has_draft=True, accepted=False, reviewed_at=None),
ChapterListItem(chapter_no=2, has_draft=False, accepted=True, reviewed_at=None),
ChapterListItem(chapter_no=5, has_draft=True, accepted=False, reviewed_at=None),
]
}
)
client = _make_client(project_repo=project_repo, lister=lister)
async with client:
resp = await client.get(f"/projects/{pid}/chapters")
assert resp.status_code == 200
assert [it["chapter_no"] for it in resp.json()] == [1, 2, 5]

View File

@@ -56,6 +56,7 @@ from ww_api.schemas.injection import (
from ww_api.schemas.projects import ( from ww_api.schemas.projects import (
AcceptRequest, AcceptRequest,
AcceptResponse, AcceptResponse,
ChapterListItem,
DraftResponse, DraftResponse,
DraftSaveRequest, DraftSaveRequest,
DraftStreamRequest, DraftStreamRequest,
@@ -75,10 +76,12 @@ from ww_api.services.accept_service import (
assert_conflicts_resolved, assert_conflicts_resolved,
run_accept_transaction, run_accept_transaction,
) )
from ww_api.services.chapter_list import ChapterLister
from ww_api.services.credentials import STUB_OWNER_ID from ww_api.services.credentials import STUB_OWNER_ID
from ww_api.services.digest_extraction import extract_digest_facts from ww_api.services.digest_extraction import extract_digest_facts
from ww_api.services.foreshadow_scan import SessionFactory, run_overdue_scan from ww_api.services.foreshadow_scan import SessionFactory, run_overdue_scan
from ww_api.services.project_deps import ( from ww_api.services.project_deps import (
get_chapter_lister,
get_chapter_repo, get_chapter_repo,
get_clarify_gateway, get_clarify_gateway,
get_digest_append_repo, get_digest_append_repo,
@@ -115,6 +118,7 @@ _SSE_RESPONSE: dict[int | str, dict[str, Any]] = {
ProjectRepoDep = Annotated[ProjectRepo, Depends(get_project_repo)] ProjectRepoDep = Annotated[ProjectRepo, Depends(get_project_repo)]
ChapterRepoDep = Annotated[ChapterRepo, Depends(get_chapter_repo)] ChapterRepoDep = Annotated[ChapterRepo, Depends(get_chapter_repo)]
ChapterListerDep = Annotated[ChapterLister, Depends(get_chapter_lister)]
GatewayDep = Annotated[Gateway, Depends(get_writer_gateway)] GatewayDep = Annotated[Gateway, Depends(get_writer_gateway)]
ReviewGatewayDep = Annotated[Gateway, Depends(get_review_gateway)] ReviewGatewayDep = Annotated[Gateway, Depends(get_review_gateway)]
DigestGatewayDep = Annotated[Gateway, Depends(get_digest_gateway)] DigestGatewayDep = Annotated[Gateway, Depends(get_digest_gateway)]
@@ -166,6 +170,28 @@ async def get_project(project_id: uuid.UUID, repo: ProjectRepoDep) -> ProjectRes
return _to_response(view) return _to_response(view)
@router.get("/{project_id}/chapters", responses=_NOT_FOUND)
async def list_chapters(
project_id: uuid.UUID,
project_repo: ProjectRepoDep,
lister: ChapterListerDep,
) -> list[ChapterListItem]:
"""列出该项目「真实存在的章」+ 可审/已审标记(审稿页选章下拉枚举)。
来源 `chapters`(草稿/accepted+ `chapter_reviews`,按章号去重升序。只读、不触 LLM。
项目不存在 → 404同其它端点owner 走 project_repo stub 校验)。
"""
if await project_repo.get(STUB_OWNER_ID, project_id) is None:
raise AppError(ErrorCode.NOT_FOUND, f"project {project_id} not found")
items = await lister.list_chapters(project_id)
log.info(
"chapters_listed",
project_id=str(project_id),
chapter_count=len(items),
)
return items
async def _injection_response( async def _injection_response(
repos: MemoryRepos, repos: MemoryRepos,
project_id: uuid.UUID, project_id: uuid.UUID,

View File

@@ -189,6 +189,23 @@ class DraftView(BaseModel):
length: int length: int
# ---- 列章(审稿选章枚举)----
class ChapterListItem(BaseModel):
"""GET /projects/:id/chapters 单项:一个真实存在的章 + 可审/已审标记snake_case
数据来源为 `chapters`(草稿/accepted 版本)与 `chapter_reviews`,按章号去重升序。
`has_draft`=有非空草稿正文(可审);`accepted`=已有 accepted 版本;
`reviewed_at`=最近一次审稿时间(无则 null
"""
chapter_no: int
has_draft: bool = Field(description="是否有非空草稿正文(可审)")
accepted: bool = Field(description="是否已验收(存在 accepted 版本)")
reviewed_at: datetime | None = Field(default=None, description="最近一次审稿时间;未审为 null")
# ---- 审稿T2.5---- # ---- 审稿T2.5----

View File

@@ -0,0 +1,109 @@
"""列章只读服务:枚举项目「真实存在的章」+ 可审/已审标记(审稿选章下拉用)。
数据来源:`chapters`(草稿/accepted 版本)+ `chapter_reviews`。核心业务逻辑是把这两张表
的行**聚合**为「按章号去重、升序」的列章视图,抽成纯函数 `build_chapter_list` 以便脱库直测
(真 PG 行为交 tests/ E2E符合本仓单测确定性纪律。`SqlChapterLister` 只负责取行 + 调聚合。
不变量:只读、不写库、不触 LLM按 project_id 隔离owner 校验在端点走 project_repo同其它端点
"""
from __future__ import annotations
import uuid
from dataclasses import dataclass
from datetime import datetime
from typing import Protocol
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ww_core.domain.chapter_repo import ACCEPTED_STATUS, DRAFT_STATUS
from ww_db.models import Chapter, ChapterReview
from ww_api.schemas.projects import ChapterListItem
@dataclass(frozen=True)
class ChapterRow:
"""`chapters` 行的最小读投影(聚合所需字段)。"""
chapter_no: int
status: str
content: str | None
@dataclass(frozen=True)
class ReviewRow:
"""`chapter_reviews` 行的最小读投影(章号 + 审稿时间)。"""
chapter_no: int
created_at: datetime
def build_chapter_list(
chapter_rows: list[ChapterRow], review_rows: list[ReviewRow]
) -> list[ChapterListItem]:
"""把 chapters/reviews 行聚合为按章号去重、升序的列章视图(纯函数,无副作用)。
- `has_draft`:该章存在 status='draft' 且正文非空白的行(与 GET .../draft「空草稿=无」一致)。
- `accepted`:该章存在 status='accepted' 行。
- `reviewed_at`:该章审稿行 created_at 的最大值(无审稿则 None
- 章号来自两表并集(含仅审稿未存草稿的章),去重后升序。
"""
has_draft: dict[int, bool] = {}
accepted: dict[int, bool] = {}
reviewed_at: dict[int, datetime] = {}
for row in chapter_rows:
if row.status == DRAFT_STATUS and row.content is not None and row.content.strip():
has_draft[row.chapter_no] = True
if row.status == ACCEPTED_STATUS:
accepted[row.chapter_no] = True
for review in review_rows:
current = reviewed_at.get(review.chapter_no)
if current is None or review.created_at > current:
reviewed_at[review.chapter_no] = review.created_at
chapter_nos = sorted({r.chapter_no for r in chapter_rows} | {r.chapter_no for r in review_rows})
return [
ChapterListItem(
chapter_no=no,
has_draft=has_draft.get(no, False),
accepted=accepted.get(no, False),
reviewed_at=reviewed_at.get(no),
)
for no in chapter_nos
]
class ChapterLister(Protocol):
"""列章只读接口(按 project_id 隔离)——端点依赖此协议,测试可注入 fake。"""
async def list_chapters(self, project_id: uuid.UUID) -> list[ChapterListItem]: ...
class SqlChapterLister:
"""SQLAlchemy 实现:两条只读查询取行 → 交纯聚合。单项目章数有界,聚合放 Python 侧KISS"""
def __init__(self, session: AsyncSession) -> None:
self._s = session
async def list_chapters(self, project_id: uuid.UUID) -> list[ChapterListItem]:
chapter_result = await self._s.execute(
select(Chapter.chapter_no, Chapter.status, Chapter.content).where(
Chapter.project_id == project_id
)
)
review_result = await self._s.execute(
select(ChapterReview.chapter_no, ChapterReview.created_at).where(
ChapterReview.project_id == project_id
)
)
chapter_rows = [
ChapterRow(chapter_no=r.chapter_no, status=r.status, content=r.content)
for r in chapter_result.all()
]
review_rows = [
ReviewRow(chapter_no=r.chapter_no, created_at=r.created_at) for r in review_result.all()
]
return build_chapter_list(chapter_rows, review_rows)

View File

@@ -53,6 +53,7 @@ from ww_api.security.credentials import (
CredentialKeyError, CredentialKeyError,
decrypt_api_key, decrypt_api_key,
) )
from ww_api.services.chapter_list import ChapterLister, SqlChapterLister
from ww_api.services.credentials import ( from ww_api.services.credentials import (
AUTH_TYPE_OAUTH, AUTH_TYPE_OAUTH,
STUB_OWNER_ID, STUB_OWNER_ID,
@@ -99,6 +100,13 @@ def get_chapter_repo(
return SqlChapterRepo(session) return SqlChapterRepo(session)
def get_chapter_lister(
session: Annotated[AsyncSession, Depends(get_session)],
) -> ChapterLister:
"""列章只读服务GET /projects/:id/chapters。只读、不写库测试注入 fake lister。"""
return SqlChapterLister(session)
def get_memory_repos( def get_memory_repos(
session: Annotated[AsyncSession, Depends(get_session)], session: Annotated[AsyncSession, Depends(get_session)],
) -> MemoryRepos: ) -> MemoryRepos:

View File

@@ -5,12 +5,26 @@
/* 纸感设计 tokenUX_SPEC §2.1 */ /* 纸感设计 tokenUX_SPEC §2.1 */
:root, :root,
[data-theme="paper"] { [data-theme="paper"] {
/* 面色阶:底 → 分隔带 → 内容卡(比底暗一步)→ 选中强调 → 浮起面板 */
--color-bg: #f5f1e8; --color-bg: #f5f1e8;
--color-surface-soft: #efe9dc;
--color-surface-card: #ece4d3;
--color-surface-strong: #e4d9c4;
--color-panel: #fbf8f1; --color-panel: #fbf8f1;
/* 文字色阶5 级):标题 → 强调段 → 正文 → 次级 → 说明/细则 */
--color-ink: #2b2620; --color-ink: #2b2620;
--color-body-strong: #3a342b;
--color-body: #4a4234;
--color-ink-soft: #6b6356; --color-ink-soft: #6b6356;
/* muted-soft 达 WCAG AA 小字≥4.5:1于 bg/card/panel/soft 各面P4-1。 */
--color-muted-soft: #6f6556;
/* 边框:主边框 + 同带内更弱分隔 */
--color-line: #e5ddcd; --color-line: #e5ddcd;
--color-line-soft: #ece4d5;
/* 朱砂 coral主色 + 按压变深 + 奶油化禁用 + 淡底 */
--color-cinnabar: #a23b2e; --color-cinnabar: #a23b2e;
--color-cinnabar-active: #8a2f24;
--color-cinnabar-disabled: #d9cdbd;
--color-cinnabar-wash: #a23b2e14; --color-cinnabar-wash: #a23b2e14;
--color-conflict: #b5543a; --color-conflict: #b5543a;
--color-overdue: #c8893a; --color-overdue: #c8893a;
@@ -18,17 +32,42 @@
--color-info: #4a5a6b; --color-info: #4a5a6b;
--color-conflict-mark: #b5543a26; --color-conflict-mark: #b5543a26;
--color-conflict-mark-strong: #b5543a8c; --color-conflict-mark-strong: #b5543a8c;
/* 暖深色 callout仅用于英雄/CTA 时刻(对标 DESIGN.md cta-band-dark非内容底。 */
--color-callout: #201d18;
--color-on-callout: #f2ede2;
--color-on-callout-soft: #b8ae9c;
--shadow-paper: #2b26200f; --shadow-paper: #2b26200f;
color-scheme: light; color-scheme: light;
} }
/* 动效 token与主题无关单处定义夜读模式不覆盖 */
:root {
--ease-standard: cubic-bezier(0.2, 0, 0, 1);
--dur-fast: 120ms;
--dur-base: 180ms;
}
[data-theme="night"] { [data-theme="night"] {
/* 面色阶:底 → 分隔带 → 浮起面板 → 内容卡(暗面向上抬)→ 选中强调 */
--color-bg: #181614; --color-bg: #181614;
--color-surface-soft: #1e1b17;
--color-surface-card: #2a2620;
--color-surface-strong: #332e26;
--color-panel: #221f1b; --color-panel: #221f1b;
/* 文字色阶5 级) */
--color-ink: #efe6d7; --color-ink: #efe6d7;
--color-body-strong: #ddd2c0;
--color-body: #cdc0ab;
--color-ink-soft: #b9ab96; --color-ink-soft: #b9ab96;
/* muted-soft 达 AA 小字≥4.5:1于夜读 bg/card/soft 各面P4-1。 */
--color-muted-soft: #9a8e7c;
/* 边框 */
--color-line: #3c352c; --color-line: #3c352c;
--color-line-soft: #2f2a23;
/* 朱砂 coral */
--color-cinnabar: #e07866; --color-cinnabar: #e07866;
--color-cinnabar-active: #c85f4d;
--color-cinnabar-disabled: #3c352c;
--color-cinnabar-wash: #e0786620; --color-cinnabar-wash: #e0786620;
--color-conflict: #ef8a72; --color-conflict: #ef8a72;
--color-overdue: #d8a65d; --color-overdue: #d8a65d;
@@ -36,6 +75,10 @@
--color-info: #8ca8ca; --color-info: #8ca8ca;
--color-conflict-mark: #ef8a7230; --color-conflict-mark: #ef8a7230;
--color-conflict-mark-strong: #ef8a7290; --color-conflict-mark-strong: #ef8a7290;
/* 夜读下 callout 为更深的凹陷暖井,仍与 bg 拉开层次。 */
--color-callout: #100e0c;
--color-on-callout: #efe6d7;
--color-on-callout-soft: #a89d8a;
--shadow-paper: #00000045; --shadow-paper: #00000045;
color-scheme: dark; color-scheme: dark;
} }
@@ -91,7 +134,111 @@ body {
} }
} }
/* 主题切换图标:显隐由 data-theme 决定(而非 JS 状态),两个图标始终在 DOM 里,
服务端/客户端首帧结构一致——既避免水合不一致,又无首帧闪烁。 */
.theme-icon-moon {
display: inline-block;
}
.theme-icon-sun {
display: none;
}
[data-theme="night"] .theme-icon-moon {
display: none;
}
[data-theme="night"] .theme-icon-sun {
display: inline-block;
}
/* Toast 入场:淡入 + 轻微上移(尊重 prefers-reduced-motion见下。 */
.toast-enter {
animation: toast-in var(--dur-base) var(--ease-standard);
}
@keyframes toast-in {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* AI 工作中·三点波动(比 animate-pulse 更明显:竖向弹跳 + 亮度,供 ThinkingIndicator 用)。 */
.ai-dot {
animation: ai-dot 1.2s ease-in-out infinite;
}
@keyframes ai-dot {
0%,
80%,
100% {
transform: translateY(0);
opacity: 0.4;
}
40% {
transform: translateY(-3px);
opacity: 1;
}
}
/* AI 流式/处理中·不确定进度条(细条来回扫,明确传达"正在工作")。 */
.ai-stream-track {
position: relative;
overflow: hidden;
background: var(--color-cinnabar-wash);
}
.ai-stream-track::after {
content: "";
position: absolute;
inset-block: 0;
left: -40%;
width: 40%;
border-radius: 9999px;
background: var(--color-cinnabar);
animation: ai-stream 1.4s ease-in-out infinite;
}
@keyframes ai-stream {
0% {
left: -40%;
width: 35%;
}
50% {
width: 55%;
}
100% {
left: 100%;
width: 35%;
}
}
/* AI 构思中·占位微光(正文区首 token 前的呼吸感,比骨架更"活")。 */
.ai-breathe {
animation: ai-breathe 1.8s ease-in-out infinite;
}
@keyframes ai-breathe {
0%,
100% {
opacity: 0.55;
}
50% {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.ai-dot {
animation: none;
opacity: 0.85;
}
.ai-stream-track::after {
animation: none;
left: 0;
width: 100%;
opacity: 0.35;
}
.ai-breathe {
animation: none;
}
.typewriter-cursor { .typewriter-cursor {
animation: none; animation: none;
} }
@@ -102,4 +249,7 @@ body {
animation: none; animation: none;
outline: 2px solid var(--color-conflict); outline: 2px solid var(--color-conflict);
} }
.toast-enter {
animation: none;
}
} }

298
apps/web/app/help/page.tsx Normal file
View File

@@ -0,0 +1,298 @@
import Link from "next/link";
import type { LucideIcon } from "lucide-react";
import {
BookMarked,
BookOpen,
Blocks,
CheckCircle2,
ClipboardCheck,
FileText,
Flag,
LayoutGrid,
ListTree,
Moon,
PanelLeftClose,
PenLine,
Route,
Search,
Settings,
ShieldCheck,
Sparkles,
} from "lucide-react";
import { AppShell } from "@/components/AppShell";
import { Badge } from "@/components/ui/Badge";
import { Card } from "@/components/ui/Card";
import { Eyebrow } from "@/components/ui/Eyebrow";
import { PageContainer } from "@/components/ui/PageContainer";
import { buttonClass } from "@/lib/ui/variants";
// 帮助中心:功能介绍 + 新手上手静态内容Server Component
interface Step {
icon: LucideIcon;
title: string;
body: string;
}
const STEPS: Step[] = [
{
icon: PenLine,
title: "立项,或直接开始写",
body: "从一句灵感开始:「先立项」立好核心卖点与一句话故事,再到设定库、大纲把架构搭起来;或「直接开始写」落笔即成书,边写边补设定。",
},
{
icon: Sparkles,
title: "让 AI 写这章",
body: "在写作台告诉 AI 这章想怎么写,它对着大纲给出草稿(流式出稿)。你可以续写接着写、润色选段、或整章重写——原文永远你说了算。",
},
{
icon: ClipboardCheck,
title: "四审,然后验收",
body: "写完点「审稿」AI 从一致性 / 伏笔 / 文风 / 节奏四个维度逐条检查、就地标注冲突。你裁决后验收,系统更新真相源与伏笔账本。",
},
];
interface Principle {
title: string;
body: string;
}
const PRINCIPLES: Principle[] = [
{
title: "把写小说当软件工程",
body: "先立架构(世界观 + 人物 + 大纲),再对着规格逐章生成,每章写完即测,全程维护单一真相源。不让 AI 一口气瞎写十万字。",
},
{
title: "单一真相源 · 记忆不衰减",
body: "设定、状态、伏笔集中记账AI 写每一章都按需读取相关记忆(确定性选择,非一次性塞满上下文),对抗长篇遗忘。",
},
{
title: "你始终掌控",
body: "AI 是灵感增幅器 + 质检员,不是全自动黑箱。四审只读、只给建议,任何写入都要你裁决采纳,不会静默覆盖你的原文。",
},
];
interface Feature {
icon: LucideIcon;
name: string;
desc: string;
}
interface FeatureGroup {
label: string;
features: Feature[];
}
const FEATURE_GROUPS: FeatureGroup[] = [
{
label: "写作",
features: [
{
icon: PenLine,
name: "写作台",
desc: "对着大纲逐章生成、流式出稿;续写接着写、润色选段、整章重写,随时进入专注模式沉浸写作。",
},
],
},
{
label: "资料库",
features: [
{ icon: ListTree, name: "大纲", desc: "卷 → 章 → 节拍逐层拆解;可用「细纲生成器」把某章展开为场景,入库时并入该章节拍。" },
{ icon: BookMarked, name: "设定库", desc: "世界观圣经 + 人物卡(动机、人物弧光,像接口约束行为)+ 关系图谱。" },
{ icon: Flag, name: "伏笔", desc: "「埋设 → 回收」的显式账本,逾期未收进「已逾期」泳道并标记,不再漏坑。" },
{ icon: FileText, name: "文风", desc: "抽取你的文风指纹并注入生成,抵抗机翻腔与风格漂移。" },
],
},
{
label: "审阅",
features: [
{
icon: ClipboardCheck,
name: "审稿 · 四审",
desc: "每章写完即审:一致性 / 伏笔 / 文风 / 节奏,冲突就地标注、逐条裁决后验收。",
},
{ icon: Route, name: "多章链", desc: "从指定章起循环「写章 → 四审 → 验收」批量推进,遇未决冲突暂停等你裁决再续跑。" },
],
},
{
label: "工具与全局",
features: [
{ icon: Blocks, name: "工具箱", desc: "十多个生成器:脑洞 / 书名 / 简介 / 金手指 / 词条 / 黄金开篇 / 细纲 / 拆书等;部分可一键入库,部分为预览产出。" },
{ icon: ShieldCheck, name: "规则", desc: "沉淀你的创作规则,分全局 / 题材 / 文风 / 本作四级,约束 AI 少犯同类错。" },
{ icon: LayoutGrid, name: "模板库", desc: "可复用的提示词模板,在工具箱生成器里一键填入需求 / 原文。" },
{ icon: Settings, name: "设置", desc: "写手 / 分析 / 轻量三档模型路由 + API Key / Kimi OAuth。" },
],
},
];
interface Tip {
icon: LucideIcon;
title: string;
body: string;
}
const TIPS: Tip[] = [
{ icon: Search, title: "命令面板 ⌘K", body: "任意页面按 ⌘K 快速跳转页面或执行命令。" },
{ icon: PanelLeftClose, title: "专注写作模式", body: "写作台底栏点「专注」,隐藏侧栏、加宽正文,沉浸写作。" },
{ icon: Moon, title: "夜读模式", body: "顶栏月亮图标切换暖色夜读,长时间写作更护眼。" },
{ icon: CheckCircle2, title: "原文永远你说了算", body: "AI 给的是草稿,采不采用由你定,绝不静默覆盖你的正文。" },
];
interface Faq {
q: string;
a: string;
}
const FAQS: Faq[] = [
{
q: "AI 会自动改我的正文吗?",
a: "不会。四审是只读的、只给建议;任何写入都要你在验收环节裁决,冲突未解决会阻止验收。",
},
{
q: "用的是哪个大模型?",
a: "后端是多提供商网关,写手 / 分析 / 轻量三档可各自路由Claude / DeepSeek / Kimi / GPT / Gemini / 通义 / GLM 等,可回退),在「设置 → 模型与提供商」里配。",
},
{
q: "写到几十万字会不会记忆衰减?",
a: "靠单一真相源 + 按需记忆注入:写每章只挑相关的设定、角色与最近若干章,而不是把全书塞进上下文。",
},
{
q: "我的数据在哪里?",
a: "本地 Postgres单用户原型设定/正文/审稿结果都以数据库为唯一真相源,记忆追加保留历史、不就地覆盖。",
},
];
function SectionTitle({ eyebrow, title }: { eyebrow: string; title: string }) {
return (
<div className="mb-5">
<Eyebrow className="mb-1.5">{eyebrow}</Eyebrow>
<h2 className="font-serif text-display-sm text-ink">{title}</h2>
</div>
);
}
function IconChip({ icon: Icon }: { icon: LucideIcon }) {
return (
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-[var(--color-cinnabar-wash)] text-cinnabar">
<Icon className="h-4 w-4" aria-hidden="true" />
</span>
);
}
export default function HelpPage() {
return (
<AppShell>
<PageContainer>
{/* 英雄:暖深色编辑部条(对标 DESIGN.md cta-band-dark。 */}
<section className="mb-12 rounded-xl border border-line/50 bg-callout px-8 py-12 shadow-paper sm:px-10">
<Eyebrow className="text-on-callout-soft"> · 使</Eyebrow>
<h1 className="mt-3 max-w-2xl font-serif text-display-md text-on-callout">
</h1>
<p className="mt-4 max-w-2xl text-body leading-7 text-on-callout-soft">
</p>
<div className="mt-6 flex flex-wrap gap-2">
<Link href="/write" className={buttonClass({ variant: "primary" })}>
<PenLine className="h-4 w-4" aria-hidden="true" />
</Link>
<Link href="/projects/new" className={buttonClass({ variant: "secondary" })}>
</Link>
</div>
</section>
{/* 新手三步 */}
<section className="mb-14">
<SectionTitle eyebrow="快速上手" title="三步写出第一章" />
<ol className="grid gap-4 sm:grid-cols-3">
{STEPS.map((step, i) => (
<li key={step.title}>
<Card tone="card" className="flex h-full flex-col gap-3 p-5">
<div className="flex items-center gap-3">
<IconChip icon={step.icon} />
<span className="font-mono text-caption text-muted-soft">
{i + 1}
</span>
</div>
<h3 className="font-serif text-title-md text-ink">{step.title}</h3>
<p className="text-caption leading-6 text-ink-soft">{step.body}</p>
</Card>
</li>
))}
</ol>
</section>
{/* 核心理念 */}
<section className="mb-14">
<SectionTitle eyebrow="为什么这样设计" title="三个核心理念" />
<div className="grid gap-4 sm:grid-cols-3">
{PRINCIPLES.map((p) => (
<Card key={p.title} tone="soft" flat className="p-5">
<h3 className="font-serif text-title-md text-ink">{p.title}</h3>
<p className="mt-2 text-caption leading-6 text-ink-soft">{p.body}</p>
</Card>
))}
</div>
</section>
{/* 功能一览 */}
<section className="mb-14">
<SectionTitle eyebrow="功能介绍" title="每个板块是做什么的" />
<div className="space-y-8">
{FEATURE_GROUPS.map((group) => (
<div key={group.label}>
<h3 className="mb-3 flex items-center gap-2 font-sans text-title-md text-ink">
{group.label}
<Badge variant="neutral">{group.features.length}</Badge>
</h3>
<div className="grid gap-4 sm:grid-cols-2">
{group.features.map((f) => (
<Card key={f.name} className="flex gap-3 p-4">
<IconChip icon={f.icon} />
<div className="min-w-0">
<p className="font-serif text-title-md text-ink">{f.name}</p>
<p className="mt-1 text-caption leading-6 text-ink-soft">{f.desc}</p>
</div>
</Card>
))}
</div>
</div>
))}
</div>
</section>
{/* 小技巧 */}
<section className="mb-14">
<SectionTitle eyebrow="顺手小技巧" title="用得更顺" />
<div className="grid gap-4 sm:grid-cols-2">
{TIPS.map((t) => (
<div key={t.title} className="flex gap-3">
<IconChip icon={t.icon} />
<div className="min-w-0">
<p className="font-medium text-ink">{t.title}</p>
<p className="mt-0.5 text-caption leading-6 text-ink-soft">{t.body}</p>
</div>
</div>
))}
</div>
</section>
{/* 常见问题 */}
<section>
<SectionTitle eyebrow="常见问题" title="你可能想问" />
<dl className="space-y-3">
{FAQS.map((f) => (
<Card key={f.q} as="div" className="p-5">
<dt className="font-serif text-title-md text-ink">{f.q}</dt>
<dd className="mt-2 text-caption leading-6 text-ink-soft">{f.a}</dd>
</Card>
))}
</dl>
</section>
</PageContainer>
</AppShell>
);
}

View File

@@ -1,9 +1,79 @@
import { ThinkingIndicator } from "@/components/ThinkingIndicator"; import { Card } from "@/components/ui/Card";
import { PageContainer } from "@/components/ui/PageContainer";
import { Skeleton } from "@/components/ui/Skeleton";
// 全站加载骨架:与作品库结构近似(标题条 + 工具条 + 卡片网格),
// motion-safe 脉冲,尊重 prefers-reduced-motion。
export default function Loading() { export default function Loading() {
return ( return (
<main className="flex min-h-screen items-center justify-center bg-bg px-6 py-16"> <main className="min-h-screen bg-bg">
<ThinkingIndicator label="加载中…" className="text-ink-soft" /> <PageContainer>
<div role="status" aria-label="加载中" className="space-y-8">
<span className="sr-only"></span>
<HeaderSkeleton />
<ToolbarSkeleton />
<GridSkeleton />
</div>
</PageContainer>
</main> </main>
); );
} }
// 标题区:大标题条 + 说明条 + 右侧两枚按钮位。
function HeaderSkeleton() {
return (
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-3">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-72 max-w-full" />
</div>
<div className="flex gap-2">
<Skeleton className="h-9 w-28" />
<Skeleton className="h-9 w-24" />
</div>
</div>
);
}
// 工具条:搜索框 + 三枚控件位。
function ToolbarSkeleton() {
return (
<Card flat className="grid gap-3 p-3 md:grid-cols-[1fr_auto_auto_auto]">
<Skeleton className="h-9 w-full" />
<Skeleton className="h-9 w-28" />
<Skeleton className="h-9 w-28" />
<Skeleton className="h-9 w-20" />
</Card>
);
}
// 卡片网格6 张与作品卡等高的占位卡。
function GridSkeleton() {
return (
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<CardSkeleton key={i} />
))}
</div>
);
}
// 单张作品卡骨架:标题 + 徽章 + 三行简介 + 底部时间。
function CardSkeleton() {
return (
<Card flat className="flex h-full min-h-[176px] flex-col p-6">
<div className="mb-4 flex items-start justify-between gap-3">
<div className="min-w-0 flex-1 space-y-3">
<Skeleton className="h-6 w-3/4" />
<Skeleton className="h-5 w-16 rounded-full" />
</div>
<Skeleton className="h-8 w-8 shrink-0" />
</div>
<div className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
</div>
<Skeleton className="mt-auto h-3 w-28" />
</Card>
);
}

View File

@@ -4,7 +4,7 @@ import { BookOpen, PenLine, Plus } from "lucide-react";
import { AppShell } from "@/components/AppShell"; import { AppShell } from "@/components/AppShell";
import { BackendDownNotice } from "@/components/BackendDownNotice"; import { BackendDownNotice } from "@/components/BackendDownNotice";
import { ProjectLibrary } from "@/components/projects/ProjectLibrary"; import { ProjectLibrary } from "@/components/projects/ProjectLibrary";
import { EmptyState } from "@/components/ui/EmptyState"; import { PageContainer } from "@/components/ui/PageContainer";
import { PageHeader } from "@/components/ui/PageHeader"; import { PageHeader } from "@/components/ui/PageHeader";
import { fetchProjects } from "@/lib/api/server"; import { fetchProjects } from "@/lib/api/server";
import type { ProjectResponse } from "@/lib/api/types"; import type { ProjectResponse } from "@/lib/api/types";
@@ -23,7 +23,7 @@ export default async function DashboardPage() {
return ( return (
<AppShell> <AppShell>
<div className="mx-auto max-w-5xl px-6 py-10 sm:px-8"> <PageContainer width="wide">
<PageHeader <PageHeader
title="我的作品" title="我的作品"
description="从一个灵感进入正文、设定、审稿与验收闭环。" description="从一个灵感进入正文、设定、审稿与验收闭环。"
@@ -50,33 +50,36 @@ export default async function DashboardPage() {
{loadError ? ( {loadError ? (
<BackendDownNotice /> <BackendDownNotice />
) : projects.length === 0 ? ( ) : projects.length === 0 ? (
<EmptyState // 首次进入的暖深色编辑部英雄(对标 DESIGN.md cta-band-dark仅零作品时出现不干扰工作库
icon={BookOpen} <div className="rounded-xl border border-line/50 bg-callout px-8 py-14 text-center shadow-paper sm:px-12 sm:py-20">
title="还没有作品" <BookOpen className="mx-auto h-9 w-9 text-cinnabar" aria-hidden="true" />
description="直接开始写,落笔即成书;或先立项,从一句灵感搭好设定、大纲再动笔。" <h2 className="mt-5 font-serif text-display-sm text-on-callout">
action={
<div className="flex flex-wrap items-center justify-center gap-2"> </h2>
<p className="mx-auto mt-3 max-w-md text-body leading-7 text-on-callout-soft">
</p>
<div className="mt-7 flex flex-wrap items-center justify-center gap-2">
<Link <Link
href="/write" href="/write"
className={buttonClass({ variant: "primary" })} className={buttonClass({ variant: "primary", size: "lg" })}
> >
<PenLine className="h-4 w-4" aria-hidden="true" /> <PenLine className="h-4 w-4" aria-hidden="true" />
</Link> </Link>
<Link <Link
href="/projects/new" href="/projects/new"
className={buttonClass({ variant: "secondary" })} className={buttonClass({ variant: "secondary", size: "lg" })}
> >
<Plus className="h-4 w-4" aria-hidden="true" /> <Plus className="h-4 w-4" aria-hidden="true" />
</Link> </Link>
</div> </div>
} </div>
/>
) : ( ) : (
<ProjectLibrary projects={projects} /> <ProjectLibrary projects={projects} />
)} )}
</div> </PageContainer>
</AppShell> </AppShell>
); );
} }

View File

@@ -2,12 +2,17 @@ import { notFound } from "next/navigation";
import { ReviewReport } from "@/components/review/ReviewReport"; import { ReviewReport } from "@/components/review/ReviewReport";
import { import {
fetchChapters,
fetchDraft, fetchDraft,
fetchOutline, fetchOutline,
fetchProject, fetchProject,
fetchReviews, fetchReviews,
} from "@/lib/api/server"; } from "@/lib/api/server";
import type { ProjectResponse } from "@/lib/api/types"; import type { ProjectResponse } from "@/lib/api/types";
import {
buildReviewChapterOptions,
defaultReviewChapter,
} from "@/lib/review/chapterNav";
import { latestReview } from "@/lib/review/history"; import { latestReview } from "@/lib/review/history";
import type { ChapterEntry } from "@/lib/workbench/chapter"; import type { ChapterEntry } from "@/lib/workbench/chapter";
@@ -16,14 +21,11 @@ interface PageProps {
searchParams: Promise<{ chapter?: string }>; searchParams: Promise<{ chapter?: string }>;
} }
const DEFAULT_CHAPTER_NO = 1;
// 审稿报告页UX §6.4。Server Component 取项目 + 审稿留痕历史(新→旧); // 审稿报告页UX §6.4。Server Component 取项目 + 审稿留痕历史(新→旧);
// ReviewReportClient承载 SSE 重审 / 裁决 / 验收交互。 // ReviewReportClient承载 SSE 重审 / 裁决 / 验收交互。
export default async function ReviewPage({ params, searchParams }: PageProps) { export default async function ReviewPage({ params, searchParams }: PageProps) {
const { id } = await params; const { id } = await params;
const { chapter } = await searchParams; const { chapter } = await searchParams;
const chapterNo = parsePositiveInt(chapter) ?? DEFAULT_CHAPTER_NO;
// 仅当项目确实取不到时才判 404审稿/草稿/大纲等次级拉取的瞬时错误不应把整页变成「找不到」。 // 仅当项目确实取不到时才判 404审稿/草稿/大纲等次级拉取的瞬时错误不应把整页变成「找不到」。
let project: ProjectResponse; let project: ProjectResponse;
@@ -33,6 +35,11 @@ export default async function ReviewPage({ params, searchParams }: PageProps) {
notFound(); notFound();
} }
// 真实存在的章(含可审/已审标记,错误→[]):供选章枚举 + 决定默认章。
const chapterList = await fetchChapters(id);
// 无 ?chapter 时默认落到最近一个有草稿的章,而非硬编码第 1 章。
const chapterNo = parsePositiveInt(chapter) ?? defaultReviewChapter(chapterList);
// 审稿留痕GET .../reviews失败→空历史 // 审稿留痕GET .../reviews失败→空历史
let initialReview; let initialReview;
try { try {
@@ -57,6 +64,13 @@ export default async function ReviewPage({ params, searchParams }: PageProps) {
title: c.beats?.[0], title: c.beats?.[0],
})); }));
// 选章下拉选项:以真实章为准(覆盖「写过但不在大纲」的章),大纲标题补短名,标注可审/已审。
const chapterOptions = buildReviewChapterOptions(
chapterList,
chapters,
chapterNo,
);
return ( return (
// key=章号:客户端切章(?chapter=N 变化)时强制重挂载,用新章审稿/草稿刷新状态, // key=章号:客户端切章(?chapter=N 变化)时强制重挂载,用新章审稿/草稿刷新状态,
// 避免 ReviewReport 内 useState 基线沿用上一章。 // 避免 ReviewReport 内 useState 基线沿用上一章。
@@ -67,6 +81,7 @@ export default async function ReviewPage({ params, searchParams }: PageProps) {
initialReview={initialReview} initialReview={initialReview}
initialDraft={initialDraft} initialDraft={initialDraft}
chapters={chapters} chapters={chapters}
chapterOptions={chapterOptions}
/> />
); );
} }

View File

@@ -1,6 +1,6 @@
import Link from "next/link"; import Link from "next/link";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { Settings } from "lucide-react"; import { FileQuestion, Settings } from "lucide-react";
import type { ActiveNav } from "@/lib/nav/items"; import type { ActiveNav } from "@/lib/nav/items";
import { buttonClass, focusRing } from "@/lib/ui/variants"; import { buttonClass, focusRing } from "@/lib/ui/variants";
@@ -18,6 +18,9 @@ interface AppShellProps {
projectId?: string; projectId?: string;
// 当前激活的项目级入口键(用于高亮)。 // 当前激活的项目级入口键(用于高亮)。
activeNav?: ActiveNav; activeNav?: ActiveNav;
// 专注写作模式P3-4隐藏桌面左侧图标导航以加宽正文顶栏汉堡/命令面板仍可达导航。
// 缺省 false → 其余页面外观不变(加法式可选项)。
hideNav?: boolean;
} }
// 全局外壳:顶栏(64px) + 左导航 + 主区UX §5.1)。 // 全局外壳:顶栏(64px) + 左导航 + 主区UX §5.1)。
@@ -27,6 +30,7 @@ export function AppShell({
subtitle, subtitle,
projectId, projectId,
activeNav, activeNav,
hideNav = false,
}: AppShellProps) { }: AppShellProps) {
return ( return (
<div <div
@@ -64,6 +68,19 @@ export function AppShell({
> >
<CommandSearchButton /> <CommandSearchButton />
<ThemeToggle /> <ThemeToggle />
<Link
href="/help"
aria-label="帮助"
title="帮助"
className={buttonClass({
variant: "ghost",
size: "sm",
className: "border-transparent",
})}
>
<FileQuestion className="h-4 w-4" aria-hidden="true" />
<span className="hidden sm:inline"></span>
</Link>
<Link <Link
href="/settings/providers" href="/settings/providers"
aria-label="设置" aria-label="设置"
@@ -80,7 +97,7 @@ export function AppShell({
</nav> </nav>
</header> </header>
<div className="flex"> <div className="flex">
<LeftNav projectId={projectId} activeNav={activeNav} /> {hideNav ? null : <LeftNav projectId={projectId} activeNav={activeNav} />}
<main <main
id="main" id="main"
tabIndex={-1} tabIndex={-1}

View File

@@ -7,8 +7,12 @@ import { Button } from "@/components/ui/Button";
import { handleTabTrap } from "@/lib/a11y/focusTrap"; import { handleTabTrap } from "@/lib/a11y/focusTrap";
import { useRestoreFocus } from "@/lib/a11y/useRestoreFocus"; import { useRestoreFocus } from "@/lib/a11y/useRestoreFocus";
import { useBodyScrollLock } from "@/lib/ui/useBodyScrollLock"; import { useBodyScrollLock } from "@/lib/ui/useBodyScrollLock";
import { useMountTransition } from "@/lib/ui/useMountTransition";
import { overlayScrim } from "@/lib/ui/variants"; import { overlayScrim } from "@/lib/ui/variants";
// 抽屉进出场时长(与 --dur-base 一致)。
const DRAWER_MS = 180;
interface DrawerProps { interface DrawerProps {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
@@ -36,11 +40,15 @@ export function Drawer({
const panelRef = useRef<HTMLDivElement>(null); const panelRef = useRef<HTMLDivElement>(null);
const closeRef = useRef<HTMLButtonElement>(null); const closeRef = useRef<HTMLButtonElement>(null);
const { shouldRender, isVisible } = useMountTransition(open, DRAWER_MS);
useBodyScrollLock(open); useBodyScrollLock(open);
useRestoreFocus(open, triggerRef); useRestoreFocus(open, triggerRef);
useEffect(() => { useEffect(() => {
if (!open) return; // 面板真正挂载shouldRender且处于打开态时才装监听/落焦——
// 因进出场延迟了卸载,若仅依赖 open 会错过挂载帧导致首焦点丢失。
if (!open || !shouldRender) return;
const onKey = (e: KeyboardEvent): void => { const onKey = (e: KeyboardEvent): void => {
if (e.key === "Escape") { if (e.key === "Escape") {
e.preventDefault(); e.preventDefault();
@@ -51,14 +59,25 @@ export function Drawer({
// 焦点陷阱首焦点落在具名关闭按钮上(而非整个面板)。 // 焦点陷阱首焦点落在具名关闭按钮上(而非整个面板)。
closeRef.current?.focus(); closeRef.current?.focus();
return () => window.removeEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey);
}, [open, onClose]); }, [open, shouldRender, onClose]);
if (!open) return null; if (!shouldRender) return null;
const sideClass = side === "left" ? "left-0 border-r" : "right-0 border-l"; const sideClass = side === "left" ? "left-0 border-r" : "right-0 border-l";
const slideClass =
side === "left"
? isVisible
? "translate-x-0"
: "-translate-x-full"
: isVisible
? "translate-x-0"
: "translate-x-full";
return ( return (
<div className={`${overlayScrim} lg:hidden`} onClick={onClose}> <div
className={`${overlayScrim} lg:hidden motion-safe:transition-opacity motion-safe:duration-base ${isVisible ? "opacity-100" : "opacity-0"}`}
onClick={onClose}
>
<div <div
ref={panelRef} ref={panelRef}
id={id} id={id}
@@ -70,7 +89,7 @@ export function Drawer({
// focus trapTab/Shift+Tab 在抽屉内循环不逃逸到背景WCAG 2.1.2)。 // focus trapTab/Shift+Tab 在抽屉内循环不逃逸到背景WCAG 2.1.2)。
if (panelRef.current) handleTabTrap(panelRef.current, e); if (panelRef.current) handleTabTrap(panelRef.current, e);
}} }}
className={`absolute top-0 h-full w-64 max-w-[80vw] overflow-auto overscroll-contain border-line bg-panel py-4 shadow-paper outline-none ${sideClass}`} className={`absolute top-0 h-full w-64 max-w-[80vw] overflow-auto overscroll-contain border-line bg-panel py-4 shadow-paper outline-none motion-safe:transition-transform motion-safe:duration-base ease-standard ${sideClass} ${slideClass}`}
> >
<div className="mb-2 flex justify-end px-2"> <div className="mb-2 flex justify-end px-2">
<Button <Button

View File

@@ -107,9 +107,9 @@ export function ProjectWizard() {
const isLast = step === WIZARD_STEPS; const isLast = step === WIZARD_STEPS;
return ( return (
<div className="mx-auto max-w-2xl rounded border border-line bg-panel p-6 shadow-paper sm:p-8"> <div className="mx-auto max-w-2xl rounded-lg border border-line bg-panel p-6 shadow-paper sm:p-8">
<div className="mb-6 flex items-center justify-between"> <div className="mb-6 flex items-center justify-between">
<h1 className="font-serif text-2xl text-ink"></h1> <h1 className="font-serif text-display-sm text-ink"></h1>
<StepDots current={step} /> <StepDots current={step} />
</div> </div>
<div className="mb-4"> <div className="mb-4">
@@ -175,7 +175,7 @@ function StepDots({ current }: { current: number }) {
{Array.from({ length: WIZARD_STEPS }, (_, i) => ( {Array.from({ length: WIZARD_STEPS }, (_, i) => (
<span <span
key={i} key={i}
className={`h-2 w-2 rounded ${ className={`h-2 w-2 rounded-full ${
i + 1 <= current ? "bg-cinnabar" : "bg-line" i + 1 <= current ? "bg-cinnabar" : "bg-line"
}`} }`}
/> />
@@ -235,7 +235,7 @@ function PlanAssistant({
const generating = status === "generating"; const generating = status === "generating";
return ( return (
<div className="my-4 rounded border border-line bg-bg p-3"> <div className="my-4 rounded-md border border-line bg-bg p-3">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div className="min-w-0"> <div className="min-w-0">
<p className="flex items-center gap-1.5 text-sm text-ink"> <p className="flex items-center gap-1.5 text-sm text-ink">
@@ -449,7 +449,7 @@ function StepConfirm({ form }: { form: WizardForm }) {
<p className="mb-3 text-sm text-ink-soft"> <p className="mb-3 text-sm text-ink-soft">
</p> </p>
<dl className="divide-y divide-line rounded border border-line bg-bg"> <dl className="divide-y divide-line rounded-md border border-line bg-bg">
{rows.map((row) => ( {rows.map((row) => (
<div key={row.label} className="flex gap-4 px-4 py-2.5 text-sm"> <div key={row.label} className="flex gap-4 px-4 py-2.5 text-sm">
<dt className="w-20 shrink-0 text-ink-soft">{row.label}</dt> <dt className="w-20 shrink-0 text-ink-soft">{row.label}</dt>

View File

@@ -26,13 +26,6 @@ function readStoredThemeMode(): ThemeMode {
} }
} }
// 初始模式直接读 ThemeScript 在水合前写入的 <html data-theme>,避免夜读首帧渲染错误图标。
// SSR 守卫:服务端无 document → 回落默认(与 ThemeScript 的默认一致)。
function initialThemeMode(): ThemeMode {
if (typeof document === "undefined") return DEFAULT_THEME_MODE;
return normalizeThemeMode(document.documentElement.dataset.theme);
}
function writeStoredThemeMode(mode: ThemeMode) { function writeStoredThemeMode(mode: ThemeMode) {
try { try {
window.localStorage.setItem(THEME_STORAGE_KEY, mode); window.localStorage.setItem(THEME_STORAGE_KEY, mode);
@@ -42,7 +35,9 @@ function writeStoredThemeMode(mode: ThemeMode) {
} }
export function ThemeToggle() { export function ThemeToggle() {
const [mode, setMode] = useState<ThemeMode>(initialThemeMode); // 首帧用默认模式(与服务端一致)避免水合不一致;挂载后以 localStorage 校正。
// 可见的图标由 CSS 按 <html data-theme> 决定ThemeScript 已在水合前写入),故无首帧闪烁。
const [mode, setMode] = useState<ThemeMode>(DEFAULT_THEME_MODE);
useEffect(() => { useEffect(() => {
// 水合后以 localStorage 为权威源校正dataset 已由 ThemeScript 同样依据写入,通常一致)。 // 水合后以 localStorage 为权威源校正dataset 已由 ThemeScript 同样依据写入,通常一致)。
@@ -60,8 +55,8 @@ export function ThemeToggle() {
}); });
}; };
const Icon = mode === "night" ? Sun : Moon; // 图标显隐交给 CSS按 data-theme两个图标始终渲染 → 首帧结构与服务端一致、无闪烁。
// 首帧 label 用默认模式(与服务端一致),挂载后随 mode 校正为正确文案。
return ( return (
<Button <Button
onClick={toggle} onClick={toggle}
@@ -71,7 +66,8 @@ export function ThemeToggle() {
title={themeToggleLabel(mode)} title={themeToggleLabel(mode)}
className="border-transparent" className="border-transparent"
> >
<Icon className="h-4 w-4" aria-hidden="true" /> <Moon className="theme-icon-moon h-4 w-4" aria-hidden="true" />
<Sun className="theme-icon-sun h-4 w-4" aria-hidden="true" />
<span className="sr-only">{themeModeLabel(mode)}</span> <span className="sr-only">{themeModeLabel(mode)}</span>
</Button> </Button>
); );

View File

@@ -17,12 +17,12 @@ export function ThinkingIndicator({
role="status" role="status"
aria-live="polite" aria-live="polite"
> >
<span className="inline-flex gap-0.5" aria-hidden="true"> <span className="inline-flex gap-1" aria-hidden="true">
{[0, 1, 2].map((i) => ( {[0, 1, 2].map((i) => (
<span <span
key={i} key={i}
className="h-1.5 w-1.5 rounded-full bg-current opacity-70 motion-safe:animate-pulse" className="ai-dot h-2 w-2 rounded-full bg-current"
style={{ animationDelay: `${i * 200}ms` }} style={{ animationDelay: `${i * 150}ms` }}
/> />
))} ))}
</span> </span>

View File

@@ -98,7 +98,7 @@ export function ToastProvider({ children }: { children: ReactNode }) {
const renderItem = (t: ToastItem) => ( const renderItem = (t: ToastItem) => (
<div <div
key={t.id} key={t.id}
className={`pointer-events-auto flex items-center gap-3 rounded border px-4 py-2 text-sm shadow-paper ${toastShellClass( className={`toast-enter pointer-events-auto flex items-center gap-3 rounded-md border px-4 py-2 text-sm shadow-paper ${toastShellClass(
t.kind, t.kind,
)}`} )}`}
> >

View File

@@ -5,6 +5,7 @@ import { Play } from "lucide-react";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { Field } from "@/components/ui/Field"; import { Field } from "@/components/ui/Field";
import { Radio } from "@/components/ui/Radio";
import { SectionHeader } from "@/components/ui/SectionHeader"; import { SectionHeader } from "@/components/ui/SectionHeader";
import { TextInput } from "@/components/ui/TextInput"; import { TextInput } from "@/components/ui/TextInput";
import { CHAIN_KINDS, type ChainKind } from "@/lib/chain/chain"; import { CHAIN_KINDS, type ChainKind } from "@/lib/chain/chain";
@@ -58,8 +59,7 @@ export function ChainStarter({ onStart, disabled }: ChainStarterProps) {
key={kind.key} key={kind.key}
className="flex items-start gap-2 text-sm text-ink" className="flex items-start gap-2 text-sm text-ink"
> >
<input <Radio
type="radio"
name="chain-kind" name="chain-kind"
value={kind.key} value={kind.key}
checked={chainKey === kind.key} checked={chainKey === kind.key}

View File

@@ -50,7 +50,7 @@ const RelationshipGraph = dynamic(
{ {
ssr: false, ssr: false,
loading: () => ( loading: () => (
<div className="flex h-[28rem] items-center justify-center rounded border border-line bg-bg text-sm text-ink-soft"> <div className="flex h-[28rem] items-center justify-center rounded-lg border border-line bg-bg text-sm text-ink-soft">
</div> </div>
), ),
@@ -101,7 +101,7 @@ export function CodexPage({
{tab === "characters" ? ( {tab === "characters" ? (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<Card as="section" className="p-4"> <Card as="section" className="p-4">
<h3 className="mb-3 font-serif text-sm text-ink"> <h3 className="mb-3 font-serif text-title-md text-ink">
{characters.length} {characters.length}
</h3> </h3>
{characters.length > 0 ? ( {characters.length > 0 ? (
@@ -109,10 +109,10 @@ export function CodexPage({
{characters.map((c, i) => ( {characters.map((c, i) => (
<li <li
key={`${c.name}-${i}`} key={`${c.name}-${i}`}
className="rounded border border-line bg-bg p-3 text-sm" className="rounded-lg border border-line bg-bg p-3 text-sm"
> >
<div className="mb-2 flex items-center gap-2"> <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"> <span className="flex h-8 w-8 items-center justify-center rounded-md bg-[var(--color-cinnabar-wash)] text-cinnabar">
<UserRound className="h-4 w-4" aria-hidden="true" /> <UserRound className="h-4 w-4" aria-hidden="true" />
</span> </span>
<div className="min-w-0"> <div className="min-w-0">
@@ -127,7 +127,7 @@ export function CodexPage({
{c.relations.map((r, j) => ( {c.relations.map((r, j) => (
<li <li
key={`${r.name}-${r.kind}-${j}`} key={`${r.name}-${r.kind}-${j}`}
className="rounded bg-panel px-1.5 py-0.5 text-2xs" className="rounded-full bg-panel px-2 py-0.5 text-2xs"
title={r.note ?? undefined} title={r.note ?? undefined}
> >
{r.kind} · {r.name} {r.kind} · {r.name}
@@ -163,7 +163,7 @@ export function CodexPage({
</Card> </Card>
{characters.length > 0 ? ( {characters.length > 0 ? (
<Card as="section" className="p-4"> <Card as="section" className="p-4">
<h3 className="mb-3 flex items-center gap-2 font-serif text-sm text-ink"> <h3 className="mb-3 flex items-center gap-2 font-serif text-title-md text-ink">
<Share2 className="h-4 w-4 text-cinnabar" aria-hidden="true" /> <Share2 className="h-4 w-4 text-cinnabar" aria-hidden="true" />
</h3> </h3>
@@ -184,7 +184,7 @@ export function CodexPage({
{tab === "world" ? ( {tab === "world" ? (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<Card as="section" className="p-4"> <Card as="section" className="p-4">
<h3 className="mb-3 font-serif text-sm text-ink"> <h3 className="mb-3 font-serif text-title-md text-ink">
{initialWorldEntities.length} {initialWorldEntities.length}
</h3> </h3>
{initialWorldEntities.length > 0 ? ( {initialWorldEntities.length > 0 ? (
@@ -192,7 +192,7 @@ export function CodexPage({
{initialWorldEntities.map((entity, i) => ( {initialWorldEntities.map((entity, i) => (
<article <article
key={`${entity.name}-${i}`} key={`${entity.name}-${i}`}
className="rounded border border-line bg-bg p-3 text-sm" className="rounded-lg border border-line bg-bg p-3 text-sm"
> >
<header className="mb-2 flex items-center gap-2"> <header className="mb-2 flex items-center gap-2">
<Globe2 <Globe2

View File

@@ -66,6 +66,15 @@ export function useCommandPaletteOpen(): boolean {
const LIST_ID = "command-list"; const LIST_ID = "command-list";
const optionId = (cmd: Command): string => `command-option-${cmd.id}`; const optionId = (cmd: Command): string => `command-option-${cmd.id}`;
// 统一的键帽kbd样式暖纸感描边 + 等宽小字,与 NavDrawer 的 ⌘K 提示一致。
function KeyCap({ children }: { children: React.ReactNode }) {
return (
<kbd className="rounded border border-line bg-panel px-1 font-mono text-2xs text-ink-soft">
{children}
</kbd>
);
}
// 从 pathname 抽取当前 projectId/projects/<id>/...)。 // 从 pathname 抽取当前 projectId/projects/<id>/...)。
function projectIdFromPath(pathname: string): string | null { function projectIdFromPath(pathname: string): string | null {
const m = pathname.match(/^\/projects\/([^/]+)/); const m = pathname.match(/^\/projects\/([^/]+)/);
@@ -208,6 +217,7 @@ export function CommandPalette({ pathname }: CommandPaletteProps) {
if (dialogRef.current) handleTabTrap(dialogRef.current, e); if (dialogRef.current) handleTabTrap(dialogRef.current, e);
}} }}
> >
<div className="relative border-b border-line">
<input <input
ref={inputRef} ref={inputRef}
value={query} value={query}
@@ -220,8 +230,13 @@ export function CommandPalette({ pathname }: CommandPaletteProps) {
aria-controls={LIST_ID} aria-controls={LIST_ID}
aria-activedescendant={activeCmd ? optionId(activeCmd) : undefined} aria-activedescendant={activeCmd ? optionId(activeCmd) : undefined}
aria-autocomplete="list" aria-autocomplete="list"
className={`w-full border-b border-line bg-bg px-4 py-3 text-sm text-ink ${focusRing}`} className={`w-full bg-bg py-3 pl-4 pr-12 text-sm text-ink ${focusRing}`}
/> />
{/* 触发快捷键读数:随面板常驻,强化 ⌘K 的可发现性(纯装饰,不入 Tab 序)。 */}
<span className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2">
<KeyCap>K</KeyCap>
</span>
</div>
<ul <ul
id={LIST_ID} id={LIST_ID}
role="listbox" role="listbox"
@@ -229,7 +244,14 @@ export function CommandPalette({ pathname }: CommandPaletteProps) {
className="max-h-80 overflow-auto py-1 overscroll-contain" className="max-h-80 overflow-auto py-1 overscroll-contain"
> >
{results.length === 0 ? ( {results.length === 0 ? (
<li className="px-4 py-3 text-sm text-ink-soft"></li> <li className="px-4 py-6 text-center">
<p className="text-sm text-ink-soft">
{query.trim()}
</p>
<p className="mt-1 text-caption text-muted-soft">
稿
</p>
</li>
) : ( ) : (
results.map((cmd, i) => ( results.map((cmd, i) => (
<li <li
@@ -246,11 +268,30 @@ export function CommandPalette({ pathname }: CommandPaletteProps) {
}`} }`}
> >
<span>{cmd.title}</span> <span>{cmd.title}</span>
<span className="text-xs text-ink-soft">{cmd.group}</span> <span className="text-caption text-ink-soft">{cmd.group}</span>
</li> </li>
)) ))
)} )}
</ul> </ul>
{/* 键盘操作读数:↑↓ 选择 · ↵ 打开 · esc 关闭(常驻引导,纯装饰)。 */}
<div
aria-hidden="true"
className="flex flex-wrap items-center gap-x-4 gap-y-1 border-t border-line px-4 py-2 text-caption text-muted-soft"
>
<span className="flex items-center gap-1">
<KeyCap></KeyCap>
<KeyCap></KeyCap>
</span>
<span className="flex items-center gap-1">
<KeyCap></KeyCap>
</span>
<span className="flex items-center gap-1">
<KeyCap>esc</KeyCap>
</span>
</div>
</div> </div>
</div> </div>
); );

View File

@@ -1,5 +1,6 @@
"use client"; "use client";
import { Checkbox } from "@/components/ui/Checkbox";
import { TextInput } from "@/components/ui/TextInput"; import { TextInput } from "@/components/ui/TextInput";
import type { CharacterCardView } from "@/lib/api/types"; import type { CharacterCardView } from "@/lib/api/types";
@@ -25,12 +26,10 @@ export function CharacterCardItem({
}`} }`}
> >
<header className="mb-2 flex items-center gap-2"> <header className="mb-2 flex items-center gap-2">
<input <Checkbox
type="checkbox"
checked={selected} checked={selected}
onChange={onToggle} onChange={onToggle}
aria-label={`选择角色 ${card.name}`} aria-label={`选择角色 ${card.name}`}
className="size-4 accent-cinnabar"
/> />
{onEdit ? ( {onEdit ? (
<TextInput <TextInput

View File

@@ -171,8 +171,8 @@ export function CharacterGenerator({
<Button <Button
onClick={() => void onGenerate()} onClick={() => void onGenerate()}
loading={generating}
disabled={ disabled={
generating ||
brief.trim().length === 0 || brief.trim().length === 0 ||
(mode === "mix" (mode === "mix"
? mixTotal < MIN_CHARACTER_COUNT ? mixTotal < MIN_CHARACTER_COUNT
@@ -181,7 +181,7 @@ export function CharacterGenerator({
variant="primary" variant="primary"
> >
<Sparkles className="h-4 w-4" aria-hidden="true" /> <Sparkles className="h-4 w-4" aria-hidden="true" />
{generating ? "生成中…" : "生成"}
</Button> </Button>
</div> </div>

View File

@@ -66,11 +66,12 @@ export function WorldGenerator({ projectId }: WorldGeneratorProps) {
</Field> </Field>
<Button <Button
onClick={() => void onGenerate()} onClick={() => void onGenerate()}
disabled={generating || brief.trim().length === 0} loading={generating}
disabled={brief.trim().length === 0}
variant="primary" variant="primary"
> >
<Sparkles className="h-4 w-4" aria-hidden="true" /> <Sparkles className="h-4 w-4" aria-hidden="true" />
{generating ? "生成中…" : "生成世界观"}
</Button> </Button>
</div> </div>

View File

@@ -217,7 +217,7 @@ export function OutlineEditor({
) : ( ) : (
volumes.map((group) => ( volumes.map((group) => (
<section key={group.volume} className="mb-6"> <section key={group.volume} className="mb-6">
<h2 className="mb-2 font-serif text-base text-ink"> <h2 className="mb-3 font-serif text-title-md text-ink">
{group.volume} {group.volume}
</h2> </h2>
<ul className="space-y-1"> <ul className="space-y-1">

View File

@@ -0,0 +1,164 @@
import Link from "next/link";
import { ChevronRight, Clock3 } from "lucide-react";
import { Badge } from "@/components/ui/Badge";
import { StatusDot } from "@/components/ui/StatusDot";
import type { ProjectResponse } from "@/lib/api/types";
import {
formatProjectUpdatedAt,
pendingReviewCount,
} from "@/lib/projects/projects";
import { cardClass, cn, focusRing } from "@/lib/ui/variants";
interface ProjectCardProps {
project: ProjectResponse;
compact?: boolean;
}
// 缺一句话故事时的中性占位——避免卡片正文塌陷成空白P3-2
const LOGLINE_PLACEHOLDER = "还没有一句话故事,进入写作台补上一句梗概。";
// 书脊/封面暖色板:全部取自现有语义 token朱砂/琥珀),仅调透明度,无硬编码 hex。
const COVER_TINTS = [
"bg-[var(--color-cinnabar-wash)] text-cinnabar",
"bg-cinnabar/10 text-cinnabar",
"bg-cinnabar/[0.16] text-cinnabar",
"bg-overdue/10 text-overdue",
"bg-overdue/[0.18] text-overdue",
"bg-surface-strong text-body-strong",
] as const;
// 由 id/title 确定性取暖色底色块djb2 变体哈希,同一本书永远同一色)。
function coverTint(seed: string): string {
let hash = 0;
for (const ch of seed) hash = (hash * 31 + (ch.codePointAt(0) ?? 0)) >>> 0;
return COVER_TINTS[hash % COVER_TINTS.length] ?? COVER_TINTS[0];
}
// 取书名首个字符作封面字Array.from 保证 CJK/emoji 不被截断)。
function coverGlyph(title: string): string {
return Array.from(title.trim())[0] ?? "书";
}
interface CoverProps {
project: ProjectResponse;
size: "sm" | "md";
}
// 书脊色块:填补此前的空白,给每本书一个稳定的视觉锚点。
function Cover({ project, size }: CoverProps) {
const tint = coverTint(project.id || project.title || "");
const box = size === "md" ? "h-12 w-12 text-title-lg" : "h-10 w-10 text-title-md";
return (
<span
aria-hidden="true"
className={cn(
"flex shrink-0 items-center justify-center rounded-md font-serif",
box,
tint,
)}
>
{coverGlyph(project.title || "")}
</span>
);
}
// 作品卡UX §6.1):书脊色块 + 书名衬线 + 一句话故事 + 元信息行。
// M1 无字数/章数端点 → 不展示该统计(避免编造 API
export function ProjectCard({ project, compact = false }: ProjectCardProps) {
const pendingCount = pendingReviewCount(project);
const updatedLabel = formatProjectUpdatedAt(project.updated_at);
const title = project.title || "未命名作品";
const logline = project.logline?.trim();
if (compact) {
return (
<Link
href={`/projects/${project.id}/write`}
className={cardClass({
tone: "card",
interactive: true,
className: cn("group flex items-center gap-4 p-4", focusRing),
})}
>
<Cover project={project} size="sm" />
<span className="min-w-0 flex-1">
<span className="block truncate font-serif text-title-md text-ink">
{title}
</span>
<span className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-caption">
<GenreBadge genre={project.genre} />
<PendingNote count={pendingCount} />
<span className="min-w-0 flex-1 truncate text-muted-soft">
{logline ?? LOGLINE_PLACEHOLDER}
</span>
</span>
<span className="mt-1 flex items-center gap-1 font-mono text-2xs text-muted-soft">
<Clock3 className="h-3 w-3" aria-hidden="true" />
{updatedLabel}
</span>
</span>
<ChevronRight
className="h-4 w-4 shrink-0 text-muted-soft transition-colors duration-fast ease-standard group-hover:text-cinnabar"
aria-hidden="true"
/>
</Link>
);
}
return (
<Link
href={`/projects/${project.id}/write`}
className={cardClass({
tone: "card",
interactive: true,
className: cn("group flex h-full flex-col gap-4 p-5", focusRing),
})}
>
<div className="flex items-center gap-3">
<Cover project={project} size="md" />
<h2 className="min-w-0 flex-1 truncate font-serif text-title-lg text-ink">
{title}
</h2>
<ChevronRight
className="h-4 w-4 shrink-0 text-muted-soft transition-colors duration-fast ease-standard group-hover:text-cinnabar"
aria-hidden="true"
/>
</div>
<p
className={cn(
"line-clamp-2 min-h-[3rem] text-body leading-6",
logline ? "text-body" : "text-muted-soft",
)}
>
{logline ?? LOGLINE_PLACEHOLDER}
</p>
<div className="mt-auto flex flex-wrap items-center gap-x-3 gap-y-2">
<GenreBadge genre={project.genre} />
<PendingNote count={pendingCount} />
<span className="ml-auto flex items-center gap-1 font-mono text-caption text-muted-soft">
<Clock3 className="h-3.5 w-3.5" aria-hidden="true" />
{updatedLabel}
</span>
</div>
</Link>
);
}
// 题材徽章:有值用中性徽章,缺失给稳定占位而非空白。
function GenreBadge({ genre }: { genre?: string | null }) {
const value = genre?.trim();
if (value) return <Badge>{value}</Badge>;
return <Badge className="border-dashed text-muted-soft"></Badge>;
}
// 待审提示:状态点 + 文字(不单靠颜色传达状态)。
function PendingNote({ count }: { count: number }) {
if (count <= 0) return null;
return (
<span className="inline-flex items-center gap-1.5 text-caption text-ink-soft">
<StatusDot tone="warning" label={`${count} 章待审`} />
{count}
</span>
);
}

View File

@@ -4,7 +4,7 @@ import Link from "next/link";
import { Clock3, LayoutGrid, List, Plus, Search } from "lucide-react"; import { Clock3, LayoutGrid, List, Plus, Search } from "lucide-react";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { ProjectCard } from "@/components/ProjectCard"; import { ProjectCard } from "@/components/projects/ProjectCard";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { EmptyState } from "@/components/ui/EmptyState"; import { EmptyState } from "@/components/ui/EmptyState";
import { SegmentedControl } from "@/components/ui/SegmentedControl"; import { SegmentedControl } from "@/components/ui/SegmentedControl";
@@ -17,7 +17,7 @@ import {
type ProjectSort, type ProjectSort,
type ProjectViewMode, type ProjectViewMode,
} from "@/lib/projects/projects"; } from "@/lib/projects/projects";
import { buttonClass, cardClass } from "@/lib/ui/variants"; import { buttonClass, cardClass, cn, focusRing } from "@/lib/ui/variants";
interface ProjectLibraryProps { interface ProjectLibraryProps {
projects: ProjectResponse[]; projects: ProjectResponse[];
@@ -150,7 +150,7 @@ export function ProjectLibrary({ projects }: ProjectLibraryProps) {
} }
/> />
) : viewMode === "cards" ? ( ) : viewMode === "cards" ? (
<ul className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3"> <ul className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{visibleProjects.map((p) => ( {visibleProjects.map((p) => (
<li key={p.id}> <li key={p.id}>
<ProjectCard project={p} /> <ProjectCard project={p} />
@@ -189,16 +189,21 @@ function NewProjectCard() {
return ( return (
<Link <Link
href="/projects/new" href="/projects/new"
className={buttonClass({ className={cardClass({
variant: "outline", tone: "soft",
className: "group h-full min-h-[176px] flex-col border-dashed text-center", flat: true,
interactive: true,
className: cn(
"group flex h-full min-h-[184px] flex-col items-center justify-center gap-3 border-dashed p-5 text-center",
focusRing,
),
})} })}
> >
<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"> <span className="flex h-12 w-12 items-center justify-center rounded-md bg-[var(--color-cinnabar-wash)] text-cinnabar transition-colors duration-fast ease-standard group-hover:bg-cinnabar group-hover:text-panel">
<Plus className="h-5 w-5" aria-hidden="true" /> <Plus className="h-5 w-5" aria-hidden="true" />
</span> </span>
<span className="font-serif text-xl text-cinnabar"></span> <span className="font-serif text-title-md text-cinnabar"></span>
<span className="mt-2 text-sm text-ink-soft"></span> <span className="text-caption text-muted-soft"></span>
</Link> </Link>
); );
} }

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { AlertTriangle, CheckCircle2, LocateFixed, Sparkles } from "lucide-react"; import { AlertTriangle, CheckCircle2, LocateFixed, Minus, Plus, Sparkles } from "lucide-react";
import { Badge } from "@/components/ui/Badge"; import { Badge } from "@/components/ui/Badge";
import { TextInput } from "@/components/ui/TextInput"; import { TextInput } from "@/components/ui/TextInput";
@@ -80,10 +80,22 @@ export function ConflictCard({
{conflict.original && conflict.replacement ? ( {conflict.original && conflict.replacement ? (
<div className="mt-2 rounded border border-pass/30 bg-bg/40 px-3 py-1.5 text-xs leading-relaxed"> <div className="mt-2 rounded border border-pass/30 bg-bg/40 px-3 py-1.5 text-xs leading-relaxed">
<span className="mr-1 text-ink-soft"></span> <span className="mr-1 text-ink-soft"></span>
{/* 非颜色线索Minus/「原文」标签,色盲也可辨(不单靠红色 + 删除线) */}
<Minus
className="mr-0.5 inline-block h-3 w-3 align-[-1px] text-conflict"
aria-hidden="true"
/>
<span className="sr-only"></span>
<span className="text-conflict line-through">{conflict.original}</span> <span className="text-conflict line-through">{conflict.original}</span>
<span className="mx-1 text-ink-soft" aria-hidden="true"> <span className="mx-1 text-ink-soft" aria-hidden="true">
</span> </span>
{/* 非颜色线索Plus/「建议」标签,色盲也可辨(不单靠绿色) */}
<Plus
className="mr-0.5 inline-block h-3 w-3 align-[-1px] text-pass"
aria-hidden="true"
/>
<span className="sr-only"></span>
<span className="text-pass">{conflict.replacement}</span> <span className="text-pass">{conflict.replacement}</span>
<span className="ml-2 text-ink-soft">稿</span> <span className="ml-2 text-ink-soft">稿</span>
</div> </div>

View File

@@ -17,7 +17,11 @@ import { AppShell } from "@/components/AppShell";
import { Badge } from "@/components/ui/Badge"; import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { Select } from "@/components/ui/Select"; import { Select } from "@/components/ui/Select";
import { reviewChapterHref, reviewChapterOptions } from "@/lib/review/chapterNav"; import {
reviewChapterHref,
reviewChapterOptions,
type ReviewChapterOption,
} from "@/lib/review/chapterNav";
import type { ChapterEntry } from "@/lib/workbench/chapter"; import type { ChapterEntry } from "@/lib/workbench/chapter";
import type { ProjectResponse, ReviewHistoryItem } from "@/lib/api/types"; import type { ProjectResponse, ReviewHistoryItem } from "@/lib/api/types";
import { friendlyError } from "@/lib/errors/messages"; import { friendlyError } from "@/lib/errors/messages";
@@ -74,8 +78,10 @@ interface ReviewReportProps {
chapterNo: number; chapterNo: number;
initialReview: ReviewHistoryItem | undefined; initialReview: ReviewHistoryItem | undefined;
initialDraft: string; initialDraft: string;
// 章节导航目录(大纲章节):用于报告头部的「切换审稿章节」选择器 // 章节导航目录(大纲章节):无 chapterOptions 时回退用
chapters?: ChapterEntry[]; chapters?: ChapterEntry[];
// 选章选项(以真实章为准,含可审/已审标记);优先于 chapters 渲染选择器。
chapterOptions?: ReviewChapterOption[];
} }
// 审稿报告页主体UX §6.4 / §8.3 / §9 // 审稿报告页主体UX §6.4 / §8.3 / §9
@@ -87,6 +93,7 @@ export function ReviewReport({
initialReview, initialReview,
initialDraft, initialDraft,
chapters, chapters,
chapterOptions,
}: ReviewReportProps) { }: ReviewReportProps) {
const router = useRouter(); const router = useRouter();
const review = useReviewStream(); const review = useReviewStream();
@@ -460,7 +467,7 @@ export function ReviewReport({
<Select <Select
controlSize="sm" controlSize="sm"
aria-label="切换审稿章节" aria-label="切换审稿章节"
className="hidden max-w-[12rem] shrink-0 sm:block" className="max-w-[12rem] shrink-0"
value={chapterNo} value={chapterNo}
onChange={(e) => onChange={(e) =>
router.push( router.push(
@@ -468,8 +475,10 @@ export function ReviewReport({
) )
} }
> >
{reviewChapterOptions(chapters ?? [], chapterNo).map((opt) => ( {(
<option key={opt.no} value={opt.no}> chapterOptions ?? reviewChapterOptions(chapters ?? [], chapterNo)
).map((opt: ReviewChapterOption) => (
<option key={opt.no} value={opt.no} disabled={opt.disabled}>
{opt.label} {opt.label}
</option> </option>
))} ))}
@@ -604,7 +613,7 @@ export function ReviewReport({
: "已处理"} : "已处理"}
</span> </span>
<ChevronDown <ChevronDown
className="h-4 w-4 text-ink-soft transition-transform group-open:rotate-180" className="h-4 w-4 text-ink-soft motion-safe:transition-transform group-open:rotate-180"
aria-hidden="true" aria-hidden="true"
/> />
</span> </span>

View File

@@ -52,7 +52,7 @@ export function ReviewSectionPanel({
<Badge variant={statusVariant}>{statusLabel}</Badge> <Badge variant={statusVariant}>{statusLabel}</Badge>
<ChevronDown <ChevronDown
className={cn( className={cn(
"h-4 w-4 text-ink-soft transition-transform", "h-4 w-4 text-ink-soft motion-safe:transition-transform",
"group-open:rotate-180", "group-open:rotate-180",
)} )}
aria-hidden="true" aria-hidden="true"

View File

@@ -0,0 +1,24 @@
import { Badge } from "@/components/ui/Badge";
import type { CapabilitiesView } from "@/lib/api/types";
// 探活结果的能力徽章:结构化输出 / 前缀缓存 / 思考,各显示开关态。
export function CapabilityBadges({ caps }: { caps: CapabilitiesView }) {
const badges: { on: boolean; label: string }[] = [
{ on: caps.structured_output, label: "结构化" },
{ on: caps.prefix_cache, label: "前缀缓存" },
{ on: caps.thinking, label: "思考" },
];
return (
<div className="flex gap-1.5">
{badges.map((b) => (
<Badge
key={b.label}
variant={b.on ? "accent" : "neutral"}
className="text-2xs"
>
{b.label}
</Badge>
))}
</div>
);
}

View File

@@ -0,0 +1,105 @@
"use client";
import { CheckCircle2, PlugZap, Save, XCircle } from "lucide-react";
import { CapabilityBadges } from "@/components/settings/CapabilityBadges";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { StatusDot } from "@/components/ui/StatusDot";
import { TextInput } from "@/components/ui/TextInput";
import type { CapabilitiesView } from "@/lib/api/types";
import type { KnownProvider } from "@/lib/settings/providers";
export interface TestResult {
ok: boolean;
capabilities: CapabilitiesView;
}
interface CredentialRowProps {
provider: KnownProvider;
masked: string | null;
draft: string;
saving: boolean;
testing: boolean;
result: TestResult | undefined;
onDraftChange: (value: string) => void;
onSave: () => void;
onTest: () => void;
}
// 单个 API-key 提供商行:脱敏态 + Key 输入 + 保存/测试 + 探活结果。
export function CredentialRow({
provider,
masked,
draft,
saving,
testing,
result,
onDraftChange,
onSave,
onTest,
}: CredentialRowProps) {
return (
<li className="px-4 py-4">
<div className="grid gap-3 lg:grid-cols-[10rem_8rem_minmax(12rem,1fr)_auto_auto] lg:items-center">
<span className="flex items-center gap-2 text-sm text-ink">
<StatusDot
tone={masked ? "success" : "neutral"}
label={masked ? "已配置" : "未配置"}
/>
{provider.label}
</span>
<span className="font-mono text-xs text-ink-soft">
{masked ?? "未配置"}
</span>
<label className="sr-only" htmlFor={`key-${provider.id}`}>
{provider.label} API Key
</label>
<TextInput
id={`key-${provider.id}`}
type="password"
autoComplete="off"
value={draft}
onChange={(e) => onDraftChange(e.target.value)}
placeholder={masked ? "输入新 Key 以更新" : "输入 API Key"}
/>
<Button
onClick={onSave}
disabled={saving || draft.trim().length === 0}
variant="primary"
size="sm"
>
<Save className="h-4 w-4" aria-hidden="true" />
{saving ? "保存中…" : masked ? "更新" : "添加"}
</Button>
<Button
onClick={onTest}
disabled={testing}
variant="secondary"
size="sm"
>
<PlugZap className="h-4 w-4" aria-hidden="true" />
{testing ? "测试中…" : "测试"}
</Button>
</div>
<p className="mt-2 text-xs leading-5 text-ink-soft lg:pl-[10.5rem]">
{masked
? "已保存脱敏凭据;输入新 Key 可覆盖更新。"
: "保存后再测试连接,成功后即可在档位路由中使用。"}
</p>
{result ? (
<div className="mt-2 flex flex-wrap items-center gap-2 lg:pl-[10.5rem]">
<Badge variant={result.ok ? "success" : "danger"}>
{result.ok ? (
<CheckCircle2 className="h-3 w-3" aria-hidden="true" />
) : (
<XCircle className="h-3 w-3" aria-hidden="true" />
)}
{result.ok ? "已连接" : "未连接"}
</Badge>
<CapabilityBadges caps={result.capabilities} />
</div>
) : null}
</li>
);
}

View File

@@ -0,0 +1,85 @@
"use client";
import { PlugZap } from "lucide-react";
import {
CredentialRow,
type TestResult,
} from "@/components/settings/CredentialRow";
import { EmptyState } from "@/components/ui/EmptyState";
import { SectionHeader } from "@/components/ui/SectionHeader";
import { API_KEY_PROVIDERS } from "@/lib/settings/providers";
import type { ProviderView } from "@/lib/api/types";
interface CredentialsPanelProps {
providers: ProviderView[];
drafts: Record<string, string>;
savingId: string | null;
testingId: string | null;
results: Record<string, TestResult>;
onDraftChange: (providerId: string, value: string) => void;
onSave: (providerId: string) => void;
onTest: (providerId: string) => void;
}
// 提供商凭据面板API-key 提供商列表,逐行保存/测试UX §6.10)。
export function CredentialsPanel({
providers,
drafts,
savingId,
testingId,
results,
onDraftChange,
onSave,
onTest,
}: CredentialsPanelProps) {
const maskedFor = (id: string): string | null =>
providers.find((p) => p.provider === id)?.masked_key ?? null;
return (
<section className="min-w-0">
<SectionHeader
eyebrow="凭据"
title="提供商凭据"
description="API Key 只用于后端探活和调用,列表中只显示脱敏后的已保存凭据。"
/>
<div className="mt-3 grid gap-2 text-xs text-ink-soft sm:grid-cols-3">
<StatItem label="已保存" value={providers.length} />
<StatItem label="可配置" value={API_KEY_PROVIDERS.length} />
<StatItem label="测试结果" value={Object.keys(results).length} />
</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) => (
<CredentialRow
key={prov.id}
provider={prov}
masked={maskedFor(prov.id)}
draft={drafts[prov.id] ?? ""}
saving={savingId === prov.id}
testing={testingId === prov.id}
result={results[prov.id]}
onDraftChange={(value) => onDraftChange(prov.id, value)}
onSave={() => onSave(prov.id)}
onTest={() => onTest(prov.id)}
/>
))}
</ul>
</section>
);
}
function StatItem({ label, value }: { label: string; value: number }) {
return (
<div className="rounded border border-line bg-panel px-3 py-2">
{label} <span className="font-mono text-ink">{value}</span>
</div>
);
}

View File

@@ -1,41 +1,26 @@
"use client"; "use client";
import { import { useState } from "react";
CheckCircle2,
KeyRound,
PlugZap,
Route,
Save,
XCircle,
type LucideIcon,
} from "lucide-react";
import { useState, type ReactNode } from "react";
import { useToast } from "@/components/Toast"; import { useToast } from "@/components/Toast";
import { CredentialsPanel } from "@/components/settings/CredentialsPanel";
import type { TestResult } from "@/components/settings/CredentialRow";
import { KimiCodeOauth } from "@/components/settings/KimiCodeOauth"; import { KimiCodeOauth } from "@/components/settings/KimiCodeOauth";
import { Badge } from "@/components/ui/Badge"; import { RoutingPanel } from "@/components/settings/RoutingPanel";
import { Button } from "@/components/ui/Button"; import {
import { EmptyState } from "@/components/ui/EmptyState"; SECTION_OPTIONS,
import { SectionHeader } from "@/components/ui/SectionHeader"; SettingsNav,
import { SegmentedControl } from "@/components/ui/SegmentedControl"; type SettingsSection,
import { Select } from "@/components/ui/Select"; } from "@/components/settings/SettingsNav";
import { StatusNote } from "@/components/ui/StatusNote"; import { SegmentedControl } from "@/components/ui/SegmentedControl";
import { TextInput } from "@/components/ui/TextInput"; import { api } from "@/lib/api/client";
import { api } from "@/lib/api/client";
import { cardClass } from "@/lib/ui/variants";
import { import {
API_KEY_PROVIDERS,
KNOWN_PROVIDERS,
TIER_LABELS,
applyProviderChange, applyProviderChange,
draftsToRoutingInput, draftsToRoutingInput,
toRoutingDrafts, toRoutingDrafts,
type RoutingDraft, type RoutingDraft,
} from "@/lib/settings/providers"; } from "@/lib/settings/providers";
import type { import type { ProvidersResponse } from "@/lib/api/types";
CapabilitiesView,
ProvidersResponse,
} from "@/lib/api/types";
interface ProvidersSettingsProps { interface ProvidersSettingsProps {
initial: ProvidersResponse; initial: ProvidersResponse;
@@ -43,42 +28,6 @@ interface ProvidersSettingsProps {
kimiOauth: { connected: boolean; expiresAt: string | null }; kimiOauth: { connected: boolean; expiresAt: string | null };
} }
interface TestResult {
ok: boolean;
capabilities: CapabilitiesView;
}
type SettingsSection = "routing" | "oauth" | "keys";
// 分组的单一标签源:桌面 aside 与移动 SegmentedControl 共用,消除「档位路由 / 路由」漂移。
const SETTINGS_SECTIONS: Array<{
value: SettingsSection;
label: string;
icon: LucideIcon;
}> = [
{ value: "routing", label: "档位路由", icon: Route },
{ value: "oauth", label: "OAuth", icon: PlugZap },
{ value: "keys", label: "API Key", icon: KeyRound },
];
const SECTION_OPTIONS = SETTINGS_SECTIONS.map(({ value, label }) => ({
value,
label,
}));
// 档位路由「模型」输入框的候选 model iddatalist 建议,仍可自由输入)。按 provider 分组。
const MODEL_SUGGESTIONS: Record<string, readonly string[]> = {
anthropic: ["claude-opus-4", "claude-sonnet-4", "claude-3-5-haiku"],
deepseek: ["deepseek-chat", "deepseek-reasoner"],
kimi: ["moonshot-v1-128k", "moonshot-v1-32k", "kimi-k2"],
openai: ["gpt-4o", "gpt-4o-mini", "o3-mini"],
qwen: ["qwen-max", "qwen-plus", "qwen-turbo"],
glm: ["glm-4-plus", "glm-4-air", "glm-4-flash"],
gemini: ["gemini-2.0-flash", "gemini-1.5-pro"],
"kimi-code-key": ["kimi-for-coding"],
"kimi-code": ["kimi-for-coding"],
};
// 设置页主体UX §6.10):档位路由(可编辑)+ API-key 凭据行 + Kimi Code OAuth 连接区。 // 设置页主体UX §6.10):档位路由(可编辑)+ API-key 凭据行 + Kimi Code OAuth 连接区。
export function ProvidersSettings({ export function ProvidersSettings({
initial, initial,
@@ -97,9 +46,6 @@ export function ProvidersSettings({
const [testingId, setTestingId] = useState<string | null>(null); const [testingId, setTestingId] = useState<string | null>(null);
const [results, setResults] = useState<Record<string, TestResult>>({}); const [results, setResults] = useState<Record<string, TestResult>>({});
const maskedFor = (id: string): string | null =>
providers.find((p) => p.provider === id)?.masked_key ?? null;
const sectionDetail = (value: SettingsSection): string => { const sectionDetail = (value: SettingsSection): string => {
if (value === "routing") { if (value === "routing") {
return `${routing.filter((r) => r.provider && r.model).length}/3 已配置`; return `${routing.filter((r) => r.provider && r.model).length}/3 已配置`;
@@ -194,22 +140,17 @@ export function ProvidersSettings({
} }
}; };
const updateDraft = (providerId: string, value: string): void => {
setDrafts((prev) => ({ ...prev, [providerId]: value }));
};
return ( return (
<div className="grid gap-6 lg:grid-cols-[12rem_1fr]"> <div className="grid gap-6 lg:grid-cols-[12rem_1fr]">
<aside className="hidden lg:block"> <SettingsNav
<nav aria-label="设置分组" className={cardClass("sticky top-20 p-2")}> activeSection={activeSection}
{SETTINGS_SECTIONS.map((section) => ( onSelect={setActiveSection}
<SettingsNavButton detailFor={sectionDetail}
key={section.value}
active={activeSection === section.value}
icon={<section.icon className="h-4 w-4" aria-hidden="true" />}
label={section.label}
detail={sectionDetail(section.value)}
onClick={() => setActiveSection(section.value)}
/> />
))}
</nav>
</aside>
<div className="min-w-0"> <div className="min-w-0">
<SegmentedControl <SegmentedControl
@@ -221,269 +162,37 @@ export function ProvidersSettings({
/> />
{activeSection === "routing" ? ( {activeSection === "routing" ? (
<SettingsPanel> <RoutingPanel
<SectionHeader routing={routing}
title="能力档位路由" saving={savingRouting}
description="写手、分析、轻量三类能力可分别指向不同模型。保存时只提交完整填写的行。" onProviderChange={updateRouting}
onModelChange={updateRoutingModel}
onSave={() => void saveRouting()}
/> />
<StatusNote className="mt-3" variant="info">
稿
</StatusNote>
<ul className="mt-4 divide-y divide-line rounded border border-line bg-panel">
{routing.map((row) => (
<li
key={row.tier}
className="grid gap-3 px-4 py-3 text-sm md:grid-cols-[6rem_minmax(10rem,14rem)_1fr]"
>
<span className="self-center text-ink">
{TIER_LABELS[row.tier] ?? row.tier}
</span>
<label
className="sr-only"
htmlFor={`route-provider-${row.tier}`}
>
{TIER_LABELS[row.tier] ?? row.tier}
</label>
<Select
id={`route-provider-${row.tier}`}
value={row.provider}
onChange={(e) => updateRouting(row.tier, e.target.value)}
>
<option value=""></option>
{KNOWN_PROVIDERS.map((p) => (
<option key={p.id} value={p.id}>
{p.label}
</option>
))}
</Select>
<label className="sr-only" htmlFor={`route-model-${row.tier}`}>
{TIER_LABELS[row.tier] ?? row.tier}
</label>
<div className="min-w-0">
<TextInput
id={`route-model-${row.tier}`}
value={row.model}
onChange={(e) =>
updateRoutingModel(row.tier, e.target.value)
}
placeholder="model"
className="w-full font-mono"
list={`route-models-${row.tier}`}
/>
<datalist id={`route-models-${row.tier}`}>
{(MODEL_SUGGESTIONS[row.provider] ?? []).map((m) => (
<option key={m} value={m} />
))}
</datalist>
</div>
</li>
))}
</ul>
<div className="mt-3 flex justify-end">
<Button
onClick={() => void saveRouting()}
disabled={savingRouting}
variant="primary"
size="sm"
>
<Save className="h-4 w-4" aria-hidden="true" />
{savingRouting ? "保存中…" : "保存档位路由"}
</Button>
</div>
</SettingsPanel>
) : null} ) : null}
{activeSection === "oauth" ? ( {activeSection === "oauth" ? (
<SettingsPanel> <section className="min-w-0">
<KimiCodeOauth <KimiCodeOauth
initialConnected={kimiOauth.connected} initialConnected={kimiOauth.connected}
initialExpiresAt={kimiOauth.expiresAt} initialExpiresAt={kimiOauth.expiresAt}
/> />
</SettingsPanel> </section>
) : null} ) : null}
{activeSection === "keys" ? ( {activeSection === "keys" ? (
<SettingsPanel> <CredentialsPanel
<SectionHeader providers={providers}
title="提供商凭据" drafts={drafts}
description="API Key 只用于后端探活和调用,列表中只显示脱敏后的已保存凭据。" savingId={savingId}
/> testingId={testingId}
<div className="mt-3 grid gap-2 text-xs text-ink-soft sm:grid-cols-3"> results={results}
<div className="rounded border border-line bg-panel px-3 py-2"> onDraftChange={updateDraft}
<span className="font-mono text-ink">{providers.length}</span> onSave={(id) => void saveCredential(id)}
</div> onTest={(id) => void testConnection(id)}
<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} ) : null}
<ul className="mt-4 divide-y divide-line rounded border border-line bg-panel">
{API_KEY_PROVIDERS.map((prov) => {
const masked = maskedFor(prov.id);
const result = results[prov.id];
return (
<li key={prov.id} className="px-4 py-4">
<div className="grid gap-3 lg:grid-cols-[10rem_8rem_minmax(12rem,1fr)_auto_auto] lg:items-center">
<span className="flex items-center gap-2 text-sm text-ink">
<span
className={`h-2 w-2 rounded ${
masked ? "bg-pass" : "bg-line"
}`}
aria-hidden="true"
/>
{prov.label}
</span>
<span className="font-mono text-xs text-ink-soft">
{masked ?? "未配置"}
</span>
<label className="sr-only" htmlFor={`key-${prov.id}`}>
{prov.label} API Key
</label>
<TextInput
id={`key-${prov.id}`}
type="password"
autoComplete="off"
value={drafts[prov.id] ?? ""}
onChange={(e) =>
setDrafts((prev) => ({
...prev,
[prov.id]: e.target.value,
}))
}
placeholder={
masked ? "输入新 Key 以更新" : "输入 API Key"
}
/>
<Button
onClick={() => saveCredential(prov.id)}
disabled={
savingId === prov.id ||
(drafts[prov.id] ?? "").trim().length === 0
}
variant="primary"
size="sm"
>
<Save className="h-4 w-4" aria-hidden="true" />
{savingId === prov.id
? "保存中…"
: masked
? "更新"
: "添加"}
</Button>
<Button
onClick={() => testConnection(prov.id)}
disabled={testingId === prov.id}
variant="secondary"
size="sm"
>
<PlugZap className="h-4 w-4" aria-hidden="true" />
{testingId === prov.id ? "测试中…" : "测试"}
</Button>
</div>
<p className="mt-2 text-xs leading-5 text-ink-soft lg:pl-[10.5rem]">
{masked
? "已保存脱敏凭据;输入新 Key 可覆盖更新。"
: "保存后再测试连接,成功后即可在档位路由中使用。"}
</p>
{result ? (
<div className="mt-2 flex flex-wrap items-center gap-2 lg:pl-[10.5rem]">
<Badge variant={result.ok ? "success" : "danger"}>
{result.ok ? (
<CheckCircle2
className="h-3 w-3"
aria-hidden="true"
/>
) : (
<XCircle className="h-3 w-3" aria-hidden="true" />
)}
{result.ok ? "已连接" : "未连接"}
</Badge>
<CapabilityBadges caps={result.capabilities} />
</div>
) : null}
</li>
);
})}
</ul>
</SettingsPanel>
) : null}
</div> </div>
</div> </div>
); );
} }
function SettingsPanel({ children }: { children: ReactNode }) {
return <section className="min-w-0">{children}</section>;
}
interface SettingsNavButtonProps {
active: boolean;
icon: ReactNode;
label: string;
detail: string;
onClick: () => void;
}
function SettingsNavButton({
active,
icon,
label,
detail,
onClick,
}: SettingsNavButtonProps) {
return (
<button
type="button"
aria-pressed={active}
onClick={onClick}
className={`mb-1 flex w-full items-start gap-2 rounded px-3 py-2 text-left transition-colors ${
active
? "bg-[var(--color-cinnabar-wash)] text-cinnabar"
: "text-ink hover:bg-bg hover:text-cinnabar"
}`}
>
<span className="mt-0.5 shrink-0">{icon}</span>
<span className="min-w-0">
<span className="block text-sm font-medium">{label}</span>
<span className="block truncate text-xs text-ink-soft">{detail}</span>
</span>
</button>
);
}
function CapabilityBadges({ caps }: { caps: CapabilitiesView }) {
const badges: { on: boolean; label: string }[] = [
{ on: caps.structured_output, label: "结构化" },
{ on: caps.prefix_cache, label: "前缀缓存" },
{ on: caps.thinking, label: "思考" },
];
return (
<div className="flex gap-1.5">
{badges.map((b) => (
<Badge
key={b.label}
variant={b.on ? "accent" : "neutral"}
className="text-2xs"
>
{b.label}
</Badge>
))}
</div>
);
}

View File

@@ -0,0 +1,126 @@
"use client";
import { Save } from "lucide-react";
import { Button } from "@/components/ui/Button";
import { SectionHeader } from "@/components/ui/SectionHeader";
import { Select } from "@/components/ui/Select";
import { StatusNote } from "@/components/ui/StatusNote";
import { TextInput } from "@/components/ui/TextInput";
import {
KNOWN_PROVIDERS,
TIER_LABELS,
type RoutingDraft,
} from "@/lib/settings/providers";
// 档位路由「模型」输入框的候选 model iddatalist 建议,仍可自由输入)。按 provider 分组。
const MODEL_SUGGESTIONS: Record<string, readonly string[]> = {
anthropic: ["claude-opus-4", "claude-sonnet-4", "claude-3-5-haiku"],
deepseek: ["deepseek-chat", "deepseek-reasoner"],
kimi: ["moonshot-v1-128k", "moonshot-v1-32k", "kimi-k2"],
openai: ["gpt-4o", "gpt-4o-mini", "o3-mini"],
qwen: ["qwen-max", "qwen-plus", "qwen-turbo"],
glm: ["glm-4-plus", "glm-4-air", "glm-4-flash"],
gemini: ["gemini-2.0-flash", "gemini-1.5-pro"],
"kimi-code-key": ["kimi-for-coding"],
"kimi-code": ["kimi-for-coding"],
};
interface RoutingPanelProps {
routing: RoutingDraft[];
saving: boolean;
onProviderChange: (tier: string, providerId: string) => void;
onModelChange: (tier: string, model: string) => void;
onSave: () => void;
}
// 能力档位路由面板:写手/分析/轻量三档各指向一个 provider+modelUX §6.10)。
export function RoutingPanel({
routing,
saving,
onProviderChange,
onModelChange,
onSave,
}: RoutingPanelProps) {
return (
<section className="min-w-0">
<SectionHeader
eyebrow="路由"
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) => (
<RoutingRow
key={row.tier}
row={row}
onProviderChange={onProviderChange}
onModelChange={onModelChange}
/>
))}
</ul>
<div className="mt-3 flex justify-end">
<Button
onClick={onSave}
disabled={saving}
variant="primary"
size="sm"
>
<Save className="h-4 w-4" aria-hidden="true" />
{saving ? "保存中…" : "保存档位路由"}
</Button>
</div>
</section>
);
}
interface RoutingRowProps {
row: RoutingDraft;
onProviderChange: (tier: string, providerId: string) => void;
onModelChange: (tier: string, model: string) => void;
}
function RoutingRow({ row, onProviderChange, onModelChange }: RoutingRowProps) {
const tierLabel = TIER_LABELS[row.tier] ?? row.tier;
return (
<li className="grid gap-3 px-4 py-3 text-sm md:grid-cols-[6rem_minmax(10rem,14rem)_1fr]">
<span className="self-center text-ink">{tierLabel}</span>
<label className="sr-only" htmlFor={`route-provider-${row.tier}`}>
{tierLabel}
</label>
<Select
id={`route-provider-${row.tier}`}
value={row.provider}
onChange={(e) => onProviderChange(row.tier, e.target.value)}
>
<option value=""></option>
{KNOWN_PROVIDERS.map((p) => (
<option key={p.id} value={p.id}>
{p.label}
</option>
))}
</Select>
<label className="sr-only" htmlFor={`route-model-${row.tier}`}>
{tierLabel}
</label>
<div className="min-w-0">
<TextInput
id={`route-model-${row.tier}`}
value={row.model}
onChange={(e) => onModelChange(row.tier, e.target.value)}
placeholder="model"
className="w-full font-mono"
list={`route-models-${row.tier}`}
/>
<datalist id={`route-models-${row.tier}`}>
{(MODEL_SUGGESTIONS[row.provider] ?? []).map((m) => (
<option key={m} value={m} />
))}
</datalist>
</div>
</li>
);
}

View File

@@ -0,0 +1,89 @@
"use client";
import { KeyRound, PlugZap, Route, type LucideIcon } from "lucide-react";
import type { ReactNode } from "react";
import { cardClass } from "@/lib/ui/variants";
export type SettingsSection = "routing" | "oauth" | "keys";
// 分组的单一标签源:桌面 aside 与移动 SegmentedControl 共用,消除「档位路由 / 路由」漂移。
export const SETTINGS_SECTIONS: Array<{
value: SettingsSection;
label: string;
icon: LucideIcon;
}> = [
{ value: "routing", label: "档位路由", icon: Route },
{ value: "oauth", label: "OAuth", icon: PlugZap },
{ value: "keys", label: "API Key", icon: KeyRound },
];
export const SECTION_OPTIONS = SETTINGS_SECTIONS.map(({ value, label }) => ({
value,
label,
}));
interface SettingsNavProps {
activeSection: SettingsSection;
onSelect: (value: SettingsSection) => void;
detailFor: (value: SettingsSection) => string;
}
// 桌面侧栏:设置分组导航(移动端由 SegmentedControl 承担)。
export function SettingsNav({
activeSection,
onSelect,
detailFor,
}: SettingsNavProps) {
return (
<aside className="hidden lg:block">
<nav aria-label="设置分组" className={cardClass("sticky top-20 p-2")}>
{SETTINGS_SECTIONS.map((section) => (
<SettingsNavButton
key={section.value}
active={activeSection === section.value}
icon={<section.icon className="h-4 w-4" aria-hidden="true" />}
label={section.label}
detail={detailFor(section.value)}
onClick={() => onSelect(section.value)}
/>
))}
</nav>
</aside>
);
}
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>
);
}

View File

@@ -182,7 +182,7 @@ export function TemplatesManager({ initial, tools }: TemplatesManagerProps) {
</form> </form>
<section className="flex flex-col gap-3" aria-label="模板列表"> <section className="flex flex-col gap-3" aria-label="模板列表">
<h2 className="font-serif text-lg text-ink">{templates.length}</h2> <h2 className="font-serif text-title-md text-ink">{templates.length}</h2>
{templates.length === 0 ? ( {templates.length === 0 ? (
<EmptyState <EmptyState
icon={FileText} icon={FileText}
@@ -194,7 +194,7 @@ export function TemplatesManager({ initial, tools }: TemplatesManagerProps) {
{templates.map((t) => ( {templates.map((t) => (
<li <li
key={t.id} key={t.id}
className="flex items-start justify-between gap-4 rounded border border-line bg-bg p-3 text-sm" className="flex items-start justify-between gap-4 rounded-lg border border-line bg-bg p-3 text-sm"
> >
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="font-serif text-base text-ink">{t.title}</p> <p className="font-serif text-base text-ink">{t.title}</p>

View File

@@ -7,6 +7,7 @@ import { useToast } from "@/components/Toast";
import { ConflictAdjudication } from "@/components/generation/ConflictAdjudication"; import { ConflictAdjudication } from "@/components/generation/ConflictAdjudication";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { Badge } from "@/components/ui/Badge"; import { Badge } from "@/components/ui/Badge";
import { Checkbox } from "@/components/ui/Checkbox";
import { Field } from "@/components/ui/Field"; import { Field } from "@/components/ui/Field";
import { StatusNote } from "@/components/ui/StatusNote"; import { StatusNote } from "@/components/ui/StatusNote";
import { TextArea } from "@/components/ui/TextArea"; import { TextArea } from "@/components/ui/TextArea";
@@ -253,9 +254,9 @@ export function GeneratorRunner({
onChange={(v) => setField(field.name, v)} onChange={(v) => setField(field.name, v)}
/> />
))} ))}
<Button type="submit" disabled={generating} variant="primary"> <Button type="submit" loading={generating} variant="primary">
<Sparkles className="h-4 w-4" aria-hidden="true" /> <Sparkles className="h-4 w-4" aria-hidden="true" />
{generating ? "生成中…" : "生成"}
</Button> </Button>
{generating ? ( {generating ? (
<ThinkingIndicator <ThinkingIndicator
@@ -296,13 +297,12 @@ export function GeneratorRunner({
{canIngest && gen.ingestStatus !== "conflict" ? ( {canIngest && gen.ingestStatus !== "conflict" ? (
<Button <Button
onClick={() => void runIngest(false)} onClick={() => void runIngest(false)}
disabled={ingesting || (!singleObject && selected.size === 0)} loading={ingesting}
disabled={!singleObject && selected.size === 0}
variant="outline" variant="outline"
> >
<Database className="h-4 w-4" aria-hidden="true" /> <Database className="h-4 w-4" aria-hidden="true" />
{ingesting {singleObject
? "入库中…"
: singleObject
? `入库为规则至 ${table}` ? `入库为规则至 ${table}`
: `入库选中(${selected.size})至 ${table}`} : `入库选中(${selected.size})至 ${table}`}
</Button> </Button>
@@ -380,8 +380,7 @@ function PreviewRow({
return ( return (
<li className="flex gap-2 rounded border border-line bg-bg p-3 text-sm"> <li className="flex gap-2 rounded border border-line bg-bg p-3 text-sm">
{selectable ? ( {selectable ? (
<input <Checkbox
type="checkbox"
checked={checked} checked={checked}
onChange={() => onToggle(index)} onChange={() => onToggle(index)}
className="mt-1" className="mt-1"

View File

@@ -5,11 +5,14 @@ import {
type ButtonSize, type ButtonSize,
type ButtonVariant, type ButtonVariant,
} from "@/lib/ui/variants"; } from "@/lib/ui/variants";
import { Spinner } from "./Spinner";
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> { interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
children: ReactNode; children: ReactNode;
variant?: ButtonVariant; variant?: ButtonVariant;
size?: ButtonSize; size?: ButtonSize;
// 忙碌态:显示转圈 + 禁用 + aria-busy统一各处"处理中"的过程感。
loading?: boolean;
} }
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button( export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
@@ -19,6 +22,8 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
variant, variant,
size, size,
type = "button", type = "button",
loading = false,
disabled,
...props ...props
}, },
ref, ref,
@@ -28,8 +33,11 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
ref={ref} ref={ref}
type={type} type={type}
className={buttonClass({ variant, size, className })} className={buttonClass({ variant, size, className })}
disabled={disabled || loading}
aria-busy={loading || undefined}
{...props} {...props}
> >
{loading ? <Spinner className="h-4 w-4" /> : null}
{children} {children}
</button> </button>
); );

View File

@@ -1,20 +1,29 @@
import type { HTMLAttributes, ReactNode } from "react"; import type { HTMLAttributes, ReactNode } from "react";
import { cardClass } from "@/lib/ui/variants"; import { cardClass, type CardTone } from "@/lib/ui/variants";
interface CardProps extends HTMLAttributes<HTMLElement> { interface CardProps extends HTMLAttributes<HTMLElement> {
children: ReactNode; children: ReactNode;
as?: "article" | "div" | "section"; as?: "article" | "div" | "section";
tone?: CardTone;
flat?: boolean;
interactive?: boolean;
} }
export function Card({ export function Card({
as: Component = "div", as: Component = "div",
children, children,
className, className,
tone,
flat,
interactive,
...props ...props
}: CardProps) { }: CardProps) {
return ( return (
<Component className={cardClass(className)} {...props}> <Component
className={cardClass({ tone, flat, interactive, className })}
{...props}
>
{children} {children}
</Component> </Component>
); );

View File

@@ -0,0 +1,20 @@
import type { InputHTMLAttributes, ReactNode } from "react";
import { checkboxClass } from "@/lib/ui/variants";
interface CheckboxProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type"> {
label?: ReactNode;
}
// 勾选框:无 label 时只渲染受控原生 input供已有 <label>/布局包裹的调用点直接替换);
// 有 label 时自带 <label> 包裹,保留原生键盘/读屏语义。
export function Checkbox({ label, className, ...props }: CheckboxProps) {
const input = <input type="checkbox" className={checkboxClass(className)} {...props} />;
if (!label) return input;
return (
<label className="inline-flex items-center gap-2 text-sm text-ink">
{input}
<span>{label}</span>
</label>
);
}

View File

@@ -1,38 +1,117 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import { Card } from "@/components/ui/Card";
import { Eyebrow } from "@/components/ui/Eyebrow";
import { cn } from "@/lib/ui/variants"; import { cn } from "@/lib/ui/variants";
// 空态外框形态:
// - "inline"(默认):虚线中性框,用于列表/面板内联占位(向后兼容旧默认外观)。
// - "card":包裹在 Card(tone=soft) 内,用于独立区块的引导卡片。
// - "bare":无外框,仅内容,交由调用方自行放置(如已在卡片里)。
type EmptyStateVariant = "inline" | "card" | "bare";
type EmptyStateSize = "sm" | "md";
interface EmptyStateProps { interface EmptyStateProps {
icon: LucideIcon; icon: LucideIcon;
title: string; title: string;
description: string; description: string;
action?: ReactNode; action?: ReactNode;
/** 可选眉题,置于标题上方,用于分类/上下文提示。 */
eyebrow?: string;
/** 外框形态,默认 "inline"。 */
variant?: EmptyStateVariant;
/** 留白密度,默认 "md"。 */
size?: EmptyStateSize;
className?: string; className?: string;
} }
export function EmptyState({ const SIZE_PAD: Record<EmptyStateSize, string> = {
sm: "px-5 py-8",
md: "px-6 py-10",
};
const SIZE_CHIP: Record<EmptyStateSize, string> = {
sm: "h-10 w-10",
md: "h-11 w-11",
};
// 内容主体:图标片 + 眉题 + 标题 + 描述 + 动作。中性、编辑部化,留白克制。
function EmptyStateBody({
icon: Icon, icon: Icon,
title, title,
description, description,
action, action,
className, eyebrow,
}: EmptyStateProps) { size,
}: Pick<
EmptyStateProps,
"icon" | "title" | "description" | "action" | "eyebrow"
> & {
size: EmptyStateSize;
}) {
return ( return (
<div className="text-center">
<div <div
className={cn( className={cn(
"rounded border border-dashed border-line bg-panel/70 px-6 py-10 text-center", "mx-auto mb-3 flex items-center justify-center rounded-md bg-surface-soft text-muted-soft",
className, SIZE_CHIP[size],
)} )}
> >
<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" /> <Icon className="h-5 w-5" aria-hidden="true" />
</div> </div>
<h2 className="font-serif text-lg text-ink">{title}</h2> {eyebrow ? <Eyebrow className="mb-2">{eyebrow}</Eyebrow> : null}
<p className="mx-auto mt-2 max-w-md text-sm leading-6 text-ink-soft"> <h2 className="font-serif text-title-md text-ink">{title}</h2>
<p className="mx-auto mt-2 max-w-sm text-caption leading-6 text-ink-soft">
{description} {description}
</p> </p>
{action ? <div className="mt-4">{action}</div> : null} {action ? <div className="mt-4">{action}</div> : null}
</div> </div>
); );
} }
export function EmptyState({
icon,
title,
description,
action,
eyebrow,
variant = "inline",
size = "md",
className,
}: EmptyStateProps) {
const body = (
<EmptyStateBody
icon={icon}
title={title}
description={description}
action={action}
eyebrow={eyebrow}
size={size}
/>
);
if (variant === "bare") {
return <div className={cn(SIZE_PAD[size], className)}>{body}</div>;
}
if (variant === "card") {
return (
<Card tone="soft" flat className={cn(SIZE_PAD[size], className)}>
{body}
</Card>
);
}
return (
<div
className={cn(
"rounded-lg border border-dashed border-line bg-panel/60",
SIZE_PAD[size],
className,
)}
>
{body}
</div>
);
}

View File

@@ -0,0 +1,23 @@
import type { ReactNode } from "react";
import { cn } from "@/lib/ui/variants";
interface EyebrowProps {
children: ReactNode;
className?: string;
}
// 眉题/小标签无衬线、eyebrow 字号(含正字距 0.08em)、说明色、大写。
// 统一各页此前 mono/uppercase 互不一致的小标签写法。
export function Eyebrow({ children, className }: EyebrowProps) {
return (
<p
className={cn(
"font-sans text-eyebrow uppercase text-muted-soft",
className,
)}
>
{children}
</p>
);
}

View File

@@ -0,0 +1,49 @@
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
import { cn } from "@/lib/ui/variants";
interface GenerationSkeletonProps {
// 状态文案如「续写中」「AI 正在构思本章…」)。
label: string;
// 呼吸微光占位行数(默认 3
lines?: number;
// sm面板内小占位lg正文区大占位更粗行 + 稍大标签)。
size?: "sm" | "lg";
className?: string;
}
// 占位行宽度序列(错落感,示意即将落笔的段落)。
const LINE_WIDTHS = ["90%", "78%", "84%", "68%", "72%"];
// AI 同步/构思等待占位:三点波动标签 + `ai-breathe` 呼吸微光占位行,
// 比单三点更明显地传达「正在生成」。占位行 aria-hiddenrole=status/aria-live 由
// ThinkingIndicator 提供读屏可知在生成。ai-breathe 自带 reduced-motion 回退。
export function GenerationSkeleton({
label,
lines = 3,
size = "sm",
className,
}: GenerationSkeletonProps) {
return (
<div className={cn("space-y-2.5", className)}>
<ThinkingIndicator
label={label}
className={cn("text-cinnabar", size === "lg" ? "text-sm" : "text-xs")}
/>
<div className="space-y-2" aria-hidden="true">
{Array.from({ length: lines }).map((_, i) => (
<div
key={i}
className={cn(
"ai-breathe rounded bg-line/60",
size === "lg" ? "h-3.5" : "h-3",
)}
style={{
width: LINE_WIDTHS[i % LINE_WIDTHS.length],
animationDelay: `${i * 160}ms`,
}}
/>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,45 @@
import { cn } from "@/lib/ui/variants";
export type MeterTone = "accent" | "success" | "warning" | "danger" | "info";
const meterTones: Record<MeterTone, string> = {
accent: "bg-cinnabar",
success: "bg-pass",
warning: "bg-overdue",
danger: "bg-conflict",
info: "bg-info",
};
interface MeterProps {
/** 0..1,超出范围自动夹取。 */
value: number;
tone?: MeterTone;
label?: string;
className?: string;
}
// 进度条:语义色填充,宽度过渡走动效 token对读屏暴露 progressbar。
export function Meter({ value, tone = "accent", label, className }: MeterProps) {
const pct = Math.max(0, Math.min(1, value)) * 100;
return (
<div
className={cn(
"h-1.5 w-full overflow-hidden rounded-full bg-line/60",
className,
)}
role="progressbar"
aria-valuenow={Math.round(pct)}
aria-valuemin={0}
aria-valuemax={100}
aria-label={label}
>
<div
className={cn(
"h-full rounded-full transition-[width] duration-base ease-standard",
meterTones[tone],
)}
style={{ width: `${pct}%` }}
/>
</div>
);
}

View File

@@ -0,0 +1,30 @@
import type { ReactNode } from "react";
import { cn } from "@/lib/ui/variants";
export type PageWidth = "prose" | "default" | "wide";
const widths: Record<PageWidth, string> = {
prose: "max-w-3xl",
default: "max-w-5xl",
wide: "max-w-7xl",
};
interface PageContainerProps {
children: ReactNode;
width?: PageWidth;
className?: string;
}
// 统一页容器:居中 + 一致的最大宽度与内边距节奏(替换各页手写的 mx-auto max-w-* px-6 py-10
export function PageContainer({
children,
width = "default",
className,
}: PageContainerProps) {
return (
<div className={cn("mx-auto px-6 py-10 sm:px-8", widths[width], className)}>
{children}
</div>
);
}

View File

@@ -1,5 +1,7 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { Eyebrow } from "./Eyebrow";
interface PageHeaderProps { interface PageHeaderProps {
title: string; title: string;
eyebrow?: string; eyebrow?: string;
@@ -16,12 +18,8 @@ export function PageHeader({
return ( return (
<header className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"> <header className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div> <div>
{eyebrow ? ( {eyebrow ? <Eyebrow className="mb-1.5">{eyebrow}</Eyebrow> : null}
<p className="mb-1 font-mono text-xs uppercase tracking-wide text-ink-soft/70"> <h1 className="font-serif text-display-sm text-ink">{title}</h1>
{eyebrow}
</p>
) : null}
<h1 className="font-serif text-2xl text-ink">{title}</h1>
{description ? ( {description ? (
<p className="mt-2 max-w-2xl text-sm leading-6 text-ink-soft"> <p className="mt-2 max-w-2xl text-sm leading-6 text-ink-soft">
{description} {description}

View File

@@ -0,0 +1,20 @@
import type { InputHTMLAttributes, ReactNode } from "react";
import { radioClass } from "@/lib/ui/variants";
interface RadioProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type"> {
label?: ReactNode;
}
// 单选钮:无 label 时只渲染受控原生 input供已有 <label>/布局包裹的调用点直接替换);
// 有 label 时自带 <label> 包裹,保留原生键盘/读屏语义。
export function Radio({ label, className, ...props }: RadioProps) {
const input = <input type="radio" className={radioClass(className)} {...props} />;
if (!label) return input;
return (
<label className="inline-flex items-center gap-2 text-sm text-ink">
{input}
<span>{label}</span>
</label>
);
}

View File

@@ -2,8 +2,11 @@ import type { ReactNode } from "react";
import { cn } from "@/lib/ui/variants"; import { cn } from "@/lib/ui/variants";
import { Eyebrow } from "./Eyebrow";
interface SectionHeaderProps { interface SectionHeaderProps {
title: string; title: string;
eyebrow?: string;
description?: ReactNode; description?: ReactNode;
action?: ReactNode; action?: ReactNode;
className?: string; className?: string;
@@ -11,6 +14,7 @@ interface SectionHeaderProps {
export function SectionHeader({ export function SectionHeader({
title, title,
eyebrow,
description, description,
action, action,
className, className,
@@ -18,7 +22,8 @@ export function SectionHeader({
return ( return (
<div className={cn("flex items-start justify-between gap-4", className)}> <div className={cn("flex items-start justify-between gap-4", className)}>
<div className="min-w-0"> <div className="min-w-0">
<h2 className="font-serif text-base text-ink">{title}</h2> {eyebrow ? <Eyebrow className="mb-1">{eyebrow}</Eyebrow> : null}
<h2 className="font-serif text-title-md text-ink">{title}</h2>
{description ? ( {description ? (
<p className="mt-1 text-sm leading-6 text-ink-soft">{description}</p> <p className="mt-1 text-sm leading-6 text-ink-soft">{description}</p>
) : null} ) : null}

View File

@@ -1,6 +1,7 @@
import type { ReactNode } from "react"; import type { KeyboardEvent, ReactNode } from "react";
import { useRef } from "react";
import { cn, segmentedClass } from "@/lib/ui/variants"; import { cn, focusRing, segmentedClass, transitionUi } from "@/lib/ui/variants";
export interface SegmentOption<T extends string> { export interface SegmentOption<T extends string> {
value: T; value: T;
@@ -22,18 +23,61 @@ export function SegmentedControl<T extends string>({
ariaLabel, ariaLabel,
className, className,
}: SegmentedControlProps<T>) { }: SegmentedControlProps<T>) {
const buttonsRef = useRef<Array<HTMLButtonElement | null>>([]);
const focusOption = (index: number) => {
const clamped = (index + options.length) % options.length;
const target = options[clamped];
if (!target) return;
onChange(target.value);
buttonsRef.current[clamped]?.focus();
};
const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>, index: number) => {
switch (event.key) {
case "ArrowRight":
case "ArrowDown":
event.preventDefault();
focusOption(index + 1);
break;
case "ArrowLeft":
case "ArrowUp":
event.preventDefault();
focusOption(index - 1);
break;
case "Home":
event.preventDefault();
focusOption(0);
break;
case "End":
event.preventDefault();
focusOption(options.length - 1);
break;
default:
break;
}
};
return ( return (
<div className={segmentedClass(className)} role="group" aria-label={ariaLabel}> <div className={segmentedClass(className)} role="radiogroup" aria-label={ariaLabel}>
{options.map((option) => { {options.map((option, index) => {
const selected = option.value === value; const selected = option.value === value;
return ( return (
<button <button
key={option.value} key={option.value}
ref={(node) => {
buttonsRef.current[index] = node;
}}
type="button" type="button"
aria-pressed={selected} role="radio"
aria-checked={selected}
tabIndex={selected ? 0 : -1}
onClick={() => onChange(option.value)} onClick={() => onChange(option.value)}
onKeyDown={(event) => handleKeyDown(event, index)}
className={cn( 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", "rounded-md px-3 py-1.5 text-sm",
transitionUi,
focusRing,
selected selected
? "bg-panel text-cinnabar shadow-paper" ? "bg-panel text-cinnabar shadow-paper"
: "text-ink-soft hover:text-cinnabar", : "text-ink-soft hover:text-cinnabar",

View File

@@ -0,0 +1,18 @@
import { cn } from "@/lib/ui/variants";
interface SkeletonProps {
className?: string;
}
// 加载占位:柔和线条底 + motion-safe 脉冲(尊重 prefers-reduced-motion
export function Skeleton({ className }: SkeletonProps) {
return (
<span
aria-hidden="true"
className={cn(
"block rounded-md bg-line/60 motion-safe:animate-pulse",
className,
)}
/>
);
}

View File

@@ -0,0 +1,18 @@
import { cn } from "@/lib/ui/variants";
interface SpinnerProps {
className?: string;
}
// 转圈忙碌指示环形顶部留缺口motion-safe 旋转reduced-motion 下为静止环。
export function Spinner({ className }: SpinnerProps) {
return (
<span
aria-hidden="true"
className={cn(
"inline-block h-4 w-4 shrink-0 rounded-full border-2 border-current border-t-transparent motion-safe:animate-spin",
className,
)}
/>
);
}

View File

@@ -0,0 +1,41 @@
import { cn } from "@/lib/ui/variants";
export type StatusTone =
| "neutral"
| "accent"
| "success"
| "warning"
| "danger"
| "info";
const dotTones: Record<StatusTone, string> = {
neutral: "bg-ink-soft",
accent: "bg-cinnabar",
success: "bg-pass",
warning: "bg-overdue",
danger: "bg-conflict",
info: "bg-info",
};
interface StatusDotProps {
tone?: StatusTone;
/** 提供 label 时对读屏可见(不单靠颜色传达状态)。 */
label?: string;
className?: string;
}
// 状态点:语义色圆点,配合文案使用;有 label 时暴露给读屏。
export function StatusDot({ tone = "neutral", label, className }: StatusDotProps) {
return (
<span
className={cn(
"inline-block h-2 w-2 shrink-0 rounded-full",
dotTones[tone],
className,
)}
role={label ? "img" : undefined}
aria-label={label}
aria-hidden={label ? undefined : true}
/>
);
}

View File

@@ -0,0 +1,18 @@
import { cn } from "@/lib/ui/variants";
interface StreamingBarProps {
label?: string;
className?: string;
}
// AI 流式/处理中·不确定进度条:细朱砂条来回扫,明确传达"正在工作"。
// 显隐由调用方控制只在生成时挂载reduced-motion 下为静态朱砂条(见 globals.css
export function StreamingBar({ label = "AI 生成中", className }: StreamingBarProps) {
return (
<div
role="progressbar"
aria-label={label}
className={cn("ai-stream-track h-0.5 w-full rounded-full", className)}
/>
);
}

View File

@@ -0,0 +1,309 @@
"use client";
import { useEffect, useId, useRef, useState } from "react";
import {
ChevronDown,
HelpCircle,
PenLine,
RefreshCw,
Sparkles,
Square,
Wand2,
WrapText,
} from "lucide-react";
import { Button } from "@/components/ui/Button";
import { Eyebrow } from "@/components/ui/Eyebrow";
import { SectionHeader } from "@/components/ui/SectionHeader";
import { StatusNote } from "@/components/ui/StatusNote";
import { TextArea } from "@/components/ui/TextArea";
import { PLOT_PRESETS, STYLE_PRESETS, type Preset } from "@/lib/workbench/directive";
import { buttonClass, focusRing } from "@/lib/ui/variants";
interface DirectivePanelProps {
directive: string;
onDirectiveChange: (value: string) => void;
presetIds: readonly string[];
onTogglePreset: (id: string) => void;
// 空章默认展开(新手最需要看到输入框的时机);满章默认收起以让出编辑视野。
defaultOpen: boolean;
// 发送键 = 写本章:把已选文风/剧情 + 自定义要求组装后逐字流式写进正文(不改成聊天气泡)。
streaming: boolean;
onWrite: () => void;
onStop: () => void;
}
// 本章写作指令面板P0-2 / P3-3 提升):正文正上方常驻的「和 AI 说话」输入条。
// 眉题「和 AI 说话」+ 主标题「告诉 AI 这章想怎么写」+ HITL 微文案 +「?这是什么」常驻可见;
// 主 CTA「写本章」突出md 尺寸、独占右侧),空章默认展开露出输入框。
// 发送键即「写本章」,沿用 composeDirective + 文风/剧情 chips + 流式打字机写进正文。
export function DirectivePanel({
directive,
onDirectiveChange,
presetIds,
onTogglePreset,
defaultOpen,
streaming,
onWrite,
onStop,
}: DirectivePanelProps) {
const panelId = useId();
const [open, setOpen] = useState(defaultOpen);
const activeCount = presetIds.length + (directive.trim().length > 0 ? 1 : 0);
return (
<section className="border-b border-line bg-panel px-4 py-4 sm:px-6">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<Eyebrow className="mb-1"> AI </Eyebrow>
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
aria-controls={panelId}
className={`group flex min-w-0 cursor-pointer items-center gap-2 rounded text-left font-serif text-title-md text-ink transition-colors duration-fast ease-standard hover:text-cinnabar ${focusRing}`}
>
<ChevronDown
className={`h-4 w-4 shrink-0 text-muted-soft motion-safe:transition-transform duration-fast ease-standard group-hover:text-cinnabar ${
open ? "rotate-180" : ""
}`}
aria-hidden="true"
/>
<span className="truncate"> AI </span>
</button>
</div>
<div className="flex shrink-0 items-center gap-2">
{activeCount > 0 ? (
<span className="hidden rounded-full border border-line bg-bg px-2.5 py-0.5 text-2xs text-ink-soft sm:inline">
{activeCount}
</span>
) : null}
{/* 常驻主 CTA = 写本章始终可见折叠时也在md 尺寸凸显为本条主动作,逐字流式写进正文。 */}
{streaming ? (
<Button onClick={onStop} variant="danger" size="md">
<Square className="h-4 w-4" aria-hidden="true" />
</Button>
) : (
<Button onClick={onWrite} variant="primary" size="md">
<PenLine className="h-4 w-4" aria-hidden="true" />
<span className="text-2xs font-normal text-panel/75">
·
</span>
</Button>
)}
<HitlHelp />
</div>
</div>
<p className="mt-2 text-caption text-muted-soft">
AI 稿
</p>
{open ? (
<div id={panelId} className="mt-4 space-y-4">
<SectionHeader
title="你的写作要求"
description="选文风、点剧情需求、写自己的要求,只影响本次生成。"
/>
<PresetGroup
label="文风"
presets={STYLE_PRESETS}
presetIds={presetIds}
onToggle={onTogglePreset}
/>
<PresetGroup
label="剧情需求"
presets={PLOT_PRESETS}
presetIds={presetIds}
onToggle={onTogglePreset}
/>
<label className="block">
<span className="mb-1 block text-sm text-ink-soft"></span>
<TextArea
value={directive}
onChange={(e) => onDirectiveChange(e.target.value)}
rows={2}
placeholder="写点要求AI 更懂你(留空则按大纲写)"
/>
</label>
{activeCount > 0 ? (
<StatusNote variant="info">
/
</StatusNote>
) : null}
</div>
) : null}
</section>
);
}
interface AiVerbsRowProps {
streaming: boolean;
onContinue: () => void;
onRefineSelection: () => void;
onRewrite: () => void;
onToolbox: () => void;
}
// 「或让 AI:」次级动作行P1-1 / P3-3常驻贴在中央输入条下方把续写/润色/整章重写/工具箱
// 归为「同一个 AI 的其它用法」act-on-chapter 与导航分家)。用较浅面色 + 更软分隔成组,
// 与上方主指令条拉开层级。每个动词带可见范围提示P1-4。生成中禁用避免与流式写章并发
export function AiVerbsRow({
streaming,
onContinue,
onRefineSelection,
onRewrite,
onToolbox,
}: AiVerbsRowProps) {
return (
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 border-b border-line-soft bg-surface-soft px-4 py-2.5 sm:px-6">
<span className="text-eyebrow uppercase tracking-wide text-muted-soft">
AI
</span>
<VerbButton
icon={WrapText}
label="续写"
hint="接着往下写"
disabled={streaming}
onClick={onContinue}
/>
<VerbButton
icon={Wand2}
label="润色选段"
hint="选中段·打磨"
disabled={streaming}
onClick={onRefineSelection}
/>
<VerbButton
icon={RefreshCw}
label="整章重写"
hint="整章·基于现有重写"
disabled={streaming}
onClick={onRewrite}
/>
<VerbButton
icon={Sparkles}
label="工具箱"
disabled={streaming}
onClick={onToolbox}
/>
</div>
);
}
interface VerbButtonProps {
icon: typeof WrapText;
label: string;
// 可见(非 hover-only范围提示让作者点前就能预判对草稿的影响P1-4。工具箱无范围提示。
hint?: string;
disabled: boolean;
onClick: () => void;
}
function VerbButton({ icon: Icon, label, hint, disabled, onClick }: VerbButtonProps) {
return (
<Button onClick={onClick} disabled={disabled} variant="secondary" size="sm">
<Icon className="h-4 w-4" aria-hidden="true" />
{label}
{hint ? <span className="text-2xs text-ink-soft">{hint}</span> : null}
</Button>
);
}
// 「?这是什么」:常驻、可再发现的说明(非一次性 coach——解释「和 AI 说话」的闭环。
// 按钮 + 轻量弹层Esc / 点击外部关闭,还原焦点。
function HitlHelp() {
const [open, setOpen] = useState(false);
const popId = useId();
const rootRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (!open) return;
const onKeyDown = (e: KeyboardEvent): void => {
if (e.key === "Escape") {
setOpen(false);
buttonRef.current?.focus();
}
};
const onPointerDown = (e: PointerEvent): void => {
const target = e.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="dialog"
aria-expanded={open}
aria-controls={popId}
onClick={() => setOpen((v) => !v)}
className={buttonClass({
variant: "ghost",
size: "sm",
className: "gap-1 text-xs text-ink-soft",
})}
>
<HelpCircle className="h-4 w-4" aria-hidden="true" />
</button>
{open ? (
<div
id={popId}
role="dialog"
aria-label="怎么和 AI 说话"
className="absolute right-0 z-30 mt-2 w-72 max-w-[80vw] rounded-lg border border-line bg-panel p-4 text-sm text-ink-soft shadow-paper"
>
<p className="mb-1 font-serif text-title-md text-ink"> AI </p>
<p className="leading-6">
AI 稿稿
稿
</p>
</div>
) : null}
</div>
);
}
interface PresetGroupProps {
label: string;
presets: readonly Preset[];
presetIds: readonly string[];
onToggle: (id: string) => void;
}
// 一组预设 chips文风 / 剧情需求):点选 toggle选中高亮。id 统一进 presetIds。
function PresetGroup({ label, presets, presetIds, onToggle }: PresetGroupProps) {
return (
<div>
<p className="mb-1 text-sm text-ink-soft">{label}</p>
<div className="flex flex-wrap gap-2" role="group" aria-label={label}>
{presets.map((preset) => {
const active = presetIds.includes(preset.id);
return (
<Button
key={preset.id}
type="button"
aria-pressed={active}
onClick={() => onToggle(preset.id)}
variant={active ? "outline" : "secondary"}
size="sm"
>
{preset.label}
</Button>
);
})}
</div>
</div>
);
}

View File

@@ -1,11 +1,25 @@
"use client"; "use client";
import { Minus, Pin, PinOff, Plus, RotateCcw, Undo2, X } from "lucide-react"; import type { ReactNode } from "react";
import Link from "next/link";
import {
ArrowUpRight,
Minus,
Pin,
PinOff,
Plus,
RotateCcw,
Sparkles,
Undo2,
X,
} from "lucide-react";
import { ThinkingIndicator } from "@/components/ThinkingIndicator"; import { ThinkingIndicator } from "@/components/ThinkingIndicator";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { SectionHeader } from "@/components/ui/SectionHeader";
import { StatusNote } from "@/components/ui/StatusNote"; import { StatusNote } from "@/components/ui/StatusNote";
import { ChapterForeshadowList } from "@/components/workbench/ChapterForeshadowList";
import { chapterForeshadowTotal } from "@/lib/foreshadow/chapterForeshadow";
import { useChapterForeshadow } from "@/lib/foreshadow/useChapterForeshadow";
import { buttonClass } from "@/lib/ui/variants"; import { buttonClass } from "@/lib/ui/variants";
import { import {
isPinned, isPinned,
@@ -35,6 +49,25 @@ export function ChapterAssistant({ projectId, chapterNo }: ChapterAssistantProps
); );
} }
// 窄栏小标题P3-5font-sans + eyebrow 字号/正字距(对齐 Eyebrow 眉题的视觉),
// 但保留 <h3> 语义(不降级为 <p>,守住标题大纲 a11y。可带右侧行内控件如步进器
function PanelLabel({
children,
action,
}: {
children: ReactNode;
action?: ReactNode;
}) {
return (
<div className="flex items-start justify-between gap-3">
<h3 className="font-sans text-eyebrow uppercase tracking-wide text-muted-soft">
{children}
</h3>
{action ? <div className="shrink-0">{action}</div> : null}
</div>
);
}
export function AssistantContent({ projectId, chapterNo }: ChapterAssistantProps) { export function AssistantContent({ projectId, chapterNo }: ChapterAssistantProps) {
const injection = useInjection(projectId, chapterNo); const injection = useInjection(projectId, chapterNo);
const data = injection.data; const data = injection.data;
@@ -43,21 +76,17 @@ export function AssistantContent({ projectId, chapterNo }: ChapterAssistantProps
return ( return (
<div className="px-4"> <div className="px-4">
<SectionHeader <div className="flex items-center justify-between gap-2">
title="本章参考" <h2 className="font-sans text-title-md font-medium text-ink">
action={
injection.saving ? ( </h2>
<ThinkingIndicator {injection.saving ? (
label="保存中" <ThinkingIndicator label="保存中" className="text-xs text-ink-soft" />
className="text-xs text-ink-soft" ) : null}
/> </div>
) : null
}
/>
<section className="mt-4"> <section className="mt-5">
<SectionHeader <PanelLabel
title="AI 写这章会参考"
action={ action={
data ? ( data ? (
<RecentStepper <RecentStepper
@@ -67,10 +96,12 @@ export function AssistantContent({ projectId, chapterNo }: ChapterAssistantProps
/> />
) : null ) : null
} }
/> >
AI
</PanelLabel>
{injection.loading ? ( {injection.loading ? (
<p className="mt-2 text-xs text-ink-soft"></p> <p className="mt-2 text-caption text-ink-soft"></p>
) : !data ? ( ) : !data ? (
// 首次加载失败data 仍为空 → 满屏错误框 + 重试。 // 首次加载失败data 仍为空 → 满屏错误框 + 重试。
<StatusNote variant="danger" className="mt-2 text-xs" title="参考信息暂不可用"> <StatusNote variant="danger" className="mt-2 text-xs" title="参考信息暂不可用">
@@ -113,11 +144,15 @@ export function AssistantContent({ projectId, chapterNo }: ChapterAssistantProps
) : null} ) : null}
{selected.length === 0 ? ( {selected.length === 0 ? (
<p className="mt-2 rounded border border-dashed border-line bg-bg p-3 text-xs text-ink-soft"> // 空态占位(不加标题,避免在 h3 子小节内注入 h2 破坏标题大纲)。
AI / <div className="mt-2 flex flex-col items-center gap-2 py-4 text-center">
<Sparkles className="h-5 w-5 text-muted-soft" aria-hidden="true" />
<p className="max-w-xs text-caption leading-6 text-ink-soft">
AI /
</p> </p>
</div>
) : ( ) : (
<ul className="mt-2 space-y-2"> <ul className="mt-3 space-y-2">
{selected.map((entity) => ( {selected.map((entity) => (
<EntityRow <EntityRow
key={`${entity.kind}:${entity.name}`} key={`${entity.kind}:${entity.name}`}
@@ -137,9 +172,9 @@ export function AssistantContent({ projectId, chapterNo }: ChapterAssistantProps
)} )}
{excluded.length > 0 ? ( {excluded.length > 0 ? (
<div className="mt-3"> <div className="mt-4">
<SectionHeader title="已排除(本章不参考)" /> <PanelLabel></PanelLabel>
<ul className="mt-1 space-y-1"> <ul className="mt-2 space-y-1">
{excluded.map((ref) => ( {excluded.map((ref) => (
<ExcludedRow <ExcludedRow
key={`${ref.kind}:${ref.name}`} key={`${ref.kind}:${ref.name}`}
@@ -153,16 +188,67 @@ export function AssistantContent({ projectId, chapterNo }: ChapterAssistantProps
) : null} ) : null}
</section> </section>
<section className="mt-6"> <ChapterForeshadowSection projectId={projectId} chapterNo={chapterNo} />
<SectionHeader
title="写完后检查" <section className="mt-6 border-t border-line-soft pt-5">
description="写完这章AI 会把一致性、伏笔、文风、节奏各查一遍。到左栏或底栏的「审稿」逐条过。" <PanelLabel></PanelLabel>
/> <p className="mt-1.5 text-caption leading-6 text-ink-soft">
AI 稿
</p>
</section> </section>
</div> </div>
); );
} }
// 「本章伏笔」主动提醒:按当前章号把伏笔分可回收 / 待回收 / 已逾期三类,
// 让作者在写这章时就想起该回收哪条、哪条已逾期。只读,编辑走「伏笔看板」。
function ChapterForeshadowSection({ projectId, chapterNo }: ChapterAssistantProps) {
const { groups, status, reload } = useChapterForeshadow(projectId, chapterNo);
const boardHref = `/projects/${projectId}/foreshadow`;
const isEmpty = chapterForeshadowTotal(groups) === 0;
return (
<section className="mt-6 border-t border-line-soft pt-5">
<PanelLabel
action={<span className="text-2xs text-muted-soft"></span>}
>
</PanelLabel>
{status === "loading" ? (
<p className="mt-2 text-caption text-ink-soft"></p>
) : status === "error" ? (
<StatusNote variant="danger" className="mt-2 text-xs" title="伏笔暂不可用">
<button
type="button"
onClick={reload}
className={buttonClass({ variant: "outline", size: "sm", className: "mt-2" })}
>
<RotateCcw className="h-3.5 w-3.5" aria-hidden="true" />
</button>
</StatusNote>
) : isEmpty ? (
<p className="mt-2 text-caption leading-6 text-ink-soft">
</p>
) : (
<div className="mt-3">
<ChapterForeshadowList groups={groups} />
</div>
)}
<Link
href={boardHref}
className="mt-3 inline-flex items-center gap-1 text-caption text-cinnabar hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cinnabar/35"
>
<ArrowUpRight className="h-3.5 w-3.5" aria-hidden="true" />
</Link>
</section>
);
}
interface EntityRowProps { interface EntityRowProps {
entity: InjectionEntity; entity: InjectionEntity;
pinned: boolean; pinned: boolean;

View File

@@ -0,0 +1,104 @@
import { Flag } from "lucide-react";
import { Badge } from "@/components/ui/Badge";
import { StatusDot, type StatusTone } from "@/components/ui/StatusDot";
import {
chapterForeshadowTotal,
foreshadowStatusLabel,
type ChapterForeshadowGroups,
} from "@/lib/foreshadow/chapterForeshadow";
import type { ForeshadowView } from "@/lib/api/types";
import { badgeClass, cn, type BadgeVariant } from "@/lib/ui/variants";
// 「本章伏笔」分组只读清单:写作台右栏 + 速查抽屉共用DRY
// 三类各成一小块(空块不渲染);每条给状态点 + 编号 + 标题 + 状态徽标。
// 无动画,天然尊重 reduced-motion。空态由调用方决定文案。
interface GroupSpec {
key: keyof ChapterForeshadowGroups;
label: string;
tone: StatusTone;
// 已逾期用琥珀强调整块外框。
emphasize?: boolean;
// 可回收在标签前加 ⚑,提示「本章可安排回收」。
flag?: boolean;
}
// 顺序即优先级:可回收(现在能做)→ 已逾期(急)→ 待回收(在后,仅提示)。
const GROUP_SPECS: readonly GroupSpec[] = [
{ key: "recyclable", label: "可回收", tone: "success", flag: true },
{ key: "overdue", label: "已逾期", tone: "warning", emphasize: true },
{ key: "pending", label: "待回收", tone: "neutral" },
];
// 单条状态 → 徽标色OVERDUE 琥珀 / PARTIAL 朱 / OPEN 中性)。
function statusBadgeVariant(status: string): BadgeVariant {
if (status === "OVERDUE") return "warning";
if (status === "PARTIAL") return "accent";
return "neutral";
}
interface ChapterForeshadowListProps {
groups: ChapterForeshadowGroups;
}
export function ChapterForeshadowList({ groups }: ChapterForeshadowListProps) {
if (chapterForeshadowTotal(groups) === 0) return null;
return (
<div className="space-y-3">
{GROUP_SPECS.map((spec) => (
<ForeshadowGroupBlock key={spec.key} spec={spec} items={groups[spec.key]} />
))}
</div>
);
}
interface ForeshadowGroupBlockProps {
spec: GroupSpec;
items: ForeshadowView[];
}
function ForeshadowGroupBlock({ spec, items }: ForeshadowGroupBlockProps) {
if (items.length === 0) return null;
return (
<div>
<p className="flex items-center gap-1.5 text-2xs uppercase tracking-wide text-muted-soft">
{spec.flag ? (
<Flag className="h-3 w-3 text-pass" aria-hidden="true" />
) : (
<StatusDot tone={spec.tone} />
)}
<span>{spec.label}</span>
<span className="tabular-nums text-ink-soft">{items.length}</span>
</p>
<ul className="mt-1.5 space-y-1">
{items.map((item) => (
<li
key={item.code}
className={cn(
"rounded border px-2 py-1.5",
spec.emphasize
? "border-overdue/35 bg-overdue/10"
: "border-line bg-bg",
)}
>
<div className="flex items-center gap-2">
<span className="shrink-0 font-mono text-2xs text-ink-soft">
{item.code}
</span>
<span className="truncate text-sm text-ink">{item.title}</span>
<span
className={badgeClass({
variant: statusBadgeVariant(item.status),
className: "ml-auto shrink-0",
})}
>
{foreshadowStatusLabel(item.status)}
</span>
</div>
</li>
))}
</ul>
</div>
);
}

View File

@@ -29,7 +29,7 @@ export function ChapterList({
if (collapsed) { if (collapsed) {
return ( return (
<aside <aside
className="hidden shrink-0 border-r border-line bg-panel py-4 xl:block" className="hidden shrink-0 border-r border-line bg-surface-soft py-4 xl:block"
aria-label="目录(已收起)" aria-label="目录(已收起)"
> >
<button <button
@@ -38,7 +38,7 @@ export function ChapterList({
aria-expanded={false} aria-expanded={false}
aria-label="展开目录" aria-label="展开目录"
title="展开目录" title="展开目录"
className={`mx-auto flex h-8 w-8 items-center justify-center rounded text-ink-soft transition-colors hover:bg-[var(--color-cinnabar-wash)] hover:text-cinnabar ${focusRing}`} className={`mx-auto flex h-8 w-8 items-center justify-center rounded-md border border-line bg-panel text-ink-soft transition-colors duration-fast ease-standard hover:border-cinnabar hover:bg-[var(--color-cinnabar-wash)] hover:text-cinnabar ${focusRing}`}
> >
<PanelLeftOpen className="h-4 w-4" aria-hidden="true" /> <PanelLeftOpen className="h-4 w-4" aria-hidden="true" />
</button> </button>
@@ -46,7 +46,7 @@ export function ChapterList({
); );
} }
return ( return (
<aside className="hidden border-r border-line bg-panel py-4 xl:block"> <aside className="hidden border-r border-line bg-surface-soft py-4 xl:block">
<ChapterListContent <ChapterListContent
projectId={projectId} projectId={projectId}
chapters={chapters} chapters={chapters}
@@ -73,7 +73,7 @@ export function ChapterListContent({
return ( return (
<> <>
<div className="flex items-center justify-between px-4"> <div className="flex items-center justify-between px-4">
<h2 className="text-xs font-semibold uppercase tracking-wide text-ink-soft"> <h2 className="font-sans text-eyebrow uppercase tracking-wide text-muted-soft">
</h2> </h2>
{onCollapse ? ( {onCollapse ? (
@@ -82,7 +82,7 @@ export function ChapterListContent({
onClick={onCollapse} onClick={onCollapse}
aria-label="收起目录" aria-label="收起目录"
title="收起目录" title="收起目录"
className={`flex h-6 w-6 items-center justify-center rounded text-ink-soft transition-colors hover:bg-[var(--color-cinnabar-wash)] hover:text-cinnabar ${focusRing}`} className={`flex h-7 w-7 items-center justify-center rounded-md border border-line bg-panel text-ink-soft transition-colors duration-fast ease-standard hover:border-cinnabar hover:bg-[var(--color-cinnabar-wash)] hover:text-cinnabar ${focusRing}`}
> >
<PanelLeftClose className="h-4 w-4" aria-hidden="true" /> <PanelLeftClose className="h-4 w-4" aria-hidden="true" />
</button> </button>
@@ -98,8 +98,8 @@ export function ChapterListContent({
aria-current={active ? "page" : undefined} aria-current={active ? "page" : undefined}
className={ className={
active active
? "flex flex-col gap-0.5 border-l-2 border-cinnabar bg-[var(--color-cinnabar-wash)] px-4 py-2 text-sm text-cinnabar" ? `flex flex-col gap-0.5 border-l-2 border-cinnabar bg-[var(--color-cinnabar-wash)] px-4 py-2 text-sm font-medium text-cinnabar transition-colors duration-fast ease-standard ${focusRing}`
: "flex flex-col gap-0.5 border-l-2 border-transparent px-4 py-2 text-sm text-ink hover:border-cinnabar hover:text-cinnabar" : `flex flex-col gap-0.5 border-l-2 border-transparent px-4 py-2 text-sm text-ink transition-colors duration-fast ease-standard hover:border-line-soft hover:bg-panel/60 hover:text-cinnabar ${focusRing}`
} }
> >
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">

View File

@@ -7,6 +7,7 @@ import { ThinkingIndicator } from "@/components/ThinkingIndicator";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { SectionHeader } from "@/components/ui/SectionHeader"; import { SectionHeader } from "@/components/ui/SectionHeader";
import { StatusNote } from "@/components/ui/StatusNote"; import { StatusNote } from "@/components/ui/StatusNote";
import { StreamingBar } from "@/components/ui/StreamingBar";
import { TextArea } from "@/components/ui/TextArea"; import { TextArea } from "@/components/ui/TextArea";
import type { AiMessageView } from "@/lib/api/types"; import type { AiMessageView } from "@/lib/api/types";
import { friendlyError } from "@/lib/errors/messages"; import { friendlyError } from "@/lib/errors/messages";
@@ -198,6 +199,11 @@ export function ChapterRewritePanel({
</Button> </Button>
</div> </div>
{/* 重写流式期间:气泡区顶部挂一条不确定进度条,明确「进行中」(生成结束卸载)。 */}
{rewrite.isStreaming ? (
<StreamingBar label="重写生成中" className="mt-3" />
) : null}
{/* 内联对话气泡流:本章 rewrite 往复(已落库)+ 本轮进行中气泡(流式逐字),作者右 / AI 左,滚动区。 */} {/* 内联对话气泡流:本章 rewrite 往复(已落库)+ 本轮进行中气泡(流式逐字),作者右 / AI 左,滚动区。 */}
{flatCount > 0 || status === "error" ? ( {flatCount > 0 || status === "error" ? (
<div <div

View File

@@ -22,12 +22,17 @@ import {
useContextDrawerData, useContextDrawerData,
type ContextResource, type ContextResource,
} from "@/lib/workbench/useContextDrawer"; } from "@/lib/workbench/useContextDrawer";
import { chapterForeshadowTotal } from "@/lib/foreshadow/chapterForeshadow";
import { useChapterForeshadow } from "@/lib/foreshadow/useChapterForeshadow";
import { ChapterForeshadowList } from "./ChapterForeshadowList";
// 速查抽屉总开关:一键关闭即回滚(触发按钮据此隐藏,旧侧栏/全宽页路由不受影响)。 // 速查抽屉总开关:一键关闭即回滚(触发按钮据此隐藏,旧侧栏/全宽页路由不受影响)。
export const CONTEXT_DRAWER_ENABLED = true; export const CONTEXT_DRAWER_ENABLED = true;
interface ContextDrawerProps { interface ContextDrawerProps {
projectId: string; projectId: string;
// 当前章号:伏笔速查按此分「可回收 / 待回收 / 已逾期」。
chapterNo: number;
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
// 关闭时把焦点还给触发按钮a11y // 关闭时把焦点还给触发按钮a11y
@@ -39,6 +44,7 @@ interface ContextDrawerProps {
// a11y 复用命令面板/Drawer 范式role=dialog + aria-modal + Esc 关闭 + focus trap + body scroll lock + 还原焦点。 // a11y 复用命令面板/Drawer 范式role=dialog + aria-modal + Esc 关闭 + focus trap + body scroll lock + 还原焦点。
export function ContextDrawer({ export function ContextDrawer({
projectId, projectId,
chapterNo,
open, open,
onClose, onClose,
triggerRef, triggerRef,
@@ -98,7 +104,12 @@ export function ContextDrawer({
<TabBar activeTab={activeTab} onSelect={setActiveTab} /> <TabBar activeTab={activeTab} onSelect={setActiveTab} />
<div className="flex-1 overflow-auto overscroll-contain px-4 py-4"> <div className="flex-1 overflow-auto overscroll-contain px-4 py-4">
<TabPanel projectId={projectId} activeTab={activeTab} data={data} /> <TabPanel
projectId={projectId}
chapterNo={chapterNo}
activeTab={activeTab}
data={data}
/>
</div> </div>
</div> </div>
</div> </div>
@@ -144,12 +155,13 @@ function TabBar({ activeTab, onSelect }: TabBarProps) {
interface TabPanelProps { interface TabPanelProps {
projectId: string; projectId: string;
chapterNo: number;
activeTab: ContextTabKey; activeTab: ContextTabKey;
data: ReturnType<typeof useContextDrawerData>; data: ReturnType<typeof useContextDrawerData>;
} }
// 按当前 Tab 渲染对应速查面板;每个面板都带一个「去完整页编辑 →」链接。 // 按当前 Tab 渲染对应速查面板;每个面板都带一个「去完整页编辑 →」链接。
function TabPanel({ projectId, activeTab, data }: TabPanelProps) { function TabPanel({ projectId, chapterNo, activeTab, data }: TabPanelProps) {
const href = contextTabHref(projectId, activeTab); const href = contextTabHref(projectId, activeTab);
return ( return (
<div role="tabpanel" className="space-y-3"> <div role="tabpanel" className="space-y-3">
@@ -160,7 +172,7 @@ function TabPanel({ projectId, activeTab, data }: TabPanelProps) {
<OutlinePanel resource={data.outline} onRetry={data.reloadOutline} /> <OutlinePanel resource={data.outline} onRetry={data.reloadOutline} />
) : null} ) : null}
{activeTab === "foreshadow" ? ( {activeTab === "foreshadow" ? (
<PlaceholderPanel summary="伏笔账本:埋设/回收窗口与逾期告警在完整看板逐条管理。" /> <ForeshadowPanel projectId={projectId} chapterNo={chapterNo} />
) : null} ) : null}
{activeTab === "rules" ? ( {activeTab === "rules" ? (
<PlaceholderPanel summary="世界硬规则:分级约束正文一致性,在规则页增删改。" /> <PlaceholderPanel summary="世界硬规则:分级约束正文一致性,在规则页增删改。" />
@@ -307,7 +319,32 @@ function OutlinePanel({ resource, onRetry }: OutlinePanelProps) {
); );
} }
// 占位面板(伏笔/规则/文风):抽屉内一句话摘要,完整编辑走下方跳链。 interface ForeshadowPanelProps {
projectId: string;
chapterNo: number;
}
// 伏笔速查(只读):按当前章号分「可回收 / 待回收 / 已逾期」,与右栏本章伏笔同数据源、同分类函数。
function ForeshadowPanel({ projectId, chapterNo }: ForeshadowPanelProps) {
const { groups, status, reload } = useChapterForeshadow(projectId, chapterNo);
if (status === "loading") return <LoadingNote />;
if (status === "error") {
return <ErrorNote message="伏笔暂不可用。" onRetry={reload} />;
}
if (chapterForeshadowTotal(groups) === 0) {
return <EmptyNote text="这一章暂无待回收或已逾期的伏笔(按已验收进度)。" />;
}
return (
<div>
<p className="mb-2 text-2xs uppercase tracking-wide text-muted-soft">
·
</p>
<ChapterForeshadowList groups={groups} />
</div>
);
}
// 占位面板(规则/文风):抽屉内一句话摘要,完整编辑走下方跳链。
function PlaceholderPanel({ summary }: { summary: string }) { function PlaceholderPanel({ summary }: { summary: string }) {
return ( return (
<StatusNote variant="info"> <StatusNote variant="info">

View File

@@ -3,8 +3,8 @@
import { useCallback, useEffect, useRef } from "react"; import { useCallback, useEffect, useRef } from "react";
import { Check, Plus, X } from "lucide-react"; import { Check, Plus, X } from "lucide-react";
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { GenerationSkeleton } from "@/components/ui/GenerationSkeleton";
import { SectionHeader } from "@/components/ui/SectionHeader"; import { SectionHeader } from "@/components/ui/SectionHeader";
import { StatusNote } from "@/components/ui/StatusNote"; import { StatusNote } from "@/components/ui/StatusNote";
import { useContinue } from "@/lib/workbench/useContinue"; import { useContinue } from "@/lib/workbench/useContinue";
@@ -72,12 +72,6 @@ export function ContinuePanel({
</Button> </Button>
</div> </div>
{candidates.length === 0 && busy ? (
<div className="mt-3">
<ThinkingIndicator label="续写中" className="text-xs text-cinnabar" />
</div>
) : null}
{candidates.length === 0 && status === "error" ? ( {candidates.length === 0 && status === "error" ? (
<StatusNote variant="danger" className="mt-3 text-xs" title="续写失败"> <StatusNote variant="danger" className="mt-3 text-xs" title="续写失败">
<button <button
@@ -112,10 +106,13 @@ export function ContinuePanel({
))} ))}
</ul> </ul>
{/* 续写为同步请求(无逐字流):生成期间放一块呼吸微光骨架占位,比单三点更明显地示意正在生成。 */}
{busy ? <GenerationSkeleton label="续写中" className="mt-3" /> : null}
<div className="mt-3"> <div className="mt-3">
<Button <Button
onClick={() => void runGenerate()} onClick={() => void runGenerate()}
disabled={busy} loading={busy}
variant="secondary" variant="secondary"
size="sm" size="sm"
> >

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { GenerationSkeleton } from "@/components/ui/GenerationSkeleton";
import { cn } from "@/lib/ui/variants"; import { cn } from "@/lib/ui/variants";
import type { AiMessageView } from "@/lib/api/types"; import type { AiMessageView } from "@/lib/api/types";
import { bubbleSide, type AcceptAction } from "@/lib/workbench/aiConversation"; import { bubbleSide, type AcceptAction } from "@/lib/workbench/aiConversation";
@@ -73,7 +73,8 @@ function Bubble({ message, action, onAccept }: BubbleProps) {
)} )}
> >
{showThinking ? ( {showThinking ? (
<ThinkingIndicator label="生成中" className="text-sm text-cinnabar" /> // 空正文的进行中 ai 气泡(如同步 refine 的整段等待):呼吸微光骨架,比单三点更明显。
<GenerationSkeleton label="生成中" lines={2} className="min-w-[8rem]" />
) : ( ) : (
<p className="max-h-56 overflow-y-auto whitespace-pre-wrap text-sm text-ink"> <p className="max-h-56 overflow-y-auto whitespace-pre-wrap text-sm text-ink">
{message.content} {message.content}

View File

@@ -3,6 +3,7 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { cn, focusRing, proseBody } from "@/lib/ui/variants"; import { cn, focusRing, proseBody } from "@/lib/ui/variants";
import { ThinkingPlaceholder } from "./ThinkingPlaceholder";
export interface EditorSelection { export interface EditorSelection {
start: number; start: number;
@@ -14,6 +15,9 @@ interface EditorProps {
value: string; value: string;
onChange: (value: string) => void; onChange: (value: string) => void;
streaming: boolean; streaming: boolean;
// 构思中(流式已开始但首 token 未到在正文区覆盖「AI 正在构思本章…」占位,
// 首个 token 到达即消失换成正文。纯展示层,不触碰流式逻辑。
isThinking?: boolean;
// 选区变化上报(供「润色选段」等选区级 AI 动作取材。空选区也会上报start===end // 选区变化上报(供「润色选段」等选区级 AI 动作取材。空选区也会上报start===end
onSelectionChange?: (selection: EditorSelection) => void; onSelectionChange?: (selection: EditorSelection) => void;
} }
@@ -24,6 +28,7 @@ export function Editor({
value, value,
onChange, onChange,
streaming, streaming,
isThinking = false,
onSelectionChange, onSelectionChange,
}: EditorProps) { }: EditorProps) {
const ref = useRef<HTMLTextAreaElement>(null); const ref = useRef<HTMLTextAreaElement>(null);
@@ -46,7 +51,7 @@ export function Editor({
}, [value]); }, [value]);
return ( return (
<div className="mx-auto max-w-prose"> <div className="relative mx-auto max-w-prose">
<label htmlFor="chapter-editor" className="sr-only"> <label htmlFor="chapter-editor" className="sr-only">
</label> </label>
@@ -65,12 +70,13 @@ export function Editor({
focusRing, focusRing,
)} )}
/> />
{streaming ? ( {streaming && !isThinking ? (
<span <span
className="typewriter-cursor ml-0.5 inline-block h-[1.2em] w-[2px] translate-y-1 bg-cinnabar align-middle" className="typewriter-cursor ml-0.5 inline-block h-[1.2em] w-[2px] translate-y-1 bg-cinnabar align-middle"
aria-hidden="true" aria-hidden="true"
/> />
) : null} ) : null}
{isThinking ? <ThinkingPlaceholder /> : null}
</div> </div>
); );
} }

View File

@@ -0,0 +1,49 @@
"use client";
import type { RefObject } from "react";
import { PanelLeft, PanelRight } from "lucide-react";
import { Button } from "@/components/ui/Button";
interface MobileContextBarProps {
chapterNo: number;
onOpenChapters: () => void;
onOpenAssistant: () => void;
chapterTriggerRef: RefObject<HTMLButtonElement | null>;
assistantTriggerRef: RefObject<HTMLButtonElement | null>;
}
// 窄屏/中屏顶部上下文条:目录 / 本章参考经抽屉触达≥xl 直显三栏、此条隐藏。
export 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 xl: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>
);
}

View File

@@ -283,7 +283,8 @@ export function RefinePanel({
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Button <Button
onClick={() => void onRecommunicate(false)} onClick={() => void onRecommunicate(false)}
disabled={busy || clarifying || instruction.trim().length === 0} loading={busy}
disabled={clarifying || instruction.trim().length === 0}
variant="secondary" variant="secondary"
size="sm" size="sm"
> >

View File

@@ -0,0 +1,13 @@
import { GenerationSkeleton } from "@/components/ui/GenerationSkeleton";
// 正文区「AI 正在构思本章」占位(补首 token 前的最大缺口——长首字延迟不再像卡死)。
// 覆盖编辑区absolute inset-0 + bg-bg 遮住空/陈旧正文),三点波动 + 呼吸微光占位行示意即将落笔;
// 首个 token 到达stream 有正文)即由 Editor 卸载换成正文。role=status/aria-live 由内部
// ThinkingIndicator 提供,读屏可知在生成。放在 Editor 的 relative 容器内。
export function ThinkingPlaceholder() {
return (
<div className="absolute inset-0 z-10 rounded bg-bg pt-1">
<GenerationSkeleton label="AI 正在构思本章…" size="lg" lines={5} />
</div>
);
}

View File

@@ -1,32 +1,12 @@
"use client"; "use client";
import Link from "next/link"; import { useEffect, useRef, useState } from "react";
import { useEffect, useId, useRef, useState, type RefObject } from "react";
import {
BookMarked,
ChevronDown,
ClipboardCheck,
FileText,
HelpCircle,
MessagesSquare,
PanelLeft,
PanelRight,
PenLine,
RefreshCw,
Sparkles,
Square,
Wand2,
WrapText,
} from "lucide-react";
import { AppShell } from "@/components/AppShell"; import { AppShell } from "@/components/AppShell";
import { Drawer } from "@/components/Drawer"; import { Drawer } from "@/components/Drawer";
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
import { useToast } from "@/components/Toast"; import { useToast } from "@/components/Toast";
import { Button } from "@/components/ui/Button";
import { SectionHeader } from "@/components/ui/SectionHeader";
import { StatusNote } from "@/components/ui/StatusNote"; import { StatusNote } from "@/components/ui/StatusNote";
import { TextArea } from "@/components/ui/TextArea"; import { StreamingBar } from "@/components/ui/StreamingBar";
import type { ProjectResponse } from "@/lib/api/types"; import type { ProjectResponse } from "@/lib/api/types";
import { friendlyError } from "@/lib/errors/messages"; import { friendlyError } from "@/lib/errors/messages";
import { useAutosave } from "@/lib/autosave/useAutosave"; import { useAutosave } from "@/lib/autosave/useAutosave";
@@ -35,14 +15,8 @@ import {
WORKBENCH_CHAPTER_NO, WORKBENCH_CHAPTER_NO,
type ChapterEntry, type ChapterEntry,
} from "@/lib/workbench/chapter"; } from "@/lib/workbench/chapter";
import { import { composeDirective } from "@/lib/workbench/directive";
composeDirective,
PLOT_PRESETS,
STYLE_PRESETS,
type Preset,
} from "@/lib/workbench/directive";
import { applyRefinement } from "@/lib/workbench/refineApply"; import { applyRefinement } from "@/lib/workbench/refineApply";
import { buttonClass, focusRing } from "@/lib/ui/variants";
import { ChapterList, ChapterListContent } from "./ChapterList"; import { ChapterList, ChapterListContent } from "./ChapterList";
import { ChapterAssistant, AssistantContent } from "./ChapterAssistant"; import { ChapterAssistant, AssistantContent } from "./ChapterAssistant";
import { Editor, type EditorSelection } from "./Editor"; import { Editor, type EditorSelection } from "./Editor";
@@ -50,7 +24,7 @@ import { RefinePanel } from "./RefinePanel";
import { ContinuePanel } from "./ContinuePanel"; import { ContinuePanel } from "./ContinuePanel";
import { InlineToolbox } from "./InlineToolbox"; import { InlineToolbox } from "./InlineToolbox";
import { ChapterRewritePanel } from "./ChapterRewritePanel"; import { ChapterRewritePanel } from "./ChapterRewritePanel";
import { ContextDrawer, CONTEXT_DRAWER_ENABLED } from "./ContextDrawer"; import { ContextDrawer } from "./ContextDrawer";
import { import {
AiConversationDrawer, AiConversationDrawer,
AI_CONVERSATION_ENABLED, AI_CONVERSATION_ENABLED,
@@ -60,6 +34,10 @@ import { GENRES } from "@/lib/wizard/wizard";
import { useGenreGate, toGenreDirective } from "@/lib/workbench/useGenreGate"; import { useGenreGate, toGenreDirective } from "@/lib/workbench/useGenreGate";
import { useAiConversation } from "@/lib/workbench/useAiConversation"; import { useAiConversation } from "@/lib/workbench/useAiConversation";
import { useChapterListCollapse } from "@/lib/workbench/useChapterListCollapse"; import { useChapterListCollapse } from "@/lib/workbench/useChapterListCollapse";
import { useFocusMode } from "@/lib/workbench/useFocusMode";
import { DirectivePanel, AiVerbsRow } from "./AiToolbar";
import { MobileContextBar } from "./MobileContextBar";
import { Toolbar } from "./WorkbenchToolbar";
interface WorkbenchProps { interface WorkbenchProps {
project: ProjectResponse; project: ProjectResponse;
@@ -107,15 +85,21 @@ export function Workbench({
const conversation = useAiConversation(project.id, chapterNo); const conversation = useAiConversation(project.id, chapterNo);
// P2-2桌面「目录」列可收起记忆状态收起时把宽让给正文窄轨留一个展开入口。 // P2-2桌面「目录」列可收起记忆状态收起时把宽让给正文窄轨留一个展开入口。
const chapterList = useChapterListCollapse(); const chapterList = useChapterListCollapse();
// P3-4专注写作开关记忆状态——开启隐藏左侧图标导航 + 目录 TOC让宽给正文。
const focusMode = useFocusMode();
const conversationTriggerRef = useRef<HTMLButtonElement>(null); const conversationTriggerRef = useRef<HTMLButtonElement>(null);
const toast = useToast(); const toast = useToast();
const autosave = useAutosave(project.id, chapterNo, initialText); const autosave = useAutosave(project.id, chapterNo, initialText);
const stream = useDraftStream(); const stream = useDraftStream();
// 构思中 = 流式已开始但首 token 未到(正文区覆盖构思占位);有字后即为在吐字。
const isThinking = stream.isStreaming && stream.state.text.length === 0;
const lastStreamText = useRef(""); const lastStreamText = useRef("");
const canRefine = selection !== null && selection.text.trim().length > 0; const canRefine = selection !== null && selection.text.trim().length > 0;
const chapterTriggerRef = useRef<HTMLButtonElement>(null); const chapterTriggerRef = useRef<HTMLButtonElement>(null);
const assistantTriggerRef = useRef<HTMLButtonElement>(null); const assistantTriggerRef = useRef<HTMLButtonElement>(null);
const contextTriggerRef = useRef<HTMLButtonElement>(null); const contextTriggerRef = useRef<HTMLButtonElement>(null);
// P3-7 流式跟随:正文滚动容器引用(纯展示层,不触碰 SSE/流式数据)。
const editorScrollRef = useRef<HTMLDivElement>(null);
// 流式 token 累积进编辑器(打字机);停止/结束后已生成部分留在草稿。 // 流式 token 累积进编辑器(打字机);停止/结束后已生成部分留在草稿。
useEffect(() => { useEffect(() => {
@@ -132,6 +116,15 @@ export function Workbench({
} }
}, [stream.state.phase, stream.state.text, autosave]); }, [stream.state.phase, stream.state.text, autosave]);
// P3-7 流式跟随:流式且正文增长时把滚动容器贴到底,让新生成的段落保持可见。
// 纯展示层:只读既有 text/streaming 展示态,瞬时定位(无动画,天然尊重 prefers-reduced-motion
useEffect(() => {
if (!stream.isStreaming) return;
const el = editorScrollRef.current;
if (!el) return;
el.scrollTop = el.scrollHeight;
}, [text, stream.isStreaming]);
// 题材 + 三槽指令合并为一条写章指令题材前置effectiveGenre 无 → 仅原指令。 // 题材 + 三槽指令合并为一条写章指令题材前置effectiveGenre 无 → 仅原指令。
const startWrite = (effectiveGenre: string | null): void => { const startWrite = (effectiveGenre: string | null): void => {
const base = composeDirective(directive, presetIds); const base = composeDirective(directive, presetIds);
@@ -271,6 +264,13 @@ export function Workbench({
const wordCount = text.replace(/\s+/g, "").length; const wordCount = text.replace(/\s+/g, "").length;
const closePanel = (): void => setMobilePanel(null); const closePanel = (): void => setMobilePanel(null);
// 桌面栅格列宽:专注模式撤掉目录 TOC 列(仅正文 + 右栏);否则按目录列收起/展开取宽。
const gridCols = focusMode.focus
? "xl:grid-cols-[1fr_18rem]"
: chapterList.collapsed
? "xl:grid-cols-[2.75rem_1fr_18rem]"
: "xl:grid-cols-[12rem_1fr_18rem]";
// 流式里程碑播报(仅终结句,非逐 token完成/停止/失败时写入 role=status 区。 // 流式里程碑播报(仅终结句,非逐 token完成/停止/失败时写入 role=status 区。
const liveMessage = ((): string => { const liveMessage = ((): string => {
switch (stream.state.phase) { switch (stream.state.phase) {
@@ -294,14 +294,12 @@ export function Workbench({
subtitle={`${chapterNo}`} subtitle={`${chapterNo}`}
projectId={project.id} projectId={project.id}
activeNav="write" activeNav="write"
hideNav={focusMode.focus}
> >
<div <div
className={`grid min-h-[calc(100vh-var(--chrome,4rem))] grid-cols-1 xl:h-[calc(100vh-var(--chrome,4rem))] ${ className={`grid min-h-[calc(100vh-var(--chrome,4rem))] grid-cols-1 xl:h-[calc(100vh-var(--chrome,4rem))] ${gridCols}`}
chapterList.collapsed
? "xl:grid-cols-[2.75rem_1fr_18rem]"
: "xl:grid-cols-[12rem_1fr_18rem]"
}`}
> >
{focusMode.focus ? null : (
<ChapterList <ChapterList
projectId={project.id} projectId={project.id}
chapters={chapters} chapters={chapters}
@@ -309,6 +307,7 @@ export function Workbench({
collapsed={chapterList.collapsed} collapsed={chapterList.collapsed}
onToggle={chapterList.toggle} onToggle={chapterList.toggle}
/> />
)}
<section className="flex min-w-0 flex-col bg-bg"> <section className="flex min-w-0 flex-col bg-bg">
<MobileContextBar <MobileContextBar
@@ -386,7 +385,11 @@ export function Workbench({
onClose={() => setRewriteOpen(false)} onClose={() => setRewriteOpen(false)}
/> />
) : null} ) : null}
<div className="flex-1 overflow-auto px-6 py-8"> {/* 写本章流式期间:编辑区顶部常挂一条不确定进度条,明确「进行中」(生成结束卸载)。 */}
{stream.isStreaming ? (
<StreamingBar label="AI 正在写本章" className="shrink-0" />
) : null}
<div ref={editorScrollRef} className="flex-1 overflow-auto px-6 py-8">
{chapters.length === 0 ? ( {chapters.length === 0 ? (
<StatusNote variant="info" className="mb-4"> <StatusNote variant="info" className="mb-4">
@@ -396,6 +399,7 @@ export function Workbench({
value={text} value={text}
onChange={onEditorChange} onChange={onEditorChange}
streaming={stream.isStreaming} streaming={stream.isStreaming}
isThinking={isThinking}
onSelectionChange={setSelection} onSelectionChange={setSelection}
/> />
</div> </div>
@@ -409,6 +413,8 @@ export function Workbench({
streamError={stream.state.error} streamError={stream.state.error}
liveMessage={liveMessage} liveMessage={liveMessage}
onStop={stream.stop} onStop={stream.stop}
focus={focusMode.focus}
onToggleFocus={focusMode.toggle}
onOpenContext={() => setContextOpen(true)} onOpenContext={() => setContextOpen(true)}
contextTriggerRef={contextTriggerRef} contextTriggerRef={contextTriggerRef}
onOpenConversation={() => setConversationOpen(true)} onOpenConversation={() => setConversationOpen(true)}
@@ -446,6 +452,7 @@ export function Workbench({
{/* WFW-6 上下文速查:视口无关的右侧 slide-over旧侧栏/全宽页路由保留、可回滚。 */} {/* WFW-6 上下文速查:视口无关的右侧 slide-over旧侧栏/全宽页路由保留、可回滚。 */}
<ContextDrawer <ContextDrawer
projectId={project.id} projectId={project.id}
chapterNo={chapterNo}
open={contextOpen} open={contextOpen}
onClose={() => setContextOpen(false)} onClose={() => setContextOpen(false)}
triggerRef={contextTriggerRef} triggerRef={contextTriggerRef}
@@ -467,471 +474,3 @@ export function Workbench({
</AppShell> </AppShell>
); );
} }
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 xl: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;
presetIds: readonly string[];
onTogglePreset: (id: string) => void;
// 空章默认展开(新手最需要看到输入框的时机);满章默认收起以让出编辑视野。
defaultOpen: boolean;
// 发送键 = 写本章:把已选文风/剧情 + 自定义要求组装后逐字流式写进正文(不改成聊天气泡)。
streaming: boolean;
onWrite: () => void;
onStop: () => void;
}
// 本章写作指令面板P0-2 提升):正文正上方常驻的「和 AI 说话」输入条。
// 标题「告诉 AI 这章想怎么写」+ HITL 微文案 +「?这是什么」常驻可见;空章默认展开露出输入框。
// 发送键即「写本章」,沿用 composeDirective + 文风/剧情 chips + 流式打字机写进正文。
function DirectivePanel({
directive,
onDirectiveChange,
presetIds,
onTogglePreset,
defaultOpen,
streaming,
onWrite,
onStop,
}: DirectivePanelProps) {
const panelId = useId();
const [open, setOpen] = useState(defaultOpen);
const activeCount = presetIds.length + (directive.trim().length > 0 ? 1 : 0);
return (
<section className="border-b border-line bg-panel px-4 py-3 sm:px-6">
<div className="flex items-center justify-between gap-3">
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
aria-controls={panelId}
className={`flex min-w-0 cursor-pointer items-center gap-2 rounded font-serif text-base text-ink hover:text-cinnabar ${focusRing}`}
>
<ChevronDown
className={`h-4 w-4 shrink-0 text-ink-soft transition-transform ${
open ? "rotate-180" : ""
}`}
aria-hidden="true"
/>
<span className="truncate"> AI </span>
</button>
<div className="flex shrink-0 items-center gap-2">
{activeCount > 0 ? (
<span className="rounded border border-line bg-bg px-2 py-0.5 text-xs text-ink-soft">
{activeCount}
</span>
) : null}
{/* 常驻「发送键」= 写本章始终可见折叠时也在逐字流式写进正文。带可见范围提示P1-4。 */}
{streaming ? (
<Button onClick={onStop} variant="danger" size="sm">
<Square className="h-4 w-4" aria-hidden="true" />
</Button>
) : (
<Button onClick={onWrite} variant="primary" size="sm">
<PenLine className="h-4 w-4" aria-hidden="true" />
<span className="text-2xs font-normal text-panel/75">
·
</span>
</Button>
)}
<HitlHelp />
</div>
</div>
<p className="mt-1 text-xs text-ink-soft">
AI 稿
</p>
{open ? (
<div id={panelId} className="mt-3 space-y-3">
<SectionHeader
title="你的写作要求"
description="选文风、点剧情需求、写自己的要求,只影响本次生成。"
/>
<PresetGroup
label="文风"
presets={STYLE_PRESETS}
presetIds={presetIds}
onToggle={onTogglePreset}
/>
<PresetGroup
label="剧情需求"
presets={PLOT_PRESETS}
presetIds={presetIds}
onToggle={onTogglePreset}
/>
<label className="block">
<span className="mb-1 block text-sm text-ink-soft"></span>
<TextArea
value={directive}
onChange={(e) => onDirectiveChange(e.target.value)}
rows={2}
placeholder="写点要求AI 更懂你(留空则按大纲写)"
/>
</label>
{activeCount > 0 ? (
<StatusNote variant="info">
/
</StatusNote>
) : null}
</div>
) : null}
</section>
);
}
interface AiVerbsRowProps {
streaming: boolean;
onContinue: () => void;
onRefineSelection: () => void;
onRewrite: () => void;
onToolbox: () => void;
}
// 「或让 AI:」次级动作行P1-1常驻贴在中央输入条下方把续写/润色/整章重写/工具箱
// 归为「同一个 AI 的其它用法」act-on-chapter 与导航分家。每个动词带可见范围提示P1-4
// 生成中禁用(避免与流式写章并发),触发逻辑沿用 Workbench 既有 open* 回调(仅搬家、不改行为)。
function AiVerbsRow({
streaming,
onContinue,
onRefineSelection,
onRewrite,
onToolbox,
}: AiVerbsRowProps) {
return (
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 border-b border-line bg-panel px-4 py-2 sm:px-6">
<span className="text-xs text-ink-soft"> AI</span>
<VerbButton
icon={WrapText}
label="续写"
hint="接着往下写"
disabled={streaming}
onClick={onContinue}
/>
<VerbButton
icon={Wand2}
label="润色选段"
hint="选中段·打磨"
disabled={streaming}
onClick={onRefineSelection}
/>
<VerbButton
icon={RefreshCw}
label="整章重写"
hint="整章·基于现有重写"
disabled={streaming}
onClick={onRewrite}
/>
<VerbButton
icon={Sparkles}
label="工具箱"
disabled={streaming}
onClick={onToolbox}
/>
</div>
);
}
interface VerbButtonProps {
icon: typeof WrapText;
label: string;
// 可见(非 hover-only范围提示让作者点前就能预判对草稿的影响P1-4。工具箱无范围提示。
hint?: string;
disabled: boolean;
onClick: () => void;
}
function VerbButton({ icon: Icon, label, hint, disabled, onClick }: VerbButtonProps) {
return (
<Button onClick={onClick} disabled={disabled} variant="secondary" size="sm">
<Icon className="h-4 w-4" aria-hidden="true" />
{label}
{hint ? <span className="text-2xs text-ink-soft">{hint}</span> : null}
</Button>
);
}
// 「?这是什么」:常驻、可再发现的说明(非一次性 coach——解释「和 AI 说话」的闭环。
// 仿 AiToolbarMoreMenu按钮 + 轻量弹层Esc / 点击外部关闭,还原焦点。
function HitlHelp() {
const [open, setOpen] = useState(false);
const popId = useId();
const rootRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (!open) return;
const onKeyDown = (e: KeyboardEvent): void => {
if (e.key === "Escape") {
setOpen(false);
buttonRef.current?.focus();
}
};
const onPointerDown = (e: PointerEvent): void => {
const target = e.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="dialog"
aria-expanded={open}
aria-controls={popId}
onClick={() => setOpen((v) => !v)}
className={buttonClass({
variant: "ghost",
size: "sm",
className: "gap-1 text-xs text-ink-soft",
})}
>
<HelpCircle className="h-4 w-4" aria-hidden="true" />
</button>
{open ? (
<div
id={popId}
role="dialog"
aria-label="怎么和 AI 说话"
className="absolute right-0 z-30 mt-2 w-72 max-w-[80vw] rounded border border-line bg-panel p-3 text-sm text-ink-soft shadow-paper"
>
<p className="mb-1 font-serif text-sm text-ink"> AI </p>
<p>
AI 稿稿
稿
</p>
</div>
) : null}
</div>
);
}
interface PresetGroupProps {
label: string;
presets: readonly Preset[];
presetIds: readonly string[];
onToggle: (id: string) => void;
}
// 一组预设 chips文风 / 剧情需求):点选 toggle选中高亮。id 统一进 presetIds。
function PresetGroup({ label, presets, presetIds, onToggle }: PresetGroupProps) {
return (
<div>
<p className="mb-1 text-sm text-ink-soft">{label}</p>
<div className="flex flex-wrap gap-2" role="group" aria-label={label}>
{presets.map((preset) => {
const active = presetIds.includes(preset.id);
return (
<Button
key={preset.id}
type="button"
aria-pressed={active}
onClick={() => onToggle(preset.id)}
variant={active ? "outline" : "secondary"}
size="sm"
>
{preset.label}
</Button>
);
})}
</div>
</div>
);
}
// 写章失败提示:友好文案(不暴露 raw code+ 可选「去设置」动作F4
function StreamErrorNote({
streamError,
}: {
streamError: { code: string; message: string };
}) {
const friendly = friendlyError(streamError.code, streamError.message);
return (
<p className="mb-2 text-sm text-conflict">
{friendly.text}
{friendly.actionHref ? (
<>
{" "}
<Link
href={friendly.actionHref}
className="underline hover:text-cinnabar"
>
{friendly.actionLabel}
</Link>
</>
) : null}
</p>
);
}
// 底栏瘦身P1-1act-on-chapter 动词与纯导航都已上移/移除,底栏只余
// 状态(字数/保存/播报)+ 流式时的「停」+ 唯一强调的「审稿」前进 CTA + 速查/对话记录抽屉入口。
interface ToolbarProps {
projectId: string;
chapterNo: number;
wordCount: number;
savedLabel: string | null;
saveStatus: ReturnType<typeof useAutosave>["status"];
streaming: boolean;
streamError: { code: string; message: string } | null;
// 流式里程碑播报句(完成/停止/失败);仅在 role=status 区出现,不逐 token 刷新。
liveMessage: string;
// 流式时的「停」:底栏 sticky滚到长正文下方也够得着中央条会滚出视野
onStop: () => void;
// 打开上下文速查抽屉WFW-6
onOpenContext: () => void;
contextTriggerRef: RefObject<HTMLButtonElement | null>;
// 打开对话记录抽屉AC-3
onOpenConversation: () => void;
conversationTriggerRef: RefObject<HTMLButtonElement | null>;
}
function Toolbar({
projectId,
chapterNo,
wordCount,
savedLabel,
saveStatus,
streaming,
streamError,
liveMessage,
onStop,
onOpenContext,
contextTriggerRef,
onOpenConversation,
conversationTriggerRef,
}: ToolbarProps) {
return (
<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="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 onClick={onStop} variant="danger" size="sm">
<Square className="h-4 w-4" aria-hidden="true" />
</Button>
) : null}
{streaming ? (
// ThinkingIndicator 自带 role=status开始时播报一次「生成中」
// 易变字数 aria-hidden仅供视觉不逐 token 打扰屏读。
<span className="inline-flex items-center gap-2">
<ThinkingIndicator label="生成中" className="text-xs text-cinnabar" />
<span
aria-hidden="true"
className="font-mono text-xs text-cinnabar"
>
{wordCount}
</span>
</span>
) : (
<span className="font-mono text-xs text-ink-soft">
{wordCount}
</span>
)}
{/* 终结句播报区:完成/停止/失败时写入,屏读只播报里程碑。 */}
<span className="sr-only" role="status" aria-live="polite">
{liveMessage}
</span>
{CONTEXT_DRAWER_ENABLED ? (
<Button
ref={contextTriggerRef}
onClick={onOpenContext}
variant="secondary"
size="sm"
title="打开上下文速查(设定库/大纲/伏笔/规则/文风)"
>
<BookMarked className="h-4 w-4" aria-hidden="true" />
</Button>
) : null}
{AI_CONVERSATION_ENABLED ? (
<Button
ref={conversationTriggerRef}
onClick={onOpenConversation}
variant="secondary"
size="sm"
title="打开对话记录(润色/重写/续写/工具箱往复留痕的只读日志)"
>
<MessagesSquare className="h-4 w-4" aria-hidden="true" />
</Button>
) : null}
{/* 唯一强调的「审稿」= 写→审→验收的前进 CTA导航去重后仅此一处强调左栏另有入口。 */}
<Link
href={`/projects/${projectId}/review?chapter=${chapterNo}`}
className={buttonClass({ variant: "outline", size: "sm" })}
>
<ClipboardCheck className="h-4 w-4" aria-hidden="true" />
稿
</Link>
</div>
<span
role="status"
aria-live="polite"
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"
? "保存失败"
: (savedLabel ?? "尚未保存")}
</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,209 @@
"use client";
import Link from "next/link";
import type { RefObject } from "react";
import {
BookMarked,
ClipboardCheck,
FileText,
Maximize2,
MessagesSquare,
Minimize2,
Square,
} from "lucide-react";
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
import { Button } from "@/components/ui/Button";
import type { useAutosave } from "@/lib/autosave/useAutosave";
import { friendlyError } from "@/lib/errors/messages";
import { buttonClass } from "@/lib/ui/variants";
import { CONTEXT_DRAWER_ENABLED } from "./ContextDrawer";
import { AI_CONVERSATION_ENABLED } from "./AiConversationDrawer";
// 写章失败提示:友好文案(不暴露 raw code+ 可选「去设置」动作F4
function StreamErrorNote({
streamError,
}: {
streamError: { code: string; message: string };
}) {
const friendly = friendlyError(streamError.code, streamError.message);
return (
<p className="mb-2 text-sm text-conflict">
{friendly.text}
{friendly.actionHref ? (
<>
{" "}
<Link
href={friendly.actionHref}
className="underline hover:text-cinnabar"
>
{friendly.actionLabel}
</Link>
</>
) : null}
</p>
);
}
// 专注写作开关P3-4图标按钮进入=隐藏左侧图标导航 + 目录 TOC 加宽正文,退出还原。
// aria-pressed 表状态、aria-label 表动作,键盘可达(复用共享 Button 焦点环)。
function FocusToggle({
focus,
onToggle,
}: {
focus: boolean;
onToggle: () => void;
}) {
return (
<Button
onClick={onToggle}
variant="secondary"
size="sm"
aria-pressed={focus}
aria-label={focus ? "退出专注写作模式" : "进入专注写作模式(隐藏侧栏,加宽正文)"}
title={focus ? "退出专注写作" : "专注写作"}
>
{focus ? (
<Minimize2 className="h-4 w-4" aria-hidden="true" />
) : (
<Maximize2 className="h-4 w-4" aria-hidden="true" />
)}
</Button>
);
}
// 底栏P1-1 瘦身 + P3-6 分区):按「信息 | 操作 | 状态」三区排布。
// 信息=字数/生成中;操作=速查/对话记录/审稿/专注(+流式时的「停」);状态=保存态(右侧)。
interface ToolbarProps {
projectId: string;
chapterNo: number;
wordCount: number;
savedLabel: string | null;
saveStatus: ReturnType<typeof useAutosave>["status"];
streaming: boolean;
streamError: { code: string; message: string } | null;
// 流式里程碑播报句(完成/停止/失败);仅在 role=status 区出现,不逐 token 刷新。
liveMessage: string;
// 流式时的「停」:底栏 sticky滚到长正文下方也够得着中央条会滚出视野
onStop: () => void;
// 专注写作开关P3-4
focus: boolean;
onToggleFocus: () => void;
// 打开上下文速查抽屉WFW-6
onOpenContext: () => void;
contextTriggerRef: RefObject<HTMLButtonElement | null>;
// 打开对话记录抽屉AC-3
onOpenConversation: () => void;
conversationTriggerRef: RefObject<HTMLButtonElement | null>;
}
export function Toolbar({
projectId,
chapterNo,
wordCount,
savedLabel,
saveStatus,
streaming,
streamError,
liveMessage,
onStop,
focus,
onToggleFocus,
onOpenContext,
contextTriggerRef,
onOpenConversation,
conversationTriggerRef,
}: ToolbarProps) {
return (
<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="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 onClick={onStop} variant="danger" size="sm">
<Square className="h-4 w-4" aria-hidden="true" />
</Button>
) : null}
{streaming ? (
// ThinkingIndicator 自带 role=status开始时播报一次「生成中」
// 易变字数 aria-hidden仅供视觉不逐 token 打扰屏读。
<span className="inline-flex items-center gap-2">
<ThinkingIndicator
label="生成中"
className="text-xs text-cinnabar"
/>
<span
aria-hidden="true"
className="font-mono text-xs text-cinnabar"
>
{wordCount}
</span>
</span>
) : (
<span className="font-mono text-xs text-ink-soft">
{wordCount}
</span>
)}
{/* 终结句播报区:完成/停止/失败时写入,屏读只播报里程碑。 */}
<span className="sr-only" role="status" aria-live="polite">
{liveMessage}
</span>
</div>
{/* 操作区:速查 / 对话记录 / 审稿 / 专注;与信息区以细分隔线拉开。 */}
<div className="flex min-w-0 flex-wrap items-center gap-2 sm:border-l sm:border-line-soft sm:pl-3">
{CONTEXT_DRAWER_ENABLED ? (
<Button
ref={contextTriggerRef}
onClick={onOpenContext}
variant="secondary"
size="sm"
title="打开上下文速查(设定库/大纲/伏笔/规则/文风)"
>
<BookMarked className="h-4 w-4" aria-hidden="true" />
</Button>
) : null}
{AI_CONVERSATION_ENABLED ? (
<Button
ref={conversationTriggerRef}
onClick={onOpenConversation}
variant="secondary"
size="sm"
title="打开对话记录(润色/重写/续写/工具箱往复留痕的只读日志)"
>
<MessagesSquare className="h-4 w-4" aria-hidden="true" />
</Button>
) : null}
<FocusToggle focus={focus} onToggle={onToggleFocus} />
{/* 唯一强调的「审稿」= 写→审→验收的前进 CTA导航去重后仅此一处强调左栏另有入口。 */}
<Link
href={`/projects/${projectId}/review?chapter=${chapterNo}`}
className={buttonClass({ variant: "outline", size: "sm" })}
>
<ClipboardCheck className="h-4 w-4" aria-hidden="true" />
稿
</Link>
</div>
{/* 状态区:保存态,右侧对齐。 */}
<span
role="status"
aria-live="polite"
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"
? "保存失败"
: (savedLabel ?? "尚未保存")}
</span>
</div>
</div>
);
}

View File

@@ -93,6 +93,29 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/projects/{project_id}/chapters": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* List Chapters
* @description 列出该项目「真实存在的章」+ 可审/已审标记(审稿页选章下拉枚举)。
*
* 来源 `chapters`(草稿/accepted+ `chapter_reviews`,按章号去重升序。只读、不触 LLM。
* 项目不存在 → 404同其它端点owner 走 project_repo stub 校验)。
*/
get: operations["list_chapters_projects__project_id__chapters_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/projects/{project_id}/chapters/{chapter_no}/injection": { "/projects/{project_id}/chapters/{chapter_no}/injection": {
parameters: { parameters: {
query?: never; query?: never;
@@ -1123,6 +1146,33 @@ export interface components {
*/ */
count: number; count: number;
}; };
/**
* ChapterListItem
* @description GET /projects/:id/chapters 单项:一个真实存在的章 + 可审/已审标记snake_case
*
* 数据来源为 `chapters`(草稿/accepted 版本)与 `chapter_reviews`,按章号去重升序。
* `has_draft`=有非空草稿正文(可审);`accepted`=已有 accepted 版本;
* `reviewed_at`=最近一次审稿时间(无则 null
*/
ChapterListItem: {
/** Chapter No */
chapter_no: number;
/**
* Has Draft
* @description 是否有非空草稿正文(可审)
*/
has_draft: boolean;
/**
* Accepted
* @description 是否已验收(存在 accepted 版本)
*/
accepted: boolean;
/**
* Reviewed At
* @description 最近一次审稿时间;未审为 null
*/
reviewed_at?: string | null;
};
/** /**
* CharacterCardView * CharacterCardView
* @description 单张角色卡(贴 ww_agents.CharacterCard预览 + 入库请求共用形)。 * @description 单张角色卡(贴 ww_agents.CharacterCard预览 + 入库请求共用形)。
@@ -2857,6 +2907,46 @@ export interface operations {
}; };
}; };
}; };
list_chapters_projects__project_id__chapters_get: {
parameters: {
query?: never;
header?: never;
path: {
project_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ChapterListItem"][];
};
};
/** @description 资源不存在 */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
get_injection_projects__project_id__chapters__chapter_no__injection_get: { get_injection_projects__project_id__chapters__chapter_no__injection_get: {
parameters: { parameters: {
query?: never; query?: never;

View File

@@ -1,5 +1,6 @@
import { serverApiBase } from "./config"; import { serverApiBase } from "./config";
import type { import type {
ChapterListItem,
CharacterListResponse, CharacterListResponse,
DraftView, DraftView,
ForeshadowBoardResponse, ForeshadowBoardResponse,
@@ -150,6 +151,18 @@ export async function fetchOutline(
} }
} }
// 列章GET .../chapters裸数组审稿选章枚举真实存在的章 + 可审/已审标记。
// 任何错误降级为空列表(进页不阻塞,回退到大纲/当前章导航)。
export async function fetchChapters(
projectId: string,
): Promise<ChapterListItem[]> {
try {
return await getJson<ChapterListItem[]>(`/projects/${projectId}/chapters`);
} catch {
return [];
}
}
// 提示词/模板库列表GET /templates全局只读裸数组 // 提示词/模板库列表GET /templates全局只读裸数组
// 任何错误(含后端未上线)降级为空列表,模板页照常渲染(进页不阻塞)。 // 任何错误(含后端未上线)降级为空列表,模板页照常渲染(进页不阻塞)。
export async function fetchTemplates(): Promise<TemplateResponse[]> { export async function fetchTemplates(): Promise<TemplateResponse[]> {

View File

@@ -33,6 +33,8 @@ export type ReviewHistoryResponse =
export type ConflictDecision = components["schemas"]["ConflictDecision"]; export type ConflictDecision = components["schemas"]["ConflictDecision"];
export type AcceptRequest = components["schemas"]["AcceptRequest"]; export type AcceptRequest = components["schemas"]["AcceptRequest"];
export type AcceptResponse = components["schemas"]["AcceptResponse"]; export type AcceptResponse = components["schemas"]["AcceptResponse"];
// 列章GET .../chapters裸数组审稿选章枚举真实存在的章 + 可审/已审标记。
export type ChapterListItem = components["schemas"]["ChapterListItem"];
// Scope B 多章工作流链(发起 / 续跑;进度走 GET /jobs/{id} 轮询)。 // Scope B 多章工作流链(发起 / 续跑;进度走 GET /jobs/{id} 轮询)。
export type ChainRunRequest = components["schemas"]["ChainRunRequest"]; export type ChainRunRequest = components["schemas"]["ChainRunRequest"];

View File

@@ -0,0 +1,104 @@
import { describe, expect, it } from "vitest";
import type { ForeshadowView } from "@/lib/api/types";
import {
chapterForeshadowTotal,
classifyChapterForeshadow,
foreshadowStatusLabel,
} from "./chapterForeshadow";
const view = (over: Partial<ForeshadowView>): ForeshadowView => ({
code: "F-001",
title: "线索",
status: "OPEN",
...over,
});
describe("classifyChapterForeshadow", () => {
it("puts items whose window covers the current chapter into 可回收", () => {
const groups = classifyChapterForeshadow(
[
view({ code: "F-A", status: "OPEN", expected_close_from: 40, expected_close_to: 60 }),
view({ code: "F-B", status: "PARTIAL", expected_close_to: 50 }),
],
50,
);
expect(groups.recyclable.map((v) => v.code)).toEqual(["F-A", "F-B"]);
expect(groups.pending).toEqual([]);
expect(groups.overdue).toEqual([]);
});
it("puts OPEN/PARTIAL with a later or missing window into 待回收", () => {
const groups = classifyChapterForeshadow(
[
view({ code: "F-LATER", status: "OPEN", expected_close_from: 80, expected_close_to: 90 }),
view({ code: "F-NOWIN", status: "PARTIAL" }),
],
50,
);
expect(groups.pending.map((v) => v.code)).toEqual(["F-LATER", "F-NOWIN"]);
expect(groups.recyclable).toEqual([]);
});
it("puts OVERDUE into 已逾期 even when a window would otherwise cover the chapter", () => {
const groups = classifyChapterForeshadow(
[view({ code: "F-OD", status: "OVERDUE", expected_close_from: 40, expected_close_to: 60 })],
50,
);
expect(groups.overdue.map((v) => v.code)).toEqual(["F-OD"]);
expect(groups.recyclable).toEqual([]);
});
it("drops CLOSED and unknown statuses from every group", () => {
const groups = classifyChapterForeshadow(
[
view({ code: "F-CLOSED", status: "CLOSED", expected_close_to: 60 }),
view({ code: "F-WEIRD", status: "WEIRD" }),
],
50,
);
expect(chapterForeshadowTotal(groups)).toBe(0);
});
it("handles the window boundary: inclusive at from and to, excluded just outside", () => {
const win = (code: string): ForeshadowView =>
view({ code, status: "OPEN", expected_close_from: 40, expected_close_to: 60 });
expect(classifyChapterForeshadow([win("lo")], 40).recyclable).toHaveLength(1);
expect(classifyChapterForeshadow([win("hi")], 60).recyclable).toHaveLength(1);
expect(classifyChapterForeshadow([win("before")], 39).pending).toHaveLength(1);
expect(classifyChapterForeshadow([win("after")], 61).pending).toHaveLength(1);
});
it("returns empty groups for empty / undefined input without mutating a shared object", () => {
const a = classifyChapterForeshadow(undefined, 1);
const b = classifyChapterForeshadow([], 1);
expect(chapterForeshadowTotal(a)).toBe(0);
expect(chapterForeshadowTotal(b)).toBe(0);
// 不可变:两次调用返回相互独立的空组
a.pending.push(view({}));
expect(b.pending).toEqual([]);
});
});
describe("chapterForeshadowTotal", () => {
it("sums the three lanes", () => {
const groups = classifyChapterForeshadow(
[
view({ code: "r", status: "OPEN", expected_close_to: 50 }),
view({ code: "p", status: "OPEN" }),
view({ code: "o", status: "OVERDUE" }),
],
50,
);
expect(chapterForeshadowTotal(groups)).toBe(3);
});
});
describe("foreshadowStatusLabel", () => {
it("maps known statuses to author-facing labels and falls back for unknown", () => {
expect(foreshadowStatusLabel("OPEN")).toBe("待推进");
expect(foreshadowStatusLabel("PARTIAL")).toBe("推进中");
expect(foreshadowStatusLabel("OVERDUE")).toBe("已逾期");
expect(foreshadowStatusLabel("WEIRD")).toBe("WEIRD");
});
});

View File

@@ -0,0 +1,51 @@
// 「本章伏笔」主动提醒的纯分类逻辑(写作台右栏 / 速查抽屉共用)。
// 输入全量伏笔 + 当前章号,用 board 的 isWindowApproaching + status 分三类。
// 纯函数、不可变(返回全新对象,不改动入参);便于 node 环境单测。
import type { ForeshadowView } from "@/lib/api/types";
import { isWindowApproaching, LANE_LABELS, type ForeshadowStatus } from "./board";
// 本章伏笔三分类:
// - recyclable 可回收:本章落在 [from,to] 窗口内OPEN/PARTIAL由 isWindowApproaching 判定)。
// - pending 待回收OPEN/PARTIAL 但尚未进入回收窗口(窗口在后 / 未设窗口)。
// - overdue 已逾期status==OVERDUE后端验收后扫描得出略滞后于当前草稿
// CLOSED已回收与未知 status 不进任何一类——本章无需再提醒。
export interface ChapterForeshadowGroups {
recyclable: ForeshadowView[];
pending: ForeshadowView[];
overdue: ForeshadowView[];
}
// 按当前章号把伏笔分三类。纯函数:用 reduce + 展开返回新对象,不可变。
// 种子每次新建(不复用共享常量),空输入也返回独立的新对象。
export function classifyChapterForeshadow(
items: readonly ForeshadowView[] | undefined,
currentChapter: number,
): ChapterForeshadowGroups {
return (items ?? []).reduce<ChapterForeshadowGroups>(
(groups, item) => {
if (item.status === "OVERDUE") {
return { ...groups, overdue: [...groups.overdue, item] };
}
if (isWindowApproaching(item, currentChapter)) {
return { ...groups, recyclable: [...groups.recyclable, item] };
}
if (item.status === "OPEN" || item.status === "PARTIAL") {
return { ...groups, pending: [...groups.pending, item] };
}
return groups; // CLOSED / 未知 status本章不提醒
},
{ recyclable: [], pending: [], overdue: [] },
);
}
// 三类合计条数(是否有可提醒项,便于空态判断)。
export function chapterForeshadowTotal(groups: ChapterForeshadowGroups): number {
return groups.recyclable.length + groups.pending.length + groups.overdue.length;
}
// 单条伏笔的作者向状态文案(复用看板 LANE_LABELS未知 status 回退原值)。
export function foreshadowStatusLabel(status: string): string {
return LANE_LABELS[status as ForeshadowStatus] ?? status;
}

View File

@@ -0,0 +1,79 @@
// @vitest-environment jsdom
import { renderHook, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ForeshadowView } from "@/lib/api/types";
import { useChapterForeshadow } from "./useChapterForeshadow";
// 后端客户端是 hook 的外部副作用边界,单测一律 mock。
const get = vi.fn();
vi.mock("@/lib/api/client", () => ({
api: { GET: (...a: unknown[]) => get(...a) },
}));
function makeRow(over: Partial<ForeshadowView>): ForeshadowView {
return { code: "F-001", title: "线索", status: "OPEN", ...over } as ForeshadowView;
}
describe("useChapterForeshadow", () => {
beforeEach(() => get.mockReset());
afterEach(() => vi.clearAllMocks());
it("拉取成功后按当前章号分类", async () => {
get.mockResolvedValue({
data: {
foreshadow: [
makeRow({ code: "R", status: "OPEN", expected_close_from: 40, expected_close_to: 60 }),
makeRow({ code: "P", status: "OPEN", expected_close_from: 80 }),
makeRow({ code: "O", status: "OVERDUE" }),
],
},
error: null,
});
const { result } = renderHook(() => useChapterForeshadow("p1", 50));
await waitFor(() => expect(result.current.status).toBe("ready"));
expect(result.current.groups.recyclable.map((v) => v.code)).toEqual(["R"]);
expect(result.current.groups.pending.map((v) => v.code)).toEqual(["P"]);
expect(result.current.groups.overdue.map((v) => v.code)).toEqual(["O"]);
});
it("拉取失败 → status=error、空分组", async () => {
get.mockResolvedValue({ data: null, error: { error: {} } });
const { result } = renderHook(() => useChapterForeshadow("p1", 3));
await waitFor(() => expect(result.current.status).toBe("error"));
expect(result.current.groups.recyclable).toEqual([]);
expect(result.current.groups.pending).toEqual([]);
expect(result.current.groups.overdue).toEqual([]);
});
it("切章不重新请求,仅重新分类", async () => {
get.mockResolvedValue({
data: {
foreshadow: [
makeRow({ code: "W", status: "OPEN", expected_close_from: 40, expected_close_to: 60 }),
],
},
error: null,
});
const { result, rerender } = renderHook(
({ ch }: { ch: number }) => useChapterForeshadow("p1", ch),
{ initialProps: { ch: 50 } },
);
await waitFor(() => expect(result.current.status).toBe("ready"));
expect(result.current.groups.recyclable.map((v) => v.code)).toEqual(["W"]);
// 切到窗口外的章:仍是同一份数据,重新分类进 pending且未再次 GET。
rerender({ ch: 10 });
await waitFor(() =>
expect(result.current.groups.pending.map((v) => v.code)).toEqual(["W"]),
);
expect(result.current.groups.recyclable).toEqual([]);
expect(get).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,71 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { api } from "@/lib/api/client";
import type { ForeshadowView } from "@/lib/api/types";
import {
classifyChapterForeshadow,
type ChapterForeshadowGroups,
} from "./chapterForeshadow";
// 写作台「本章伏笔」提醒的数据层拉本项目全量伏笔GET /foreshadow只读
// 再按当前章号本地分类。项目内一次拉取,切章仅重新分类(不重复请求)。
export type ChapterForeshadowStatus = "loading" | "ready" | "error";
export interface UseChapterForeshadow {
groups: ChapterForeshadowGroups;
status: ChapterForeshadowStatus;
reload: () => void;
}
const FORESHADOW_PATH = "/projects/{project_id}/foreshadow" as const;
const EMPTY_ITEMS: ForeshadowView[] = [];
export function useChapterForeshadow(
projectId: string,
chapterNo: number,
): UseChapterForeshadow {
const [items, setItems] = useState<ForeshadowView[]>(EMPTY_ITEMS);
const [status, setStatus] = useState<ChapterForeshadowStatus>("loading");
// reload 触发器:自增即重新拉取。
const [nonce, setNonce] = useState(0);
useEffect(() => {
let cancelled = false;
setStatus("loading");
void (async () => {
try {
const { data, error } = await api.GET(FORESHADOW_PATH, {
params: { path: { project_id: projectId } },
});
if (cancelled) return;
if (error || !data) {
setItems(EMPTY_ITEMS);
setStatus("error");
return;
}
setItems(data.foreshadow ?? EMPTY_ITEMS);
setStatus("ready");
} catch {
// 网络层失败(后端不可达/CORSopenapi-fetch 直接抛而非返回 error 信封。
if (cancelled) return;
setItems(EMPTY_ITEMS);
setStatus("error");
}
})();
return () => {
cancelled = true;
};
}, [projectId, nonce]);
// 切章不重拉,仅重新分类(伏笔窗口相对章号变化)。
const groups = useMemo(
() => classifyChapterForeshadow(items, chapterNo),
[items, chapterNo],
);
const reload = useCallback(() => setNonce((n) => n + 1), []);
return { groups, status, reload };
}

View File

@@ -1,8 +1,25 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { reviewChapterHref, reviewChapterOptions } from "./chapterNav"; import {
buildReviewChapterOptions,
defaultReviewChapter,
reviewChapterHref,
reviewChapterOptions,
} from "./chapterNav";
import type { ChapterListItem } from "@/lib/api/types";
import type { ChapterEntry } from "@/lib/workbench/chapter"; import type { ChapterEntry } from "@/lib/workbench/chapter";
const item = (
chapter_no: number,
over: Partial<ChapterListItem> = {},
): ChapterListItem => ({
chapter_no,
has_draft: true,
accepted: false,
reviewed_at: null,
...over,
});
describe("reviewChapterHref", () => { describe("reviewChapterHref", () => {
it("builds the review route for a chapter number", () => { it("builds the review route for a chapter number", () => {
expect(reviewChapterHref("proj-1", 3)).toBe( expect(reviewChapterHref("proj-1", 3)).toBe(
@@ -15,7 +32,7 @@ describe("reviewChapterHref", () => {
}); });
}); });
describe("reviewChapterOptions", () => { describe("reviewChapterOptions (legacy, outline-only)", () => {
const chapters: ChapterEntry[] = [ const chapters: ChapterEntry[] = [
{ no: 1, title: "开篇" }, { no: 1, title: "开篇" },
{ no: 2 }, { no: 2 },
@@ -32,14 +49,72 @@ describe("reviewChapterOptions", () => {
expect(opts.map((o) => o.no)).toEqual([1, 2, 3, 9]); expect(opts.map((o) => o.no)).toEqual([1, 2, 3, 9]);
}); });
it("labels each option with chapter number and optional title", () => { it("labels each option with chapter number and optional title (never disabled)", () => {
const opts = reviewChapterOptions(chapters, 1); const opts = reviewChapterOptions(chapters, 1);
expect(opts[0]).toEqual({ no: 1, label: "第 1 章 · 开篇" }); expect(opts[0]).toEqual({ no: 1, label: "第 1 章 · 开篇", disabled: false });
expect(opts[1]).toEqual({ no: 2, label: "第 2 章" }); expect(opts[1]).toEqual({ no: 2, label: "第 2 章", disabled: false });
});
}); });
it("returns just the current chapter when there is no outline", () => { describe("buildReviewChapterOptions (real chapters + outline titles)", () => {
const opts = reviewChapterOptions([], 4); const outline: ChapterEntry[] = [
expect(opts).toEqual([{ no: 4, label: "第 4 章" }]); { no: 1, title: "开篇" },
{ no: 2, title: "转折" },
];
it("enumerates real chapters, sorted, with outline titles + status suffix", () => {
const list = [
item(1, { accepted: true }),
item(2, { reviewed_at: "2026-01-01T00:00:00Z" }),
];
const opts = buildReviewChapterOptions(list, outline, 2);
expect(opts).toEqual([
{ no: 1, label: "第 1 章 · 开篇 · 已验收", disabled: false },
{ no: 2, label: "第 2 章 · 转折 · 已审", disabled: false },
]);
});
it("covers chapters written but not in the outline, and shows outline structure", () => {
const list = [item(1), item(5)]; // 第 5 章 写过但大纲没有;大纲有第 2 章但未写
const opts = buildReviewChapterOptions(list, outline, 1);
expect(opts.map((o) => o.no)).toEqual([1, 2, 5]);
expect(opts.find((o) => o.no === 5)?.label).toBe("第 5 章 · 草稿");
// 大纲有、未写的章出现在结构里但置灰(不可审)
expect(opts.find((o) => o.no === 2)?.disabled).toBe(true);
});
it("disables chapters with no draft, but never the current chapter", () => {
const list = [item(1), item(2, { has_draft: false })];
const optsOnOther = buildReviewChapterOptions(list, outline, 1);
expect(optsOnOther.find((o) => o.no === 2)?.disabled).toBe(true);
// 当前就在第 2 章时不置灰(否则无法停留)
const optsOnEmpty = buildReviewChapterOptions(list, outline, 2);
expect(optsOnEmpty.find((o) => o.no === 2)?.disabled).toBe(false);
});
it("always includes the current chapter even if absent from list and outline", () => {
const opts = buildReviewChapterOptions([item(1)], outline, 7);
expect(opts.map((o) => o.no)).toEqual([1, 2, 7]);
expect(opts.find((o) => o.no === 7)).toEqual({
no: 7,
label: "第 7 章",
disabled: false,
});
});
});
describe("defaultReviewChapter", () => {
it("prefers the latest chapter that has a draft", () => {
const list = [item(1), item(2), item(3, { has_draft: false })];
expect(defaultReviewChapter(list)).toBe(2);
});
it("falls back to the max chapter number when none have drafts", () => {
const list = [item(1, { has_draft: false }), item(4, { has_draft: false })];
expect(defaultReviewChapter(list)).toBe(4);
});
it("defaults to chapter 1 for an empty list", () => {
expect(defaultReviewChapter([])).toBe(1);
}); });
}); });

View File

@@ -1,15 +1,19 @@
import type { ChapterListItem } from "@/lib/api/types";
import type { ChapterEntry } from "@/lib/workbench/chapter"; import type { ChapterEntry } from "@/lib/workbench/chapter";
import { buildChapterEntries } from "@/lib/workbench/chapter"; import { buildChapterEntries } from "@/lib/workbench/chapter";
export interface ReviewChapterOption { export interface ReviewChapterOption {
no: number; no: number;
label: string; label: string;
// 有草稿正文=可审;无草稿章置灰(审稿依赖草稿,选空章会 422
disabled: boolean;
} }
export function reviewChapterHref(projectId: string, chapterNo: number): string { export function reviewChapterHref(projectId: string, chapterNo: number): string {
return `/projects/${projectId}/review?chapter=${chapterNo}`; return `/projects/${projectId}/review?chapter=${chapterNo}`;
} }
// 旧签名:仅按大纲章 + 当前章构建(无草稿状态)。保留兼容,无 chapterList 时回退用。
export function reviewChapterOptions( export function reviewChapterOptions(
chapters: readonly ChapterEntry[], chapters: readonly ChapterEntry[],
currentChapterNo: number, currentChapterNo: number,
@@ -19,5 +23,58 @@ export function reviewChapterOptions(
label: chapter.title label: chapter.title
? `${chapter.no} 章 · ${chapter.title}` ? `${chapter.no} 章 · ${chapter.title}`
: `${chapter.no}`, : `${chapter.no}`,
disabled: false,
})); }));
} }
function statusSuffix(item: ChapterListItem | undefined): string {
if (!item) return "";
if (item.accepted) return " · 已验收";
if (item.reviewed_at) return " · 已审";
if (item.has_draft) return " · 草稿";
return " · 空";
}
// 新签名:以「真实存在的章」(chapterList) 为准枚举,覆盖「写过但不在大纲」的章,
// 用大纲标题补短名,并标注可审/已审/已验收;无草稿章置灰(但当前章永不置灰)。
export function buildReviewChapterOptions(
chapterList: readonly ChapterListItem[],
outline: readonly ChapterEntry[],
currentChapterNo: number,
): ReviewChapterOption[] {
const titleByNo = new Map(outline.map((c) => [c.no, c.title]));
const itemByNo = new Map(chapterList.map((c) => [c.chapter_no, c]));
// 枚举真实写过的章(chapterList) 大纲章(outline展示全书结构) 当前章。
const nos = new Set<number>([
...chapterList.map((c) => c.chapter_no),
...outline.map((c) => c.no),
currentChapterNo,
]);
return [...nos]
.sort((a, b) => a - b)
.map((no) => {
const item = itemByNo.get(no);
const title = titleByNo.get(no);
const base = title ? `${no} 章 · ${title}` : `${no}`;
return {
no,
label: base + statusSuffix(item),
// 无草稿的章不可审(审稿依赖草稿);仅当前章例外,始终可停留。
disabled: no !== currentChapterNo && !item?.has_draft,
};
});
}
// 无 ?chapter 时的默认章:优先最近一个有草稿的章,其次最大章号,兜底第 1 章。
export function defaultReviewChapter(
chapterList: readonly ChapterListItem[],
): number {
const withDraft = chapterList
.filter((c) => c.has_draft)
.map((c) => c.chapter_no);
if (withDraft.length > 0) return Math.max(...withDraft);
if (chapterList.length > 0) {
return Math.max(...chapterList.map((c) => c.chapter_no));
}
return 1;
}

View File

@@ -0,0 +1,42 @@
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
// 双主题 token 奇偶校验P1-8paper 与 night 两块的 --color-* 变量必须成对,
// 任一模式漏配某个颜色 token 都会让该主题回退为无效变量而破面——此测试提前拦截。
const cssPath = fileURLToPath(
new URL("../../app/globals.css", import.meta.url),
);
const css = readFileSync(cssPath, "utf8");
function colorVarsInBlock(selector: string): Set<string> {
const start = css.indexOf(selector);
if (start === -1) {
throw new Error(`未找到主题块选择器:${selector}`);
}
const open = css.indexOf("{", start);
const close = css.indexOf("}", open);
const body = css.slice(open + 1, close);
const names = body.match(/--color-[\w-]+/g) ?? [];
return new Set(names);
}
describe("theme token parity", () => {
const paper = colorVarsInBlock('[data-theme="paper"]');
const night = colorVarsInBlock('[data-theme="night"]');
it("defines at least the core palette in each theme", () => {
expect(paper.size).toBeGreaterThan(10);
expect(night.size).toBeGreaterThan(10);
});
it("keeps paper and night color tokens paired (no theme drops a --color-* var)", () => {
const missingInNight = [...paper].filter((name) => !night.has(name));
const missingInPaper = [...night].filter((name) => !paper.has(name));
expect(missingInNight, "night 缺失paper 有)").toEqual([]);
expect(missingInPaper, "paper 缺失night 有)").toEqual([]);
});
});

View File

@@ -0,0 +1,53 @@
// @vitest-environment jsdom
import { act, renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useMountTransition } from "./useMountTransition";
beforeEach(() => {
vi.useFakeTimers();
// 让 rAF 同步执行,便于断言 isVisible 翻转。
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
cb(0);
return 1;
});
vi.stubGlobal("cancelAnimationFrame", () => {});
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
describe("useMountTransition", () => {
it("renders nothing while closed", () => {
const { result } = renderHook(() => useMountTransition(false, 180));
expect(result.current.shouldRender).toBe(false);
expect(result.current.isVisible).toBe(false);
});
it("mounts and becomes visible when opened", () => {
const { result, rerender } = renderHook(
({ open }) => useMountTransition(open, 180),
{ initialProps: { open: false } },
);
act(() => rerender({ open: true }));
expect(result.current.shouldRender).toBe(true);
expect(result.current.isVisible).toBe(true);
});
it("keeps rendering during exit, then unmounts after the duration", () => {
const { result, rerender } = renderHook(
({ open }) => useMountTransition(open, 180),
{ initialProps: { open: true } },
);
act(() => rerender({ open: false }));
expect(result.current.isVisible).toBe(false);
expect(result.current.shouldRender).toBe(true);
act(() => {
vi.advanceTimersByTime(180);
});
expect(result.current.shouldRender).toBe(false);
});
});

View File

@@ -0,0 +1,29 @@
"use client";
import { useEffect, useState } from "react";
// 弹层进出场:打开即挂载并在下一帧置为可见(触发入场过渡);关闭先置不可见播放出场,
// 延迟 durationMs 后再卸载。调用方用 motion-safe: 门控过渡类——reduced-motion 下无动画,
// 仍功能正常(仅多挂载 durationMs。守住既有 focus-trap/scroll-lock它们仍由 open 驱动,
// 本 hook 只负责“延迟卸载”以便播放退场。
export function useMountTransition(
isOpen: boolean,
durationMs: number,
): { shouldRender: boolean; isVisible: boolean } {
const [shouldRender, setShouldRender] = useState(isOpen);
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
if (isOpen) {
setShouldRender(true);
// 下一帧再置可见,确保入场从初始(隐藏)态开始过渡。
const raf = requestAnimationFrame(() => setIsVisible(true));
return () => cancelAnimationFrame(raf);
}
setIsVisible(false);
const timer = window.setTimeout(() => setShouldRender(false), durationMs);
return () => window.clearTimeout(timer);
}, [isOpen, durationMs]);
return { shouldRender, isVisible };
}

View File

@@ -3,13 +3,17 @@ import { describe, expect, it } from "vitest";
import { import {
badgeClass, badgeClass,
buttonClass, buttonClass,
cardClass,
checkboxClass,
cn, cn,
focusRing, focusRing,
inputClass, inputClass,
overlayScrim, overlayScrim,
proseBody, proseBody,
radioClass,
segmentedClass, segmentedClass,
statusNoteClass, statusNoteClass,
transitionUi,
} from "./variants"; } from "./variants";
describe("ui variants", () => { describe("ui variants", () => {
@@ -25,6 +29,56 @@ describe("ui variants", () => {
expect(klass).toContain("focus-visible:ring-2"); expect(klass).toContain("focus-visible:ring-2");
}); });
it("uses layered radius tokens (button md / card lg / badge pill)", () => {
expect(buttonClass({ variant: "primary" })).toContain("rounded-md");
expect(cardClass()).toContain("rounded-lg");
expect(badgeClass()).toContain("rounded-full");
expect(inputClass()).toContain("rounded-md");
});
it("gives the primary button a press-darken and cream disabled state", () => {
const klass = buttonClass({ variant: "primary" });
expect(klass).toContain("hover:bg-cinnabar-active");
expect(klass).toContain("active:bg-cinnabar-active");
expect(klass).toContain("disabled:bg-cinnabar-disabled");
expect(klass).not.toContain("hover:bg-cinnabar/95");
});
it("shares one interaction-transition token across controls", () => {
expect(transitionUi).toContain("duration-fast");
expect(transitionUi).toContain("ease-standard");
expect(buttonClass({ variant: "primary" })).toContain(transitionUi);
expect(inputClass()).toContain(transitionUi);
});
it("builds a coral inline link button variant", () => {
const klass = buttonClass({ variant: "link" });
expect(klass).toContain("text-cinnabar");
expect(klass).toContain("underline-offset-4");
expect(klass).toContain("hover:underline");
});
it("supports a large button size for prominent CTAs", () => {
expect(buttonClass({ size: "lg" })).toContain("px-5");
});
it("layers card surface tones and can drop the shadow", () => {
expect(cardClass({ tone: "card" })).toContain("bg-surface-card");
expect(cardClass({ tone: "soft" })).toContain("bg-surface-soft");
expect(cardClass()).toContain("bg-panel");
expect(cardClass({ flat: true })).not.toContain("shadow-paper");
expect(cardClass({ interactive: true })).toContain("hover:border-cinnabar/40");
});
it("keeps the legacy string cardClass signature working", () => {
const klass = cardClass("mt-4");
expect(klass).toContain("mt-4");
expect(klass).toContain("bg-panel");
expect(klass).toContain("rounded-lg");
});
it("keeps warning badges readable on the paper background", () => { it("keeps warning badges readable on the paper background", () => {
const klass = badgeClass({ variant: "warning" }); const klass = badgeClass({ variant: "warning" });
@@ -46,6 +100,14 @@ describe("ui variants", () => {
expect(klass).toContain("px-3"); expect(klass).toContain("px-3");
}); });
it("styles checkbox and radio with accent + shared focus ring", () => {
expect(checkboxClass()).toContain("accent-cinnabar");
expect(checkboxClass()).toContain("rounded-xs");
expect(checkboxClass()).toContain("focus-visible:ring-cinnabar/35");
expect(radioClass()).toContain("rounded-full");
expect(radioClass()).toContain("accent-cinnabar");
});
it("builds segmented control chrome", () => { it("builds segmented control chrome", () => {
const klass = segmentedClass(); const klass = segmentedClass();
@@ -59,10 +121,9 @@ describe("ui variants", () => {
expect(buttonClass({ variant: "primary" })).toContain(focusRing); expect(buttonClass({ variant: "primary" })).toContain(focusRing);
}); });
it("renders manuscript body as serif 18px with airy leading", () => { it("renders manuscript body as serif prose scale", () => {
expect(proseBody).toContain("font-serif"); expect(proseBody).toContain("font-serif");
expect(proseBody).toContain("text-[18px]"); expect(proseBody).toContain("text-prose");
expect(proseBody).toContain("leading-[1.9]");
}); });
it("provides a full-screen overlay scrim below dialog chrome", () => { it("provides a full-screen overlay scrim below dialog chrome", () => {

View File

@@ -3,9 +3,12 @@ export type ButtonVariant =
| "secondary" | "secondary"
| "outline" | "outline"
| "ghost" | "ghost"
| "danger"; | "danger"
| "link";
export type ButtonSize = "sm" | "md" | "icon"; export type ButtonSize = "sm" | "md" | "lg" | "icon";
export type CardTone = "panel" | "card" | "soft";
export type BadgeVariant = export type BadgeVariant =
| "neutral" | "neutral"
@@ -23,35 +26,38 @@ export function cn(...classes: Array<string | false | null | undefined>): string
return classes.filter(Boolean).join(" "); return classes.filter(Boolean).join(" ");
} }
// 统一焦点环(与 buttonBase 现有焦点环一致),供导航/输入等可聚焦元素复用 // 统一焦点环(供按钮/输入/导航等所有可聚焦元素复用,统一到 cinnabar/35
export const focusRing = export const focusRing =
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cinnabar/35"; "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cinnabar/35";
// 手稿正文统一排版:衬线 18px、行高 1.9 // 统一交互过渡(颜色变化 + 动效 token供按钮/输入/导航复用,全局可调
export const proseBody = "font-serif text-[18px] leading-[1.9]"; export const transitionUi = "transition-colors duration-fast ease-standard";
// 手稿正文统一排版:衬线 18pxtext-prose 档)、行高 1.9。
export const proseBody = "font-serif text-prose";
// Drawer / CommandPalette 共用的全屏遮罩。 // Drawer / CommandPalette 共用的全屏遮罩。
export const overlayScrim = "fixed inset-0 z-40 bg-black/30"; export const overlayScrim = "fixed inset-0 z-40 bg-black/30";
const buttonBase = const buttonBase = `inline-flex items-center justify-center gap-1.5 rounded-md border text-sm disabled:cursor-not-allowed ${transitionUi} ${focusRing}`;
"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 disabledFade = "disabled:opacity-45";
const buttonVariants: Record<ButtonVariant, string> = { const buttonVariants: Record<ButtonVariant, string> = {
primary: primary:
"border-cinnabar bg-cinnabar text-panel shadow-paper hover:bg-cinnabar/95", "border-cinnabar bg-cinnabar text-panel shadow-paper hover:bg-cinnabar-active active:bg-cinnabar-active disabled:border-cinnabar-disabled disabled:bg-cinnabar-disabled disabled:text-muted-soft",
secondary: secondary: `border-line bg-panel text-ink hover:border-cinnabar hover:text-cinnabar ${disabledFade}`,
"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)] active:bg-[var(--color-cinnabar-wash)] ${disabledFade}`,
outline: ghost: `border-transparent bg-transparent text-ink-soft hover:bg-[var(--color-cinnabar-wash)] hover:text-cinnabar ${disabledFade}`,
"border-cinnabar bg-transparent text-cinnabar hover:bg-[var(--color-cinnabar-wash)]", danger: `border-conflict bg-transparent text-conflict hover:bg-conflict/10 ${disabledFade}`,
ghost: link: `border-transparent bg-transparent text-cinnabar underline-offset-4 hover:text-cinnabar-active hover:underline ${disabledFade}`,
"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> = { const buttonSizes: Record<ButtonSize, string> = {
sm: "px-3 py-1.5 text-xs", sm: "px-3 py-1.5 text-xs",
md: "px-4 py-2", md: "px-4 py-2",
lg: "px-5 py-2.5 text-base",
icon: "h-9 w-9 p-0", icon: "h-9 w-9 p-0",
}; };
@@ -68,7 +74,7 @@ export function buttonClass({
} }
const badgeBase = const badgeBase =
"inline-flex items-center gap-1 rounded border px-2 py-0.5 text-xs leading-5"; "inline-flex items-center gap-1 rounded-full border px-2.5 py-0.5 text-xs leading-5";
const badgeVariants: Record<BadgeVariant, string> = { const badgeVariants: Record<BadgeVariant, string> = {
neutral: "border-line bg-bg text-ink-soft", neutral: "border-line bg-bg text-ink-soft",
@@ -90,8 +96,30 @@ export function badgeClass({
return cn(badgeBase, badgeVariants[variant], className); return cn(badgeBase, badgeVariants[variant], className);
} }
export function cardClass(className?: string): string { const cardTones: Record<CardTone, string> = {
return cn("rounded border border-line bg-panel shadow-paper", className); panel: "bg-panel",
card: "bg-surface-card",
soft: "bg-surface-soft",
};
interface CardClassOptions {
tone?: CardTone;
flat?: boolean;
interactive?: boolean;
className?: string;
}
// 兼容旧签名 cardClass("extra"),同时支持面色分层/去阴影/可交互卡片。
export function cardClass(opts?: string | CardClassOptions): string {
const o: CardClassOptions = typeof opts === "string" ? { className: opts } : opts ?? {};
const tone = o.tone ?? "panel";
return cn(
"rounded-lg border border-line",
cardTones[tone],
o.flat ? null : "shadow-paper",
o.interactive ? `${transitionUi} hover:border-cinnabar/40 hover:shadow-paper` : null,
o.className,
);
} }
const fieldTextBase = "block text-sm font-medium text-ink"; const fieldTextBase = "block text-sm font-medium text-ink";
@@ -108,8 +136,7 @@ export function fieldErrorClass(className?: string): string {
return cn("mt-1 text-xs leading-5 text-conflict", className); return cn("mt-1 text-xs leading-5 text-conflict", className);
} }
const inputBase = const inputBase = `w-full rounded-md border bg-bg text-ink placeholder:text-ink-soft/80 disabled:cursor-not-allowed disabled:bg-line/20 disabled:text-ink-soft ${transitionUi} ${focusRing}`;
"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> = { const inputStates: Record<InputState, string> = {
default: "border-line focus:border-cinnabar", default: "border-line focus:border-cinnabar",
@@ -149,3 +176,15 @@ export function statusNoteClass({
export function segmentedClass(className?: string): string { export function segmentedClass(className?: string): string {
return cn("inline-flex rounded border border-line bg-bg p-1", className); return cn("inline-flex rounded border border-line bg-bg p-1", className);
} }
// 勾选/单选控件:原生 input + accent 上色,保留原生键盘/读屏语义,套统一焦点环。
const controlBoxBase =
"h-4 w-4 shrink-0 border-line accent-cinnabar disabled:cursor-not-allowed disabled:opacity-45";
export function checkboxClass(className?: string): string {
return cn(controlBoxBase, "rounded-xs", focusRing, className);
}
export function radioClass(className?: string): string {
return cn(controlBoxBase, "rounded-full", focusRing, className);
}

View File

@@ -0,0 +1,61 @@
// @vitest-environment jsdom
import { act, renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { FOCUS_MODE_KEY, useFocusMode } from "./useFocusMode";
// 本仓库 jsdom 未提供可用的 StoragesetItem/clear 缺失),装一个内存版 localStorage。
function installMemoryStorage(): Storage {
const map = new Map<string, string>();
const storage: Storage = {
get length() {
return map.size;
},
clear: () => map.clear(),
getItem: (key) => (map.has(key) ? (map.get(key) ?? null) : null),
key: (index) => Array.from(map.keys())[index] ?? null,
removeItem: (key) => map.delete(key),
setItem: (key, value) => map.set(key, String(value)),
};
Object.defineProperty(window, "localStorage", {
value: storage,
configurable: true,
});
return storage;
}
describe("useFocusMode", () => {
beforeEach(() => {
installMemoryStorage();
});
afterEach(() => {
window.localStorage.clear();
});
it("默认关闭(无存档时 focus=false", () => {
const { result } = renderHook(() => useFocusMode());
expect(result.current.focus).toBe(false);
});
it("toggle 翻转并把状态写入 localStorage", () => {
const { result } = renderHook(() => useFocusMode());
act(() => result.current.toggle());
expect(result.current.focus).toBe(true);
expect(window.localStorage.getItem(FOCUS_MODE_KEY)).toBe("1");
act(() => result.current.toggle());
expect(result.current.focus).toBe(false);
expect(window.localStorage.getItem(FOCUS_MODE_KEY)).toBe("0");
});
it("挂载时从 localStorage 恢复已开启状态", () => {
window.localStorage.setItem(FOCUS_MODE_KEY, "1");
const { result } = renderHook(() => useFocusMode());
expect(result.current.focus).toBe(true);
});
});

View File

@@ -0,0 +1,46 @@
"use client";
import { useCallback, useEffect, useState } from "react";
// 「专注写作」偏好的持久化键(对齐既有 `ww.` 前缀约定)。
export const FOCUS_MODE_KEY = "ww.workbench_focus_mode";
export interface FocusMode {
// true=专注模式:隐藏左侧图标导航 + 目录 TOC让宽给正文false=常规四栏。
focus: boolean;
toggle: () => void;
}
function persist(focus: boolean): void {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(FOCUS_MODE_KEY, focus ? "1" : "0");
} catch {
// 隐私模式/配额异常忽略:专注模式是纯 UI 偏好,写盘失败不影响写作功能。
}
}
// 记住「专注写作」开关P3-4SSR 首帧恒关(避免 hydration 不一致),
// 挂载后再从 localStorage 恢复toggle 即时写回。仅桌面≥xl改变栅格窄屏本就单列不受影响。
export function useFocusMode(): FocusMode {
const [focus, setFocus] = useState(false);
useEffect(() => {
if (typeof window === "undefined") return;
try {
setFocus(window.localStorage.getItem(FOCUS_MODE_KEY) === "1");
} catch {
// 读盘失败则维持默认关闭。
}
}, []);
const toggle = useCallback(() => {
setFocus((prev) => {
const next = !prev;
persist(next);
return next;
});
}, []);
return { focus, toggle };
}

View File

@@ -11,27 +11,84 @@ const config: Config = {
extend: { extend: {
colors: { colors: {
bg: "var(--color-bg)", bg: "var(--color-bg)",
"surface-soft": "var(--color-surface-soft)",
"surface-card": "var(--color-surface-card)",
"surface-strong": "var(--color-surface-strong)",
panel: "var(--color-panel)", panel: "var(--color-panel)",
ink: "var(--color-ink)", ink: "var(--color-ink)",
"body-strong": "var(--color-body-strong)",
body: "var(--color-body)",
"ink-soft": "var(--color-ink-soft)", "ink-soft": "var(--color-ink-soft)",
"muted-soft": "var(--color-muted-soft)",
line: "var(--color-line)", line: "var(--color-line)",
"line-soft": "var(--color-line-soft)",
cinnabar: "var(--color-cinnabar)", cinnabar: "var(--color-cinnabar)",
"cinnabar-active": "var(--color-cinnabar-active)",
"cinnabar-disabled": "var(--color-cinnabar-disabled)",
callout: "var(--color-callout)",
"on-callout": "var(--color-on-callout)",
"on-callout-soft": "var(--color-on-callout-soft)",
conflict: "var(--color-conflict)", conflict: "var(--color-conflict)",
overdue: "var(--color-overdue)", overdue: "var(--color-overdue)",
pass: "var(--color-pass)", pass: "var(--color-pass)",
info: "var(--color-info)", info: "var(--color-info)",
}, },
fontFamily: { fontFamily: {
serif: ['"Noto Serif SC"', '"Songti SC"', "serif"], // 衬线 display 与 sans 分工是品牌声音的一部分(对标 DESIGN.md
sans: ['"Noto Sans SC"', '"PingFang SC"', "system-ui", "sans-serif"], // 决策:不自托管 CJK webfont —— Noto Serif SC 全量子集达数 MB会拖慢
mono: ['"JetBrains Mono"', "ui-monospace"], // 首屏且需构建期外部拉取(违反 CSP 友好)。改用跨 OS 系统衬线兜底栈,
// 保证中文标题在任意设备可靠命中高质量衬线,零 webfont 字节。
// macOS/iOS → Songti/STSongWindows → SimSun/YaHeiLinux/Android → Noto CJK。
serif: [
'"Noto Serif SC"',
'"Source Han Serif SC"',
'"Songti SC"',
"STSong",
"SimSun",
'"Noto Serif CJK SC"',
"serif",
],
sans: [
'"Noto Sans SC"',
'"PingFang SC"',
'"Source Han Sans SC"',
'"Microsoft YaHei"',
"system-ui",
"sans-serif",
],
mono: ['"JetBrains Mono"', "ui-monospace", "monospace"],
}, },
fontSize: { fontSize: {
// 编辑部字号刻度(衬线 display 用负字距;标签用正字距 eyebrow
"2xs": ["0.6875rem", { lineHeight: "1rem" }], "2xs": ["0.6875rem", { lineHeight: "1rem" }],
eyebrow: ["0.75rem", { lineHeight: "1.4", letterSpacing: "0.08em" }],
caption: ["0.8125rem", { lineHeight: "1.4" }],
body: ["1rem", { lineHeight: "1.55" }],
prose: ["1.125rem", { lineHeight: "1.9" }],
"title-md": ["1.125rem", { lineHeight: "1.4" }],
"title-lg": ["1.375rem", { lineHeight: "1.3" }],
"display-sm": ["1.75rem", { lineHeight: "1.2", letterSpacing: "-0.01em" }],
"display-md": ["2.25rem", { lineHeight: "1.15", letterSpacing: "-0.015em" }],
"display-lg": ["3rem", { lineHeight: "1.1", letterSpacing: "-0.02em" }],
},
borderRadius: {
xs: "4px",
sm: "6px",
DEFAULT: "6px",
md: "8px",
lg: "12px",
xl: "16px",
full: "9999px",
}, },
borderRadius: { DEFAULT: "6px" },
boxShadow: { paper: "0 1px 3px var(--shadow-paper)" }, boxShadow: { paper: "0 1px 3px var(--shadow-paper)" },
maxWidth: { prose: "720px" }, transitionDuration: {
fast: "var(--dur-fast)",
base: "var(--dur-base)",
},
transitionTimingFunction: {
standard: "var(--ease-standard)",
},
maxWidth: { prose: "768px" },
}, },
}, },
plugins: [], plugins: [],

View File

@@ -11,7 +11,7 @@
"de-ai": "9ce3020cdd4b223cc4d13c289babd2811bbfece4b392711dcba4fd0301c87249", "de-ai": "9ce3020cdd4b223cc4d13c289babd2811bbfece4b392711dcba4fd0301c87249",
"expand": "74a5d71c4ffc11b78bf80c7ab3fc3eebfc1192ffff2b0bfa9957ec2c33d9d05e", "expand": "74a5d71c4ffc11b78bf80c7ab3fc3eebfc1192ffff2b0bfa9957ec2c33d9d05e",
"fine-outline": "0ae0b71f4144e82ffa2a7a0059505525857f7d9797e94ced25da3129355a46ed", "fine-outline": "0ae0b71f4144e82ffa2a7a0059505525857f7d9797e94ced25da3129355a46ed",
"foreshadow": "405696461fef445e4609227c40df6c4812737735edea7e1966ee84afe7635645", "foreshadow": "b3f80c86d5f410ac4abe1fbfcacf0ad3c8ef547a0082cb0ea7a63b2edf5f6f24",
"glossary": "54e95d21d10b9209287c10517134f485ca5ce4a3ef08291aac954dcf492d73d0", "glossary": "54e95d21d10b9209287c10517134f485ca5ce4a3ef08291aac954dcf492d73d0",
"golden-finger": "416e8591d99ea1296db550bc428012aa937eed47eea03ef435e92c6843005e21", "golden-finger": "416e8591d99ea1296db550bc428012aa937eed47eea03ef435e92c6843005e21",
"name": "ee759bc967773e3300813f250f233c872fd3a4f0cb00086509ffc3552d67700b", "name": "ee759bc967773e3300813f250f233c872fd3a4f0cb00086509ffc3552d67700b",

View File

@@ -1,7 +1,7 @@
你是长篇连载小说的「伏笔续审」foreshadow-analyst——一位专盯长线叙事「埋设—回收」契约的资深审稿人。你的唯一职责把本章草稿与作品已登记伏笔逐项比对找出本章**新埋的伏笔**与**疑似回收/收束**的伏笔,产出结构化建议清单。你只读、只产建议,不改稿、不写库。 你是长篇连载小说的「伏笔续审」foreshadow-analyst——一位专盯长线叙事「埋设—回收」契约的资深审稿人。你的唯一职责把本章草稿与作品已登记伏笔逐项比对找出本章**新埋的伏笔**与**疑似回收/收束**的伏笔,产出结构化建议清单。你只读、只产建议,不改稿、不写库。
## 比对依据(注入材料) ## 比对依据(注入材料)
- 已登记伏笔foreshadow每条含编码code、标题、当前状态、期望回收窗口 - 已登记伏笔foreshadow每条含编码code、标题、当前状态;登记了回收窗口的还带「期望回收 X-Y章」逾期者标「已逾期」
- 本章草稿正文。 - 本章草稿正文。
材料未提供的内容一律视为「不存在」,不得凭世界观常识或前文记忆脑补。 材料未提供的内容一律视为「不存在」,不得凭世界观常识或前文记忆脑补。

View File

@@ -25,6 +25,11 @@
- 密点厚、疏点薄。**绝不为凑字数注水**:删掉重复的心理复述、冗余的形容词堆叠、无推进的对话与说明。 - 密点厚、疏点薄。**绝不为凑字数注水**:删掉重复的心理复述、冗余的形容词堆叠、无推进的对话与说明。
- 篇幅是结果而非目标——先想清楚这一章每个部分承担什么功能,再决定它值多少笔墨。 - 篇幅是结果而非目标——先想清楚这一章每个部分承担什么功能,再决定它值多少笔墨。
## 伏笔:到点顺势收,别为收而收
- 注入材料里的伏笔可能带「期望回收窗口」或「已逾期」标记。若本章正落在某条伏笔的回收窗口内、或它已被标记逾期,可**顺着当下的情节自然地推进或兑现它**——让它收在剧情本就该走到的地方。
- 回收要**服务当下的冲突与情感**:能自然收束就收;一时收得牵强,就先推进一步、留待更合适的时机,也好过硬来。**切忌为凑一个「回收」而打断节奏、突兀交代**——宁可这一章只把线往前带一带,也不要为收而收、硬凑。
## 文字质感 ## 文字质感
- **句长错落**:长短句交替,避免连续同长度、同结构的句子带来的机械节奏。 - **句长错落**:长短句交替,避免连续同长度、同结构的句子带来的机械节奏。

View File

@@ -9,6 +9,7 @@ import uuid
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any from typing import Any
from ww_core.domain.foreshadow_repo import ForeshadowLedgerRepo, ForeshadowLedgerView
from ww_core.domain.repositories import ( from ww_core.domain.repositories import (
CharacterView, CharacterView,
DigestView, DigestView,
@@ -30,6 +31,12 @@ from ww_core.memory import (
render_cards, render_cards,
select_relevant_entities, select_relevant_entities,
) )
from ww_core.memory.foreshadow_inject import (
ForeshadowLine,
merge_foreshadow_lines,
render_foreshadow_line,
select_auto_lines,
)
PROJECT = uuid.UUID("00000000-0000-0000-0000-000000000001") PROJECT = uuid.UUID("00000000-0000-0000-0000-000000000001")
@@ -130,6 +137,44 @@ class FakeReviewRepo:
raise NotImplementedError raise NotImplementedError
@dataclass
class FakeForeshadowLedgerRepo:
"""内存伏笔账本 fake按章自动挑选——只需 `list_by_status` 供 assemble 全量反读。
其余写方法只为满足 `ForeshadowLedgerRepo` Protocol 结构类型assemble 读路径不用。
"""
rows: list[ForeshadowLedgerView] = field(default_factory=list)
async def list_by_status(
self, project_id: uuid.UUID, status: str | None = None
) -> list[ForeshadowLedgerView]:
return [r for r in self.rows if status is None or r.status == status]
async def register(self, *args: Any, **kwargs: Any) -> ForeshadowLedgerView: # pragma: no cover
raise NotImplementedError
async def get(
self, *args: Any, **kwargs: Any
) -> ForeshadowLedgerView | None: # pragma: no cover
raise NotImplementedError
async def transition(
self, *args: Any, **kwargs: Any
) -> ForeshadowLedgerView: # pragma: no cover
raise NotImplementedError
async def record_progress(
self, *args: Any, **kwargs: Any
) -> ForeshadowLedgerView: # pragma: no cover
raise NotImplementedError
async def scan_overdue(
self, *args: Any, **kwargs: Any
) -> list[ForeshadowLedgerView]: # pragma: no cover
raise NotImplementedError
def build_repos( def build_repos(
*, *,
outline: dict[int, OutlineView] | None = None, outline: dict[int, OutlineView] | None = None,
@@ -141,6 +186,7 @@ def build_repos(
rules: list[RuleView] | None = None, rules: list[RuleView] | None = None,
spec: ProjectSpecView | None = None, spec: ProjectSpecView | None = None,
review: ReviewRepo | None = None, review: ReviewRepo | None = None,
foreshadow_ledger: ForeshadowLedgerRepo | None = None,
) -> MemoryRepos: ) -> MemoryRepos:
return MemoryRepos( return MemoryRepos(
outline=FakeOutlineRepo(outline or {}), outline=FakeOutlineRepo(outline or {}),
@@ -152,6 +198,7 @@ def build_repos(
rules=FakeRulesRepo(rules or []), rules=FakeRulesRepo(rules or []),
project=FakeProjectSpecRepo(spec), project=FakeProjectSpecRepo(spec),
review=review, review=review,
foreshadow_ledger=foreshadow_ledger,
) )
@@ -659,6 +706,238 @@ async def test_no_prior_conflict_notes_for_first_chapter() -> None:
assert "上一章冲突提醒" not in ctx.volatile assert "上一章冲突提醒" not in ctx.volatile
# ---- 伏笔注入丰富化 + 按章自动挑选(本任务) ----
def _line(
code: str,
status: str = "OPEN",
*,
from_: int | None = None,
to: int | None = None,
title: str = "某伏笔",
) -> ForeshadowLine:
return ForeshadowLine(
code=code,
title=title,
status=status,
expected_close_from=from_,
expected_close_to=to,
)
def _ledger(
code: str,
status: str = "OPEN",
*,
from_: int | None = None,
to: int | None = None,
title: str = "某伏笔",
) -> ForeshadowLedgerView:
return ForeshadowLedgerView(
code=code,
title=title,
status=status,
expected_close_from=from_,
expected_close_to=to,
)
# --- 纯函数:渲染格式(窗口 + 逾期标记 + 优雅降级) ---
def test_render_line_with_window() -> None:
line = _line("F1", "OPEN", from_=5, to=8, title="神秘石符")
assert render_foreshadow_line(line, 5) == "【伏笔】F1 神秘石符 [OPEN] 期望回收 5-8章"
def test_render_line_overdue_marker_by_chapter() -> None:
# 第 6 章已越过期望回收上界 4 → 追加逾期标记。
line = _line("F2", "OPEN", from_=3, to=4, title="断剑")
assert render_foreshadow_line(line, 6) == "【伏笔】F2 断剑 [OPEN] 期望回收 3-4章已逾期"
def test_render_line_overdue_marker_by_status() -> None:
# status==OVERDUE 直接标逾期,不论当前章号是否越界。
line = _line("F3", "OVERDUE", from_=3, to=8, title="旧账")
assert render_foreshadow_line(line, 5) == "【伏笔】F3 旧账 [OVERDUE] 期望回收 3-8章已逾期"
def test_render_line_window_missing_gracefully_omitted() -> None:
line = _line("F4", "OPEN", title="无窗伏笔")
out = render_foreshadow_line(line, 5)
assert out == "【伏笔】F4 无窗伏笔 [OPEN]"
assert "期望回收" not in out
def test_render_line_partial_window_omitted() -> None:
# 只有上界、无下界 → 窗口段优雅省略(仍可按上界判逾期)。
line = _line("F5", "OPEN", to=4, title="半窗")
out = render_foreshadow_line(line, 6)
assert "期望回收" not in out
assert "(已逾期!)" in out # 第 6 章 > 上界 4 仍算逾期
# --- 纯函数:按章自动挑选(确定性、无向量、无随机) ---
def test_select_auto_includes_in_window() -> None:
assert [line.code for line in select_auto_lines([_line("A", "OPEN", from_=5, to=8)], 6)] == [
"A"
]
def test_select_auto_includes_boundary_chapters() -> None:
lines = [_line("A", "OPEN", from_=5, to=8)]
assert select_auto_lines(lines, 5) # 下界
assert select_auto_lines(lines, 8) # 上界
def test_select_auto_includes_overdue() -> None:
# 第 10 章远超上界 3 → 逾期且未 CLOSED纳入。
assert [
line.code for line in select_auto_lines([_line("B", "PARTIAL", from_=1, to=3)], 10)
] == ["B"]
def test_select_auto_excludes_closed_in_window() -> None:
assert select_auto_lines([_line("C", "CLOSED", from_=5, to=8)], 6) == []
def test_select_auto_excludes_future_window_not_overdue() -> None:
assert select_auto_lines([_line("D", "OPEN", from_=20, to=30)], 6) == []
def test_select_auto_excludes_windowless_open() -> None:
# 无回收窗口、未逾期 → 不自动纳入(既非窗口内也非逾期)。
assert select_auto_lines([_line("E", "OPEN")], 6) == []
def test_render_unknown_status_defensively_not_overdue() -> None:
# 未知 status非四态枚举→ 保守视为未逾期,不加逾期标记、不崩。
line = _line("F6", "WEIRD", from_=1, to=2, title="怪态")
out = render_foreshadow_line(line, 99)
assert "(已逾期!)" not in out
assert out == "【伏笔】F6 怪态 [WEIRD] 期望回收 1-2章"
def test_merge_dedups_by_code_and_sorts() -> None:
outline = [_line("B", "OPEN", from_=1, to=2)]
auto = [_line("A", "OPEN", from_=5, to=6), _line("B", "OPEN", from_=1, to=2)]
merged = merge_foreshadow_lines(outline, auto)
assert [line.code for line in merged] == ["A", "B"] # 去重 + 按 code 升序
# --- assemble 集成:丰富注入 + 自动挑选 + volatile 边界 ---
async def test_assemble_enriches_outline_foreshadow_window() -> None:
repos = build_repos(
outline={5: OutlineView(volume=1, chapter_no=5, foreshadow_windows=[{"code": "F1"}])},
foreshadows=[
ForeshadowView(
code="F1",
title="神秘石符",
status="OPEN",
expected_close_from=5,
expected_close_to=8,
)
],
spec=ProjectSpecView(title=""),
)
ctx = await assemble(repos, PROJECT, 5)
assert "【伏笔】F1 神秘石符 [OPEN] 期望回收 5-8章" in ctx.volatile
# 不变量 #9伏笔注入含窗口只在 volatile绝不进缓存前缀。
assert "期望回收" not in ctx.stable_core
assert "F1" not in ctx.stable_core
async def test_assemble_auto_injects_in_window_beyond_outline() -> None:
# 大纲未登记 F9但账本里 F9 的回收窗口覆盖本章 → 自动纳入(修「大纲没登记=零注入」)。
ledger = FakeForeshadowLedgerRepo(rows=[_ledger("F9", "OPEN", from_=4, to=7, title="旧誓")])
repos = build_repos(
outline={5: OutlineView(volume=1, chapter_no=5)}, # 无 foreshadow_windows
spec=ProjectSpecView(title=""),
foreshadow_ledger=ledger,
)
ctx = await assemble(repos, PROJECT, 5)
assert "【伏笔】F9 旧誓 [OPEN] 期望回收 4-7章" in ctx.volatile
async def test_assemble_auto_injects_overdue_beyond_outline() -> None:
ledger = FakeForeshadowLedgerRepo(rows=[_ledger("F8", "PARTIAL", from_=1, to=3, title="欠债")])
repos = build_repos(
outline={10: OutlineView(volume=1, chapter_no=10)},
spec=ProjectSpecView(title=""),
foreshadow_ledger=ledger,
)
ctx = await assemble(repos, PROJECT, 10)
assert "【伏笔】F8 欠债 [PARTIAL]" in ctx.volatile
assert "(已逾期!)" in ctx.volatile
async def test_assemble_auto_skips_closed_and_future() -> None:
ledger = FakeForeshadowLedgerRepo(
rows=[
_ledger("CLD", "CLOSED", from_=4, to=7, title="已收"),
_ledger("FUT", "OPEN", from_=20, to=30, title="未来"),
]
)
repos = build_repos(
outline={5: OutlineView(volume=1, chapter_no=5)},
spec=ProjectSpecView(title=""),
foreshadow_ledger=ledger,
)
ctx = await assemble(repos, PROJECT, 5)
assert "CLD" not in ctx.volatile
assert "FUT" not in ctx.volatile
async def test_assemble_merges_outline_and_auto_dedup() -> None:
# F1 同时被大纲窗口显式登记 + 账本回收窗口自动命中 → 合并去重,只出现一次。
ledger = FakeForeshadowLedgerRepo(rows=[_ledger("F1", "OPEN", from_=5, to=8, title="石符")])
repos = build_repos(
outline={5: OutlineView(volume=1, chapter_no=5, foreshadow_windows=[{"code": "F1"}])},
foreshadows=[
ForeshadowView(
code="F1",
title="石符",
status="OPEN",
expected_close_from=5,
expected_close_to=8,
)
],
spec=ProjectSpecView(title=""),
foreshadow_ledger=ledger,
)
ctx = await assemble(repos, PROJECT, 5)
assert ctx.volatile.count("【伏笔】F1") == 1
async def test_assemble_without_ledger_only_outline_foreshadows() -> None:
# 无账本注入(默认)→ 仅大纲登记的伏笔,优雅降级不崩。
repos = build_repos(
outline={5: OutlineView(volume=1, chapter_no=5, foreshadow_windows=[{"code": "F1"}])},
foreshadows=[ForeshadowView(code="F1", title="石符", status="OPEN")],
spec=ProjectSpecView(title=""),
)
ctx = await assemble(repos, PROJECT, 5)
assert "【伏笔】F1 石符 [OPEN]" in ctx.volatile
async def test_assemble_auto_foreshadow_never_enters_stable_core() -> None:
# 不变量 #9按章自动挑选的伏笔每章易变只入 volatile绝不进缓存前缀。
ledger = FakeForeshadowLedgerRepo(rows=[_ledger("F9", "OPEN", from_=4, to=7, title="旧誓")])
repos = build_repos(
outline={5: OutlineView(volume=1, chapter_no=5)},
spec=ProjectSpecView(title="", genre="玄幻"),
foreshadow_ledger=ledger,
)
ctx = await assemble(repos, PROJECT, 5)
assert "F9" not in ctx.stable_core
assert "旧誓" not in ctx.stable_core
# ---- helpers ---- # ---- helpers ----

View File

@@ -15,6 +15,7 @@ from typing import Any, Protocol
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from ww_core.domain.foreshadow_repo import ForeshadowLedgerRepo
from ww_core.domain.review_repo import ReviewRepo from ww_core.domain.review_repo import ReviewRepo
# ---- 只读视图snake_casefrozen 防止意外突变)---- # ---- 只读视图snake_casefrozen 防止意外突变)----
@@ -156,8 +157,11 @@ class ProjectSpecRepo(Protocol):
class MemoryRepos: class MemoryRepos:
"""记忆服务所需的 9 个 repo 的依赖捆绑(注入点)。 """记忆服务所需的 9 个 repo 的依赖捆绑(注入点)。
`review` 末位默认 None只有冲突裁决反哺灵感②需要它不注入时 assemble `review`/`foreshadow_ledger` 末位默认 None同 optional 注入模式):
优雅降级(不反哺),故既有构造点无需感知(守风险#6 - `review`:只有冲突裁决反哺(灵感②)需要它;
- `foreshadow_ledger`:只有按章自动挑选伏笔(本任务)需要它——账本全量读侧,
供 assemble 在大纲窗口之外自动纳入本章回收窗口内/逾期的伏笔(复用写侧账本 repo
不注入时 assemble 优雅降级(对应能力关闭),故既有构造点无需感知(守风险#6
""" """
outline: OutlineRepo outline: OutlineRepo
@@ -169,3 +173,4 @@ class MemoryRepos:
rules: RulesRepo rules: RulesRepo
project: ProjectSpecRepo project: ProjectSpecRepo
review: ReviewRepo | None = None review: ReviewRepo | None = None
foreshadow_ledger: ForeshadowLedgerRepo | None = None

View File

@@ -17,7 +17,6 @@ from ww_core.domain.injection_repo import InjectionOverride
from ww_core.domain.repositories import ( from ww_core.domain.repositories import (
CharacterView, CharacterView,
DigestView, DigestView,
ForeshadowView,
MemoryRepos, MemoryRepos,
OutlineView, OutlineView,
ProjectSpecView, ProjectSpecView,
@@ -27,6 +26,13 @@ from ww_core.domain.repositories import (
) )
from ww_core.domain.review_repo import ReviewView from ww_core.domain.review_repo import ReviewView
from .foreshadow_inject import (
ForeshadowLine,
merge_foreshadow_lines,
render_foreshadow_line,
select_auto_lines,
to_line,
)
from .render import render_cards from .render import render_cards
from .selection import MAIN_ROLES, RECENT_DIGEST_COUNT, EntityKey, select_relevant_entities from .selection import MAIN_ROLES, RECENT_DIGEST_COUNT, EntityKey, select_relevant_entities
from .types import AssembledContext, EntityKind from .types import AssembledContext, EntityKind
@@ -199,16 +205,14 @@ def _prior_conflict_notes(
def _build_volatile( def _build_volatile(
chapter_no: int, chapter_no: int,
cards: str, cards: str,
foreshadows: list[ForeshadowView], foreshadow_lines: list[ForeshadowLine],
digests: list[DigestView], digests: list[DigestView],
beats: dict[str, Any], beats: dict[str, Any],
directive: str | None = None, directive: str | None = None,
prior_conflict_notes: str | None = None, prior_conflict_notes: str | None = None,
) -> str: ) -> str:
fore_lines = [ # 伏笔行已在 assemble() 合并去重、按 code 排序;此处只逐行渲染(含窗口 + 逾期标记)。
f"【伏笔】{f.code} {f.title} [{f.status}]" fore_lines = [render_foreshadow_line(line, chapter_no) for line in foreshadow_lines]
for f in sorted(foreshadows, key=lambda f: f.code)
]
digest_lines = [ digest_lines = [
f"{d.chapter_no}章:{_ser(d.facts)}" for d in sorted(digests, key=lambda d: d.chapter_no) f"{d.chapter_no}章:{_ser(d.facts)}" for d in sorted(digests, key=lambda d: d.chapter_no)
] ]
@@ -271,8 +275,18 @@ async def assemble(
excluded=excluded, excluded=excluded,
) )
# 伏笔注入两路并集(守不变量 #6确定性纯函数选择无向量、无随机
# 1) 大纲手工登记的窗口 code——作者显式点名始终注入不论状态
codes = [str(w["code"]) for w in outline.foreshadow_windows if w.get("code")] codes = [str(w["code"]) for w in outline.foreshadow_windows if w.get("code")]
foreshadows = await repos.foreshadow.list_for_codes(project_id, codes) outline_fore = await repos.foreshadow.list_for_codes(project_id, codes)
outline_lines = [to_line(f) for f in outline_fore]
# 2) 按本章章号自动纳入——账本全量 → 纯函数筛「回收窗口内 / 逾期且未 CLOSED」
# 修「大纲没登记=零注入」。账本未注入(默认)则优雅降级为只用大纲路(守风险#6
auto_lines: list[ForeshadowLine] = []
if repos.foreshadow_ledger is not None:
all_fore = await repos.foreshadow_ledger.list_by_status(project_id)
auto_lines = select_auto_lines([to_line(f) for f in all_fore], chapter_no)
foreshadow_lines = merge_foreshadow_lines(outline_lines, auto_lines)
style = await repos.style.latest(project_id) style = await repos.style.latest(project_id)
rules = await repos.rules.all_for_project(project_id) rules = await repos.rules.all_for_project(project_id)
spec = await repos.project.spec(project_id) spec = await repos.project.spec(project_id)
@@ -292,7 +306,7 @@ async def assemble(
volatile = _build_volatile( volatile = _build_volatile(
chapter_no, chapter_no,
cards, cards,
foreshadows, foreshadow_lines,
recent_digests, recent_digests,
outline.beats, outline.beats,
directive, directive,

View File

@@ -0,0 +1,121 @@
"""伏笔注入的确定性纯函数——选择 + 渲染ARCH §5.3 / 不变量 #6、#9
写章时把「相关伏笔」注入 volatile缓存断点之后守不变量 #9。相关性来自两路并集
1. 大纲手工登记的 `outline.foreshadow_windows`(作者显式点名)——始终注入,不论状态;
2. 按本章章号自动纳入本章处于回收窗口内from<=ch<=to或已逾期且未 CLOSED。
两路都是**确定性纯函数**选择:无向量、无随机(不变量 #6渲染字符串无时间戳/UUID
故安全留在 volatile。逾期判据复用 `domain.foreshadow_state.is_overdue`(单一真源)。
"""
from __future__ import annotations
from dataclasses import dataclass
from ww_core.domain.foreshadow_repo import ForeshadowLedgerView
from ww_core.domain.foreshadow_state import (
CLOSED,
OVERDUE,
ForeshadowStatus,
is_overdue,
)
from ww_core.domain.repositories import ForeshadowView
@dataclass(frozen=True)
class ForeshadowLine:
"""渲染/选择用的中性伏笔行——从读侧 `ForeshadowView` 与账本 `ForeshadowLedgerView`
收敛出的共同最小字段,避免两路视图类型在选择/渲染层扩散(不可变,确定性)。"""
code: str
title: str
status: str
expected_close_from: int | None
expected_close_to: int | None
def to_line(view: ForeshadowView | ForeshadowLedgerView) -> ForeshadowLine:
"""把任一伏笔视图收敛成中性 `ForeshadowLine`(两路视图字段同名,纯映射)。"""
return ForeshadowLine(
code=view.code,
title=view.title,
status=view.status,
expected_close_from=view.expected_close_from,
expected_close_to=view.expected_close_to,
)
def _has_window(line: ForeshadowLine) -> bool:
return line.expected_close_from is not None and line.expected_close_to is not None
def _in_window(line: ForeshadowLine, chapter_no: int) -> bool:
"""本章处于回收窗口内from<=ch<=to含边界——需上下界俱在否则视为无窗口。"""
if not _has_window(line):
return False
# _has_window 已保证两界非空,此处类型收窄由显式断言给出。
assert line.expected_close_from is not None
assert line.expected_close_to is not None
return line.expected_close_from <= chapter_no <= line.expected_close_to
def _is_overdue_line(line: ForeshadowLine, chapter_no: int) -> bool:
"""逾期判据:`status==OVERDUE`(账本扫描已置位)或越过期望回收上界且未 CLOSED。
后者复用 `foreshadow_state.is_overdue`(单一真源);未知 status 保守视为未逾期。
"""
if line.status == OVERDUE.value:
return True
try:
status = ForeshadowStatus(line.status)
except ValueError:
return False
return is_overdue(
current_chapter=chapter_no,
expected_close_to=line.expected_close_to,
status=status,
)
def _is_relevant_this_chapter(line: ForeshadowLine, chapter_no: int) -> bool:
"""按章自动纳入判据:未 CLOSED 且(本章在回收窗口内 或 已逾期)。"""
if line.status == CLOSED.value:
return False
return _in_window(line, chapter_no) or _is_overdue_line(line, chapter_no)
def select_auto_lines(all_lines: list[ForeshadowLine], chapter_no: int) -> list[ForeshadowLine]:
"""从全量伏笔里按当前章号自动挑选相关项(确定性纯函数,守不变量 #6"""
return [line for line in all_lines if _is_relevant_this_chapter(line, chapter_no)]
def merge_foreshadow_lines(*groups: list[ForeshadowLine]) -> list[ForeshadowLine]:
"""按 code 合并去重多路来源、按 code 升序返回(确定性;先到者优先,同 code 同数据)。"""
by_code: dict[str, ForeshadowLine] = {}
for group in groups:
for line in group:
by_code.setdefault(line.code, line)
return [by_code[code] for code in sorted(by_code)]
def _render_window(line: ForeshadowLine) -> str:
"""渲染「期望回收 {from}-{to}章」;窗口缺失(任一界为空)则优雅省略该段。"""
if not _has_window(line):
return ""
return f"期望回收 {line.expected_close_from}-{line.expected_close_to}"
def render_foreshadow_line(line: ForeshadowLine, chapter_no: int) -> str:
"""渲染一行注入伏笔:编码/标题/状态 + 期望回收窗口 +(逾期时)逾期标记。
例:`【伏笔】F1 神秘石符 [OPEN] 期望回收 5-8章已逾期`。
窗口缺失省略「期望回收…」段;未逾期不加标记。全程无时间戳/UUID安全入 volatile。
"""
text = f"【伏笔】{line.code} {line.title} [{line.status}]"
window = _render_window(line)
if window:
text = f"{text} {window}"
if _is_overdue_line(line, chapter_no):
text = f"{text}(已逾期!)"
return text

View File

@@ -21,6 +21,7 @@ from ww_db.models import (
WorldEntity, WorldEntity,
) )
from ww_core.domain.foreshadow_repo import SqlForeshadowLedgerRepo
from ww_core.domain.repositories import ( from ww_core.domain.repositories import (
CharacterView, CharacterView,
DigestView, DigestView,
@@ -228,6 +229,8 @@ def sql_memory_repos(session: AsyncSession) -> MemoryRepos:
"""用一个 AsyncSession 装配全部 9 个 SQLAlchemy repoT1.4 注入点)。 """用一个 AsyncSession 装配全部 9 个 SQLAlchemy repoT1.4 注入点)。
`review=SqlReviewRepo`:供 assemble 反读上一已验收章的 continuity 冲突裁决(灵感②)。 `review=SqlReviewRepo`:供 assemble 反读上一已验收章的 continuity 冲突裁决(灵感②)。
`foreshadow_ledger=SqlForeshadowLedgerRepo`:供 assemble 按本章章号自动挑选伏笔
(回收窗口内/逾期,复用账本读侧 `list_by_status`)——大纲未登记也能自动注入。
""" """
return MemoryRepos( return MemoryRepos(
outline=SqlOutlineRepo(session), outline=SqlOutlineRepo(session),
@@ -239,4 +242,5 @@ def sql_memory_repos(session: AsyncSession) -> MemoryRepos:
rules=SqlRulesRepo(session), rules=SqlRulesRepo(session),
project=SqlProjectSpecRepo(session), project=SqlProjectSpecRepo(session),
review=SqlReviewRepo(session), review=SqlReviewRepo(session),
foreshadow_ledger=SqlForeshadowLedgerRepo(session),
) )