feat: improve app ui and project metadata

This commit is contained in:
Yaojia Wang
2026-06-28 07:31:20 +02:00
parent 3bd464d400
commit 90a66437d7
86 changed files with 4892 additions and 1108 deletions

View File

@@ -1539,6 +1539,17 @@ export interface components {
selling_points?: string[];
/** Structure */
structure?: string | null;
/**
* Updated At
* @description 项目最近更新时间
*/
updated_at?: string | null;
/**
* Pending Review Count
* @description 待审稿章节数
* @default 0
*/
pending_review_count: number;
};
/**
* ProviderCredentialInput

View File

@@ -4,9 +4,12 @@ import type { ForeshadowView } from "@/lib/api/types";
import {
buildRegisterRequest,
buildTransitionRequest,
countByStatus,
extractReason,
groupByStatus,
isWindowApproaching,
LANE_LABELS,
nextFreeForeshadowCode,
suggestForeshadowCode,
validationReasonMessage,
} from "./board";
@@ -24,6 +27,24 @@ describe("suggestForeshadowCode", () => {
});
});
describe("nextFreeForeshadowCode", () => {
it("keeps the preferred code when it is free", () => {
expect(nextFreeForeshadowCode("FS-04", new Set(["FS-01", "FS-02"]))).toBe(
"FS-04",
);
});
it("skips taken codes and returns the first free FS-NN", () => {
const taken = new Set(["FS-01", "FS-02", "FS-03", "FS-05", "FS-OLD"]);
// preferred FS-01 已占 → 取首个空闲 FS-04
expect(nextFreeForeshadowCode("FS-01", taken)).toBe("FS-04");
});
it("returns FS-01 when nothing is taken", () => {
expect(nextFreeForeshadowCode("FS-01", new Set())).toBe("FS-01");
});
});
const view = (over: Partial<ForeshadowView>): ForeshadowView => ({
code: "F-001",
title: "线索",
@@ -53,6 +74,32 @@ describe("groupByStatus", () => {
});
});
describe("countByStatus", () => {
it("counts items in the same lane model used by the board", () => {
expect(
countByStatus([
view({ status: "OPEN" }),
view({ status: "OPEN" }),
view({ status: "PARTIAL" }),
view({ status: "CLOSED" }),
view({ status: "OVERDUE" }),
view({ status: "WEIRD" }),
]),
).toEqual({ OPEN: 3, PARTIAL: 1, CLOSED: 1, OVERDUE: 1 });
});
});
describe("LANE_LABELS", () => {
it("keeps author-facing labels distinct from status codes", () => {
expect(LANE_LABELS).toEqual({
OPEN: "待推进",
PARTIAL: "推进中",
CLOSED: "已回收",
OVERDUE: "已逾期",
});
});
});
describe("isWindowApproaching", () => {
it("is true when current chapter is inside [from,to] for OPEN/PARTIAL", () => {
const f = view({

View File

@@ -18,13 +18,14 @@ export const LANES: readonly ForeshadowStatus[] = [
] as const;
export const LANE_LABELS: Record<ForeshadowStatus, string> = {
OPEN: "OPEN",
PARTIAL: "PARTIAL",
CLOSED: "CLOSED",
OVERDUE: "OVERDUE",
OPEN: "待推进",
PARTIAL: "推进中",
CLOSED: "已回收",
OVERDUE: "已逾期",
};
export type ForeshadowLanes = Record<ForeshadowStatus, ForeshadowView[]>;
export type ForeshadowLaneCounts = Record<ForeshadowStatus, number>;
function emptyLanes(): ForeshadowLanes {
return { OPEN: [], PARTIAL: [], CLOSED: [], OVERDUE: [] };
@@ -46,6 +47,18 @@ export function groupByStatus(
return lanes;
}
export function countByStatus(
items: readonly ForeshadowView[] | undefined,
): ForeshadowLaneCounts {
const lanes = groupByStatus(items);
return {
OPEN: lanes.OPEN.length,
PARTIAL: lanes.PARTIAL.length,
CLOSED: lanes.CLOSED.length,
OVERDUE: lanes.OVERDUE.length,
};
}
// 接近回收窗口判定current 在 [from, to] 内,或已超 from 但尚未 CLOSED提示安排回收
// 仅对 OPEN/PARTIAL 提示CLOSED 已回收、OVERDUE 已逾期另有强调。
export function isWindowApproaching(
@@ -70,6 +83,21 @@ export function suggestForeshadowCode(
return `FS-${String(index + 1).padStart(2, "0")}`;
}
// 在已占用代号taken区分大小写按原样为预填代号挑一个不冲突的值
// preferred 未被占用就用它;否则从 FS-01 起取首个空闲的 FS-<两位序号>。
// 纯函数(可单测)。避免登记时与库里已存代号撞码 → 422 duplicate。
export function nextFreeForeshadowCode(
preferred: string,
taken: ReadonlySet<string>,
): string {
if (preferred && !taken.has(preferred)) return preferred;
for (let n = 1; n < 1000; n++) {
const candidate = `FS-${String(n).padStart(2, "0")}`;
if (!taken.has(candidate)) return candidate;
}
return preferred; // 理论不可达1000 个代号全占用时退回原值
}
export interface RegisterInput {
projectId: string;
code: string;

View File

@@ -1,6 +1,10 @@
import { describe, expect, it } from "vitest";
import { aiToolItems } from "./ai-tools";
import {
aiToolItems,
primaryAiToolItems,
secondaryAiToolItems,
} from "./ai-tools";
describe("aiToolItems", () => {
it("returns 5 items", () => {
@@ -25,4 +29,16 @@ describe("aiToolItems", () => {
expect(review?.href).toBe("/projects/p1/review");
expect(review?.key).toBe("review");
});
it("keeps high-frequency tools outside the mobile more menu", () => {
expect(primaryAiToolItems("p1").map((item) => item.key)).toEqual([
"write",
"review",
]);
expect(secondaryAiToolItems("p1").map((item) => item.key)).toEqual([
"outline",
"codex",
"toolbox",
]);
});
});

View File

@@ -8,8 +8,6 @@ import type { ActiveNav } from "./items";
export interface AiToolItem {
href: string;
label: string;
// 行首字形纸感非语义aria-hidden 渲染)。
glyph: string;
// 高亮键(与 AppShell activeNav 比对)。
key: ActiveNav;
// 主动作(写本章)朱砂强调,其余次级。
@@ -20,10 +18,18 @@ export interface AiToolItem {
export function aiToolItems(projectId: string): AiToolItem[] {
const base = `/projects/${projectId}`;
return [
{ href: `${base}/write`, label: "写本章", glyph: "✍", key: "write", primary: true },
{ href: `${base}/review`, label: "审稿", glyph: "✦", key: "review" },
{ href: `${base}/outline`, label: "大纲", glyph: "❡", key: "outline" },
{ href: `${base}/codex`, label: "设定库", glyph: "❖", key: "codex" },
{ href: `${base}/toolbox`, label: "工具箱", glyph: "✧", key: "toolbox" },
{ href: `${base}/write`, label: "写本章", key: "write", primary: true },
{ href: `${base}/review`, label: "审稿", key: "review" },
{ href: `${base}/outline`, label: "大纲", key: "outline" },
{ href: `${base}/codex`, label: "设定库", key: "codex" },
{ href: `${base}/toolbox`, label: "工具箱", key: "toolbox" },
];
}
export function primaryAiToolItems(projectId: string): AiToolItem[] {
return aiToolItems(projectId).slice(0, 2);
}
export function secondaryAiToolItems(projectId: string): AiToolItem[] {
return aiToolItems(projectId).slice(2);
}

View File

@@ -0,0 +1,129 @@
import { describe, expect, it } from "vitest";
import type { ProjectResponse } from "@/lib/api/types";
import {
filterProjects,
formatProjectUpdatedAt,
pendingReviewCount,
} from "./projects";
const projects: ProjectResponse[] = [
{
id: "1",
title: "逐光而行",
genre: "玄幻",
logline: "少年逆袭",
premise: null,
theme: "抗争",
selling_points: [],
structure: null,
updated_at: "2026-06-27T10:00:00Z",
pending_review_count: 0,
},
{
id: "2",
title: "城南旧案",
genre: null,
logline: "悬疑调查",
premise: null,
theme: null,
selling_points: [],
structure: null,
updated_at: "2026-06-28T08:00:00Z",
pending_review_count: 2,
},
{
id: "3",
title: "星港来信",
genre: "科幻",
logline: "远航与归乡",
premise: null,
theme: null,
selling_points: [],
structure: null,
updated_at: "2026-06-26T09:00:00Z",
pending_review_count: 1,
},
];
describe("filterProjects", () => {
it("searches title, genre, logline and theme", () => {
expect(
filterProjects(projects, {
search: "悬疑",
filter: "all",
sort: "title",
}).map((p) => p.title),
).toEqual(["城南旧案"]);
expect(
filterProjects(projects, {
search: "抗争",
filter: "all",
sort: "title",
}).map((p) => p.title),
).toEqual(["逐光而行"]);
});
it("filters by categorization state", () => {
expect(
filterProjects(projects, {
search: "",
filter: "with_genre",
sort: "title",
}),
).toHaveLength(2);
expect(
filterProjects(projects, {
search: "",
filter: "uncategorized",
sort: "title",
}).map((p) => p.title),
).toEqual(["城南旧案"]);
});
it("filters projects waiting for review", () => {
expect(
filterProjects(projects, {
search: "",
filter: "pending_review",
sort: "title",
}).map((p) => p.title),
).toEqual(["城南旧案", "星港来信"]);
});
it("sorts by recently edited first", () => {
expect(
filterProjects(projects, {
search: "",
filter: "all",
sort: "updated_at",
}).map((p) => p.title),
).toEqual(["城南旧案", "逐光而行", "星港来信"]);
});
it("sorts by genre with title fallback", () => {
expect(
filterProjects(projects, {
search: "",
filter: "all",
sort: "genre",
}).map((p) => p.title),
).toEqual(["星港来信", "逐光而行", "城南旧案"]);
});
});
describe("project metadata helpers", () => {
it("normalizes pending review count", () => {
expect(pendingReviewCount(projects[1]!)).toBe(2);
expect(pendingReviewCount({ ...projects[1]!, pending_review_count: -1 })).toBe(0);
});
it("formats updated_at with a fallback", () => {
expect(formatProjectUpdatedAt(null)).toBe("暂无编辑记录");
expect(formatProjectUpdatedAt("bad")).toBe("暂无编辑记录");
expect(formatProjectUpdatedAt("2026-06-28T08:00:00Z")).not.toBe(
"暂无编辑记录",
);
});
});

View File

@@ -0,0 +1,80 @@
import type { ProjectResponse } from "@/lib/api/types";
export type ProjectFilter =
| "all"
| "with_genre"
| "uncategorized"
| "pending_review";
export type ProjectSort = "updated_at" | "title" | "genre";
export type ProjectViewMode = "cards" | "compact";
export interface ProjectQuery {
search: string;
filter: ProjectFilter;
sort: ProjectSort;
}
export function filterProjects(
projects: readonly ProjectResponse[],
query: ProjectQuery,
): ProjectResponse[] {
const needle = query.search.trim().toLocaleLowerCase();
const filtered = projects.filter((project) => {
if (query.filter === "with_genre" && !project.genre) return false;
if (query.filter === "uncategorized" && project.genre) return false;
if (query.filter === "pending_review" && pendingReviewCount(project) === 0) {
return false;
}
if (needle.length === 0) return true;
return [project.title, project.genre, project.logline, project.theme]
.filter((value): value is string => typeof value === "string")
.some((value) => value.toLocaleLowerCase().includes(needle));
});
return [...filtered].sort((a, b) => compareProject(a, b, query.sort));
}
function compareProject(
a: ProjectResponse,
b: ProjectResponse,
sort: ProjectSort,
): number {
if (sort === "updated_at") {
const byUpdated = timestamp(b.updated_at) - timestamp(a.updated_at);
if (byUpdated !== 0) return byUpdated;
}
if (sort === "genre") {
if (a.genre && !b.genre) return -1;
if (!a.genre && b.genre) return 1;
const byGenre = label(a.genre).localeCompare(label(b.genre), "zh-Hans-CN");
if (byGenre !== 0) return byGenre;
}
return label(a.title).localeCompare(label(b.title), "zh-Hans-CN");
}
export function pendingReviewCount(project: ProjectResponse): number {
return Math.max(0, project.pending_review_count ?? 0);
}
export function formatProjectUpdatedAt(value: string | null | undefined): string {
if (!value) return "暂无编辑记录";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "暂无编辑记录";
return new Intl.DateTimeFormat("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
}).format(date);
}
function label(value: string | null | undefined): string {
return value?.trim() || "未命名";
}
function timestamp(value: string | null | undefined): number {
if (!value) return 0;
const time = new Date(value).getTime();
return Number.isNaN(time) ? 0 : time;
}

View File

@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import type { SkillView } from "@/lib/api/types";
import { groupByScope, scopeLabel, tierLabel } from "./skills";
import { groupByScope, scopeLabel, summarizeSkills, tierLabel } from "./skills";
const skill = (over: Partial<SkillView>): SkillView => ({
name: "x",
@@ -36,3 +36,61 @@ describe("groupByScope", () => {
expect(groupByScope(undefined)).toEqual([]);
});
});
describe("summarizeSkills", () => {
it("counts scopes, tiers, readonly skills and unique tables", () => {
const summary = summarizeSkills([
skill({
name: "a",
scope: "builtin",
tier: "analyst",
reads: ["projects", "outline"],
writes: [],
}),
skill({
name: "b",
scope: "custom",
tier: "writer",
reads: ["projects"],
writes: ["chapters"],
}),
skill({
name: "c",
scope: "builtin",
tier: "analyst",
reads: ["world_entities"],
writes: ["outline", "chapters"],
}),
]);
expect(summary.total).toBe(3);
expect(summary.readonlyCount).toBe(1);
expect(summary.writableCount).toBe(2);
expect(summary.scopes).toEqual([
{ key: "builtin", count: 2 },
{ key: "custom", count: 1 },
]);
expect(summary.tiers).toEqual([
{ key: "analyst", count: 2 },
{ key: "writer", count: 1 },
]);
expect(summary.readableTables).toEqual([
"outline",
"projects",
"world_entities",
]);
expect(summary.writableTables).toEqual(["chapters", "outline"]);
});
it("handles undefined", () => {
expect(summarizeSkills(undefined)).toEqual({
total: 0,
readonlyCount: 0,
writableCount: 0,
scopes: [],
tiers: [],
readableTables: [],
writableTables: [],
});
});
});

View File

@@ -27,6 +27,21 @@ export function tierLabel(tier: string): string {
export type SkillGroups = { scope: string; skills: SkillView[] }[];
export interface SkillCount {
key: string;
count: number;
}
export interface SkillsSummary {
total: number;
readonlyCount: number;
writableCount: number;
scopes: SkillCount[];
tiers: SkillCount[];
readableTables: string[];
writableTables: string[];
}
// 按 scope 分组(保持服务端 name 升序scope 顺序按首次出现。
export function groupByScope(
skills: readonly SkillView[] | undefined,
@@ -42,3 +57,41 @@ export function groupByScope(
}
return order.map((scope) => ({ scope, skills: map.get(scope) ?? [] }));
}
export function summarizeSkills(
skills: readonly SkillView[] | undefined,
): SkillsSummary {
const scopeCounts = new Map<string, number>();
const tierCounts = new Map<string, number>();
const readableTables = new Set<string>();
const writableTables = new Set<string>();
let writableCount = 0;
for (const skill of skills ?? []) {
scopeCounts.set(skill.scope, (scopeCounts.get(skill.scope) ?? 0) + 1);
tierCounts.set(skill.tier, (tierCounts.get(skill.tier) ?? 0) + 1);
for (const table of skill.reads ?? []) {
readableTables.add(table);
}
if (skill.writes && skill.writes.length > 0) {
writableCount += 1;
for (const table of skill.writes) {
writableTables.add(table);
}
}
}
return {
total: skills?.length ?? 0,
readonlyCount: (skills?.length ?? 0) - writableCount,
writableCount,
scopes: countsFromMap(scopeCounts),
tiers: countsFromMap(tierCounts),
readableTables: [...readableTables].sort(),
writableTables: [...writableTables].sort(),
};
}
function countsFromMap(map: Map<string, number>): SkillCount[] {
return [...map.entries()].map(([key, count]) => ({ key, count }));
}

View File

@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import {
DEFAULT_THEME_MODE,
isThemeMode,
nextThemeMode,
normalizeThemeMode,
themeBootstrapScript,
themeModeLabel,
themeToggleLabel,
} from "./theme";
describe("theme mode", () => {
it("accepts only supported theme modes", () => {
expect(isThemeMode("paper")).toBe(true);
expect(isThemeMode("night")).toBe(true);
expect(isThemeMode("dark")).toBe(false);
expect(isThemeMode(null)).toBe(false);
});
it("falls back to paper for unknown stored values", () => {
expect(normalizeThemeMode("night")).toBe("night");
expect(normalizeThemeMode("")).toBe(DEFAULT_THEME_MODE);
expect(normalizeThemeMode("auto")).toBe(DEFAULT_THEME_MODE);
});
it("toggles between paper and night", () => {
expect(nextThemeMode("paper")).toBe("night");
expect(nextThemeMode("night")).toBe("paper");
});
it("returns labels for the current mode and the next action", () => {
expect(themeModeLabel("paper")).toBe("纸感模式");
expect(themeModeLabel("night")).toBe("夜读模式");
expect(themeToggleLabel("paper")).toBe("切换到夜读模式");
expect(themeToggleLabel("night")).toBe("切换到纸感模式");
});
it("builds a bootstrap script that applies the stored mode before hydration", () => {
const script = themeBootstrapScript();
expect(script).toContain("localStorage.getItem(\"ww.theme_mode\")");
expect(script).toContain("document.documentElement.dataset.theme");
expect(script).toContain("mode !== \"paper\" && mode !== \"night\"");
});
});

36
apps/web/lib/ui/theme.ts Normal file
View File

@@ -0,0 +1,36 @@
export type ThemeMode = "paper" | "night";
export const DEFAULT_THEME_MODE: ThemeMode = "paper";
export const THEME_STORAGE_KEY = "ww.theme_mode";
export function isThemeMode(value: unknown): value is ThemeMode {
return value === "paper" || value === "night";
}
export function normalizeThemeMode(value: unknown): ThemeMode {
return isThemeMode(value) ? value : DEFAULT_THEME_MODE;
}
export function nextThemeMode(current: ThemeMode): ThemeMode {
return current === "night" ? "paper" : "night";
}
export function themeModeLabel(mode: ThemeMode): string {
return mode === "night" ? "夜读模式" : "纸感模式";
}
export function themeToggleLabel(mode: ThemeMode): string {
return mode === "night" ? "切换到纸感模式" : "切换到夜读模式";
}
export function themeBootstrapScript(): string {
return `
try {
var mode = localStorage.getItem(${JSON.stringify(THEME_STORAGE_KEY)});
if (mode !== "paper" && mode !== "night") mode = ${JSON.stringify(DEFAULT_THEME_MODE)};
document.documentElement.dataset.theme = mode;
} catch (error) {
document.documentElement.dataset.theme = ${JSON.stringify(DEFAULT_THEME_MODE)};
}
`.trim();
}

View File

@@ -0,0 +1,52 @@
import { describe, expect, it } from "vitest";
import {
badgeClass,
buttonClass,
cn,
inputClass,
segmentedClass,
statusNoteClass,
} from "./variants";
describe("ui variants", () => {
it("joins classes without falsey values", () => {
expect(cn("a", false, undefined, null, "b")).toBe("a b");
});
it("builds stable primary button classes", () => {
const klass = buttonClass({ variant: "primary", size: "sm" });
expect(klass).toContain("bg-cinnabar");
expect(klass).toContain("px-3");
expect(klass).toContain("focus-visible:ring-2");
});
it("keeps warning badges readable on the paper background", () => {
const klass = badgeClass({ variant: "warning" });
expect(klass).toContain("bg-overdue/10");
expect(klass).toContain("text-ink");
});
it("marks invalid inputs with conflict styling", () => {
const klass = inputClass({ state: "error" });
expect(klass).toContain("border-conflict");
expect(klass).toContain("focus-visible:ring-conflict/25");
});
it("gives warning status notes a non-color icon slot friendly shell", () => {
const klass = statusNoteClass({ variant: "warning" });
expect(klass).toContain("border-overdue");
expect(klass).toContain("px-3");
});
it("builds segmented control chrome", () => {
const klass = segmentedClass();
expect(klass).toContain("inline-flex");
expect(klass).toContain("border-line");
});
});

141
apps/web/lib/ui/variants.ts Normal file
View File

@@ -0,0 +1,141 @@
export type ButtonVariant =
| "primary"
| "secondary"
| "outline"
| "ghost"
| "danger";
export type ButtonSize = "sm" | "md" | "icon";
export type BadgeVariant =
| "neutral"
| "accent"
| "success"
| "warning"
| "danger"
| "info";
export type InputState = "default" | "error";
export type StatusNoteVariant = "info" | "success" | "warning" | "danger";
export function cn(...classes: Array<string | false | null | undefined>): string {
return classes.filter(Boolean).join(" ");
}
const buttonBase =
"inline-flex items-center justify-center gap-1.5 rounded border text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cinnabar/35 disabled:cursor-not-allowed disabled:opacity-45";
const buttonVariants: Record<ButtonVariant, string> = {
primary:
"border-cinnabar bg-cinnabar text-panel shadow-paper hover:bg-cinnabar/95",
secondary:
"border-line bg-panel text-ink hover:border-cinnabar hover:text-cinnabar",
outline:
"border-cinnabar bg-transparent text-cinnabar hover:bg-[var(--color-cinnabar-wash)]",
ghost:
"border-transparent bg-transparent text-ink-soft hover:bg-[var(--color-cinnabar-wash)] hover:text-cinnabar",
danger:
"border-conflict bg-transparent text-conflict hover:bg-conflict/10",
};
const buttonSizes: Record<ButtonSize, string> = {
sm: "px-3 py-1.5 text-xs",
md: "px-4 py-2",
icon: "h-9 w-9 p-0",
};
export function buttonClass({
variant = "secondary",
size = "md",
className,
}: {
variant?: ButtonVariant;
size?: ButtonSize;
className?: string;
} = {}): string {
return cn(buttonBase, buttonVariants[variant], buttonSizes[size], className);
}
const badgeBase =
"inline-flex items-center gap-1 rounded border px-2 py-0.5 text-xs leading-5";
const badgeVariants: Record<BadgeVariant, string> = {
neutral: "border-line bg-bg text-ink-soft",
accent:
"border-cinnabar/20 bg-[var(--color-cinnabar-wash)] text-cinnabar",
success: "border-pass/25 bg-pass/10 text-pass",
warning: "border-overdue/35 bg-overdue/10 text-ink",
danger: "border-conflict/25 bg-conflict/10 text-conflict",
info: "border-info/25 bg-info/10 text-info",
};
export function badgeClass({
variant = "neutral",
className,
}: {
variant?: BadgeVariant;
className?: string;
} = {}): string {
return cn(badgeBase, badgeVariants[variant], className);
}
export function cardClass(className?: string): string {
return cn("rounded border border-line bg-panel shadow-paper", className);
}
const fieldTextBase = "block text-sm font-medium text-ink";
export function fieldLabelClass(className?: string): string {
return cn(fieldTextBase, className);
}
export function fieldHelpClass(className?: string): string {
return cn("mt-1 text-xs leading-5 text-ink-soft", className);
}
export function fieldErrorClass(className?: string): string {
return cn("mt-1 text-xs leading-5 text-conflict", className);
}
const inputBase =
"w-full rounded border bg-bg text-ink transition-colors placeholder:text-ink-soft/65 focus:outline-none focus-visible:ring-2 focus-visible:ring-cinnabar/30 disabled:cursor-not-allowed disabled:bg-line/20 disabled:text-ink-soft";
const inputStates: Record<InputState, string> = {
default: "border-line focus:border-cinnabar",
error: "border-conflict focus:border-conflict focus-visible:ring-conflict/25",
};
export function inputClass({
state = "default",
className,
}: {
state?: InputState;
className?: string;
} = {}): string {
return cn(inputBase, inputStates[state], className);
}
const statusNoteBase =
"rounded border px-3 py-2 text-sm leading-6";
const statusNoteVariants: Record<StatusNoteVariant, string> = {
info: "border-info/25 bg-info/10 text-info",
success: "border-pass/25 bg-pass/10 text-pass",
warning: "border-overdue/35 bg-overdue/10 text-ink",
danger: "border-conflict/25 bg-conflict/10 text-conflict",
};
export function statusNoteClass({
variant = "info",
className,
}: {
variant?: StatusNoteVariant;
className?: string;
} = {}): string {
return cn(statusNoteBase, statusNoteVariants[variant], className);
}
export function segmentedClass(className?: string): string {
return cn("inline-flex rounded border border-line bg-bg p-1", className);
}

View File

@@ -45,17 +45,25 @@ export function useInjection(projectId: string, chapterNo: number): UseInjection
setError(null);
void (async () => {
const { data: body, error: err } = await api.GET(INJECTION_PATH, {
params: { path: { project_id: projectId, chapter_no: chapterNo } },
});
if (cancelled) return;
if (err || !body) {
try {
const { data: body, error: err } = await api.GET(INJECTION_PATH, {
params: { path: { project_id: projectId, chapter_no: chapterNo } },
});
if (cancelled) return;
if (err || !body) {
setError("注入信息暂不可用");
setData(null);
} else {
setData(body);
}
} catch {
// 网络层失败(后端不可达/CORSopenapi-fetch 直接抛而非返回 error 信封。
if (cancelled) return;
setError("注入信息暂不可用");
setData(null);
} else {
setData(body);
} finally {
if (!cancelled) setLoading(false);
}
setLoading(false);
})();
return () => {
@@ -80,6 +88,9 @@ export function useInjection(projectId: string, chapterNo: number): UseInjection
} else {
setData(body); // 以服务端确定结果为准(不变量 #6
}
} catch {
// 网络层失败:本地状态不动(天然回滚),给可读文案、不抛。
setError("保存注入设置失败,请重试");
} finally {
savingRef.current = false;
setSaving(false);