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:
@@ -1,8 +1,45 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
describe("api base url", () => {
|
||||
it("falls back to localhost when env unset", () => {
|
||||
const base = process.env.NEXT_PUBLIC_API_BASE ?? "http://localhost:8000";
|
||||
expect(base).toMatch(/^https?:\/\//);
|
||||
// P2:真正断言——mock openapi-fetch,验证 client 用正确 baseUrl 初始化。
|
||||
// createClient 在模块加载时被调用一次,故每个用例 resetModules + 重新 import。
|
||||
|
||||
const createClientMock = vi.fn(() => ({ GET: vi.fn(), POST: vi.fn() }));
|
||||
|
||||
vi.mock("openapi-fetch", () => ({
|
||||
default: createClientMock,
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetModules();
|
||||
vi.unstubAllEnvs();
|
||||
createClientMock.mockClear();
|
||||
});
|
||||
|
||||
describe("api client baseUrl", () => {
|
||||
it("uses NEXT_PUBLIC_API_BASE when set", async () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_API_BASE", "https://api.example.com");
|
||||
|
||||
await import("./client");
|
||||
|
||||
expect(createClientMock).toHaveBeenCalledTimes(1);
|
||||
expect(createClientMock).toHaveBeenCalledWith({
|
||||
baseUrl: "https://api.example.com",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to localhost when env unset", async () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_API_BASE", undefined);
|
||||
|
||||
await import("./client");
|
||||
|
||||
expect(createClientMock).toHaveBeenCalledWith({
|
||||
baseUrl: "http://localhost:8000",
|
||||
});
|
||||
});
|
||||
|
||||
it("exposes the constructed client as `api`", async () => {
|
||||
const mod = await import("./client");
|
||||
expect(mod.api).toBeDefined();
|
||||
expect(typeof mod.api.GET).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
192
apps/web/lib/api/schema.d.ts
vendored
192
apps/web/lib/api/schema.d.ts
vendored
@@ -236,6 +236,7 @@ export interface paths {
|
||||
* List Foreshadow
|
||||
* @description 伏笔看板:按 `status` 过滤(缺省=全部),按 code 升序。
|
||||
*
|
||||
* 项目不存在 → 404(与写端点一致,避免不存在 project 返回误导性空 200)。
|
||||
* `status` 非法(不在 OPEN/PARTIAL/CLOSED/OVERDUE)→ VALIDATION 信封。四泳道前端
|
||||
* 据 `status` 分组;OVERDUE 泳道 + 逾期标记用 `expected_close_to`(看板字段已齐)。
|
||||
*/
|
||||
@@ -427,6 +428,7 @@ export interface paths {
|
||||
* List Characters
|
||||
* @description 已入库角色全量列表(设定库 Codex 真源;复用 C5 读侧 SqlCharacterRepo)。
|
||||
*
|
||||
* 项目不存在 → 404(与写端点一致,避免不存在 project 返回误导性空 200)。
|
||||
* DB JSONB dict 列 → API list/str 反向解包(入库形变的逆向,见 `_existing_characters`)。
|
||||
*/
|
||||
get: operations["list_characters_projects__project_id__characters_get"];
|
||||
@@ -453,6 +455,7 @@ export interface paths {
|
||||
* List World Entities
|
||||
* @description 已入库世界观实体全量列表(设定库 Codex 真源;复用 C5 读侧 SqlWorldEntityRepo)。
|
||||
*
|
||||
* 项目不存在 → 404(与写端点一致)。
|
||||
* DB `rules` JSONB dict `{"rules":[...]}` → 裸 list(worldbuilder 形变的逆向)。
|
||||
*/
|
||||
get: operations["list_world_entities_projects__project_id__world_entities_get"];
|
||||
@@ -813,6 +816,18 @@ export interface components {
|
||||
*/
|
||||
note?: string | null;
|
||||
};
|
||||
/**
|
||||
* DimensionEntry
|
||||
* @description 单个文风维度:名称 + 判定值 + 原文证据摘录(强类型,替代裸 dict[str, Any])。
|
||||
*/
|
||||
DimensionEntry: {
|
||||
/** Name */
|
||||
name: string;
|
||||
/** Value */
|
||||
value: string;
|
||||
/** Evidence */
|
||||
evidence?: string[];
|
||||
};
|
||||
/**
|
||||
* DraftResponse
|
||||
* @description 草稿保存结果(脱敏:只回元信息 + 长度,不回灌正文以外的衍生)。
|
||||
@@ -879,6 +894,23 @@ export interface components {
|
||||
/** Length */
|
||||
length: number;
|
||||
};
|
||||
/** ErrorBody */
|
||||
ErrorBody: {
|
||||
/** Code */
|
||||
code: string;
|
||||
/** Message */
|
||||
message: string;
|
||||
/** Details */
|
||||
details?: {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
/** Request Id */
|
||||
request_id?: string | null;
|
||||
};
|
||||
/** ErrorEnvelope */
|
||||
ErrorEnvelope: {
|
||||
error: components["schemas"]["ErrorBody"];
|
||||
};
|
||||
/**
|
||||
* ForeshadowBoardResponse
|
||||
* @description GET /projects/:id/foreshadow?status=:伏笔看板(四泳道 + OVERDUE 字段齐)。
|
||||
@@ -1077,6 +1109,35 @@ export interface components {
|
||||
*/
|
||||
excluded?: components["schemas"]["InjectionEntityRef"][];
|
||||
};
|
||||
/**
|
||||
* JobResponse
|
||||
* @description 长任务状态(`GET /jobs/{id}`):状态 + 进度 + 结果/错误。
|
||||
*
|
||||
* `result` 是任务成功后的非密摘要(如 `{"connected": true, ...}`);`error` 是失败后的
|
||||
* 面向用户文案(已脱敏,绝不含 `str(exc)`,见 `services/job_runner._classify_job_error`)。
|
||||
*/
|
||||
JobResponse: {
|
||||
/**
|
||||
* Id
|
||||
* Format: uuid
|
||||
*/
|
||||
id: string;
|
||||
/** Kind */
|
||||
kind: string;
|
||||
/** Status */
|
||||
status: string;
|
||||
/**
|
||||
* Progress
|
||||
* @default 0
|
||||
*/
|
||||
progress: number;
|
||||
/** Result */
|
||||
result?: {
|
||||
[key: string]: unknown;
|
||||
} | null;
|
||||
/** Error */
|
||||
error?: string | null;
|
||||
};
|
||||
/**
|
||||
* OAuthDisconnectResponse
|
||||
* @description 断开:是否删到凭据行。
|
||||
@@ -1171,7 +1232,7 @@ export interface components {
|
||||
/** Theme */
|
||||
theme?: string | null;
|
||||
/** Selling Points */
|
||||
selling_points?: unknown[];
|
||||
selling_points?: string[];
|
||||
/** Structure */
|
||||
structure?: string | null;
|
||||
};
|
||||
@@ -1204,7 +1265,7 @@ export interface components {
|
||||
/** Theme */
|
||||
theme?: string | null;
|
||||
/** Selling Points */
|
||||
selling_points?: unknown[];
|
||||
selling_points?: string[];
|
||||
/** Structure */
|
||||
structure?: string | null;
|
||||
};
|
||||
@@ -1268,6 +1329,31 @@ export interface components {
|
||||
/** Refined */
|
||||
refined: string;
|
||||
};
|
||||
/**
|
||||
* ReviewConflictView
|
||||
* @description 单条一致性冲突(chapter_reviews.conflicts JSONB 子项;形对齐 SSE `conflict` 事件)。
|
||||
*
|
||||
* 强类型化此前的裸 `dict[str, Any]`,给 TS 客户端真实字段类型。容忍历史/缺省(默认值)。
|
||||
*/
|
||||
ReviewConflictView: {
|
||||
/**
|
||||
* Type
|
||||
* @default
|
||||
*/
|
||||
type: string;
|
||||
/**
|
||||
* Where
|
||||
* @default
|
||||
*/
|
||||
where: string;
|
||||
/** Refs */
|
||||
refs?: string[];
|
||||
/**
|
||||
* Suggestion
|
||||
* @default
|
||||
*/
|
||||
suggestion: string;
|
||||
};
|
||||
/**
|
||||
* ReviewHistoryItem
|
||||
* @description 单条审稿留痕(GET .../reviews 历史项;snake_case)。
|
||||
@@ -1288,9 +1374,7 @@ export interface components {
|
||||
/** Chapter Version */
|
||||
chapter_version?: number | null;
|
||||
/** Conflicts */
|
||||
conflicts?: {
|
||||
[key: string]: unknown;
|
||||
}[];
|
||||
conflicts?: components["schemas"]["ReviewConflictView"][];
|
||||
/** Foreshadow Sug */
|
||||
foreshadow_sug?: {
|
||||
[key: string]: unknown;
|
||||
@@ -1397,17 +1481,14 @@ export interface components {
|
||||
};
|
||||
/**
|
||||
* StyleFingerprintResponse
|
||||
* @description 最新文风指纹(`GET /style`):完整 16 维 + 证据 + 版本(对齐 UX §6.9)。
|
||||
* @description 最新文风指纹(`GET /style`):完整 16 维(名称/值/证据)+ 版本(对齐 UX §6.9)。
|
||||
*
|
||||
* DB 存两列并行 dict(`{name:value}` + `{name:[evidence]}`);响应合并为
|
||||
* `list[DimensionEntry]`,给 TS 客户端强类型(替代弱类型 `dict[str, Any]`)。
|
||||
*/
|
||||
StyleFingerprintResponse: {
|
||||
/** Dimensions */
|
||||
dimensions?: {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
/** Evidence */
|
||||
evidence?: {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
dimensions?: components["schemas"]["DimensionEntry"][];
|
||||
/** Version */
|
||||
version: number;
|
||||
};
|
||||
@@ -1616,9 +1697,16 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
"application/json": components["schemas"]["JobResponse"];
|
||||
};
|
||||
};
|
||||
/** @description job 不存在 */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
@@ -1705,6 +1793,15 @@ export interface operations {
|
||||
"application/json": components["schemas"]["ProjectResponse"];
|
||||
};
|
||||
};
|
||||
/** @description 资源不存在 */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
@@ -1737,6 +1834,15 @@ export interface operations {
|
||||
"application/json": components["schemas"]["InjectionResponse"];
|
||||
};
|
||||
};
|
||||
/** @description 资源不存在 */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
@@ -1773,6 +1879,15 @@ export interface operations {
|
||||
"application/json": components["schemas"]["InjectionResponse"];
|
||||
};
|
||||
};
|
||||
/** @description 资源不存在 */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
@@ -1805,6 +1920,15 @@ export interface operations {
|
||||
"application/json": components["schemas"]["DraftView"];
|
||||
};
|
||||
};
|
||||
/** @description 资源不存在 */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
@@ -1981,6 +2105,24 @@ export interface operations {
|
||||
"application/json": components["schemas"]["AcceptResponse"];
|
||||
};
|
||||
};
|
||||
/** @description 资源不存在 */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description 存在未裁决冲突 */
|
||||
409: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
@@ -1990,6 +2132,15 @@ export interface operations {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
/** @description LLM 不可用 */
|
||||
503: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
list_foreshadow_projects__project_id__foreshadow_get: {
|
||||
@@ -2248,6 +2399,15 @@ export interface operations {
|
||||
"application/json": components["schemas"]["StyleFingerprintResponse"];
|
||||
};
|
||||
};
|
||||
/** @description 项目或指纹不存在 */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
|
||||
@@ -26,7 +26,7 @@ describe("fetchOutline", () => {
|
||||
const chapters = await fetchOutline("p1");
|
||||
|
||||
expect(chapters).toHaveLength(1);
|
||||
expect(chapters[0].no).toBe(1);
|
||||
expect(chapters[0]?.no).toBe(1);
|
||||
});
|
||||
|
||||
it("returns empty array when the project has no outline", async () => {
|
||||
|
||||
@@ -16,13 +16,29 @@ import type {
|
||||
WorldEntityListResponse,
|
||||
} from "./types";
|
||||
|
||||
// 解析 JSON 响应体并做最小运行时校验:非 null 对象/数组才放行,
|
||||
// 否则抛结构化错误(后端契约保证为 JSON 对象;防御被代理/网关污染的非 JSON 响应)。
|
||||
// 校验通过后 narrow 到 T(OpenAPI 生成类型为权威形状,此处仅守边界非 null)。
|
||||
async function parseJsonBody<T>(res: Response, path: string): Promise<T> {
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await res.json();
|
||||
} catch {
|
||||
throw new Error(`响应非 JSON:${path}`);
|
||||
}
|
||||
if (typeof body !== "object" || body === null) {
|
||||
throw new Error(`响应体形状异常(期望对象):${path}`);
|
||||
}
|
||||
return body as T;
|
||||
}
|
||||
|
||||
// 服务端只读取数据(Server Components)。失败时抛出,由页面边界处理。
|
||||
async function getJson<T>(path: string): Promise<T> {
|
||||
const res = await fetch(`${serverApiBase()}${path}`, { cache: "no-store" });
|
||||
if (!res.ok) {
|
||||
throw new Error(`请求失败 ${res.status}: ${path}`);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
return parseJsonBody<T>(res, path);
|
||||
}
|
||||
|
||||
// 像上面一样读取,但 404(无指纹)返回 null 而非抛出(首学前页面照常渲染)。
|
||||
@@ -32,7 +48,7 @@ async function getJsonOrNull<T>(path: string): Promise<T | null> {
|
||||
if (!res.ok) {
|
||||
throw new Error(`请求失败 ${res.status}: ${path}`);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
return parseJsonBody<T>(res, path);
|
||||
}
|
||||
|
||||
export async function fetchProjects(): Promise<ProjectListResponse> {
|
||||
|
||||
@@ -22,6 +22,8 @@ export type CapabilitiesView = components["schemas"]["CapabilitiesView"];
|
||||
// M2 审/裁/验收(C3 扩)。
|
||||
export type ReviewRequest = components["schemas"]["ReviewRequest"];
|
||||
export type ReviewHistoryItem = components["schemas"]["ReviewHistoryItem"];
|
||||
// 单条一致性冲突(chapter_reviews.conflicts 子项,强类型替代裸 dict)。
|
||||
export type ReviewConflictView = components["schemas"]["ReviewConflictView"];
|
||||
export type ReviewHistoryResponse =
|
||||
components["schemas"]["ReviewHistoryResponse"];
|
||||
export type ConflictDecision = components["schemas"]["ConflictDecision"];
|
||||
@@ -48,6 +50,8 @@ export type StyleLearnRequest = components["schemas"]["StyleLearnRequest"];
|
||||
export type StyleLearnResponse = components["schemas"]["StyleLearnResponse"];
|
||||
export type StyleFingerprintResponse =
|
||||
components["schemas"]["StyleFingerprintResponse"];
|
||||
// 单个文风维度(name/value/evidence,强类型替代裸 dict)。
|
||||
export type DimensionEntry = components["schemas"]["DimensionEntry"];
|
||||
export type RefineRequest = components["schemas"]["RefineRequest"];
|
||||
export type RefineResponse = components["schemas"]["RefineResponse"];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user