fix(web): stale-closure + focus trap + 去边界强转 + a11y/性能打磨 + 类型化客户端
P1-6 useStyleLearn/useKimiOauth 修 stale-closure 依赖。 P1-7 CommandPalette/Drawer 加 focus trap(新 lib/a11y/focusTrap)。 P1-8 SSE 解析与 server.ts 去 as 强转改类型守卫/运行时校验。 P2 删冗余强转、开 noUncheckedIndexedAccess、Toast 清理+稳定 key、列表 key 稳定化、 rel=noopener、useMemo 大文本、ConflictCard role=group、useInjection 加锁、 client.test 真断言。 codegen 重生成 schema.d.ts + 消费 DimensionEntry/ReviewConflictView 强类型。
This commit is contained in:
@@ -21,10 +21,12 @@ const reviewItem = (over: Partial<ReviewHistoryItem>): ReviewHistoryItem => ({
|
||||
});
|
||||
|
||||
describe("normalizeFingerprint", () => {
|
||||
it("aligns dims with evidence by name, preserving key order", () => {
|
||||
it("maps DimensionEntry[] preserving order, evidence per entry", () => {
|
||||
const resp: StyleFingerprintResponse = {
|
||||
dimensions: { 句长: "偏短", 比喻密度: "高" },
|
||||
evidence: { 句长: ["他来了。"], 比喻密度: ["如龙似虎", "若即若离"] },
|
||||
dimensions: [
|
||||
{ name: "句长", value: "偏短", evidence: ["他来了。"] },
|
||||
{ name: "比喻密度", value: "高", evidence: ["如龙似虎", "若即若离"] },
|
||||
],
|
||||
version: 2,
|
||||
};
|
||||
const fp = normalizeFingerprint(resp);
|
||||
@@ -37,10 +39,12 @@ describe("normalizeFingerprint", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("coerces non-string dim values and missing evidence", () => {
|
||||
it("defaults missing evidence to empty array", () => {
|
||||
const fp = normalizeFingerprint({
|
||||
dimensions: { 节奏: 5, 视角: true },
|
||||
evidence: { 节奏: ["x", 1] },
|
||||
dimensions: [
|
||||
{ name: "节奏", value: "5", evidence: ["x"] },
|
||||
{ name: "视角", value: "true" },
|
||||
],
|
||||
version: 1,
|
||||
});
|
||||
expect(fp?.dimensions).toEqual([
|
||||
|
||||
@@ -12,7 +12,7 @@ import type { StyleDriftReport, StyleDriftSegment } from "@/lib/review/sse";
|
||||
export type { StyleDriftReport, StyleDriftSegment } from "@/lib/review/sse";
|
||||
|
||||
// ── 指纹(16 维 + 证据,UX §6.9)─────────────────────────────────
|
||||
// 后端 dimensions={name:value}、evidence={name:[quotes]}(松散 JSONB)。
|
||||
// 后端 dimensions: DimensionEntry[](每条 {name,value,evidence},强类型,替代弱类型 dict)。
|
||||
export interface FingerprintDimension {
|
||||
name: string;
|
||||
value: string;
|
||||
@@ -24,31 +24,19 @@ export interface Fingerprint {
|
||||
version: number;
|
||||
}
|
||||
|
||||
function asStringArray(v: unknown): string[] {
|
||||
if (!Array.isArray(v)) return [];
|
||||
return v.filter((x): x is string => typeof x === "string");
|
||||
}
|
||||
|
||||
function asDisplayValue(v: unknown): string {
|
||||
if (typeof v === "string") return v;
|
||||
if (typeof v === "number" || typeof v === "boolean") return String(v);
|
||||
return "";
|
||||
}
|
||||
|
||||
// 把松散 GET /style 响应收窄成 Fingerprint。
|
||||
// dimensions 的 key 顺序即维度顺序(身份);evidence 按维度名对齐。
|
||||
// 把 GET /style 响应映射成 Fingerprint。
|
||||
// dimensions 数组顺序即维度顺序(身份);每条已携带 name/value/evidence。
|
||||
export function normalizeFingerprint(
|
||||
resp: StyleFingerprintResponse | undefined,
|
||||
): Fingerprint | null {
|
||||
if (!resp) return null;
|
||||
const dims = resp.dimensions ?? {};
|
||||
const evid = resp.evidence ?? {};
|
||||
const names = Object.keys(dims);
|
||||
const dimensions: FingerprintDimension[] = names.map((name) => ({
|
||||
name,
|
||||
value: asDisplayValue(dims[name]),
|
||||
evidence: asStringArray(evid[name]),
|
||||
}));
|
||||
const dimensions: FingerprintDimension[] = (resp.dimensions ?? []).map(
|
||||
(d) => ({
|
||||
name: d.name,
|
||||
value: d.value,
|
||||
evidence: d.evidence ?? [],
|
||||
}),
|
||||
);
|
||||
return { dimensions, version: resp.version };
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useCallback, useState } from "react";
|
||||
|
||||
import { api } from "@/lib/api/client";
|
||||
import { useToast } from "@/components/Toast";
|
||||
import type { RefineResponse } from "@/lib/api/types";
|
||||
import { buildRefineRequest } from "./style";
|
||||
|
||||
export type RefineStatus = "idle" | "refining" | "done" | "error";
|
||||
@@ -65,10 +64,9 @@ export function useRefine(): UseRefine {
|
||||
);
|
||||
return null;
|
||||
}
|
||||
const next = data as RefineResponse;
|
||||
const outcome: RefineResult = {
|
||||
original: next.original,
|
||||
refined: next.refined,
|
||||
original: data.original,
|
||||
refined: data.refined,
|
||||
};
|
||||
setResult(outcome);
|
||||
setStatus("done");
|
||||
|
||||
41
apps/web/lib/style/useStyleLearn.test.ts
Normal file
41
apps/web/lib/style/useStyleLearn.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
// P1-6 回归守卫:学文风轮询完成(done 边沿)时,effect 必须用「最近一次 learn 传入的
|
||||
// projectId」去拉指纹,而非闭包捕获的旧值。useStyleLearn 用 ref 追踪 projectId 来保证这点。
|
||||
//
|
||||
// 该 hook 依赖浏览器/React 渲染,node 环境无法整体跑;这里以最小模型固化 ref 追踪契约:
|
||||
// 切项目(连续两次 learn)后,done 时读到的应是后者,不会是首次的陈旧值。
|
||||
describe("useStyleLearn projectId 追踪(P1-6 stale-closure 守卫)", () => {
|
||||
// 复刻 hook 内部:learn 同步写 ref,done 时同步读 ref。
|
||||
function makeTracker() {
|
||||
const projectIdRef: { current: string | null } = { current: null };
|
||||
return {
|
||||
// learn(pid):受理时记录最近项目。
|
||||
learn(pid: string): void {
|
||||
projectIdRef.current = pid;
|
||||
},
|
||||
// onDone():轮询完成时取用于拉指纹的项目(应为最新)。
|
||||
onDone(): string | null {
|
||||
return projectIdRef.current;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it("done 时用最近一次 learn 的 projectId(切项目后不复用旧值)", () => {
|
||||
// Arrange
|
||||
const t = makeTracker();
|
||||
|
||||
// Act:先 learn 项目 A,再切到项目 B(模拟用户换项目重学),此时 A 的轮询尚未完成。
|
||||
t.learn("project-A");
|
||||
t.learn("project-B");
|
||||
const used = t.onDone();
|
||||
|
||||
// Assert:done 边沿读到的是 B,而非闭包里陈旧的 A。
|
||||
expect(used).toBe("project-B");
|
||||
});
|
||||
|
||||
it("未 learn 时为 null(不误拉指纹)", () => {
|
||||
const t = makeTracker();
|
||||
expect(t.onDone()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,6 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { api } from "@/lib/api/client";
|
||||
import { useToast } from "@/components/Toast";
|
||||
import type { StyleFingerprintResponse } from "@/lib/api/types";
|
||||
import { useJobPoll } from "@/lib/jobs/useJobPoll";
|
||||
import { styleLearnResult } from "@/lib/jobs/job";
|
||||
import {
|
||||
@@ -41,16 +40,20 @@ export function useStyleLearn(initial: Fingerprint | null): UseStyleLearn {
|
||||
const [pollStatus, setPollStatus] = useState<
|
||||
ReturnType<typeof useJobPoll>["status"] | "idle"
|
||||
>("idle");
|
||||
const [projectId, setProjectId] = useState<string | null>(null);
|
||||
// projectId 用 ref 而非 state:done 边沿的 effect 须读到「最近一次 learn 传入的」
|
||||
// 而非闭包捕获的旧值(修 stale-closure)。learn 同步写 ref,effect 同步读。
|
||||
const projectIdRef = useRef<string | null>(null);
|
||||
const poll = useJobPoll();
|
||||
const toast = useToast();
|
||||
// 仅在提交过一次学文风后才反映轮询状态(initialPollState.status 默认 "polling")。
|
||||
const startedRef = useRef(false);
|
||||
|
||||
// 轮询完成 → 拉最新指纹;失败 → toast。
|
||||
// 依赖 poll.status / poll.error / toast(均稳定或随状态变化),projectId 经 ref 取最新。
|
||||
useEffect(() => {
|
||||
if (!startedRef.current) return;
|
||||
setPollStatus(poll.status);
|
||||
const projectId = projectIdRef.current;
|
||||
if (poll.status === "done" && projectId) {
|
||||
void refetchFingerprint(projectId).then((fp) => {
|
||||
if (fp) setFingerprint(fp);
|
||||
@@ -60,14 +63,12 @@ export function useStyleLearn(initial: Fingerprint | null): UseStyleLearn {
|
||||
if (poll.status === "error") {
|
||||
toast(`学文风失败:${poll.error ?? "未知原因"}`, "error");
|
||||
}
|
||||
// 仅在 poll.status 变化时反应。
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [poll.status]);
|
||||
}, [poll.status, poll.error, toast]);
|
||||
|
||||
const learn = useCallback<UseStyleLearn["learn"]>(
|
||||
async (pid, samples, mode) => {
|
||||
setSubmitting(true);
|
||||
setProjectId(pid);
|
||||
projectIdRef.current = pid;
|
||||
try {
|
||||
const { data, error } = await api.POST("/projects/{project_id}/style", {
|
||||
params: { path: { project_id: pid } },
|
||||
@@ -111,7 +112,7 @@ async function refetchFingerprint(
|
||||
params: { path: { project_id: projectId } },
|
||||
});
|
||||
if (error || !data) return null;
|
||||
return normalizeFingerprint(data as StyleFingerprintResponse);
|
||||
return normalizeFingerprint(data);
|
||||
}
|
||||
|
||||
// 仅供测试/复用:保证 job done result 的版本回显(不阻断 UI)。
|
||||
|
||||
Reference in New Issue
Block a user