feat: improve app ui and project metadata
This commit is contained in:
11
apps/web/lib/api/schema.d.ts
vendored
11
apps/web/lib/api/schema.d.ts
vendored
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
129
apps/web/lib/projects/projects.test.ts
Normal file
129
apps/web/lib/projects/projects.test.ts
Normal 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(
|
||||
"暂无编辑记录",
|
||||
);
|
||||
});
|
||||
});
|
||||
80
apps/web/lib/projects/projects.ts
Normal file
80
apps/web/lib/projects/projects.ts
Normal 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;
|
||||
}
|
||||
@@ -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: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 }));
|
||||
}
|
||||
|
||||
46
apps/web/lib/ui/theme.test.ts
Normal file
46
apps/web/lib/ui/theme.test.ts
Normal 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
36
apps/web/lib/ui/theme.ts
Normal 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();
|
||||
}
|
||||
52
apps/web/lib/ui/variants.test.ts
Normal file
52
apps/web/lib/ui/variants.test.ts
Normal 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
141
apps/web/lib/ui/variants.ts
Normal 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);
|
||||
}
|
||||
@@ -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 {
|
||||
// 网络层失败(后端不可达/CORS):openapi-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);
|
||||
|
||||
Reference in New Issue
Block a user