Compare commits
20 Commits
57b3183564
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64732b0d1b | ||
|
|
1afeb2cdb5 | ||
|
|
9bc0e1f2e6 | ||
|
|
40cc3fc9e3 | ||
|
|
b46afd4f8c | ||
|
|
b810a3fa3c | ||
|
|
9aa0cddeaf | ||
|
|
5f28491ad6 | ||
|
|
4a334f79cc | ||
|
|
54d3a532af | ||
|
|
ac40f48e05 | ||
|
|
f4bea7f26d | ||
|
|
355a2d11cd | ||
|
|
45025c36bf | ||
|
|
5674158707 | ||
|
|
67e30a6863 | ||
|
|
d3e9ae972f | ||
|
|
83ba47ab8f | ||
|
|
fd6551b0ba | ||
|
|
b9f9134748 |
@@ -10,7 +10,7 @@
|
||||
|
||||
- **引导式立项** — 一句灵感走向连载:选题材 / 基调 / 结局走向 / 叙事视角,AI 起草世界观、主角、金手指、总纲方案,一键建库。
|
||||
- **设定库(Codex)** — 结构化的单一真相源:
|
||||
- 角色卡(外貌 / 动机 / 目标 / 口癖 / 性格弧光 / 人物关系图)
|
||||
- 角色卡(外貌 / 动机 / 性格 / 背景 / 口癖 / 性格弧光 / 人物关系图)
|
||||
- 世界观(力量体系 / 势力 / 地理 / 词条)
|
||||
- **大纲 / 细纲** — AI 排分卷分章骨架,逐层细化到场景清单,并提示伏笔回收窗口。
|
||||
- **写章(SSE 流式)** — 注入设定 + 文风指纹,按 genre-aware 的网文写作教条(黄金三章、章末钩子、爽点密度)流式产出本章草稿。
|
||||
@@ -19,6 +19,7 @@
|
||||
- **创作工具箱(15 个生成器)** — 声明式 skill 框架,「加生成器 = 加一份声明」:脑洞 / 书名 / 简介 / 取名 / 金手指 / 词条 / 黄金开篇 / 细纲 / 续写 / 扩写 / 降 AI / 拆书,外加世界观 / 角色 / 大纲。
|
||||
- **模板库** — 复用与沉淀常用创作模板。
|
||||
- **多 Provider LLM 网关** — 路由 / 回退 / 熔断的自建薄网关;Agent 只声明能力 tier,网关按配置映射到 provider+model;支持 Kimi Code 订阅 OAuth(device flow)。
|
||||
- **沉浸式写作体验** — 纸感暖色 + 夜读双主题(`data-theme` 切换)、写作台专注模式(隐藏侧栏加宽正文)、`⌘K` 命令面板(跨页跳转 / 执行命令)、应用内帮助页 [`/help`](http://localhost:4000/help)(功能介绍 + 新手上手)。UI 对标编辑部式暖奶油设计语言,全程尊重 `prefers-reduced-motion` 与 WCAG AA。
|
||||
|
||||
---
|
||||
|
||||
|
||||
213
apps/api/tests/test_chapters_list.py
Normal file
213
apps/api/tests/test_chapters_list.py
Normal 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]
|
||||
@@ -56,6 +56,7 @@ from ww_api.schemas.injection import (
|
||||
from ww_api.schemas.projects import (
|
||||
AcceptRequest,
|
||||
AcceptResponse,
|
||||
ChapterListItem,
|
||||
DraftResponse,
|
||||
DraftSaveRequest,
|
||||
DraftStreamRequest,
|
||||
@@ -75,10 +76,12 @@ from ww_api.services.accept_service import (
|
||||
assert_conflicts_resolved,
|
||||
run_accept_transaction,
|
||||
)
|
||||
from ww_api.services.chapter_list import ChapterLister
|
||||
from ww_api.services.credentials import STUB_OWNER_ID
|
||||
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.project_deps import (
|
||||
get_chapter_lister,
|
||||
get_chapter_repo,
|
||||
get_clarify_gateway,
|
||||
get_digest_append_repo,
|
||||
@@ -115,6 +118,7 @@ _SSE_RESPONSE: dict[int | str, dict[str, Any]] = {
|
||||
|
||||
ProjectRepoDep = Annotated[ProjectRepo, Depends(get_project_repo)]
|
||||
ChapterRepoDep = Annotated[ChapterRepo, Depends(get_chapter_repo)]
|
||||
ChapterListerDep = Annotated[ChapterLister, Depends(get_chapter_lister)]
|
||||
GatewayDep = Annotated[Gateway, Depends(get_writer_gateway)]
|
||||
ReviewGatewayDep = Annotated[Gateway, Depends(get_review_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)
|
||||
|
||||
|
||||
@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(
|
||||
repos: MemoryRepos,
|
||||
project_id: uuid.UUID,
|
||||
|
||||
@@ -189,6 +189,23 @@ class DraftView(BaseModel):
|
||||
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)----
|
||||
|
||||
|
||||
|
||||
109
apps/api/ww_api/services/chapter_list.py
Normal file
109
apps/api/ww_api/services/chapter_list.py
Normal 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)
|
||||
@@ -53,6 +53,7 @@ from ww_api.security.credentials import (
|
||||
CredentialKeyError,
|
||||
decrypt_api_key,
|
||||
)
|
||||
from ww_api.services.chapter_list import ChapterLister, SqlChapterLister
|
||||
from ww_api.services.credentials import (
|
||||
AUTH_TYPE_OAUTH,
|
||||
STUB_OWNER_ID,
|
||||
@@ -99,6 +100,13 @@ def get_chapter_repo(
|
||||
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(
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> MemoryRepos:
|
||||
|
||||
@@ -5,12 +5,26 @@
|
||||
/* 纸感设计 token(UX_SPEC §2.1) */
|
||||
:root,
|
||||
[data-theme="paper"] {
|
||||
/* 面色阶:底 → 分隔带 → 内容卡(比底暗一步)→ 选中强调 → 浮起面板 */
|
||||
--color-bg: #f5f1e8;
|
||||
--color-surface-soft: #efe9dc;
|
||||
--color-surface-card: #ece4d3;
|
||||
--color-surface-strong: #e4d9c4;
|
||||
--color-panel: #fbf8f1;
|
||||
/* 文字色阶(5 级):标题 → 强调段 → 正文 → 次级 → 说明/细则 */
|
||||
--color-ink: #2b2620;
|
||||
--color-body-strong: #3a342b;
|
||||
--color-body: #4a4234;
|
||||
--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-soft: #ece4d5;
|
||||
/* 朱砂 coral:主色 + 按压变深 + 奶油化禁用 + 淡底 */
|
||||
--color-cinnabar: #a23b2e;
|
||||
--color-cinnabar-active: #8a2f24;
|
||||
--color-cinnabar-disabled: #d9cdbd;
|
||||
--color-cinnabar-wash: #a23b2e14;
|
||||
--color-conflict: #b5543a;
|
||||
--color-overdue: #c8893a;
|
||||
@@ -18,17 +32,42 @@
|
||||
--color-info: #4a5a6b;
|
||||
--color-conflict-mark: #b5543a26;
|
||||
--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;
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
/* 动效 token(与主题无关,单处定义;夜读模式不覆盖) */
|
||||
:root {
|
||||
--ease-standard: cubic-bezier(0.2, 0, 0, 1);
|
||||
--dur-fast: 120ms;
|
||||
--dur-base: 180ms;
|
||||
}
|
||||
|
||||
[data-theme="night"] {
|
||||
/* 面色阶:底 → 分隔带 → 浮起面板 → 内容卡(暗面向上抬)→ 选中强调 */
|
||||
--color-bg: #181614;
|
||||
--color-surface-soft: #1e1b17;
|
||||
--color-surface-card: #2a2620;
|
||||
--color-surface-strong: #332e26;
|
||||
--color-panel: #221f1b;
|
||||
/* 文字色阶(5 级) */
|
||||
--color-ink: #efe6d7;
|
||||
--color-body-strong: #ddd2c0;
|
||||
--color-body: #cdc0ab;
|
||||
--color-ink-soft: #b9ab96;
|
||||
/* muted-soft 达 AA 小字(≥4.5:1)于夜读 bg/card/soft 各面(P4-1)。 */
|
||||
--color-muted-soft: #9a8e7c;
|
||||
/* 边框 */
|
||||
--color-line: #3c352c;
|
||||
--color-line-soft: #2f2a23;
|
||||
/* 朱砂 coral */
|
||||
--color-cinnabar: #e07866;
|
||||
--color-cinnabar-active: #c85f4d;
|
||||
--color-cinnabar-disabled: #3c352c;
|
||||
--color-cinnabar-wash: #e0786620;
|
||||
--color-conflict: #ef8a72;
|
||||
--color-overdue: #d8a65d;
|
||||
@@ -36,6 +75,10 @@
|
||||
--color-info: #8ca8ca;
|
||||
--color-conflict-mark: #ef8a7230;
|
||||
--color-conflict-mark-strong: #ef8a7290;
|
||||
/* 夜读下 callout 为更深的凹陷暖井,仍与 bg 拉开层次。 */
|
||||
--color-callout: #100e0c;
|
||||
--color-on-callout: #efe6d7;
|
||||
--color-on-callout-soft: #a89d8a;
|
||||
--shadow-paper: #00000045;
|
||||
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) {
|
||||
.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 {
|
||||
animation: none;
|
||||
}
|
||||
@@ -102,4 +249,7 @@ body {
|
||||
animation: none;
|
||||
outline: 2px solid var(--color-conflict);
|
||||
}
|
||||
.toast-enter {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
298
apps/web/app/help/page.tsx
Normal file
298
apps/web/app/help/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center bg-bg px-6 py-16">
|
||||
<ThinkingIndicator label="加载中…" className="text-ink-soft" />
|
||||
<main className="min-h-screen bg-bg">
|
||||
<PageContainer>
|
||||
<div role="status" aria-label="加载中" className="space-y-8">
|
||||
<span className="sr-only">加载中…</span>
|
||||
<HeaderSkeleton />
|
||||
<ToolbarSkeleton />
|
||||
<GridSkeleton />
|
||||
</div>
|
||||
</PageContainer>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { BookOpen, PenLine, Plus } from "lucide-react";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { BackendDownNotice } from "@/components/BackendDownNotice";
|
||||
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 { fetchProjects } from "@/lib/api/server";
|
||||
import type { ProjectResponse } from "@/lib/api/types";
|
||||
@@ -23,7 +23,7 @@ export default async function DashboardPage() {
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<div className="mx-auto max-w-5xl px-6 py-10 sm:px-8">
|
||||
<PageContainer width="wide">
|
||||
<PageHeader
|
||||
title="我的作品"
|
||||
description="从一个灵感进入正文、设定、审稿与验收闭环。"
|
||||
@@ -50,33 +50,36 @@ export default async function DashboardPage() {
|
||||
{loadError ? (
|
||||
<BackendDownNotice />
|
||||
) : projects.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={BookOpen}
|
||||
title="还没有作品"
|
||||
description="直接开始写,落笔即成书;或先立项,从一句灵感搭好设定、大纲再动笔。"
|
||||
action={
|
||||
<div className="flex flex-wrap items-center justify-center gap-2">
|
||||
// 首次进入的暖深色编辑部英雄(对标 DESIGN.md cta-band-dark;仅零作品时出现,不干扰工作库)。
|
||||
<div className="rounded-xl border border-line/50 bg-callout px-8 py-14 text-center shadow-paper sm:px-12 sm:py-20">
|
||||
<BookOpen className="mx-auto h-9 w-9 text-cinnabar" aria-hidden="true" />
|
||||
<h2 className="mt-5 font-serif text-display-sm text-on-callout">
|
||||
从一句灵感,写成一本书
|
||||
</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
|
||||
href="/write"
|
||||
className={buttonClass({ variant: "primary" })}
|
||||
className={buttonClass({ variant: "primary", size: "lg" })}
|
||||
>
|
||||
<PenLine className="h-4 w-4" aria-hidden="true" />
|
||||
直接开始写
|
||||
</Link>
|
||||
<Link
|
||||
href="/projects/new"
|
||||
className={buttonClass({ variant: "secondary" })}
|
||||
className={buttonClass({ variant: "secondary", size: "lg" })}
|
||||
>
|
||||
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||
先立项
|
||||
</Link>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<ProjectLibrary projects={projects} />
|
||||
)}
|
||||
</div>
|
||||
</PageContainer>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,12 +2,17 @@ import { notFound } from "next/navigation";
|
||||
|
||||
import { ReviewReport } from "@/components/review/ReviewReport";
|
||||
import {
|
||||
fetchChapters,
|
||||
fetchDraft,
|
||||
fetchOutline,
|
||||
fetchProject,
|
||||
fetchReviews,
|
||||
} from "@/lib/api/server";
|
||||
import type { ProjectResponse } from "@/lib/api/types";
|
||||
import {
|
||||
buildReviewChapterOptions,
|
||||
defaultReviewChapter,
|
||||
} from "@/lib/review/chapterNav";
|
||||
import { latestReview } from "@/lib/review/history";
|
||||
import type { ChapterEntry } from "@/lib/workbench/chapter";
|
||||
|
||||
@@ -16,14 +21,11 @@ interface PageProps {
|
||||
searchParams: Promise<{ chapter?: string }>;
|
||||
}
|
||||
|
||||
const DEFAULT_CHAPTER_NO = 1;
|
||||
|
||||
// 审稿报告页(UX §6.4)。Server Component 取项目 + 审稿留痕历史(新→旧);
|
||||
// ReviewReport(Client)承载 SSE 重审 / 裁决 / 验收交互。
|
||||
export default async function ReviewPage({ params, searchParams }: PageProps) {
|
||||
const { id } = await params;
|
||||
const { chapter } = await searchParams;
|
||||
const chapterNo = parsePositiveInt(chapter) ?? DEFAULT_CHAPTER_NO;
|
||||
|
||||
// 仅当项目确实取不到时才判 404;审稿/草稿/大纲等次级拉取的瞬时错误不应把整页变成「找不到」。
|
||||
let project: ProjectResponse;
|
||||
@@ -33,6 +35,11 @@ export default async function ReviewPage({ params, searchParams }: PageProps) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
// 真实存在的章(含可审/已审标记,错误→[]):供选章枚举 + 决定默认章。
|
||||
const chapterList = await fetchChapters(id);
|
||||
// 无 ?chapter 时默认落到最近一个有草稿的章,而非硬编码第 1 章。
|
||||
const chapterNo = parsePositiveInt(chapter) ?? defaultReviewChapter(chapterList);
|
||||
|
||||
// 审稿留痕(GET .../reviews,失败→空历史)。
|
||||
let initialReview;
|
||||
try {
|
||||
@@ -57,6 +64,13 @@ export default async function ReviewPage({ params, searchParams }: PageProps) {
|
||||
title: c.beats?.[0],
|
||||
}));
|
||||
|
||||
// 选章下拉选项:以真实章为准(覆盖「写过但不在大纲」的章),大纲标题补短名,标注可审/已审。
|
||||
const chapterOptions = buildReviewChapterOptions(
|
||||
chapterList,
|
||||
chapters,
|
||||
chapterNo,
|
||||
);
|
||||
|
||||
return (
|
||||
// key=章号:客户端切章(?chapter=N 变化)时强制重挂载,用新章审稿/草稿刷新状态,
|
||||
// 避免 ReviewReport 内 useState 基线沿用上一章。
|
||||
@@ -67,6 +81,7 @@ export default async function ReviewPage({ params, searchParams }: PageProps) {
|
||||
initialReview={initialReview}
|
||||
initialDraft={initialDraft}
|
||||
chapters={chapters}
|
||||
chapterOptions={chapterOptions}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Link from "next/link";
|
||||
import type { ReactNode } from "react";
|
||||
import { Settings } from "lucide-react";
|
||||
import { FileQuestion, Settings } from "lucide-react";
|
||||
|
||||
import type { ActiveNav } from "@/lib/nav/items";
|
||||
import { buttonClass, focusRing } from "@/lib/ui/variants";
|
||||
@@ -18,6 +18,9 @@ interface AppShellProps {
|
||||
projectId?: string;
|
||||
// 当前激活的项目级入口键(用于高亮)。
|
||||
activeNav?: ActiveNav;
|
||||
// 专注写作模式(P3-4):隐藏桌面左侧图标导航以加宽正文;顶栏汉堡/命令面板仍可达导航。
|
||||
// 缺省 false → 其余页面外观不变(加法式可选项)。
|
||||
hideNav?: boolean;
|
||||
}
|
||||
|
||||
// 全局外壳:顶栏(64px) + 左导航 + 主区(UX §5.1)。
|
||||
@@ -27,6 +30,7 @@ export function AppShell({
|
||||
subtitle,
|
||||
projectId,
|
||||
activeNav,
|
||||
hideNav = false,
|
||||
}: AppShellProps) {
|
||||
return (
|
||||
<div
|
||||
@@ -64,6 +68,19 @@ export function AppShell({
|
||||
>
|
||||
<CommandSearchButton />
|
||||
<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
|
||||
href="/settings/providers"
|
||||
aria-label="设置"
|
||||
@@ -80,7 +97,7 @@ export function AppShell({
|
||||
</nav>
|
||||
</header>
|
||||
<div className="flex">
|
||||
<LeftNav projectId={projectId} activeNav={activeNav} />
|
||||
{hideNav ? null : <LeftNav projectId={projectId} activeNav={activeNav} />}
|
||||
<main
|
||||
id="main"
|
||||
tabIndex={-1}
|
||||
|
||||
@@ -7,8 +7,12 @@ import { Button } from "@/components/ui/Button";
|
||||
import { handleTabTrap } from "@/lib/a11y/focusTrap";
|
||||
import { useRestoreFocus } from "@/lib/a11y/useRestoreFocus";
|
||||
import { useBodyScrollLock } from "@/lib/ui/useBodyScrollLock";
|
||||
import { useMountTransition } from "@/lib/ui/useMountTransition";
|
||||
import { overlayScrim } from "@/lib/ui/variants";
|
||||
|
||||
// 抽屉进出场时长(与 --dur-base 一致)。
|
||||
const DRAWER_MS = 180;
|
||||
|
||||
interface DrawerProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
@@ -36,11 +40,15 @@ export function Drawer({
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const closeRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const { shouldRender, isVisible } = useMountTransition(open, DRAWER_MS);
|
||||
|
||||
useBodyScrollLock(open);
|
||||
useRestoreFocus(open, triggerRef);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
// 面板真正挂载(shouldRender)且处于打开态时才装监听/落焦——
|
||||
// 因进出场延迟了卸载,若仅依赖 open 会错过挂载帧导致首焦点丢失。
|
||||
if (!open || !shouldRender) return;
|
||||
const onKey = (e: KeyboardEvent): void => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
@@ -51,14 +59,25 @@ export function Drawer({
|
||||
// 焦点陷阱首焦点落在具名关闭按钮上(而非整个面板)。
|
||||
closeRef.current?.focus();
|
||||
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 slideClass =
|
||||
side === "left"
|
||||
? isVisible
|
||||
? "translate-x-0"
|
||||
: "-translate-x-full"
|
||||
: isVisible
|
||||
? "translate-x-0"
|
||||
: "translate-x-full";
|
||||
|
||||
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
|
||||
ref={panelRef}
|
||||
id={id}
|
||||
@@ -70,7 +89,7 @@ export function Drawer({
|
||||
// focus trap:Tab/Shift+Tab 在抽屉内循环,不逃逸到背景(WCAG 2.1.2)。
|
||||
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">
|
||||
<Button
|
||||
|
||||
@@ -107,9 +107,9 @@ export function ProjectWizard() {
|
||||
const isLast = step === WIZARD_STEPS;
|
||||
|
||||
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">
|
||||
<h1 className="font-serif text-2xl text-ink">新建作品</h1>
|
||||
<h1 className="font-serif text-display-sm text-ink">新建作品</h1>
|
||||
<StepDots current={step} />
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
@@ -175,7 +175,7 @@ function StepDots({ current }: { current: number }) {
|
||||
{Array.from({ length: WIZARD_STEPS }, (_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={`h-2 w-2 rounded ${
|
||||
className={`h-2 w-2 rounded-full ${
|
||||
i + 1 <= current ? "bg-cinnabar" : "bg-line"
|
||||
}`}
|
||||
/>
|
||||
@@ -235,7 +235,7 @@ function PlanAssistant({
|
||||
const generating = status === "generating";
|
||||
|
||||
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="min-w-0">
|
||||
<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>
|
||||
<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) => (
|
||||
<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>
|
||||
|
||||
@@ -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) {
|
||||
try {
|
||||
window.localStorage.setItem(THEME_STORAGE_KEY, mode);
|
||||
@@ -42,7 +35,9 @@ function writeStoredThemeMode(mode: ThemeMode) {
|
||||
}
|
||||
|
||||
export function ThemeToggle() {
|
||||
const [mode, setMode] = useState<ThemeMode>(initialThemeMode);
|
||||
// 首帧用默认模式(与服务端一致)避免水合不一致;挂载后以 localStorage 校正。
|
||||
// 可见的图标由 CSS 按 <html data-theme> 决定(ThemeScript 已在水合前写入),故无首帧闪烁。
|
||||
const [mode, setMode] = useState<ThemeMode>(DEFAULT_THEME_MODE);
|
||||
|
||||
useEffect(() => {
|
||||
// 水合后以 localStorage 为权威源校正(dataset 已由 ThemeScript 同样依据写入,通常一致)。
|
||||
@@ -60,8 +55,8 @@ export function ThemeToggle() {
|
||||
});
|
||||
};
|
||||
|
||||
const Icon = mode === "night" ? Sun : Moon;
|
||||
|
||||
// 图标显隐交给 CSS(按 data-theme),两个图标始终渲染 → 首帧结构与服务端一致、无闪烁。
|
||||
// 首帧 label 用默认模式(与服务端一致),挂载后随 mode 校正为正确文案。
|
||||
return (
|
||||
<Button
|
||||
onClick={toggle}
|
||||
@@ -71,7 +66,8 @@ export function ThemeToggle() {
|
||||
title={themeToggleLabel(mode)}
|
||||
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>
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -17,12 +17,12 @@ export function ThinkingIndicator({
|
||||
role="status"
|
||||
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) => (
|
||||
<span
|
||||
key={i}
|
||||
className="h-1.5 w-1.5 rounded-full bg-current opacity-70 motion-safe:animate-pulse"
|
||||
style={{ animationDelay: `${i * 200}ms` }}
|
||||
className="ai-dot h-2 w-2 rounded-full bg-current"
|
||||
style={{ animationDelay: `${i * 150}ms` }}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
|
||||
@@ -98,7 +98,7 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const renderItem = (t: ToastItem) => (
|
||||
<div
|
||||
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,
|
||||
)}`}
|
||||
>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Play } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Field } from "@/components/ui/Field";
|
||||
import { Radio } from "@/components/ui/Radio";
|
||||
import { SectionHeader } from "@/components/ui/SectionHeader";
|
||||
import { TextInput } from "@/components/ui/TextInput";
|
||||
import { CHAIN_KINDS, type ChainKind } from "@/lib/chain/chain";
|
||||
@@ -58,8 +59,7 @@ export function ChainStarter({ onStart, disabled }: ChainStarterProps) {
|
||||
key={kind.key}
|
||||
className="flex items-start gap-2 text-sm text-ink"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
<Radio
|
||||
name="chain-kind"
|
||||
value={kind.key}
|
||||
checked={chainKey === kind.key}
|
||||
|
||||
@@ -50,7 +50,7 @@ const RelationshipGraph = dynamic(
|
||||
{
|
||||
ssr: false,
|
||||
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>
|
||||
),
|
||||
@@ -101,7 +101,7 @@ export function CodexPage({
|
||||
{tab === "characters" ? (
|
||||
<div className="flex flex-col gap-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})
|
||||
</h3>
|
||||
{characters.length > 0 ? (
|
||||
@@ -109,10 +109,10 @@ export function CodexPage({
|
||||
{characters.map((c, i) => (
|
||||
<li
|
||||
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">
|
||||
<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" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
@@ -127,7 +127,7 @@ export function CodexPage({
|
||||
{c.relations.map((r, j) => (
|
||||
<li
|
||||
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}
|
||||
>
|
||||
{r.kind} · {r.name}
|
||||
@@ -163,7 +163,7 @@ export function CodexPage({
|
||||
</Card>
|
||||
{characters.length > 0 ? (
|
||||
<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" />
|
||||
关系图谱
|
||||
</h3>
|
||||
@@ -184,7 +184,7 @@ export function CodexPage({
|
||||
{tab === "world" ? (
|
||||
<div className="flex flex-col gap-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})
|
||||
</h3>
|
||||
{initialWorldEntities.length > 0 ? (
|
||||
@@ -192,7 +192,7 @@ export function CodexPage({
|
||||
{initialWorldEntities.map((entity, i) => (
|
||||
<article
|
||||
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">
|
||||
<Globe2
|
||||
|
||||
@@ -66,6 +66,15 @@ export function useCommandPaletteOpen(): boolean {
|
||||
const LIST_ID = "command-list";
|
||||
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>/...)。
|
||||
function projectIdFromPath(pathname: string): string | null {
|
||||
const m = pathname.match(/^\/projects\/([^/]+)/);
|
||||
@@ -208,6 +217,7 @@ export function CommandPalette({ pathname }: CommandPaletteProps) {
|
||||
if (dialogRef.current) handleTabTrap(dialogRef.current, e);
|
||||
}}
|
||||
>
|
||||
<div className="relative border-b border-line">
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
@@ -220,8 +230,13 @@ export function CommandPalette({ pathname }: CommandPaletteProps) {
|
||||
aria-controls={LIST_ID}
|
||||
aria-activedescendant={activeCmd ? optionId(activeCmd) : undefined}
|
||||
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
|
||||
id={LIST_ID}
|
||||
role="listbox"
|
||||
@@ -229,7 +244,14 @@ export function CommandPalette({ pathname }: CommandPaletteProps) {
|
||||
className="max-h-80 overflow-auto py-1 overscroll-contain"
|
||||
>
|
||||
{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) => (
|
||||
<li
|
||||
@@ -246,11 +268,30 @@ export function CommandPalette({ pathname }: CommandPaletteProps) {
|
||||
}`}
|
||||
>
|
||||
<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>
|
||||
))
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Checkbox } from "@/components/ui/Checkbox";
|
||||
import { TextInput } from "@/components/ui/TextInput";
|
||||
import type { CharacterCardView } from "@/lib/api/types";
|
||||
|
||||
@@ -25,12 +26,10 @@ export function CharacterCardItem({
|
||||
}`}
|
||||
>
|
||||
<header className="mb-2 flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
<Checkbox
|
||||
checked={selected}
|
||||
onChange={onToggle}
|
||||
aria-label={`选择角色 ${card.name}`}
|
||||
className="size-4 accent-cinnabar"
|
||||
/>
|
||||
{onEdit ? (
|
||||
<TextInput
|
||||
|
||||
@@ -171,8 +171,8 @@ export function CharacterGenerator({
|
||||
|
||||
<Button
|
||||
onClick={() => void onGenerate()}
|
||||
loading={generating}
|
||||
disabled={
|
||||
generating ||
|
||||
brief.trim().length === 0 ||
|
||||
(mode === "mix"
|
||||
? mixTotal < MIN_CHARACTER_COUNT
|
||||
@@ -181,7 +181,7 @@ export function CharacterGenerator({
|
||||
variant="primary"
|
||||
>
|
||||
<Sparkles className="h-4 w-4" aria-hidden="true" />
|
||||
{generating ? "生成中…" : "生成"}
|
||||
生成
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -66,11 +66,12 @@ export function WorldGenerator({ projectId }: WorldGeneratorProps) {
|
||||
</Field>
|
||||
<Button
|
||||
onClick={() => void onGenerate()}
|
||||
disabled={generating || brief.trim().length === 0}
|
||||
loading={generating}
|
||||
disabled={brief.trim().length === 0}
|
||||
variant="primary"
|
||||
>
|
||||
<Sparkles className="h-4 w-4" aria-hidden="true" />
|
||||
{generating ? "生成中…" : "生成世界观"}
|
||||
生成世界观
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -217,7 +217,7 @@ export function OutlineEditor({
|
||||
) : (
|
||||
volumes.map((group) => (
|
||||
<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}
|
||||
</h2>
|
||||
<ul className="space-y-1">
|
||||
|
||||
164
apps/web/components/projects/ProjectCard.tsx
Normal file
164
apps/web/components/projects/ProjectCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import Link from "next/link";
|
||||
import { Clock3, LayoutGrid, List, Plus, Search } from "lucide-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 { EmptyState } from "@/components/ui/EmptyState";
|
||||
import { SegmentedControl } from "@/components/ui/SegmentedControl";
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
type ProjectSort,
|
||||
type ProjectViewMode,
|
||||
} from "@/lib/projects/projects";
|
||||
import { buttonClass, cardClass } from "@/lib/ui/variants";
|
||||
import { buttonClass, cardClass, cn, focusRing } from "@/lib/ui/variants";
|
||||
|
||||
interface ProjectLibraryProps {
|
||||
projects: ProjectResponse[];
|
||||
@@ -150,7 +150,7 @@ export function ProjectLibrary({ projects }: ProjectLibraryProps) {
|
||||
}
|
||||
/>
|
||||
) : 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) => (
|
||||
<li key={p.id}>
|
||||
<ProjectCard project={p} />
|
||||
@@ -189,16 +189,21 @@ function NewProjectCard() {
|
||||
return (
|
||||
<Link
|
||||
href="/projects/new"
|
||||
className={buttonClass({
|
||||
variant: "outline",
|
||||
className: "group h-full min-h-[176px] flex-col border-dashed text-center",
|
||||
className={cardClass({
|
||||
tone: "soft",
|
||||
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" />
|
||||
</span>
|
||||
<span className="font-serif text-xl text-cinnabar">新建</span>
|
||||
<span className="mt-2 text-sm text-ink-soft">从一句灵感开始一本书</span>
|
||||
<span className="font-serif text-title-md text-cinnabar">新建作品</span>
|
||||
<span className="text-caption text-muted-soft">从一句灵感开始一本书</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"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 { TextInput } from "@/components/ui/TextInput";
|
||||
@@ -80,10 +80,22 @@ export function ConflictCard({
|
||||
{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">
|
||||
<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="mx-1 text-ink-soft" aria-hidden="true">
|
||||
→
|
||||
</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="ml-2 text-ink-soft">(点「采纳改法」改入终稿)</span>
|
||||
</div>
|
||||
|
||||
@@ -17,7 +17,11 @@ import { AppShell } from "@/components/AppShell";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
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 { ProjectResponse, ReviewHistoryItem } from "@/lib/api/types";
|
||||
import { friendlyError } from "@/lib/errors/messages";
|
||||
@@ -74,8 +78,10 @@ interface ReviewReportProps {
|
||||
chapterNo: number;
|
||||
initialReview: ReviewHistoryItem | undefined;
|
||||
initialDraft: string;
|
||||
// 章节导航目录(大纲章节):用于报告头部的「切换审稿章节」选择器。
|
||||
// 章节导航目录(大纲章节):无 chapterOptions 时回退用。
|
||||
chapters?: ChapterEntry[];
|
||||
// 选章选项(以真实章为准,含可审/已审标记);优先于 chapters 渲染选择器。
|
||||
chapterOptions?: ReviewChapterOption[];
|
||||
}
|
||||
|
||||
// 审稿报告页主体(UX §6.4 / §8.3 / §9)。
|
||||
@@ -87,6 +93,7 @@ export function ReviewReport({
|
||||
initialReview,
|
||||
initialDraft,
|
||||
chapters,
|
||||
chapterOptions,
|
||||
}: ReviewReportProps) {
|
||||
const router = useRouter();
|
||||
const review = useReviewStream();
|
||||
@@ -460,7 +467,7 @@ export function ReviewReport({
|
||||
<Select
|
||||
controlSize="sm"
|
||||
aria-label="切换审稿章节"
|
||||
className="hidden max-w-[12rem] shrink-0 sm:block"
|
||||
className="max-w-[12rem] shrink-0"
|
||||
value={chapterNo}
|
||||
onChange={(e) =>
|
||||
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}
|
||||
</option>
|
||||
))}
|
||||
@@ -604,7 +613,7 @@ export function ReviewReport({
|
||||
: "已处理"}
|
||||
</span>
|
||||
<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"
|
||||
/>
|
||||
</span>
|
||||
|
||||
@@ -52,7 +52,7 @@ export function ReviewSectionPanel({
|
||||
<Badge variant={statusVariant}>{statusLabel}</Badge>
|
||||
<ChevronDown
|
||||
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",
|
||||
)}
|
||||
aria-hidden="true"
|
||||
|
||||
24
apps/web/components/settings/CapabilityBadges.tsx
Normal file
24
apps/web/components/settings/CapabilityBadges.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
105
apps/web/components/settings/CredentialRow.tsx
Normal file
105
apps/web/components/settings/CredentialRow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
85
apps/web/components/settings/CredentialsPanel.tsx
Normal file
85
apps/web/components/settings/CredentialsPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,41 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
CheckCircle2,
|
||||
KeyRound,
|
||||
PlugZap,
|
||||
Route,
|
||||
Save,
|
||||
XCircle,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
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 { Badge } from "@/components/ui/Badge";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
import { SectionHeader } from "@/components/ui/SectionHeader";
|
||||
import { SegmentedControl } from "@/components/ui/SegmentedControl";
|
||||
import { Select } from "@/components/ui/Select";
|
||||
import { StatusNote } from "@/components/ui/StatusNote";
|
||||
import { TextInput } from "@/components/ui/TextInput";
|
||||
import { api } from "@/lib/api/client";
|
||||
import { cardClass } from "@/lib/ui/variants";
|
||||
import { RoutingPanel } from "@/components/settings/RoutingPanel";
|
||||
import {
|
||||
SECTION_OPTIONS,
|
||||
SettingsNav,
|
||||
type SettingsSection,
|
||||
} from "@/components/settings/SettingsNav";
|
||||
import { SegmentedControl } from "@/components/ui/SegmentedControl";
|
||||
import { api } from "@/lib/api/client";
|
||||
import {
|
||||
API_KEY_PROVIDERS,
|
||||
KNOWN_PROVIDERS,
|
||||
TIER_LABELS,
|
||||
applyProviderChange,
|
||||
draftsToRoutingInput,
|
||||
toRoutingDrafts,
|
||||
type RoutingDraft,
|
||||
} from "@/lib/settings/providers";
|
||||
import type {
|
||||
CapabilitiesView,
|
||||
ProvidersResponse,
|
||||
} from "@/lib/api/types";
|
||||
import type { ProvidersResponse } from "@/lib/api/types";
|
||||
|
||||
interface ProvidersSettingsProps {
|
||||
initial: ProvidersResponse;
|
||||
@@ -43,42 +28,6 @@ interface ProvidersSettingsProps {
|
||||
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 id(datalist 建议,仍可自由输入)。按 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 连接区。
|
||||
export function ProvidersSettings({
|
||||
initial,
|
||||
@@ -97,9 +46,6 @@ export function ProvidersSettings({
|
||||
const [testingId, setTestingId] = useState<string | null>(null);
|
||||
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 => {
|
||||
if (value === "routing") {
|
||||
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 (
|
||||
<div className="grid gap-6 lg:grid-cols-[12rem_1fr]">
|
||||
<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={sectionDetail(section.value)}
|
||||
onClick={() => setActiveSection(section.value)}
|
||||
<SettingsNav
|
||||
activeSection={activeSection}
|
||||
onSelect={setActiveSection}
|
||||
detailFor={sectionDetail}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<div className="min-w-0">
|
||||
<SegmentedControl
|
||||
@@ -221,269 +162,37 @@ export function ProvidersSettings({
|
||||
/>
|
||||
|
||||
{activeSection === "routing" ? (
|
||||
<SettingsPanel>
|
||||
<SectionHeader
|
||||
title="能力档位路由"
|
||||
description="写手、分析、轻量三类能力可分别指向不同模型。保存时只提交完整填写的行。"
|
||||
<RoutingPanel
|
||||
routing={routing}
|
||||
saving={savingRouting}
|
||||
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}
|
||||
|
||||
{activeSection === "oauth" ? (
|
||||
<SettingsPanel>
|
||||
<section className="min-w-0">
|
||||
<KimiCodeOauth
|
||||
initialConnected={kimiOauth.connected}
|
||||
initialExpiresAt={kimiOauth.expiresAt}
|
||||
/>
|
||||
</SettingsPanel>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{activeSection === "keys" ? (
|
||||
<SettingsPanel>
|
||||
<SectionHeader
|
||||
title="提供商凭据"
|
||||
description="API Key 只用于后端探活和调用,列表中只显示脱敏后的已保存凭据。"
|
||||
/>
|
||||
<div className="mt-3 grid gap-2 text-xs text-ink-soft sm:grid-cols-3">
|
||||
<div className="rounded border border-line bg-panel px-3 py-2">
|
||||
已保存 <span className="font-mono text-ink">{providers.length}</span>
|
||||
</div>
|
||||
<div className="rounded border border-line bg-panel px-3 py-2">
|
||||
可配置{" "}
|
||||
<span className="font-mono text-ink">
|
||||
{API_KEY_PROVIDERS.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="rounded border border-line bg-panel px-3 py-2">
|
||||
测试结果{" "}
|
||||
<span className="font-mono text-ink">
|
||||
{Object.keys(results).length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{providers.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={PlugZap}
|
||||
title="还没有可用提供商"
|
||||
description="至少连接一个提供商即可开始写作。求质量可选 Anthropic,求性价比可选 DeepSeek。"
|
||||
className="mt-4"
|
||||
<CredentialsPanel
|
||||
providers={providers}
|
||||
drafts={drafts}
|
||||
savingId={savingId}
|
||||
testingId={testingId}
|
||||
results={results}
|
||||
onDraftChange={updateDraft}
|
||||
onSave={(id) => void saveCredential(id)}
|
||||
onTest={(id) => void testConnection(id)}
|
||||
/>
|
||||
) : null}
|
||||
<ul className="mt-4 divide-y divide-line rounded border border-line bg-panel">
|
||||
{API_KEY_PROVIDERS.map((prov) => {
|
||||
const masked = maskedFor(prov.id);
|
||||
const result = results[prov.id];
|
||||
return (
|
||||
<li key={prov.id} className="px-4 py-4">
|
||||
<div className="grid gap-3 lg:grid-cols-[10rem_8rem_minmax(12rem,1fr)_auto_auto] lg:items-center">
|
||||
<span className="flex items-center gap-2 text-sm text-ink">
|
||||
<span
|
||||
className={`h-2 w-2 rounded ${
|
||||
masked ? "bg-pass" : "bg-line"
|
||||
}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{prov.label}
|
||||
</span>
|
||||
<span className="font-mono text-xs text-ink-soft">
|
||||
{masked ?? "未配置"}
|
||||
</span>
|
||||
<label className="sr-only" htmlFor={`key-${prov.id}`}>
|
||||
{prov.label} API Key
|
||||
</label>
|
||||
<TextInput
|
||||
id={`key-${prov.id}`}
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={drafts[prov.id] ?? ""}
|
||||
onChange={(e) =>
|
||||
setDrafts((prev) => ({
|
||||
...prev,
|
||||
[prov.id]: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder={
|
||||
masked ? "输入新 Key 以更新" : "输入 API Key"
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => saveCredential(prov.id)}
|
||||
disabled={
|
||||
savingId === prov.id ||
|
||||
(drafts[prov.id] ?? "").trim().length === 0
|
||||
}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
>
|
||||
<Save className="h-4 w-4" aria-hidden="true" />
|
||||
{savingId === prov.id
|
||||
? "保存中…"
|
||||
: masked
|
||||
? "更新"
|
||||
: "添加"}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => testConnection(prov.id)}
|
||||
disabled={testingId === prov.id}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
<PlugZap className="h-4 w-4" aria-hidden="true" />
|
||||
{testingId === prov.id ? "测试中…" : "测试"}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-2 text-xs leading-5 text-ink-soft lg:pl-[10.5rem]">
|
||||
{masked
|
||||
? "已保存脱敏凭据;输入新 Key 可覆盖更新。"
|
||||
: "保存后再测试连接,成功后即可在档位路由中使用。"}
|
||||
</p>
|
||||
{result ? (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 lg:pl-[10.5rem]">
|
||||
<Badge variant={result.ok ? "success" : "danger"}>
|
||||
{result.ok ? (
|
||||
<CheckCircle2
|
||||
className="h-3 w-3"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
<XCircle className="h-3 w-3" aria-hidden="true" />
|
||||
)}
|
||||
{result.ok ? "已连接" : "未连接"}
|
||||
</Badge>
|
||||
<CapabilityBadges caps={result.capabilities} />
|
||||
</div>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</SettingsPanel>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsPanel({ children }: { children: ReactNode }) {
|
||||
return <section className="min-w-0">{children}</section>;
|
||||
}
|
||||
|
||||
interface SettingsNavButtonProps {
|
||||
active: boolean;
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
detail: string;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
function SettingsNavButton({
|
||||
active,
|
||||
icon,
|
||||
label,
|
||||
detail,
|
||||
onClick,
|
||||
}: SettingsNavButtonProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
onClick={onClick}
|
||||
className={`mb-1 flex w-full items-start gap-2 rounded px-3 py-2 text-left transition-colors ${
|
||||
active
|
||||
? "bg-[var(--color-cinnabar-wash)] text-cinnabar"
|
||||
: "text-ink hover:bg-bg hover:text-cinnabar"
|
||||
}`}
|
||||
>
|
||||
<span className="mt-0.5 shrink-0">{icon}</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-medium">{label}</span>
|
||||
<span className="block truncate text-xs text-ink-soft">{detail}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function CapabilityBadges({ caps }: { caps: CapabilitiesView }) {
|
||||
const badges: { on: boolean; label: string }[] = [
|
||||
{ on: caps.structured_output, label: "结构化" },
|
||||
{ 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>
|
||||
);
|
||||
}
|
||||
|
||||
126
apps/web/components/settings/RoutingPanel.tsx
Normal file
126
apps/web/components/settings/RoutingPanel.tsx
Normal 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 id(datalist 建议,仍可自由输入)。按 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+model(UX §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>
|
||||
);
|
||||
}
|
||||
89
apps/web/components/settings/SettingsNav.tsx
Normal file
89
apps/web/components/settings/SettingsNav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -182,7 +182,7 @@ export function TemplatesManager({ initial, tools }: TemplatesManagerProps) {
|
||||
</form>
|
||||
|
||||
<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 ? (
|
||||
<EmptyState
|
||||
icon={FileText}
|
||||
@@ -194,7 +194,7 @@ export function TemplatesManager({ initial, tools }: TemplatesManagerProps) {
|
||||
{templates.map((t) => (
|
||||
<li
|
||||
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">
|
||||
<p className="font-serif text-base text-ink">{t.title}</p>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useToast } from "@/components/Toast";
|
||||
import { ConflictAdjudication } from "@/components/generation/ConflictAdjudication";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { Checkbox } from "@/components/ui/Checkbox";
|
||||
import { Field } from "@/components/ui/Field";
|
||||
import { StatusNote } from "@/components/ui/StatusNote";
|
||||
import { TextArea } from "@/components/ui/TextArea";
|
||||
@@ -253,9 +254,9 @@ export function GeneratorRunner({
|
||||
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" />
|
||||
{generating ? "生成中…" : "生成"}
|
||||
生成
|
||||
</Button>
|
||||
{generating ? (
|
||||
<ThinkingIndicator
|
||||
@@ -296,13 +297,12 @@ export function GeneratorRunner({
|
||||
{canIngest && gen.ingestStatus !== "conflict" ? (
|
||||
<Button
|
||||
onClick={() => void runIngest(false)}
|
||||
disabled={ingesting || (!singleObject && selected.size === 0)}
|
||||
loading={ingesting}
|
||||
disabled={!singleObject && selected.size === 0}
|
||||
variant="outline"
|
||||
>
|
||||
<Database className="h-4 w-4" aria-hidden="true" />
|
||||
{ingesting
|
||||
? "入库中…"
|
||||
: singleObject
|
||||
{singleObject
|
||||
? `入库为规则至 ${table}`
|
||||
: `入库选中(${selected.size})至 ${table}`}
|
||||
</Button>
|
||||
@@ -380,8 +380,7 @@ function PreviewRow({
|
||||
return (
|
||||
<li className="flex gap-2 rounded border border-line bg-bg p-3 text-sm">
|
||||
{selectable ? (
|
||||
<input
|
||||
type="checkbox"
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onChange={() => onToggle(index)}
|
||||
className="mt-1"
|
||||
|
||||
@@ -5,11 +5,14 @@ import {
|
||||
type ButtonSize,
|
||||
type ButtonVariant,
|
||||
} from "@/lib/ui/variants";
|
||||
import { Spinner } from "./Spinner";
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
children: ReactNode;
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
// 忙碌态:显示转圈 + 禁用 + aria-busy,统一各处"处理中"的过程感。
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
|
||||
@@ -19,6 +22,8 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
|
||||
variant,
|
||||
size,
|
||||
type = "button",
|
||||
loading = false,
|
||||
disabled,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
@@ -28,8 +33,11 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
|
||||
ref={ref}
|
||||
type={type}
|
||||
className={buttonClass({ variant, size, className })}
|
||||
disabled={disabled || loading}
|
||||
aria-busy={loading || undefined}
|
||||
{...props}
|
||||
>
|
||||
{loading ? <Spinner className="h-4 w-4" /> : null}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -1,20 +1,29 @@
|
||||
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> {
|
||||
children: ReactNode;
|
||||
as?: "article" | "div" | "section";
|
||||
tone?: CardTone;
|
||||
flat?: boolean;
|
||||
interactive?: boolean;
|
||||
}
|
||||
|
||||
export function Card({
|
||||
as: Component = "div",
|
||||
children,
|
||||
className,
|
||||
tone,
|
||||
flat,
|
||||
interactive,
|
||||
...props
|
||||
}: CardProps) {
|
||||
return (
|
||||
<Component className={cardClass(className)} {...props}>
|
||||
<Component
|
||||
className={cardClass({ tone, flat, interactive, className })}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
|
||||
20
apps/web/components/ui/Checkbox.tsx
Normal file
20
apps/web/components/ui/Checkbox.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,38 +1,117 @@
|
||||
import type { ReactNode } from "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";
|
||||
|
||||
// 空态外框形态:
|
||||
// - "inline"(默认):虚线中性框,用于列表/面板内联占位(向后兼容旧默认外观)。
|
||||
// - "card":包裹在 Card(tone=soft) 内,用于独立区块的引导卡片。
|
||||
// - "bare":无外框,仅内容,交由调用方自行放置(如已在卡片里)。
|
||||
type EmptyStateVariant = "inline" | "card" | "bare";
|
||||
type EmptyStateSize = "sm" | "md";
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
description: string;
|
||||
action?: ReactNode;
|
||||
/** 可选眉题,置于标题上方,用于分类/上下文提示。 */
|
||||
eyebrow?: string;
|
||||
/** 外框形态,默认 "inline"。 */
|
||||
variant?: EmptyStateVariant;
|
||||
/** 留白密度,默认 "md"。 */
|
||||
size?: EmptyStateSize;
|
||||
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,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
className,
|
||||
}: EmptyStateProps) {
|
||||
eyebrow,
|
||||
size,
|
||||
}: Pick<
|
||||
EmptyStateProps,
|
||||
"icon" | "title" | "description" | "action" | "eyebrow"
|
||||
> & {
|
||||
size: EmptyStateSize;
|
||||
}) {
|
||||
return (
|
||||
<div className="text-center">
|
||||
<div
|
||||
className={cn(
|
||||
"rounded border border-dashed border-line bg-panel/70 px-6 py-10 text-center",
|
||||
className,
|
||||
"mx-auto mb-3 flex items-center justify-center rounded-md bg-surface-soft text-muted-soft",
|
||||
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" />
|
||||
</div>
|
||||
<h2 className="font-serif text-lg text-ink">{title}</h2>
|
||||
<p className="mx-auto mt-2 max-w-md text-sm leading-6 text-ink-soft">
|
||||
{eyebrow ? <Eyebrow className="mb-2">{eyebrow}</Eyebrow> : null}
|
||||
<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}
|
||||
</p>
|
||||
{action ? <div className="mt-4">{action}</div> : null}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
23
apps/web/components/ui/Eyebrow.tsx
Normal file
23
apps/web/components/ui/Eyebrow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
49
apps/web/components/ui/GenerationSkeleton.tsx
Normal file
49
apps/web/components/ui/GenerationSkeleton.tsx
Normal 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-hidden;role=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>
|
||||
);
|
||||
}
|
||||
45
apps/web/components/ui/Meter.tsx
Normal file
45
apps/web/components/ui/Meter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
apps/web/components/ui/PageContainer.tsx
Normal file
30
apps/web/components/ui/PageContainer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { Eyebrow } from "./Eyebrow";
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: string;
|
||||
eyebrow?: string;
|
||||
@@ -16,12 +18,8 @@ export function PageHeader({
|
||||
return (
|
||||
<header className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
{eyebrow ? (
|
||||
<p className="mb-1 font-mono text-xs uppercase tracking-wide text-ink-soft/70">
|
||||
{eyebrow}
|
||||
</p>
|
||||
) : null}
|
||||
<h1 className="font-serif text-2xl text-ink">{title}</h1>
|
||||
{eyebrow ? <Eyebrow className="mb-1.5">{eyebrow}</Eyebrow> : null}
|
||||
<h1 className="font-serif text-display-sm text-ink">{title}</h1>
|
||||
{description ? (
|
||||
<p className="mt-2 max-w-2xl text-sm leading-6 text-ink-soft">
|
||||
{description}
|
||||
|
||||
20
apps/web/components/ui/Radio.tsx
Normal file
20
apps/web/components/ui/Radio.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -2,8 +2,11 @@ import type { ReactNode } from "react";
|
||||
|
||||
import { cn } from "@/lib/ui/variants";
|
||||
|
||||
import { Eyebrow } from "./Eyebrow";
|
||||
|
||||
interface SectionHeaderProps {
|
||||
title: string;
|
||||
eyebrow?: string;
|
||||
description?: ReactNode;
|
||||
action?: ReactNode;
|
||||
className?: string;
|
||||
@@ -11,6 +14,7 @@ interface SectionHeaderProps {
|
||||
|
||||
export function SectionHeader({
|
||||
title,
|
||||
eyebrow,
|
||||
description,
|
||||
action,
|
||||
className,
|
||||
@@ -18,7 +22,8 @@ export function SectionHeader({
|
||||
return (
|
||||
<div className={cn("flex items-start justify-between gap-4", className)}>
|
||||
<div className="min-w-0">
|
||||
<h2 className="font-serif text-base text-ink">{title}</h2>
|
||||
{eyebrow ? <Eyebrow className="mb-1">{eyebrow}</Eyebrow> : null}
|
||||
<h2 className="font-serif text-title-md text-ink">{title}</h2>
|
||||
{description ? (
|
||||
<p className="mt-1 text-sm leading-6 text-ink-soft">{description}</p>
|
||||
) : null}
|
||||
|
||||
@@ -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> {
|
||||
value: T;
|
||||
@@ -22,18 +23,61 @@ export function SegmentedControl<T extends string>({
|
||||
ariaLabel,
|
||||
className,
|
||||
}: 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 (
|
||||
<div className={segmentedClass(className)} role="group" aria-label={ariaLabel}>
|
||||
{options.map((option) => {
|
||||
<div className={segmentedClass(className)} role="radiogroup" aria-label={ariaLabel}>
|
||||
{options.map((option, index) => {
|
||||
const selected = option.value === value;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
ref={(node) => {
|
||||
buttonsRef.current[index] = node;
|
||||
}}
|
||||
type="button"
|
||||
aria-pressed={selected}
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
tabIndex={selected ? 0 : -1}
|
||||
onClick={() => onChange(option.value)}
|
||||
onKeyDown={(event) => handleKeyDown(event, index)}
|
||||
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
|
||||
? "bg-panel text-cinnabar shadow-paper"
|
||||
: "text-ink-soft hover:text-cinnabar",
|
||||
|
||||
18
apps/web/components/ui/Skeleton.tsx
Normal file
18
apps/web/components/ui/Skeleton.tsx
Normal 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,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
18
apps/web/components/ui/Spinner.tsx
Normal file
18
apps/web/components/ui/Spinner.tsx
Normal 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,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
41
apps/web/components/ui/StatusDot.tsx
Normal file
41
apps/web/components/ui/StatusDot.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
18
apps/web/components/ui/StreamingBar.tsx
Normal file
18
apps/web/components/ui/StreamingBar.tsx
Normal 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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
309
apps/web/components/workbench/AiToolbar.tsx
Normal file
309
apps/web/components/workbench/AiToolbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,25 @@
|
||||
"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 { Button } from "@/components/ui/Button";
|
||||
import { SectionHeader } from "@/components/ui/SectionHeader";
|
||||
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 {
|
||||
isPinned,
|
||||
@@ -35,6 +49,25 @@ export function ChapterAssistant({ projectId, chapterNo }: ChapterAssistantProps
|
||||
);
|
||||
}
|
||||
|
||||
// 窄栏小标题(P3-5):font-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) {
|
||||
const injection = useInjection(projectId, chapterNo);
|
||||
const data = injection.data;
|
||||
@@ -43,21 +76,17 @@ export function AssistantContent({ projectId, chapterNo }: ChapterAssistantProps
|
||||
|
||||
return (
|
||||
<div className="px-4">
|
||||
<SectionHeader
|
||||
title="本章参考"
|
||||
action={
|
||||
injection.saving ? (
|
||||
<ThinkingIndicator
|
||||
label="保存中"
|
||||
className="text-xs text-ink-soft"
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h2 className="font-sans text-title-md font-medium text-ink">
|
||||
本章参考
|
||||
</h2>
|
||||
{injection.saving ? (
|
||||
<ThinkingIndicator label="保存中" className="text-xs text-ink-soft" />
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<section className="mt-4">
|
||||
<SectionHeader
|
||||
title="AI 写这章会参考"
|
||||
<section className="mt-5">
|
||||
<PanelLabel
|
||||
action={
|
||||
data ? (
|
||||
<RecentStepper
|
||||
@@ -67,10 +96,12 @@ export function AssistantContent({ projectId, chapterNo }: ChapterAssistantProps
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
>
|
||||
AI 写这章会参考
|
||||
</PanelLabel>
|
||||
|
||||
{injection.loading ? (
|
||||
<p className="mt-2 text-xs text-ink-soft">加载参考信息…</p>
|
||||
<p className="mt-2 text-caption text-ink-soft">加载参考信息…</p>
|
||||
) : !data ? (
|
||||
// 首次加载失败:data 仍为空 → 满屏错误框 + 重试。
|
||||
<StatusNote variant="danger" className="mt-2 text-xs" title="参考信息暂不可用">
|
||||
@@ -113,11 +144,15 @@ export function AssistantContent({ projectId, chapterNo }: ChapterAssistantProps
|
||||
) : null}
|
||||
|
||||
{selected.length === 0 ? (
|
||||
<p className="mt-2 rounded border border-dashed border-line bg-bg p-3 text-xs text-ink-soft">
|
||||
这一章 AI 还没有要参考的设定/角色。可在大纲里点名,或从下方「已排除」里恢复。
|
||||
// 空态占位(不加标题,避免在 h3 子小节内注入 h2 破坏标题大纲)。
|
||||
<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>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="mt-2 space-y-2">
|
||||
<ul className="mt-3 space-y-2">
|
||||
{selected.map((entity) => (
|
||||
<EntityRow
|
||||
key={`${entity.kind}:${entity.name}`}
|
||||
@@ -137,9 +172,9 @@ export function AssistantContent({ projectId, chapterNo }: ChapterAssistantProps
|
||||
)}
|
||||
|
||||
{excluded.length > 0 ? (
|
||||
<div className="mt-3">
|
||||
<SectionHeader title="已排除(本章不参考)" />
|
||||
<ul className="mt-1 space-y-1">
|
||||
<div className="mt-4">
|
||||
<PanelLabel>已排除(本章不参考)</PanelLabel>
|
||||
<ul className="mt-2 space-y-1">
|
||||
{excluded.map((ref) => (
|
||||
<ExcludedRow
|
||||
key={`${ref.kind}:${ref.name}`}
|
||||
@@ -153,16 +188,67 @@ export function AssistantContent({ projectId, chapterNo }: ChapterAssistantProps
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="mt-6">
|
||||
<SectionHeader
|
||||
title="写完后检查"
|
||||
description="写完这章,AI 会把一致性、伏笔、文风、节奏各查一遍。到左栏或底栏的「审稿」逐条过。"
|
||||
/>
|
||||
<ChapterForeshadowSection projectId={projectId} chapterNo={chapterNo} />
|
||||
|
||||
<section className="mt-6 border-t border-line-soft pt-5">
|
||||
<PanelLabel>写完后检查</PanelLabel>
|
||||
<p className="mt-1.5 text-caption leading-6 text-ink-soft">
|
||||
写完这章,AI 会把一致性、伏笔、文风、节奏各查一遍。到左栏或底栏的「审稿」逐条过。
|
||||
</p>
|
||||
</section>
|
||||
</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 {
|
||||
entity: InjectionEntity;
|
||||
pinned: boolean;
|
||||
|
||||
104
apps/web/components/workbench/ChapterForeshadowList.tsx
Normal file
104
apps/web/components/workbench/ChapterForeshadowList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -29,7 +29,7 @@ export function ChapterList({
|
||||
if (collapsed) {
|
||||
return (
|
||||
<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="目录(已收起)"
|
||||
>
|
||||
<button
|
||||
@@ -38,7 +38,7 @@ export function ChapterList({
|
||||
aria-expanded={false}
|
||||
aria-label="展开目录"
|
||||
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" />
|
||||
</button>
|
||||
@@ -46,7 +46,7 @@ export function ChapterList({
|
||||
);
|
||||
}
|
||||
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
|
||||
projectId={projectId}
|
||||
chapters={chapters}
|
||||
@@ -73,7 +73,7 @@ export function ChapterListContent({
|
||||
return (
|
||||
<>
|
||||
<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>
|
||||
{onCollapse ? (
|
||||
@@ -82,7 +82,7 @@ export function ChapterListContent({
|
||||
onClick={onCollapse}
|
||||
aria-label="收起目录"
|
||||
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" />
|
||||
</button>
|
||||
@@ -98,8 +98,8 @@ export function ChapterListContent({
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={
|
||||
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-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-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 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">
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ThinkingIndicator } from "@/components/ThinkingIndicator";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { SectionHeader } from "@/components/ui/SectionHeader";
|
||||
import { StatusNote } from "@/components/ui/StatusNote";
|
||||
import { StreamingBar } from "@/components/ui/StreamingBar";
|
||||
import { TextArea } from "@/components/ui/TextArea";
|
||||
import type { AiMessageView } from "@/lib/api/types";
|
||||
import { friendlyError } from "@/lib/errors/messages";
|
||||
@@ -198,6 +199,11 @@ export function ChapterRewritePanel({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 重写流式期间:气泡区顶部挂一条不确定进度条,明确「进行中」(生成结束卸载)。 */}
|
||||
{rewrite.isStreaming ? (
|
||||
<StreamingBar label="重写生成中" className="mt-3" />
|
||||
) : null}
|
||||
|
||||
{/* 内联对话气泡流:本章 rewrite 往复(已落库)+ 本轮进行中气泡(流式逐字),作者右 / AI 左,滚动区。 */}
|
||||
{flatCount > 0 || status === "error" ? (
|
||||
<div
|
||||
|
||||
@@ -22,12 +22,17 @@ import {
|
||||
useContextDrawerData,
|
||||
type ContextResource,
|
||||
} 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;
|
||||
|
||||
interface ContextDrawerProps {
|
||||
projectId: string;
|
||||
// 当前章号:伏笔速查按此分「可回收 / 待回收 / 已逾期」。
|
||||
chapterNo: number;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
// 关闭时把焦点还给触发按钮(a11y)。
|
||||
@@ -39,6 +44,7 @@ interface ContextDrawerProps {
|
||||
// a11y 复用命令面板/Drawer 范式:role=dialog + aria-modal + Esc 关闭 + focus trap + body scroll lock + 还原焦点。
|
||||
export function ContextDrawer({
|
||||
projectId,
|
||||
chapterNo,
|
||||
open,
|
||||
onClose,
|
||||
triggerRef,
|
||||
@@ -98,7 +104,12 @@ export function ContextDrawer({
|
||||
<TabBar activeTab={activeTab} onSelect={setActiveTab} />
|
||||
|
||||
<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>
|
||||
@@ -144,12 +155,13 @@ function TabBar({ activeTab, onSelect }: TabBarProps) {
|
||||
|
||||
interface TabPanelProps {
|
||||
projectId: string;
|
||||
chapterNo: number;
|
||||
activeTab: ContextTabKey;
|
||||
data: ReturnType<typeof useContextDrawerData>;
|
||||
}
|
||||
|
||||
// 按当前 Tab 渲染对应速查面板;每个面板都带一个「去完整页编辑 →」链接。
|
||||
function TabPanel({ projectId, activeTab, data }: TabPanelProps) {
|
||||
function TabPanel({ projectId, chapterNo, activeTab, data }: TabPanelProps) {
|
||||
const href = contextTabHref(projectId, activeTab);
|
||||
return (
|
||||
<div role="tabpanel" className="space-y-3">
|
||||
@@ -160,7 +172,7 @@ function TabPanel({ projectId, activeTab, data }: TabPanelProps) {
|
||||
<OutlinePanel resource={data.outline} onRetry={data.reloadOutline} />
|
||||
) : null}
|
||||
{activeTab === "foreshadow" ? (
|
||||
<PlaceholderPanel summary="伏笔账本:埋设/回收窗口与逾期告警在完整看板逐条管理。" />
|
||||
<ForeshadowPanel projectId={projectId} chapterNo={chapterNo} />
|
||||
) : null}
|
||||
{activeTab === "rules" ? (
|
||||
<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 }) {
|
||||
return (
|
||||
<StatusNote variant="info">
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { Check, Plus, X } from "lucide-react";
|
||||
|
||||
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { GenerationSkeleton } from "@/components/ui/GenerationSkeleton";
|
||||
import { SectionHeader } from "@/components/ui/SectionHeader";
|
||||
import { StatusNote } from "@/components/ui/StatusNote";
|
||||
import { useContinue } from "@/lib/workbench/useContinue";
|
||||
@@ -72,12 +72,6 @@ export function ContinuePanel({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{candidates.length === 0 && busy ? (
|
||||
<div className="mt-3">
|
||||
<ThinkingIndicator label="续写中" className="text-xs text-cinnabar" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{candidates.length === 0 && status === "error" ? (
|
||||
<StatusNote variant="danger" className="mt-3 text-xs" title="续写失败">
|
||||
<button
|
||||
@@ -112,10 +106,13 @@ export function ContinuePanel({
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{/* 续写为同步请求(无逐字流):生成期间放一块呼吸微光骨架占位,比单三点更明显地示意正在生成。 */}
|
||||
{busy ? <GenerationSkeleton label="续写中" className="mt-3" /> : null}
|
||||
|
||||
<div className="mt-3">
|
||||
<Button
|
||||
onClick={() => void runGenerate()}
|
||||
disabled={busy}
|
||||
loading={busy}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { GenerationSkeleton } from "@/components/ui/GenerationSkeleton";
|
||||
import { cn } from "@/lib/ui/variants";
|
||||
import type { AiMessageView } from "@/lib/api/types";
|
||||
import { bubbleSide, type AcceptAction } from "@/lib/workbench/aiConversation";
|
||||
@@ -73,7 +73,8 @@ function Bubble({ message, action, onAccept }: BubbleProps) {
|
||||
)}
|
||||
>
|
||||
{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">
|
||||
{message.content}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { cn, focusRing, proseBody } from "@/lib/ui/variants";
|
||||
import { ThinkingPlaceholder } from "./ThinkingPlaceholder";
|
||||
|
||||
export interface EditorSelection {
|
||||
start: number;
|
||||
@@ -14,6 +15,9 @@ interface EditorProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
streaming: boolean;
|
||||
// 构思中(流式已开始但首 token 未到):在正文区覆盖「AI 正在构思本章…」占位,
|
||||
// 首个 token 到达即消失换成正文。纯展示层,不触碰流式逻辑。
|
||||
isThinking?: boolean;
|
||||
// 选区变化上报(供「润色选段」等选区级 AI 动作取材)。空选区也会上报(start===end)。
|
||||
onSelectionChange?: (selection: EditorSelection) => void;
|
||||
}
|
||||
@@ -24,6 +28,7 @@ export function Editor({
|
||||
value,
|
||||
onChange,
|
||||
streaming,
|
||||
isThinking = false,
|
||||
onSelectionChange,
|
||||
}: EditorProps) {
|
||||
const ref = useRef<HTMLTextAreaElement>(null);
|
||||
@@ -46,7 +51,7 @@ export function Editor({
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-prose">
|
||||
<div className="relative mx-auto max-w-prose">
|
||||
<label htmlFor="chapter-editor" className="sr-only">
|
||||
正文编辑器
|
||||
</label>
|
||||
@@ -65,12 +70,13 @@ export function Editor({
|
||||
focusRing,
|
||||
)}
|
||||
/>
|
||||
{streaming ? (
|
||||
{streaming && !isThinking ? (
|
||||
<span
|
||||
className="typewriter-cursor ml-0.5 inline-block h-[1.2em] w-[2px] translate-y-1 bg-cinnabar align-middle"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : null}
|
||||
{isThinking ? <ThinkingPlaceholder /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
49
apps/web/components/workbench/MobileContextBar.tsx
Normal file
49
apps/web/components/workbench/MobileContextBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -283,7 +283,8 @@ export function RefinePanel({
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
onClick={() => void onRecommunicate(false)}
|
||||
disabled={busy || clarifying || instruction.trim().length === 0}
|
||||
loading={busy}
|
||||
disabled={clarifying || instruction.trim().length === 0}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
|
||||
13
apps/web/components/workbench/ThinkingPlaceholder.tsx
Normal file
13
apps/web/components/workbench/ThinkingPlaceholder.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,32 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
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 { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { Drawer } from "@/components/Drawer";
|
||||
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
|
||||
import { useToast } from "@/components/Toast";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { SectionHeader } from "@/components/ui/SectionHeader";
|
||||
import { StatusNote } from "@/components/ui/StatusNote";
|
||||
import { TextArea } from "@/components/ui/TextArea";
|
||||
import { StreamingBar } from "@/components/ui/StreamingBar";
|
||||
import type { ProjectResponse } from "@/lib/api/types";
|
||||
import { friendlyError } from "@/lib/errors/messages";
|
||||
import { useAutosave } from "@/lib/autosave/useAutosave";
|
||||
@@ -35,14 +15,8 @@ import {
|
||||
WORKBENCH_CHAPTER_NO,
|
||||
type ChapterEntry,
|
||||
} from "@/lib/workbench/chapter";
|
||||
import {
|
||||
composeDirective,
|
||||
PLOT_PRESETS,
|
||||
STYLE_PRESETS,
|
||||
type Preset,
|
||||
} from "@/lib/workbench/directive";
|
||||
import { composeDirective } from "@/lib/workbench/directive";
|
||||
import { applyRefinement } from "@/lib/workbench/refineApply";
|
||||
import { buttonClass, focusRing } from "@/lib/ui/variants";
|
||||
import { ChapterList, ChapterListContent } from "./ChapterList";
|
||||
import { ChapterAssistant, AssistantContent } from "./ChapterAssistant";
|
||||
import { Editor, type EditorSelection } from "./Editor";
|
||||
@@ -50,7 +24,7 @@ import { RefinePanel } from "./RefinePanel";
|
||||
import { ContinuePanel } from "./ContinuePanel";
|
||||
import { InlineToolbox } from "./InlineToolbox";
|
||||
import { ChapterRewritePanel } from "./ChapterRewritePanel";
|
||||
import { ContextDrawer, CONTEXT_DRAWER_ENABLED } from "./ContextDrawer";
|
||||
import { ContextDrawer } from "./ContextDrawer";
|
||||
import {
|
||||
AiConversationDrawer,
|
||||
AI_CONVERSATION_ENABLED,
|
||||
@@ -60,6 +34,10 @@ import { GENRES } from "@/lib/wizard/wizard";
|
||||
import { useGenreGate, toGenreDirective } from "@/lib/workbench/useGenreGate";
|
||||
import { useAiConversation } from "@/lib/workbench/useAiConversation";
|
||||
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 {
|
||||
project: ProjectResponse;
|
||||
@@ -107,15 +85,21 @@ export function Workbench({
|
||||
const conversation = useAiConversation(project.id, chapterNo);
|
||||
// P2-2:桌面「目录」列可收起(记忆状态);收起时把宽让给正文,窄轨留一个展开入口。
|
||||
const chapterList = useChapterListCollapse();
|
||||
// P3-4:专注写作开关(记忆状态)——开启隐藏左侧图标导航 + 目录 TOC,让宽给正文。
|
||||
const focusMode = useFocusMode();
|
||||
const conversationTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
const toast = useToast();
|
||||
const autosave = useAutosave(project.id, chapterNo, initialText);
|
||||
const stream = useDraftStream();
|
||||
// 构思中 = 流式已开始但首 token 未到(正文区覆盖构思占位);有字后即为在吐字。
|
||||
const isThinking = stream.isStreaming && stream.state.text.length === 0;
|
||||
const lastStreamText = useRef("");
|
||||
const canRefine = selection !== null && selection.text.trim().length > 0;
|
||||
const chapterTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
const assistantTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
const contextTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
// P3-7 流式跟随:正文滚动容器引用(纯展示层,不触碰 SSE/流式数据)。
|
||||
const editorScrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 流式 token 累积进编辑器(打字机);停止/结束后已生成部分留在草稿。
|
||||
useEffect(() => {
|
||||
@@ -132,6 +116,15 @@ export function Workbench({
|
||||
}
|
||||
}, [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 无 → 仅原指令。
|
||||
const startWrite = (effectiveGenre: string | null): void => {
|
||||
const base = composeDirective(directive, presetIds);
|
||||
@@ -271,6 +264,13 @@ export function Workbench({
|
||||
const wordCount = text.replace(/\s+/g, "").length;
|
||||
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 区。
|
||||
const liveMessage = ((): string => {
|
||||
switch (stream.state.phase) {
|
||||
@@ -294,14 +294,12 @@ export function Workbench({
|
||||
subtitle={`第 ${chapterNo} 章`}
|
||||
projectId={project.id}
|
||||
activeNav="write"
|
||||
hideNav={focusMode.focus}
|
||||
>
|
||||
<div
|
||||
className={`grid min-h-[calc(100vh-var(--chrome,4rem))] grid-cols-1 xl:h-[calc(100vh-var(--chrome,4rem))] ${
|
||||
chapterList.collapsed
|
||||
? "xl:grid-cols-[2.75rem_1fr_18rem]"
|
||||
: "xl:grid-cols-[12rem_1fr_18rem]"
|
||||
}`}
|
||||
className={`grid min-h-[calc(100vh-var(--chrome,4rem))] grid-cols-1 xl:h-[calc(100vh-var(--chrome,4rem))] ${gridCols}`}
|
||||
>
|
||||
{focusMode.focus ? null : (
|
||||
<ChapterList
|
||||
projectId={project.id}
|
||||
chapters={chapters}
|
||||
@@ -309,6 +307,7 @@ export function Workbench({
|
||||
collapsed={chapterList.collapsed}
|
||||
onToggle={chapterList.toggle}
|
||||
/>
|
||||
)}
|
||||
|
||||
<section className="flex min-w-0 flex-col bg-bg">
|
||||
<MobileContextBar
|
||||
@@ -386,7 +385,11 @@ export function Workbench({
|
||||
onClose={() => setRewriteOpen(false)}
|
||||
/>
|
||||
) : 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 ? (
|
||||
<StatusNote variant="info" className="mb-4">
|
||||
尚无大纲,将按设定自由生成。
|
||||
@@ -396,6 +399,7 @@ export function Workbench({
|
||||
value={text}
|
||||
onChange={onEditorChange}
|
||||
streaming={stream.isStreaming}
|
||||
isThinking={isThinking}
|
||||
onSelectionChange={setSelection}
|
||||
/>
|
||||
</div>
|
||||
@@ -409,6 +413,8 @@ export function Workbench({
|
||||
streamError={stream.state.error}
|
||||
liveMessage={liveMessage}
|
||||
onStop={stream.stop}
|
||||
focus={focusMode.focus}
|
||||
onToggleFocus={focusMode.toggle}
|
||||
onOpenContext={() => setContextOpen(true)}
|
||||
contextTriggerRef={contextTriggerRef}
|
||||
onOpenConversation={() => setConversationOpen(true)}
|
||||
@@ -446,6 +452,7 @@ export function Workbench({
|
||||
{/* WFW-6 上下文速查:视口无关的右侧 slide-over;旧侧栏/全宽页路由保留、可回滚。 */}
|
||||
<ContextDrawer
|
||||
projectId={project.id}
|
||||
chapterNo={chapterNo}
|
||||
open={contextOpen}
|
||||
onClose={() => setContextOpen(false)}
|
||||
triggerRef={contextTriggerRef}
|
||||
@@ -467,471 +474,3 @@ export function Workbench({
|
||||
</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-1):act-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>
|
||||
);
|
||||
}
|
||||
|
||||
209
apps/web/components/workbench/WorkbenchToolbar.tsx
Normal file
209
apps/web/components/workbench/WorkbenchToolbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
90
apps/web/lib/api/schema.d.ts
vendored
90
apps/web/lib/api/schema.d.ts
vendored
@@ -93,6 +93,29 @@ export interface paths {
|
||||
patch?: 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": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1123,6 +1146,33 @@ export interface components {
|
||||
*/
|
||||
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
|
||||
* @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: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { serverApiBase } from "./config";
|
||||
import type {
|
||||
ChapterListItem,
|
||||
CharacterListResponse,
|
||||
DraftView,
|
||||
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,全局只读;裸数组)。
|
||||
// 任何错误(含后端未上线)降级为空列表,模板页照常渲染(进页不阻塞)。
|
||||
export async function fetchTemplates(): Promise<TemplateResponse[]> {
|
||||
|
||||
@@ -33,6 +33,8 @@ export type ReviewHistoryResponse =
|
||||
export type ConflictDecision = components["schemas"]["ConflictDecision"];
|
||||
export type AcceptRequest = components["schemas"]["AcceptRequest"];
|
||||
export type AcceptResponse = components["schemas"]["AcceptResponse"];
|
||||
// 列章(GET .../chapters,裸数组):审稿选章枚举真实存在的章 + 可审/已审标记。
|
||||
export type ChapterListItem = components["schemas"]["ChapterListItem"];
|
||||
|
||||
// Scope B 多章工作流链(发起 / 续跑;进度走 GET /jobs/{id} 轮询)。
|
||||
export type ChainRunRequest = components["schemas"]["ChainRunRequest"];
|
||||
|
||||
104
apps/web/lib/foreshadow/chapterForeshadow.test.ts
Normal file
104
apps/web/lib/foreshadow/chapterForeshadow.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
51
apps/web/lib/foreshadow/chapterForeshadow.ts
Normal file
51
apps/web/lib/foreshadow/chapterForeshadow.ts
Normal 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;
|
||||
}
|
||||
79
apps/web/lib/foreshadow/useChapterForeshadow.test.ts
Normal file
79
apps/web/lib/foreshadow/useChapterForeshadow.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
71
apps/web/lib/foreshadow/useChapterForeshadow.ts
Normal file
71
apps/web/lib/foreshadow/useChapterForeshadow.ts
Normal 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 {
|
||||
// 网络层失败(后端不可达/CORS):openapi-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 };
|
||||
}
|
||||
@@ -1,8 +1,25 @@
|
||||
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";
|
||||
|
||||
const item = (
|
||||
chapter_no: number,
|
||||
over: Partial<ChapterListItem> = {},
|
||||
): ChapterListItem => ({
|
||||
chapter_no,
|
||||
has_draft: true,
|
||||
accepted: false,
|
||||
reviewed_at: null,
|
||||
...over,
|
||||
});
|
||||
|
||||
describe("reviewChapterHref", () => {
|
||||
it("builds the review route for a chapter number", () => {
|
||||
expect(reviewChapterHref("proj-1", 3)).toBe(
|
||||
@@ -15,7 +32,7 @@ describe("reviewChapterHref", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("reviewChapterOptions", () => {
|
||||
describe("reviewChapterOptions (legacy, outline-only)", () => {
|
||||
const chapters: ChapterEntry[] = [
|
||||
{ no: 1, title: "开篇" },
|
||||
{ no: 2 },
|
||||
@@ -32,14 +49,72 @@ describe("reviewChapterOptions", () => {
|
||||
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);
|
||||
expect(opts[0]).toEqual({ no: 1, label: "第 1 章 · 开篇" });
|
||||
expect(opts[1]).toEqual({ no: 2, label: "第 2 章" });
|
||||
});
|
||||
|
||||
it("returns just the current chapter when there is no outline", () => {
|
||||
const opts = reviewChapterOptions([], 4);
|
||||
expect(opts).toEqual([{ no: 4, label: "第 4 章" }]);
|
||||
expect(opts[0]).toEqual({ no: 1, label: "第 1 章 · 开篇", disabled: false });
|
||||
expect(opts[1]).toEqual({ no: 2, label: "第 2 章", disabled: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildReviewChapterOptions (real chapters + outline titles)", () => {
|
||||
const outline: ChapterEntry[] = [
|
||||
{ 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import type { ChapterListItem } from "@/lib/api/types";
|
||||
import type { ChapterEntry } from "@/lib/workbench/chapter";
|
||||
import { buildChapterEntries } from "@/lib/workbench/chapter";
|
||||
|
||||
export interface ReviewChapterOption {
|
||||
no: number;
|
||||
label: string;
|
||||
// 有草稿正文=可审;无草稿章置灰(审稿依赖草稿,选空章会 422)。
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
export function reviewChapterHref(projectId: string, chapterNo: number): string {
|
||||
return `/projects/${projectId}/review?chapter=${chapterNo}`;
|
||||
}
|
||||
|
||||
// 旧签名:仅按大纲章 + 当前章构建(无草稿状态)。保留兼容,无 chapterList 时回退用。
|
||||
export function reviewChapterOptions(
|
||||
chapters: readonly ChapterEntry[],
|
||||
currentChapterNo: number,
|
||||
@@ -19,5 +23,58 @@ export function reviewChapterOptions(
|
||||
label: chapter.title
|
||||
? `第 ${chapter.no} 章 · ${chapter.title}`
|
||||
: `第 ${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;
|
||||
}
|
||||
|
||||
42
apps/web/lib/ui/themeParity.test.ts
Normal file
42
apps/web/lib/ui/themeParity.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
// 双主题 token 奇偶校验(P1-8):paper 与 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([]);
|
||||
});
|
||||
});
|
||||
53
apps/web/lib/ui/useMountTransition.test.ts
Normal file
53
apps/web/lib/ui/useMountTransition.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
29
apps/web/lib/ui/useMountTransition.ts
Normal file
29
apps/web/lib/ui/useMountTransition.ts
Normal 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 };
|
||||
}
|
||||
@@ -3,13 +3,17 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
badgeClass,
|
||||
buttonClass,
|
||||
cardClass,
|
||||
checkboxClass,
|
||||
cn,
|
||||
focusRing,
|
||||
inputClass,
|
||||
overlayScrim,
|
||||
proseBody,
|
||||
radioClass,
|
||||
segmentedClass,
|
||||
statusNoteClass,
|
||||
transitionUi,
|
||||
} from "./variants";
|
||||
|
||||
describe("ui variants", () => {
|
||||
@@ -25,6 +29,56 @@ describe("ui variants", () => {
|
||||
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", () => {
|
||||
const klass = badgeClass({ variant: "warning" });
|
||||
|
||||
@@ -46,6 +100,14 @@ describe("ui variants", () => {
|
||||
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", () => {
|
||||
const klass = segmentedClass();
|
||||
|
||||
@@ -59,10 +121,9 @@ describe("ui variants", () => {
|
||||
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("text-[18px]");
|
||||
expect(proseBody).toContain("leading-[1.9]");
|
||||
expect(proseBody).toContain("text-prose");
|
||||
});
|
||||
|
||||
it("provides a full-screen overlay scrim below dialog chrome", () => {
|
||||
|
||||
@@ -3,9 +3,12 @@ export type ButtonVariant =
|
||||
| "secondary"
|
||||
| "outline"
|
||||
| "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 =
|
||||
| "neutral"
|
||||
@@ -23,35 +26,38 @@ export function cn(...classes: Array<string | false | null | undefined>): string
|
||||
return classes.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
// 统一焦点环(与 buttonBase 现有焦点环一致),供导航/输入等可聚焦元素复用。
|
||||
// 统一焦点环(供按钮/输入/导航等所有可聚焦元素复用,统一到 cinnabar/35)。
|
||||
export const focusRing =
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cinnabar/35";
|
||||
|
||||
// 手稿正文统一排版:衬线 18px、行高 1.9。
|
||||
export const proseBody = "font-serif text-[18px] leading-[1.9]";
|
||||
// 统一交互过渡(颜色变化 + 动效 token),供按钮/输入/导航复用,全局可调。
|
||||
export const transitionUi = "transition-colors duration-fast ease-standard";
|
||||
|
||||
// 手稿正文统一排版:衬线 18px(text-prose 档)、行高 1.9。
|
||||
export const proseBody = "font-serif text-prose";
|
||||
|
||||
// Drawer / CommandPalette 共用的全屏遮罩。
|
||||
export const overlayScrim = "fixed inset-0 z-40 bg-black/30";
|
||||
|
||||
const buttonBase =
|
||||
"inline-flex items-center justify-center gap-1.5 rounded border text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cinnabar/35 disabled:cursor-not-allowed disabled:opacity-45";
|
||||
const buttonBase = `inline-flex items-center justify-center gap-1.5 rounded-md border text-sm disabled:cursor-not-allowed ${transitionUi} ${focusRing}`;
|
||||
|
||||
// 非主按钮的禁用淡化(主按钮改用奶油化禁用底,见下)。
|
||||
const disabledFade = "disabled:opacity-45";
|
||||
|
||||
const buttonVariants: Record<ButtonVariant, string> = {
|
||||
primary:
|
||||
"border-cinnabar bg-cinnabar text-panel shadow-paper hover:bg-cinnabar/95",
|
||||
secondary:
|
||||
"border-line bg-panel text-ink hover:border-cinnabar hover:text-cinnabar",
|
||||
outline:
|
||||
"border-cinnabar bg-transparent text-cinnabar hover:bg-[var(--color-cinnabar-wash)]",
|
||||
ghost:
|
||||
"border-transparent bg-transparent text-ink-soft hover:bg-[var(--color-cinnabar-wash)] hover:text-cinnabar",
|
||||
danger:
|
||||
"border-conflict bg-transparent text-conflict hover:bg-conflict/10",
|
||||
"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: `border-line bg-panel text-ink hover:border-cinnabar hover:text-cinnabar ${disabledFade}`,
|
||||
outline: `border-cinnabar bg-transparent text-cinnabar hover:bg-[var(--color-cinnabar-wash)] active:bg-[var(--color-cinnabar-wash)] ${disabledFade}`,
|
||||
ghost: `border-transparent bg-transparent text-ink-soft hover:bg-[var(--color-cinnabar-wash)] hover:text-cinnabar ${disabledFade}`,
|
||||
danger: `border-conflict bg-transparent text-conflict hover:bg-conflict/10 ${disabledFade}`,
|
||||
link: `border-transparent bg-transparent text-cinnabar underline-offset-4 hover:text-cinnabar-active hover:underline ${disabledFade}`,
|
||||
};
|
||||
|
||||
const buttonSizes: Record<ButtonSize, string> = {
|
||||
sm: "px-3 py-1.5 text-xs",
|
||||
md: "px-4 py-2",
|
||||
lg: "px-5 py-2.5 text-base",
|
||||
icon: "h-9 w-9 p-0",
|
||||
};
|
||||
|
||||
@@ -68,7 +74,7 @@ export function buttonClass({
|
||||
}
|
||||
|
||||
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> = {
|
||||
neutral: "border-line bg-bg text-ink-soft",
|
||||
@@ -90,8 +96,30 @@ export function badgeClass({
|
||||
return cn(badgeBase, badgeVariants[variant], className);
|
||||
}
|
||||
|
||||
export function cardClass(className?: string): string {
|
||||
return cn("rounded border border-line bg-panel shadow-paper", className);
|
||||
const cardTones: Record<CardTone, string> = {
|
||||
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";
|
||||
@@ -108,8 +136,7 @@ export function fieldErrorClass(className?: string): string {
|
||||
return cn("mt-1 text-xs leading-5 text-conflict", className);
|
||||
}
|
||||
|
||||
const inputBase =
|
||||
"w-full rounded border bg-bg text-ink transition-colors placeholder:text-ink-soft/65 focus:outline-none focus-visible:ring-2 focus-visible:ring-cinnabar/30 disabled:cursor-not-allowed disabled:bg-line/20 disabled:text-ink-soft";
|
||||
const 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}`;
|
||||
|
||||
const inputStates: Record<InputState, string> = {
|
||||
default: "border-line focus:border-cinnabar",
|
||||
@@ -149,3 +176,15 @@ export function statusNoteClass({
|
||||
export function segmentedClass(className?: string): string {
|
||||
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);
|
||||
}
|
||||
|
||||
61
apps/web/lib/workbench/useFocusMode.test.ts
Normal file
61
apps/web/lib/workbench/useFocusMode.test.ts
Normal 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 未提供可用的 Storage(setItem/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);
|
||||
});
|
||||
});
|
||||
46
apps/web/lib/workbench/useFocusMode.ts
Normal file
46
apps/web/lib/workbench/useFocusMode.ts
Normal 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-4):SSR 首帧恒关(避免 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 };
|
||||
}
|
||||
@@ -11,27 +11,84 @@ const config: Config = {
|
||||
extend: {
|
||||
colors: {
|
||||
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)",
|
||||
ink: "var(--color-ink)",
|
||||
"body-strong": "var(--color-body-strong)",
|
||||
body: "var(--color-body)",
|
||||
"ink-soft": "var(--color-ink-soft)",
|
||||
"muted-soft": "var(--color-muted-soft)",
|
||||
line: "var(--color-line)",
|
||||
"line-soft": "var(--color-line-soft)",
|
||||
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)",
|
||||
overdue: "var(--color-overdue)",
|
||||
pass: "var(--color-pass)",
|
||||
info: "var(--color-info)",
|
||||
},
|
||||
fontFamily: {
|
||||
serif: ['"Noto Serif SC"', '"Songti SC"', "serif"],
|
||||
sans: ['"Noto Sans SC"', '"PingFang SC"', "system-ui", "sans-serif"],
|
||||
mono: ['"JetBrains Mono"', "ui-monospace"],
|
||||
// 衬线 display 与 sans 分工是品牌声音的一部分(对标 DESIGN.md)。
|
||||
// 决策:不自托管 CJK webfont —— Noto Serif SC 全量子集达数 MB,会拖慢
|
||||
// 首屏且需构建期外部拉取(违反 CSP 友好)。改用跨 OS 系统衬线兜底栈,
|
||||
// 保证中文标题在任意设备可靠命中高质量衬线,零 webfont 字节。
|
||||
// macOS/iOS → Songti/STSong;Windows → SimSun/YaHei;Linux/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: {
|
||||
// 编辑部字号刻度(衬线 display 用负字距;标签用正字距 eyebrow)
|
||||
"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)" },
|
||||
maxWidth: { prose: "720px" },
|
||||
transitionDuration: {
|
||||
fast: "var(--dur-fast)",
|
||||
base: "var(--dur-base)",
|
||||
},
|
||||
transitionTimingFunction: {
|
||||
standard: "var(--ease-standard)",
|
||||
},
|
||||
maxWidth: { prose: "768px" },
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"de-ai": "9ce3020cdd4b223cc4d13c289babd2811bbfece4b392711dcba4fd0301c87249",
|
||||
"expand": "74a5d71c4ffc11b78bf80c7ab3fc3eebfc1192ffff2b0bfa9957ec2c33d9d05e",
|
||||
"fine-outline": "0ae0b71f4144e82ffa2a7a0059505525857f7d9797e94ced25da3129355a46ed",
|
||||
"foreshadow": "405696461fef445e4609227c40df6c4812737735edea7e1966ee84afe7635645",
|
||||
"foreshadow": "b3f80c86d5f410ac4abe1fbfcacf0ad3c8ef547a0082cb0ea7a63b2edf5f6f24",
|
||||
"glossary": "54e95d21d10b9209287c10517134f485ca5ce4a3ef08291aac954dcf492d73d0",
|
||||
"golden-finger": "416e8591d99ea1296db550bc428012aa937eed47eea03ef435e92c6843005e21",
|
||||
"name": "ee759bc967773e3300813f250f233c872fd3a4f0cb00086509ffc3552d67700b",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
你是长篇连载小说的「伏笔续审」(foreshadow-analyst)——一位专盯长线叙事「埋设—回收」契约的资深审稿人。你的唯一职责:把本章草稿与作品已登记伏笔逐项比对,找出本章**新埋的伏笔**与**疑似回收/收束**的伏笔,产出结构化建议清单。你只读、只产建议,不改稿、不写库。
|
||||
|
||||
## 比对依据(注入材料)
|
||||
- 已登记伏笔(foreshadow):每条含编码(code)、标题、当前状态、期望回收窗口;
|
||||
- 已登记伏笔(foreshadow):每条含编码(code)、标题、当前状态;登记了回收窗口的还带「期望回收 X-Y章」,逾期者标「已逾期」;
|
||||
- 本章草稿正文。
|
||||
材料未提供的内容一律视为「不存在」,不得凭世界观常识或前文记忆脑补。
|
||||
|
||||
|
||||
@@ -25,6 +25,11 @@
|
||||
- 密点厚、疏点薄。**绝不为凑字数注水**:删掉重复的心理复述、冗余的形容词堆叠、无推进的对话与说明。
|
||||
- 篇幅是结果而非目标——先想清楚这一章每个部分承担什么功能,再决定它值多少笔墨。
|
||||
|
||||
## 伏笔:到点顺势收,别为收而收
|
||||
|
||||
- 注入材料里的伏笔可能带「期望回收窗口」或「已逾期」标记。若本章正落在某条伏笔的回收窗口内、或它已被标记逾期,可**顺着当下的情节自然地推进或兑现它**——让它收在剧情本就该走到的地方。
|
||||
- 回收要**服务当下的冲突与情感**:能自然收束就收;一时收得牵强,就先推进一步、留待更合适的时机,也好过硬来。**切忌为凑一个「回收」而打断节奏、突兀交代**——宁可这一章只把线往前带一带,也不要为收而收、硬凑。
|
||||
|
||||
## 文字质感
|
||||
|
||||
- **句长错落**:长短句交替,避免连续同长度、同结构的句子带来的机械节奏。
|
||||
|
||||
@@ -9,6 +9,7 @@ import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from ww_core.domain.foreshadow_repo import ForeshadowLedgerRepo, ForeshadowLedgerView
|
||||
from ww_core.domain.repositories import (
|
||||
CharacterView,
|
||||
DigestView,
|
||||
@@ -30,6 +31,12 @@ from ww_core.memory import (
|
||||
render_cards,
|
||||
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")
|
||||
|
||||
@@ -130,6 +137,44 @@ class FakeReviewRepo:
|
||||
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(
|
||||
*,
|
||||
outline: dict[int, OutlineView] | None = None,
|
||||
@@ -141,6 +186,7 @@ def build_repos(
|
||||
rules: list[RuleView] | None = None,
|
||||
spec: ProjectSpecView | None = None,
|
||||
review: ReviewRepo | None = None,
|
||||
foreshadow_ledger: ForeshadowLedgerRepo | None = None,
|
||||
) -> MemoryRepos:
|
||||
return MemoryRepos(
|
||||
outline=FakeOutlineRepo(outline or {}),
|
||||
@@ -152,6 +198,7 @@ def build_repos(
|
||||
rules=FakeRulesRepo(rules or []),
|
||||
project=FakeProjectSpecRepo(spec),
|
||||
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
|
||||
|
||||
|
||||
# ---- 伏笔注入丰富化 + 按章自动挑选(本任务) ----
|
||||
|
||||
|
||||
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 ----
|
||||
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ from typing import Any, Protocol
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ww_core.domain.foreshadow_repo import ForeshadowLedgerRepo
|
||||
from ww_core.domain.review_repo import ReviewRepo
|
||||
|
||||
# ---- 只读视图(snake_case,frozen 防止意外突变)----
|
||||
@@ -156,8 +157,11 @@ class ProjectSpecRepo(Protocol):
|
||||
class MemoryRepos:
|
||||
"""记忆服务所需的 9 个 repo 的依赖捆绑(注入点)。
|
||||
|
||||
`review` 末位默认 None:只有冲突裁决反哺(灵感②)需要它;不注入时 assemble
|
||||
优雅降级(不反哺),故既有构造点无需感知(守风险#6)。
|
||||
`review`/`foreshadow_ledger` 末位默认 None(同 optional 注入模式):
|
||||
- `review`:只有冲突裁决反哺(灵感②)需要它;
|
||||
- `foreshadow_ledger`:只有按章自动挑选伏笔(本任务)需要它——账本全量读侧,
|
||||
供 assemble 在大纲窗口之外自动纳入本章回收窗口内/逾期的伏笔(复用写侧账本 repo)。
|
||||
不注入时 assemble 优雅降级(对应能力关闭),故既有构造点无需感知(守风险#6)。
|
||||
"""
|
||||
|
||||
outline: OutlineRepo
|
||||
@@ -169,3 +173,4 @@ class MemoryRepos:
|
||||
rules: RulesRepo
|
||||
project: ProjectSpecRepo
|
||||
review: ReviewRepo | None = None
|
||||
foreshadow_ledger: ForeshadowLedgerRepo | None = None
|
||||
|
||||
@@ -17,7 +17,6 @@ from ww_core.domain.injection_repo import InjectionOverride
|
||||
from ww_core.domain.repositories import (
|
||||
CharacterView,
|
||||
DigestView,
|
||||
ForeshadowView,
|
||||
MemoryRepos,
|
||||
OutlineView,
|
||||
ProjectSpecView,
|
||||
@@ -27,6 +26,13 @@ from ww_core.domain.repositories import (
|
||||
)
|
||||
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 .selection import MAIN_ROLES, RECENT_DIGEST_COUNT, EntityKey, select_relevant_entities
|
||||
from .types import AssembledContext, EntityKind
|
||||
@@ -199,16 +205,14 @@ def _prior_conflict_notes(
|
||||
def _build_volatile(
|
||||
chapter_no: int,
|
||||
cards: str,
|
||||
foreshadows: list[ForeshadowView],
|
||||
foreshadow_lines: list[ForeshadowLine],
|
||||
digests: list[DigestView],
|
||||
beats: dict[str, Any],
|
||||
directive: str | None = None,
|
||||
prior_conflict_notes: str | None = None,
|
||||
) -> str:
|
||||
fore_lines = [
|
||||
f"【伏笔】{f.code} {f.title} [{f.status}]"
|
||||
for f in sorted(foreshadows, key=lambda f: f.code)
|
||||
]
|
||||
# 伏笔行已在 assemble() 合并去重、按 code 排序;此处只逐行渲染(含窗口 + 逾期标记)。
|
||||
fore_lines = [render_foreshadow_line(line, chapter_no) for line in foreshadow_lines]
|
||||
digest_lines = [
|
||||
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,
|
||||
)
|
||||
|
||||
# 伏笔注入两路并集(守不变量 #6:确定性纯函数选择,无向量、无随机):
|
||||
# 1) 大纲手工登记的窗口 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)
|
||||
rules = await repos.rules.all_for_project(project_id)
|
||||
spec = await repos.project.spec(project_id)
|
||||
@@ -292,7 +306,7 @@ async def assemble(
|
||||
volatile = _build_volatile(
|
||||
chapter_no,
|
||||
cards,
|
||||
foreshadows,
|
||||
foreshadow_lines,
|
||||
recent_digests,
|
||||
outline.beats,
|
||||
directive,
|
||||
|
||||
121
packages/core/ww_core/memory/foreshadow_inject.py
Normal file
121
packages/core/ww_core/memory/foreshadow_inject.py
Normal 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
|
||||
@@ -21,6 +21,7 @@ from ww_db.models import (
|
||||
WorldEntity,
|
||||
)
|
||||
|
||||
from ww_core.domain.foreshadow_repo import SqlForeshadowLedgerRepo
|
||||
from ww_core.domain.repositories import (
|
||||
CharacterView,
|
||||
DigestView,
|
||||
@@ -228,6 +229,8 @@ def sql_memory_repos(session: AsyncSession) -> MemoryRepos:
|
||||
"""用一个 AsyncSession 装配全部 9 个 SQLAlchemy repo(T1.4 注入点)。
|
||||
|
||||
`review=SqlReviewRepo`:供 assemble 反读上一已验收章的 continuity 冲突裁决(灵感②)。
|
||||
`foreshadow_ledger=SqlForeshadowLedgerRepo`:供 assemble 按本章章号自动挑选伏笔
|
||||
(回收窗口内/逾期,复用账本读侧 `list_by_status`)——大纲未登记也能自动注入。
|
||||
"""
|
||||
return MemoryRepos(
|
||||
outline=SqlOutlineRepo(session),
|
||||
@@ -239,4 +242,5 @@ def sql_memory_repos(session: AsyncSession) -> MemoryRepos:
|
||||
rules=SqlRulesRepo(session),
|
||||
project=SqlProjectSpecRepo(session),
|
||||
review=SqlReviewRepo(session),
|
||||
foreshadow_ledger=SqlForeshadowLedgerRepo(session),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user