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:
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import type { ProjectResponse, ReviewHistoryItem } from "@/lib/api/types";
|
||||
import { friendlyError } from "@/lib/errors/messages";
|
||||
import {
|
||||
allResolved,
|
||||
emptyDecisions,
|
||||
@@ -226,21 +227,7 @@ export function ReviewReport({
|
||||
</div>
|
||||
|
||||
{review.state.error ? (
|
||||
<p className="border-b border-line bg-panel px-6 py-2 text-sm text-conflict">
|
||||
审稿失败({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>
|
||||
<ReviewErrorNote error={review.state.error} />
|
||||
) : null}
|
||||
|
||||
<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 {
|
||||
reviewing: boolean;
|
||||
done: boolean;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import type { ProjectResponse } from "@/lib/api/types";
|
||||
import { friendlyError } from "@/lib/errors/messages";
|
||||
import { useAutosave } from "@/lib/autosave/useAutosave";
|
||||
import { useDraftStream } from "@/lib/stream/useDraftStream";
|
||||
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 {
|
||||
projectId: string;
|
||||
chapterNo: number;
|
||||
@@ -118,11 +144,7 @@ function Toolbar({
|
||||
}: ToolbarProps) {
|
||||
return (
|
||||
<div className="border-t border-line bg-panel px-6 py-3">
|
||||
{streamError ? (
|
||||
<p className="mb-2 text-sm text-conflict">
|
||||
写章失败({streamError.code}):{streamError.message}
|
||||
</p>
|
||||
) : null}
|
||||
{streamError ? <StreamErrorNote streamError={streamError} /> : null}
|
||||
<div className="flex items-center gap-4">
|
||||
{streaming ? (
|
||||
<button
|
||||
@@ -141,9 +163,15 @@ function Toolbar({
|
||||
✍ 写本章
|
||||
</button>
|
||||
)}
|
||||
<span className="font-mono text-xs text-ink-soft">
|
||||
字数 {wordCount.toLocaleString("en-US")}
|
||||
</span>
|
||||
{streaming ? (
|
||||
<span className="font-mono text-xs text-cinnabar motion-safe:animate-pulse">
|
||||
生成中… {wordCount.toLocaleString("en-US")} 字
|
||||
</span>
|
||||
) : (
|
||||
<span className="font-mono text-xs text-ink-soft">
|
||||
字数 {wordCount.toLocaleString("en-US")}
|
||||
</span>
|
||||
)}
|
||||
<Link
|
||||
href={`/projects/${projectId}/outline`}
|
||||
className="rounded border border-line px-4 py-2 text-sm text-ink hover:border-cinnabar hover:text-cinnabar"
|
||||
|
||||
28
apps/web/lib/errors/messages.test.ts
Normal file
28
apps/web/lib/errors/messages.test.ts
Normal 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("出错了,请稍后重试。");
|
||||
});
|
||||
});
|
||||
46
apps/web/lib/errors/messages.ts
Normal file
46
apps/web/lib/errors/messages.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
// 错误码 → 友好中文文案(+ 可选跳转动作)。
|
||||
// 对齐后端 ErrorCode(ARCH §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
Reference in New Issue
Block a user