- 后端新增 GET /projects/{id}/chapters(列真实存在的章 + has_draft/accepted/reviewed_at),TDD 10 测
- gen:api 重生成 TS 客户端(ChapterListItem)
- 选章下拉去掉 hidden(手机/窄屏也可切章);选项以真实章为准 + 大纲结构,
无草稿章置灰(仅当前章例外),覆盖『写过但不在大纲』的章
- 无 ?chapter 时默认落到最近一个有草稿的章,而非硬编码第 1 章
186 lines
6.1 KiB
TypeScript
186 lines
6.1 KiB
TypeScript
import { serverApiBase } from "./config";
|
||
import type {
|
||
ChapterListItem,
|
||
CharacterListResponse,
|
||
DraftView,
|
||
ForeshadowBoardResponse,
|
||
OutlineChapterView,
|
||
OutlineResponse,
|
||
ProjectListResponse,
|
||
ProjectResponse,
|
||
OAuthStatusResponse,
|
||
ProvidersResponse,
|
||
ReviewHistoryResponse,
|
||
RuleListResponse,
|
||
SkillListResponse,
|
||
StyleFingerprintResponse,
|
||
TemplateResponse,
|
||
ToolboxListResponse,
|
||
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 parseJsonBody<T>(res, path);
|
||
}
|
||
|
||
// 像上面一样读取,但 404(无指纹)返回 null 而非抛出(首学前页面照常渲染)。
|
||
async function getJsonOrNull<T>(path: string): Promise<T | null> {
|
||
const res = await fetch(`${serverApiBase()}${path}`, { cache: "no-store" });
|
||
if (res.status === 404) return null;
|
||
if (!res.ok) {
|
||
throw new Error(`请求失败 ${res.status}: ${path}`);
|
||
}
|
||
return parseJsonBody<T>(res, path);
|
||
}
|
||
|
||
export async function fetchProjects(): Promise<ProjectListResponse> {
|
||
return getJson<ProjectListResponse>("/projects");
|
||
}
|
||
|
||
export async function fetchProject(projectId: string): Promise<ProjectResponse> {
|
||
return getJson<ProjectResponse>(`/projects/${projectId}`);
|
||
}
|
||
|
||
export async function fetchProviders(): Promise<ProvidersResponse> {
|
||
return getJson<ProvidersResponse>("/settings/providers");
|
||
}
|
||
|
||
// Kimi Code OAuth 连接态(GET .../oauth/status;无 token 本体)。
|
||
export async function fetchKimiOauthStatus(): Promise<OAuthStatusResponse> {
|
||
return getJson<OAuthStatusResponse>(
|
||
"/settings/providers/kimi-code/oauth/status",
|
||
);
|
||
}
|
||
|
||
export async function fetchReviews(
|
||
projectId: string,
|
||
chapterNo: number,
|
||
): Promise<ReviewHistoryResponse> {
|
||
return getJson<ReviewHistoryResponse>(
|
||
`/projects/${projectId}/chapters/${chapterNo}/reviews`,
|
||
);
|
||
}
|
||
|
||
// 伏笔看板(全量,前端按 status 分四泳道)。
|
||
export async function fetchForeshadow(
|
||
projectId: string,
|
||
): Promise<ForeshadowBoardResponse> {
|
||
return getJson<ForeshadowBoardResponse>(`/projects/${projectId}/foreshadow`);
|
||
}
|
||
|
||
// 最新文风指纹(GET /style);无指纹(404)→ null(首学前页面照常渲染)。
|
||
export async function fetchStyleFingerprint(
|
||
projectId: string,
|
||
): Promise<StyleFingerprintResponse | null> {
|
||
return getJsonOrNull<StyleFingerprintResponse>(
|
||
`/projects/${projectId}/style`,
|
||
);
|
||
}
|
||
|
||
// 规则列表(GET .../rules,规则页)。
|
||
export async function fetchRules(
|
||
projectId: string,
|
||
): Promise<RuleListResponse> {
|
||
return getJson<RuleListResponse>(`/projects/${projectId}/rules`);
|
||
}
|
||
|
||
// 技能库注册表(GET /skills,全局只读)。
|
||
export async function fetchSkills(): Promise<SkillListResponse> {
|
||
return getJson<SkillListResponse>("/skills");
|
||
}
|
||
|
||
// 创作工具箱描述符(GET /skills/toolbox,全局只读)。
|
||
// 任何错误(含后端未上线)降级为空列表,工具箱页照常渲染(不阻塞进页)。
|
||
export async function fetchToolbox(): Promise<ToolboxListResponse> {
|
||
try {
|
||
return await getJson<ToolboxListResponse>("/skills/toolbox");
|
||
} catch {
|
||
return { tools: [] };
|
||
}
|
||
}
|
||
|
||
// 设定库 Codex:跨会话全量已入库角色(GET .../characters;无行→空列表,非 404)。
|
||
export async function fetchCharacters(
|
||
projectId: string,
|
||
): Promise<CharacterListResponse> {
|
||
return getJson<CharacterListResponse>(`/projects/${projectId}/characters`);
|
||
}
|
||
|
||
// 设定库 Codex:跨会话全量已入库世界观实体(GET .../world_entities;无行→空列表)。
|
||
export async function fetchWorldEntities(
|
||
projectId: string,
|
||
): Promise<WorldEntityListResponse> {
|
||
return getJson<WorldEntityListResponse>(
|
||
`/projects/${projectId}/world_entities`,
|
||
);
|
||
}
|
||
|
||
// 已存大纲(GET .../outline,与 POST 同形,按 chapter_no 排序)。
|
||
// 无大纲→空列表;任何错误亦降级为空(进页不阻塞,生成流照常工作)。
|
||
export async function fetchOutline(
|
||
projectId: string,
|
||
): Promise<OutlineChapterView[]> {
|
||
try {
|
||
const res = await getJson<OutlineResponse>(
|
||
`/projects/${projectId}/outline`,
|
||
);
|
||
return res.chapters ?? [];
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
// 列章(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[]> {
|
||
try {
|
||
return await getJson<TemplateResponse[]>("/templates");
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
// 已存草稿(GET .../draft,含正文,供工作台重访重载编辑器)。
|
||
// 404(尚无草稿,新章)→ null,由调用方当空编辑器处理,不抛错。
|
||
export async function fetchDraft(
|
||
projectId: string,
|
||
chapterNo: number,
|
||
): Promise<DraftView | null> {
|
||
return getJsonOrNull<DraftView>(
|
||
`/projects/${projectId}/chapters/${chapterNo}/draft`,
|
||
);
|
||
}
|