feat: improve app ui and project metadata
This commit is contained in:
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;
|
||||
}
|
||||
Reference in New Issue
Block a user