From 7cf9aec20ec806a751c58a53cd0eca2025cd82fe Mon Sep 17 00:00:00 2001 From: SpecialX <47072643+wangxiner55@users.noreply.github.com> Date: Mon, 13 Jul 2026 13:05:29 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin-portal):=20=E5=AE=8C=E6=88=90?= =?UTF-8?q?=E5=8F=82=E8=80=83=E9=A1=B9=E7=9B=AE=E5=B7=AE=E8=B7=9D=E9=97=AD?= =?UTF-8?q?=E7=8E=AF=20-=204=20=E6=89=B9=E6=AC=A1=E8=A1=A5=E9=BD=90=20+=20?= =?UTF-8?q?Vitest=20=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 差距分析闭环(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 已更新 --- .../src/app/admin/ai-settings/page.tsx | 271 +++++++ .../src/app/admin/announcements/page.tsx | 406 ++++++++++ .../admin/audit-logs/data-changes/page.tsx | 186 +++++ .../app/admin/audit-logs/login-logs/page.tsx | 171 ++++ .../app/admin/audit-logs/overview/page.tsx | 199 +++++ .../src/app/admin/audit-logs/page.tsx | 3 + .../admin-portal/src/app/admin/files/page.tsx | 334 ++++++++ .../src/app/admin/invitation-codes/page.tsx | 538 +++++++++++++ .../app/admin/school/academic-year/page.tsx | 106 +++ .../src/app/admin/school/departments/page.tsx | 77 ++ .../app/admin/school/grades/insights/page.tsx | 250 ++++++ .../src/app/admin/school/grades/page.tsx | 85 ++ .../src/app/admin/school/layout.tsx | 87 +++ .../src/app/admin/school/page.tsx | 5 + .../src/app/admin/school/schools/page.tsx | 103 +++ .../src/app/admin/system/page.tsx | 390 +++++++++- .../src/components/admin-shell.tsx | 5 + .../src/components/audit-logs-tabs.tsx | 65 ++ apps/admin-portal/src/components/ui.test.tsx | 267 +++++++ apps/admin-portal/src/components/ui.tsx | 14 + .../admin-portal/src/hooks/use-ai-settings.ts | 59 ++ .../src/hooks/use-announcements.ts | 128 +++ .../src/hooks/use-audit-overview.ts | 39 + .../src/hooks/use-data-change-logs.ts | 97 +++ apps/admin-portal/src/hooks/use-files.ts | 102 +++ .../src/hooks/use-invitation-codes.ts | 99 +++ apps/admin-portal/src/hooks/use-login-logs.ts | 61 ++ apps/admin-portal/src/hooks/use-school.ts | 99 +++ apps/admin-portal/src/lib/auth.test.ts | 157 ++++ apps/admin-portal/src/lib/graphql-client.ts | 358 +++++++++ apps/admin-portal/src/lib/i18n.test.ts | 54 ++ apps/admin-portal/src/lib/i18n.ts | 119 +++ apps/admin-portal/src/lib/permissions.test.ts | 92 +++ apps/admin-portal/src/lib/permissions.ts | 25 + apps/admin-portal/src/mocks/fixtures.test.ts | 148 ++++ apps/admin-portal/src/mocks/fixtures.ts | 728 ++++++++++++++++++ apps/admin-portal/src/mocks/handlers.ts | 288 +++++++ apps/admin-portal/src/test/setup.ts | 22 + apps/admin-portal/src/types/view-models.ts | 267 +++++++ .../issues/worklines/admin-portal_workline.md | 210 ++++- 40 files changed, 6707 insertions(+), 7 deletions(-) create mode 100644 apps/admin-portal/src/app/admin/ai-settings/page.tsx create mode 100644 apps/admin-portal/src/app/admin/announcements/page.tsx create mode 100644 apps/admin-portal/src/app/admin/audit-logs/data-changes/page.tsx create mode 100644 apps/admin-portal/src/app/admin/audit-logs/login-logs/page.tsx create mode 100644 apps/admin-portal/src/app/admin/audit-logs/overview/page.tsx create mode 100644 apps/admin-portal/src/app/admin/files/page.tsx create mode 100644 apps/admin-portal/src/app/admin/invitation-codes/page.tsx create mode 100644 apps/admin-portal/src/app/admin/school/academic-year/page.tsx create mode 100644 apps/admin-portal/src/app/admin/school/departments/page.tsx create mode 100644 apps/admin-portal/src/app/admin/school/grades/insights/page.tsx create mode 100644 apps/admin-portal/src/app/admin/school/grades/page.tsx create mode 100644 apps/admin-portal/src/app/admin/school/layout.tsx create mode 100644 apps/admin-portal/src/app/admin/school/page.tsx create mode 100644 apps/admin-portal/src/app/admin/school/schools/page.tsx create mode 100644 apps/admin-portal/src/components/audit-logs-tabs.tsx create mode 100644 apps/admin-portal/src/components/ui.test.tsx create mode 100644 apps/admin-portal/src/hooks/use-ai-settings.ts create mode 100644 apps/admin-portal/src/hooks/use-announcements.ts create mode 100644 apps/admin-portal/src/hooks/use-audit-overview.ts create mode 100644 apps/admin-portal/src/hooks/use-data-change-logs.ts create mode 100644 apps/admin-portal/src/hooks/use-files.ts create mode 100644 apps/admin-portal/src/hooks/use-invitation-codes.ts create mode 100644 apps/admin-portal/src/hooks/use-login-logs.ts create mode 100644 apps/admin-portal/src/hooks/use-school.ts create mode 100644 apps/admin-portal/src/lib/auth.test.ts create mode 100644 apps/admin-portal/src/lib/i18n.test.ts create mode 100644 apps/admin-portal/src/lib/permissions.test.ts create mode 100644 apps/admin-portal/src/mocks/fixtures.test.ts create mode 100644 apps/admin-portal/src/test/setup.ts diff --git a/apps/admin-portal/src/app/admin/ai-settings/page.tsx b/apps/admin-portal/src/app/admin/ai-settings/page.tsx new file mode 100644 index 0000000..12cd1a0 --- /dev/null +++ b/apps/admin-portal/src/app/admin/ai-settings/page.tsx @@ -0,0 +1,271 @@ +"use client"; + +import { type ReactNode } from "react"; +import { + useAiProviders, + useUpdateAiProvider, + useAiUsage, +} from "@/hooks/use-ai-settings"; +import { useToast } from "@/providers/toast-provider"; +import { + PageHeader, + PaperCard, + Button, + LoadingState, + ErrorState, + EmptyState, + Table, + TableRow, + TableCell, + Badge, +} from "@/components/ui"; +import { t } from "@/lib/i18n"; + +interface StatCardProps { + label: string; + value: number | string; + suffix?: string; +} + +function StatCard({ label, value, suffix }: StatCardProps): ReactNode { + return ( + +

+ {label} +

+

+ {value} + {suffix && ( + + {suffix} + + )} +

+
+ ); +} + +export default function AiSettingsPage(): ReactNode { + const { data: providers, loading, error, refetch } = useAiProviders(); + const { data: usage } = useAiUsage(); + const [updateProvider, mutationState] = useUpdateAiProvider(); + const { show } = useToast(); + + const handleToggle = async (id: string, isActive: boolean) => { + const res = await updateProvider(id, { isActive: !isActive }); + if (res) { + show("success", `Provider 已${!isActive ? "启用" : "禁用"}`); + refetch(); + } else if (mutationState.error) { + show("error", "操作失败", mutationState.error.message); + } + }; + + return ( +
+ + + {/* AI Provider 列表 */} + +

+ {t("admin.aiSettings.providers")} +

+ {loading && } + {error && } + {!loading && !error && providers && ( + <> + {providers.length === 0 ? ( + + ) : ( + + {providers.map((p) => ( + + {p.name} + + + {p.type} + + + + + {p.model} + + + + + + + + + + + {p.apiBase} + + + + {new Date(p.createdAt).toLocaleDateString("zh-CN")} + + + ))} +
+ )} + + )} +
+ + {/* AI 用量统计 */} + {usage && ( + <> +
+ + + +
+ + {/* Provider 用量明细 */} + +

+ {t("admin.aiSettings.usage")} - {t("admin.aiSettings.provider")} +

+ + {usage.byProvider.map((row) => ( + + {row.providerName} + + {row.requests} + + + {row.tokens} + + + ${row.cost.toFixed(2)} + + + ))} +
+
+ + {/* 7 天趋势 */} + +

+ 7 {t("admin.auditLogs.trend")} +

+ + {usage.byDay.map((row) => ( + + + {row.date} + + + {row.requests} + + + {row.tokens} + + + ))} +
+
+ + )} +
+ ); +} diff --git a/apps/admin-portal/src/app/admin/announcements/page.tsx b/apps/admin-portal/src/app/admin/announcements/page.tsx new file mode 100644 index 0000000..3598b13 --- /dev/null +++ b/apps/admin-portal/src/app/admin/announcements/page.tsx @@ -0,0 +1,406 @@ +"use client"; + +import { type ReactNode, useState, useEffect } from "react"; +import { + useAnnouncements, + useAnnouncementFilter, + useCreateAnnouncement, + usePublishAnnouncement, + useArchiveAnnouncement, + useToggleAnnouncementPin, +} from "@/hooks/use-announcements"; +import { useToast } from "@/providers/toast-provider"; +import { + PageHeader, + PaperCard, + Input, + Select, + Button, + Badge, + LoadingState, + ErrorState, + EmptyState, + Table, + TableRow, + TableCell, + Pagination, +} from "@/components/ui"; +import { t } from "@/lib/i18n"; +import type { AnnouncementViewModel } from "@/types/view-models"; + +const AUDIENCE_OPTIONS = [ + { value: "all", label: "全部" }, + { value: "teachers", label: "教师" }, + { value: "students", label: "学生" }, + { value: "parents", label: "家长" }, +] as const; + +const audienceLabel: Record = { + all: "全部", + teachers: "教师", + students: "学生", + parents: "家长", +}; + +export default function AnnouncementsPage(): ReactNode { + const { filter, setPage, setSearch, setStatus } = useAnnouncementFilter(); + const { data, loading, error } = useAnnouncements(filter); + const { show } = useToast(); + const [createAnnouncement] = useCreateAnnouncement(); + const [publishAnnouncement] = usePublishAnnouncement(); + const [archiveAnnouncement] = useArchiveAnnouncement(); + const [togglePin] = useToggleAnnouncementPin(); + const [searchInput, setSearchInput] = useState(""); + const [modalOpen, setModalOpen] = useState(false); + const [formTitle, setFormTitle] = useState(""); + const [formContent, setFormContent] = useState(""); + const [formAudience, setFormAudience] = useState(["all"]); + const [submitting, setSubmitting] = useState(false); + + useEffect(() => { + const timer = setTimeout(() => setSearch(searchInput), 300); + return () => clearTimeout(timer); + }, [searchInput, setSearch]); + + const openCreateModal = () => { + setFormTitle(""); + setFormContent(""); + setFormAudience(["all"]); + setModalOpen(true); + }; + + const toggleAudience = (value: string) => { + setFormAudience((prev) => { + if (value === "all") { + return ["all"]; + } + const next = prev.filter((a) => a !== "all"); + if (next.includes(value)) { + const filtered = next.filter((a) => a !== value); + return filtered.length === 0 ? ["all"] : filtered; + } + return [...next, value]; + }); + }; + + const handleCreate = async () => { + if (!formTitle.trim() || !formContent.trim()) { + show("warning", "请填写标题和内容"); + return; + } + setSubmitting(true); + try { + await createAnnouncement({ + title: formTitle, + content: formContent, + audience: formAudience, + }); + show("success", "公告已创建"); + setModalOpen(false); + window.location.reload(); + } catch (err) { + show( + "error", + "创建失败", + err instanceof Error ? err.message : String(err), + ); + } finally { + setSubmitting(false); + } + }; + + const handlePublish = async (ann: AnnouncementViewModel) => { + try { + await publishAnnouncement(ann.id); + show("success", "公告已发布"); + window.location.reload(); + } catch (err) { + show( + "error", + "操作失败", + err instanceof Error ? err.message : String(err), + ); + } + }; + + const handleArchive = async (ann: AnnouncementViewModel) => { + try { + await archiveAnnouncement(ann.id); + show("success", "公告已归档"); + window.location.reload(); + } catch (err) { + show( + "error", + "操作失败", + err instanceof Error ? err.message : String(err), + ); + } + }; + + const handleTogglePin = async (ann: AnnouncementViewModel) => { + try { + await togglePin(ann.id); + show("success", ann.isPinned ? "已取消置顶" : "已置顶"); + window.location.reload(); + } catch (err) { + show( + "error", + "操作失败", + err instanceof Error ? err.message : String(err), + ); + } + }; + + return ( +
+ + {t("admin.announcements.new")} + + } + /> + + +
+ setSearchInput(e.target.value)} + style={{ width: 240 }} + /> + +
+
+ + + {loading && } + {error && } + {!loading && !error && data && ( + <> + {data.items.length === 0 ? ( + + ) : ( + <> + + {data.items.map((ann) => ( + + + {ann.title} + + + + + + {ann.audience + .map((a) => audienceLabel[a] ?? a) + .join(", ")} + + {ann.authorName} + + {ann.isPinned ? : "—"} + + + {ann.readCount}/{ann.totalCount} + + + {ann.publishedAt + ? new Date(ann.publishedAt).toLocaleDateString( + "zh-CN", + ) + : "—"} + + + {new Date(ann.createdAt).toLocaleDateString("zh-CN")} + + +
+ {ann.status === "draft" && ( + + )} + {ann.status === "published" && ( + + )} + +
+
+
+ ))} +
+ + + )} + + )} +
+ + {modalOpen && ( +
+
+

+ {t("admin.announcements.new")} +

+
+
+ + 标题 + + setFormTitle(e.target.value)} + placeholder="请输入公告标题" + /> +
+