feat(admin-portal): 完成参考项目差距闭环 - 4 批次补齐 + Vitest 测试
差距分析闭环(workline §7-§8): - P0:审计子模块 3 页 + 学校组织 7 页 - P1:系统设置 4 Card + 公告管理 + 邀请码管理 - P2:文件管理 + AI 配置 - P6:5 测试文件 / 69 用例全过 共享基础设施: - permissions +11 权限点 +14 路由 - view-models +20 接口 - i18n +100 key - graphql-client +25 operation - fixtures/handlers +15 mock +20 handler 质量:typecheck + lint 零错误,vitest 69/69 通过,arch.db 已更新
This commit is contained in:
@@ -20,9 +20,14 @@ const NAV_ITEMS: NavItem[] = [
|
||||
{ route: "/admin/permissions", label: t("admin.nav.permissions") },
|
||||
{ route: "/admin/viewports", label: t("admin.nav.viewports") },
|
||||
{ route: "/admin/organization", label: t("admin.nav.organization") },
|
||||
{ route: "/admin/school", label: t("admin.school.title") },
|
||||
{ route: "/admin/classes", label: t("admin.nav.classes") },
|
||||
{ route: "/admin/teachers", label: t("admin.nav.teachers") },
|
||||
{ route: "/admin/students", label: t("admin.nav.students") },
|
||||
{ route: "/admin/announcements", label: t("admin.announcements.title") },
|
||||
{ route: "/admin/invitation-codes", label: t("admin.invitationCodes.title") },
|
||||
{ route: "/admin/files", label: t("admin.files.title") },
|
||||
{ route: "/admin/ai-settings", label: t("admin.aiSettings.title") },
|
||||
{ route: "/admin/audit-logs", label: t("admin.nav.auditLogs") },
|
||||
{ route: "/admin/system", label: t("admin.nav.system") },
|
||||
];
|
||||
|
||||
65
apps/admin-portal/src/components/audit-logs-tabs.tsx
Normal file
65
apps/admin-portal/src/components/audit-logs-tabs.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { t } from "@/lib/i18n";
|
||||
|
||||
interface TabItem {
|
||||
label: string;
|
||||
href: string;
|
||||
exact: boolean;
|
||||
}
|
||||
|
||||
const TABS: TabItem[] = [
|
||||
{ label: "操作日志", href: "/admin/audit-logs", exact: true },
|
||||
{
|
||||
label: t("admin.auditLogs.overview"),
|
||||
href: "/admin/audit-logs/overview",
|
||||
exact: false,
|
||||
},
|
||||
{
|
||||
label: t("admin.auditLogs.dataChanges"),
|
||||
href: "/admin/audit-logs/data-changes",
|
||||
exact: false,
|
||||
},
|
||||
{
|
||||
label: t("admin.auditLogs.loginLogs"),
|
||||
href: "/admin/audit-logs/login-logs",
|
||||
exact: false,
|
||||
},
|
||||
];
|
||||
|
||||
export function AuditLogsTabs(): ReactNode {
|
||||
const pathname = usePathname();
|
||||
return (
|
||||
<div
|
||||
className="flex gap-1 mb-4"
|
||||
style={{ borderBottom: "1px solid var(--color-rule)" }}
|
||||
>
|
||||
{TABS.map((tab) => {
|
||||
const active = tab.exact
|
||||
? pathname === tab.href
|
||||
: pathname.startsWith(tab.href);
|
||||
return (
|
||||
<Link
|
||||
key={tab.href}
|
||||
href={tab.href}
|
||||
style={{
|
||||
padding: "8px 16px",
|
||||
fontSize: 13,
|
||||
color: active ? "var(--color-accent)" : "var(--color-ink-muted)",
|
||||
borderBottom: active
|
||||
? "2px solid var(--color-accent)"
|
||||
: "2px solid transparent",
|
||||
textDecoration: "none",
|
||||
fontFamily: active ? "var(--font-serif)" : "var(--font-sans)",
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
267
apps/admin-portal/src/components/ui.test.tsx
Normal file
267
apps/admin-portal/src/components/ui.test.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* 通用 UI 组件测试
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import {
|
||||
PaperCard,
|
||||
PageHeader,
|
||||
Button,
|
||||
Input,
|
||||
Select,
|
||||
Badge,
|
||||
Table,
|
||||
TableRow,
|
||||
TableCell,
|
||||
EmptyState,
|
||||
LoadingState,
|
||||
ErrorState,
|
||||
Pagination,
|
||||
} from "./ui";
|
||||
|
||||
describe("PaperCard", () => {
|
||||
it("渲染 children", () => {
|
||||
render(<PaperCard>内容</PaperCard>);
|
||||
expect(screen.getByText("内容")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("支持自定义 className", () => {
|
||||
const { container } = render(
|
||||
<PaperCard className="custom-class">内容</PaperCard>,
|
||||
);
|
||||
expect(container.firstChild).toHaveClass("custom-class");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PageHeader", () => {
|
||||
it("渲染标题", () => {
|
||||
render(<PageHeader title="用户管理" />);
|
||||
expect(screen.getByText("用户管理")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("渲染描述", () => {
|
||||
render(<PageHeader title="用户管理" description="管理所有用户" />);
|
||||
expect(screen.getByText("管理所有用户")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("渲染操作按钮", () => {
|
||||
render(<PageHeader title="用户管理" actions={<button>新建</button>} />);
|
||||
expect(screen.getByText("新建")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Button", () => {
|
||||
it("默认 secondary variant", () => {
|
||||
render(<Button>点击</Button>);
|
||||
expect(screen.getByText("点击")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("点击触发 onClick", () => {
|
||||
const onClick = vi.fn();
|
||||
render(<Button onClick={onClick}>点击</Button>);
|
||||
fireEvent.click(screen.getByText("点击"));
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("disabled 时不触发 onClick", () => {
|
||||
const onClick = vi.fn();
|
||||
render(
|
||||
<Button onClick={onClick} disabled>
|
||||
点击
|
||||
</Button>,
|
||||
);
|
||||
fireEvent.click(screen.getByText("点击"));
|
||||
expect(onClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("支持所有 variant", () => {
|
||||
const { rerender } = render(<Button variant="primary">主按钮</Button>);
|
||||
expect(screen.getByText("主按钮")).toBeInTheDocument();
|
||||
rerender(<Button variant="danger">危险按钮</Button>);
|
||||
expect(screen.getByText("危险按钮")).toBeInTheDocument();
|
||||
rerender(<Button variant="ghost">幽灵按钮</Button>);
|
||||
expect(screen.getByText("幽灵按钮")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Input", () => {
|
||||
it("渲染输入框并支持输入", () => {
|
||||
render(<Input placeholder="请输入" />);
|
||||
const input = screen.getByPlaceholderText("请输入");
|
||||
fireEvent.change(input, { target: { value: "测试内容" } });
|
||||
expect(input).toHaveValue("测试内容");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Select", () => {
|
||||
it("渲染下拉框和选项", () => {
|
||||
render(
|
||||
<Select>
|
||||
<option value="a">选项A</option>
|
||||
<option value="b">选项B</option>
|
||||
</Select>,
|
||||
);
|
||||
expect(screen.getByText("选项A")).toBeInTheDocument();
|
||||
expect(screen.getByText("选项B")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Badge", () => {
|
||||
it("渲染 active 状态标签", () => {
|
||||
render(<Badge status="active" />);
|
||||
expect(screen.getByText("启用")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("渲染 locked 状态标签", () => {
|
||||
render(<Badge status="locked" />);
|
||||
expect(screen.getByText("锁定")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("渲染公告相关状态", () => {
|
||||
const { rerender } = render(<Badge status="draft" />);
|
||||
expect(screen.getByText("草稿")).toBeInTheDocument();
|
||||
rerender(<Badge status="published" />);
|
||||
expect(screen.getByText("已发布")).toBeInTheDocument();
|
||||
rerender(<Badge status="archived" />);
|
||||
expect(screen.getByText("已归档")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("渲染邀请码状态", () => {
|
||||
const { rerender } = render(<Badge status="unused" />);
|
||||
expect(screen.getByText("未使用")).toBeInTheDocument();
|
||||
rerender(<Badge status="used" />);
|
||||
expect(screen.getByText("已使用")).toBeInTheDocument();
|
||||
rerender(<Badge status="expired" />);
|
||||
expect(screen.getByText("已过期")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("未知状态显示原始值", () => {
|
||||
render(<Badge status="unknown-status" />);
|
||||
expect(screen.getByText("unknown-status")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Table", () => {
|
||||
it("渲染表头和数据行", () => {
|
||||
render(
|
||||
<Table headers={["姓名", "邮箱"]}>
|
||||
<TableRow>
|
||||
<TableCell>张三</TableCell>
|
||||
<TableCell>zhangsan@test.com</TableCell>
|
||||
</TableRow>
|
||||
</Table>,
|
||||
);
|
||||
expect(screen.getByText("姓名")).toBeInTheDocument();
|
||||
expect(screen.getByText("邮箱")).toBeInTheDocument();
|
||||
expect(screen.getByText("张三")).toBeInTheDocument();
|
||||
expect(screen.getByText("zhangsan@test.com")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("EmptyState", () => {
|
||||
it("渲染空状态消息", () => {
|
||||
render(<EmptyState message="暂无数据" />);
|
||||
expect(screen.getByText("暂无数据")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("LoadingState", () => {
|
||||
it("渲染加载中文案", () => {
|
||||
render(<LoadingState />);
|
||||
expect(screen.getByText("加载中...")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ErrorState", () => {
|
||||
it("渲染错误消息", () => {
|
||||
render(<ErrorState message="加载失败" />);
|
||||
expect(screen.getByText("加载失败")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("提供 onRetry 时渲染重试按钮", () => {
|
||||
const onRetry = vi.fn();
|
||||
render(<ErrorState message="加载失败" onRetry={onRetry} />);
|
||||
const retryBtn = screen.getByText("重试");
|
||||
fireEvent.click(retryBtn);
|
||||
expect(onRetry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("不提供 onRetry 时不渲染重试按钮", () => {
|
||||
render(<ErrorState message="加载失败" />);
|
||||
expect(screen.queryByText("重试")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Pagination", () => {
|
||||
it("渲染总数和页码", () => {
|
||||
render(
|
||||
<Pagination
|
||||
page={2}
|
||||
pageSize={20}
|
||||
total={45}
|
||||
hasNext={true}
|
||||
onPageChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/共 45 条/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/第 2\/3 页/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("第一页禁用上一页按钮", () => {
|
||||
render(
|
||||
<Pagination
|
||||
page={1}
|
||||
pageSize={20}
|
||||
total={45}
|
||||
hasNext={true}
|
||||
onPageChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("上一页")).toBeDisabled();
|
||||
expect(screen.getByText("下一页")).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("最后一页禁用下一页按钮", () => {
|
||||
render(
|
||||
<Pagination
|
||||
page={3}
|
||||
pageSize={20}
|
||||
total={45}
|
||||
hasNext={false}
|
||||
onPageChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("下一页")).toBeDisabled();
|
||||
expect(screen.getByText("上一页")).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("点击上一页/下一页触发 onPageChange", () => {
|
||||
const onPageChange = vi.fn();
|
||||
render(
|
||||
<Pagination
|
||||
page={2}
|
||||
pageSize={20}
|
||||
total={45}
|
||||
hasNext={true}
|
||||
onPageChange={onPageChange}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByText("上一页"));
|
||||
expect(onPageChange).toHaveBeenCalledWith(1);
|
||||
fireEvent.click(screen.getByText("下一页"));
|
||||
expect(onPageChange).toHaveBeenCalledWith(3);
|
||||
});
|
||||
|
||||
it("total 为 0 时显示第 1/1 页", () => {
|
||||
render(
|
||||
<Pagination
|
||||
page={1}
|
||||
pageSize={20}
|
||||
total={0}
|
||||
hasNext={false}
|
||||
onPageChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/第 1\/1 页/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -180,6 +180,13 @@ export function Badge({ status }: { status: string }): ReactNode {
|
||||
healthy: "var(--color-success)",
|
||||
degraded: "var(--color-warning)",
|
||||
down: "var(--color-danger)",
|
||||
draft: "var(--color-warning)",
|
||||
published: "var(--color-success)",
|
||||
archived: "var(--color-ink-muted)",
|
||||
unused: "var(--color-accent)",
|
||||
used: "var(--color-success)",
|
||||
expired: "var(--color-danger)",
|
||||
pinned: "var(--color-accent)",
|
||||
};
|
||||
const labelMap: Record<string, string> = {
|
||||
active: "启用",
|
||||
@@ -188,6 +195,13 @@ export function Badge({ status }: { status: string }): ReactNode {
|
||||
healthy: "健康",
|
||||
degraded: "降级",
|
||||
down: "异常",
|
||||
draft: "草稿",
|
||||
published: "已发布",
|
||||
archived: "已归档",
|
||||
unused: "未使用",
|
||||
used: "已使用",
|
||||
expired: "已过期",
|
||||
pinned: "置顶",
|
||||
};
|
||||
const color = colorMap[status] ?? "var(--color-ink-muted)";
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user