feat(ux): F3 流式进度感 + F4 友好错误文案(纯前端)

F4:新 lib/errors/messages.ts —— friendlyError(code, fallback) 把错误码映射为
中文文案 + 可选「去设置提供商」动作;覆盖后端 ErrorCode + 前端 NETWORK/
STREAM_FAILED;未知码回退原 message。写作页与审稿页错误块改用之,不再暴露
raw error code。+4 vitest。

F3:写作页工具条字数计数器流式时显示「生成中… N 字」+ 朱砂脉冲
(motion-safe:animate-pulse,尊重 prefers-reduced-motion),停后回「字数 N」。
纯 text.length,无后端改动。

前端门禁绿:lint/typecheck/vitest 171/build。无契约变更。
This commit is contained in:
Yaojia Wang
2026-06-20 11:02:27 +02:00
parent 8058cfb11a
commit b18310bcb7
6 changed files with 139 additions and 24 deletions

View File

@@ -90,6 +90,7 @@ T0.1 monorepo 骨架 ✅ @devops · T0.2 16 MVP 表迁移无漂移users st
> 格式:`- [YYYY-MM-DD] @skill 完成/进展 Txx — 一句话结果 + 影响的契约/文件` > 格式:`- [YYYY-MM-DD] @skill 完成/进展 Txx — 一句话结果 + 影响的契约/文件`
- [2026-06-20] UX计划 F3+F4 — **✅ 流式进度感 + 友好错误文案(纯前端)**。F4`apps/web/lib/errors/messages.ts``friendlyError(code,fallback)`→中文文案 + 可选「去设置提供商」动作;覆盖后端 `ErrorCode` + 前端 `NETWORK/STREAM_FAILED`;未知码回退原 message+4 vitest写作页 `Workbench` 与审稿页 `ReviewReport` 的错误块改用之,不再直接暴露 raw error code。F3`Workbench` 工具条字数计数器流式时变「生成中… N 字」+ 朱砂 `motion-safe:animate-pulse`(尊重 prefers-reduced-motion停后回「字数 N」`text.length`无后端改动。前端门禁绿lint/typecheck/vitest **171**/build。文件`lib/errors/{messages,messages.test}.ts` · `components/workbench/Workbench.tsx` · `components/review/ReviewReport.tsx`。无契约变更。
- [2026-06-20] UX计划 B0(读)+F1 — **✅ 本章注入透明(读端点 + 右栏真面板)**。兑现差异化信任牌「看到的=写章用的」(不变量 #6把先前写作页右栏「M1 暂未接 / M2 开放」的过时假占位换成真数据。**后端**(@backend)`GET /projects/{id}/chapters/{no}/injection``InjectionResponse{project_id,chapter_no,selected:[{kind,name,reasons[]}],recent_n}`,调既有 `assemble()` 回放确定性 `SelectionTrace`**无 LLM、无 commit、无 DDL**项目不存在→404无大纲→`selected:[]`。文件:`schemas/injection.py`(新) · `routers/projects.py`(+GET+2 import) · `tests/test_injection.py`(新,3 测)。**前端**(@frontend)`gen:api` 纳入端点;纯逻辑 `lib/workbench/injection.ts`(理由/类型→中文徽标 + 4 vitest) + `useInjection` 读 hook`ChapterAssistant` 改 client 组件列出选中实体 + 入选理由徽标(本章点名/主角常驻/近章出现/伏笔窗口)+ 空/载/错三态,「四审」段改指向审稿页;`Workbench``projectId/chapterNo`。**全仓门禁绿**:后端 ruff/format/mypy **159 Success**/alembic 无漂移/pytest **454 passed**;前端 lint/typecheck/vitest **167 passed**/build。记 contracts C3扩。**B0 余项可控版PUT override pin/排除/recent_n + selection 加参 + draft 同读 override + 持久化)+ F1 可控控件待后续。** - [2026-06-20] UX计划 B0(读)+F1 — **✅ 本章注入透明(读端点 + 右栏真面板)**。兑现差异化信任牌「看到的=写章用的」(不变量 #6把先前写作页右栏「M1 暂未接 / M2 开放」的过时假占位换成真数据。**后端**(@backend)`GET /projects/{id}/chapters/{no}/injection``InjectionResponse{project_id,chapter_no,selected:[{kind,name,reasons[]}],recent_n}`,调既有 `assemble()` 回放确定性 `SelectionTrace`**无 LLM、无 commit、无 DDL**项目不存在→404无大纲→`selected:[]`。文件:`schemas/injection.py`(新) · `routers/projects.py`(+GET+2 import) · `tests/test_injection.py`(新,3 测)。**前端**(@frontend)`gen:api` 纳入端点;纯逻辑 `lib/workbench/injection.ts`(理由/类型→中文徽标 + 4 vitest) + `useInjection` 读 hook`ChapterAssistant` 改 client 组件列出选中实体 + 入选理由徽标(本章点名/主角常驻/近章出现/伏笔窗口)+ 空/载/错三态,「四审」段改指向审稿页;`Workbench``projectId/chapterNo`。**全仓门禁绿**:后端 ruff/format/mypy **159 Success**/alembic 无漂移/pytest **454 passed**;前端 lint/typecheck/vitest **167 passed**/build。记 contracts C3扩。**B0 余项可控版PUT override pin/排除/recent_n + selection 加参 + draft 同读 override + 持久化)+ F1 可控控件待后续。**
- [2026-06-20] @frontend**✅ 接上两个新读端点:大纲页 / 写作工作台重访时回显已存内容**。`pnpm gen:api` 纳入 `GET .../outline` + `GET .../chapters/{no}/draft` + schema `DraftView``lib/api/types.ts``DraftView` 别名。新增两个 RSC 读 helper仿 `fetchForeshadow`/`fetchProject` 服务端读、无 CORS`fetchOutline(projectId)``OutlineChapterView[]`200 解包 chapters**任何错误/空降级为 `[]`** 不阻塞进页),`fetchDraft(projectId,chapterNo)``DraftView|null`(复用 `getJsonOrNull`**404→null 当空编辑器**,不抛)。**大纲页**(`outline/page.tsx`) 进页 `fetchOutline``initialChapters` 种入 `OutlineEditor``useOutline` 已据非空初值置 `ready`)→ 重访显已生成大纲;生成流照常覆盖所显章节。**写作工作台**(`write/page.tsx` + `Workbench`) 进页 `fetchDraft``content` 作编辑器初值(`useState(initialText)`)→ 重访重载已写正文404/新章 → `""` 空编辑器;**SSE 流式写 + AbortController + PUT 自动保存全不动**(流起始空、仅 streaming/done/aborted 变化才覆盖,初值不反扑活跃流);导出 `WORKBENCH_CHAPTER_NO`(原 `M1_CHAPTER_NO`)供页面拉对应章草稿。新增 `lib/api/server.test.ts`(5 vitestmock fetch 验解包/降级/404→null)。前端门禁全绿gen:api / lint / typecheck / vitest **160**(155→+5) / build。文件`lib/api/{types,server,server.test}.ts` · `app/projects/[id]/{outline,write}/page.tsx` · `components/workbench/Workbench.tsx`。消费 contracts C3扩@backend 两读端点)。 - [2026-06-20] @frontend**✅ 接上两个新读端点:大纲页 / 写作工作台重访时回显已存内容**。`pnpm gen:api` 纳入 `GET .../outline` + `GET .../chapters/{no}/draft` + schema `DraftView``lib/api/types.ts``DraftView` 别名。新增两个 RSC 读 helper仿 `fetchForeshadow`/`fetchProject` 服务端读、无 CORS`fetchOutline(projectId)``OutlineChapterView[]`200 解包 chapters**任何错误/空降级为 `[]`** 不阻塞进页),`fetchDraft(projectId,chapterNo)``DraftView|null`(复用 `getJsonOrNull`**404→null 当空编辑器**,不抛)。**大纲页**(`outline/page.tsx`) 进页 `fetchOutline``initialChapters` 种入 `OutlineEditor``useOutline` 已据非空初值置 `ready`)→ 重访显已生成大纲;生成流照常覆盖所显章节。**写作工作台**(`write/page.tsx` + `Workbench`) 进页 `fetchDraft``content` 作编辑器初值(`useState(initialText)`)→ 重访重载已写正文404/新章 → `""` 空编辑器;**SSE 流式写 + AbortController + PUT 自动保存全不动**(流起始空、仅 streaming/done/aborted 变化才覆盖,初值不反扑活跃流);导出 `WORKBENCH_CHAPTER_NO`(原 `M1_CHAPTER_NO`)供页面拉对应章草稿。新增 `lib/api/server.test.ts`(5 vitestmock fetch 验解包/降级/404→null)。前端门禁全绿gen:api / lint / typecheck / vitest **160**(155→+5) / build。文件`lib/api/{types,server,server.test}.ts` · `app/projects/[id]/{outline,write}/page.tsx` · `components/workbench/Workbench.tsx`。消费 contracts C3扩@backend 两读端点)。
- [2026-06-20] @backend**`GET /projects/{id}/chapters/{no}/draft` 草稿读端点(补缺口,镜像 GET /outline**。写作工作台先前只有 `POST .../draft`(SSE 流写)+`PUT .../draft`(自动保存) 无读端点 → 重访工作台编辑器空白、看似「未保存」(其实 `chapters` 行在库)。补对称读侧:`GET /projects/{id}/chapters/{no}/draft` → 200 `DraftView{project_id,chapter_no,volume,status,version,content,length}`**比 PUT 的 `DraftResponse``content`**——读侧需正文重建编辑器);无草稿行/正文空白 → **404 `NOT_FOUND`**(工作台据此呈现空编辑器,仿其它缺资源端点)。**直接复用既有 `chapter_repo.get_draft`**(同续审 `_resolve_review_draft` 读缝,`ChapterDraftView` 已含全字段,无新 repo 方法/view只读不写库多版本固定返草稿版次(`DRAFT_VERSION=1`)。文件:`schemas/projects.py`(+`DraftView`) · `routers/projects.py`(+GET endpoint+import) · `tests/test_projects.py`(+2回灌正文 / 无草稿 404)。**全仓门禁绿**ruff/format 干净、mypy **154 Success**、alembic **无漂移**、pytest **444 passed**442→+2。OpenAPI 新增 1 端点 → **@frontend`cd apps/web && pnpm gen:api`** + 工作台初次加载拉已保存草稿回灌编辑器。记 contracts C3扩。 - [2026-06-20] @backend**`GET /projects/{id}/chapters/{no}/draft` 草稿读端点(补缺口,镜像 GET /outline**。写作工作台先前只有 `POST .../draft`(SSE 流写)+`PUT .../draft`(自动保存) 无读端点 → 重访工作台编辑器空白、看似「未保存」(其实 `chapters` 行在库)。补对称读侧:`GET /projects/{id}/chapters/{no}/draft` → 200 `DraftView{project_id,chapter_no,volume,status,version,content,length}`**比 PUT 的 `DraftResponse``content`**——读侧需正文重建编辑器);无草稿行/正文空白 → **404 `NOT_FOUND`**(工作台据此呈现空编辑器,仿其它缺资源端点)。**直接复用既有 `chapter_repo.get_draft`**(同续审 `_resolve_review_draft` 读缝,`ChapterDraftView` 已含全字段,无新 repo 方法/view只读不写库多版本固定返草稿版次(`DRAFT_VERSION=1`)。文件:`schemas/projects.py`(+`DraftView`) · `routers/projects.py`(+GET endpoint+import) · `tests/test_projects.py`(+2回灌正文 / 无草稿 404)。**全仓门禁绿**ruff/format 干净、mypy **154 Success**、alembic **无漂移**、pytest **444 passed**442→+2。OpenAPI 新增 1 端点 → **@frontend`cd apps/web && pnpm gen:api`** + 工作台初次加载拉已保存草稿回灌编辑器。记 contracts C3扩。

View File

@@ -4,6 +4,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { AppShell } from "@/components/AppShell"; import { AppShell } from "@/components/AppShell";
import type { ProjectResponse, ReviewHistoryItem } from "@/lib/api/types"; import type { ProjectResponse, ReviewHistoryItem } from "@/lib/api/types";
import { friendlyError } from "@/lib/errors/messages";
import { import {
allResolved, allResolved,
emptyDecisions, emptyDecisions,
@@ -226,21 +227,7 @@ export function ReviewReport({
</div> </div>
{review.state.error ? ( {review.state.error ? (
<p className="border-b border-line bg-panel px-6 py-2 text-sm text-conflict"> <ReviewErrorNote error={review.state.error} />
稿{review.state.error.code}
{review.state.error.message}
{review.state.error.code === "LLM_UNAVAILABLE" ? (
<>
{" "}
<a
href="/settings/providers"
className="underline hover:text-cinnabar"
>
</a>
</>
) : null}
</p>
) : null} ) : null}
<div className="flex-1 overflow-auto px-6 py-6"> <div className="flex-1 overflow-auto px-6 py-6">
@@ -346,6 +333,31 @@ export function ReviewReport({
); );
} }
// 审稿失败提示:友好文案(不暴露 raw code+ 可选「去设置」动作F4
function ReviewErrorNote({
error,
}: {
error: { code: string; message: string };
}) {
const friendly = friendlyError(error.code, error.message);
return (
<p className="border-b border-line bg-panel px-6 py-2 text-sm text-conflict">
{friendly.text}
{friendly.actionHref ? (
<>
{" "}
<a
href={friendly.actionHref}
className="underline hover:text-cinnabar"
>
{friendly.actionLabel}
</a>
</>
) : null}
</p>
);
}
interface SectionStatusLineProps { interface SectionStatusLineProps {
reviewing: boolean; reviewing: boolean;
done: boolean; done: boolean;

View File

@@ -5,6 +5,7 @@ import { useEffect, useRef, useState } from "react";
import { AppShell } from "@/components/AppShell"; import { AppShell } from "@/components/AppShell";
import type { ProjectResponse } from "@/lib/api/types"; import type { ProjectResponse } from "@/lib/api/types";
import { friendlyError } from "@/lib/errors/messages";
import { useAutosave } from "@/lib/autosave/useAutosave"; import { useAutosave } from "@/lib/autosave/useAutosave";
import { useDraftStream } from "@/lib/stream/useDraftStream"; import { useDraftStream } from "@/lib/stream/useDraftStream";
import { WORKBENCH_CHAPTER_NO } from "@/lib/workbench/chapter"; import { WORKBENCH_CHAPTER_NO } from "@/lib/workbench/chapter";
@@ -93,6 +94,31 @@ export function Workbench({ project, initialText = "" }: WorkbenchProps) {
); );
} }
// 写章失败提示:友好文案(不暴露 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>
);
}
interface ToolbarProps { interface ToolbarProps {
projectId: string; projectId: string;
chapterNo: number; chapterNo: number;
@@ -118,11 +144,7 @@ function Toolbar({
}: ToolbarProps) { }: ToolbarProps) {
return ( return (
<div className="border-t border-line bg-panel px-6 py-3"> <div className="border-t border-line bg-panel px-6 py-3">
{streamError ? ( {streamError ? <StreamErrorNote streamError={streamError} /> : null}
<p className="mb-2 text-sm text-conflict">
{streamError.code}{streamError.message}
</p>
) : null}
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
{streaming ? ( {streaming ? (
<button <button
@@ -141,9 +163,15 @@ function Toolbar({
</button> </button>
)} )}
<span className="font-mono text-xs text-ink-soft"> {streaming ? (
{wordCount.toLocaleString("en-US")} <span className="font-mono text-xs text-cinnabar motion-safe:animate-pulse">
</span> {wordCount.toLocaleString("en-US")}
</span>
) : (
<span className="font-mono text-xs text-ink-soft">
{wordCount.toLocaleString("en-US")}
</span>
)}
<Link <Link
href={`/projects/${projectId}/outline`} href={`/projects/${projectId}/outline`}
className="rounded border border-line px-4 py-2 text-sm text-ink hover:border-cinnabar hover:text-cinnabar" className="rounded border border-line px-4 py-2 text-sm text-ink hover:border-cinnabar hover:text-cinnabar"

View File

@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { friendlyError } from "./messages";
describe("friendlyError", () => {
it("maps LLM_UNAVAILABLE to copy + a settings action", () => {
const e = friendlyError("LLM_UNAVAILABLE", "fallback exhausted");
expect(e.text).toContain("未连接");
expect(e.actionHref).toBe("/settings/providers");
expect(e.actionLabel).toBe("去设置提供商");
});
it("maps NETWORK to friendly copy without leaking raw message", () => {
const e = friendlyError("NETWORK", "TypeError: Failed to fetch");
expect(e.text).toBe("网络中断,请检查连接后重试。");
expect(e.actionHref).toBeUndefined();
});
it("falls back to the raw message for an unknown code", () => {
const e = friendlyError("WEIRD_CODE", "something specific happened");
expect(e.text).toBe("something specific happened");
});
it("uses generic copy when code is unknown and no message given", () => {
expect(friendlyError(undefined).text).toBe("出错了,请稍后重试。");
expect(friendlyError("WEIRD_CODE", " ").text).toBe("出错了,请稍后重试。");
});
});

View File

@@ -0,0 +1,46 @@
// 错误码 → 友好中文文案(+ 可选跳转动作)。
// 对齐后端 ErrorCodeARCH §7.1+ 前端流式/网络码useDraftStream
// 目的UI 不直接暴露 error code给作者可读的下一步。
export interface FriendlyError {
text: string;
// 可选的「下一步」链接(如未连模型 → 去设置)。
actionHref?: string;
actionLabel?: string;
}
const PROVIDER_ACTION = {
actionHref: "/settings/providers",
actionLabel: "去设置提供商",
} as const;
const MESSAGES: Record<string, FriendlyError> = {
// 后端 ErrorCode
LLM_UNAVAILABLE: {
text: "未连接可用模型,去设置里连一个 provider。",
...PROVIDER_ACTION,
},
RATE_LIMITED: { text: "请求过于频繁,稍后再试。" },
CONFLICT_UNRESOLVED: { text: "仍有未裁决的冲突,请先逐条裁决再验收。" },
NOT_FOUND: { text: "资源不存在或已被删除。" },
VALIDATION: { text: "提交内容有误,请检查后重试。" },
INTERNAL: { text: "服务出了点问题,请稍后重试。" },
// 前端流式/网络码
NETWORK: { text: "网络中断,请检查连接后重试。" },
STREAM_FAILED: { text: "写章中断,请重试。" },
};
const DEFAULT_TEXT = "出错了,请稍后重试。";
// 把 (code, 原始 message) 映射为友好文案。
// 已知码 → 预设文案 + 动作;未知码 → 回退到原始 message非空或通用兜底。
export function friendlyError(
code: string | undefined,
fallbackMessage?: string,
): FriendlyError {
if (code && MESSAGES[code]) {
return MESSAGES[code];
}
const fallback = fallbackMessage?.trim();
return { text: fallback && fallback.length > 0 ? fallback : DEFAULT_TEXT };
}

File diff suppressed because one or more lines are too long