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:
SpecialX
2026-07-13 13:05:29 +08:00
parent fad6e43b47
commit 7cf9aec20e
40 changed files with 6707 additions and 7 deletions

View File

@@ -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 (
<PaperCard className="p-4">
<p
style={{
fontSize: 11,
color: "var(--color-ink-muted)",
textTransform: "uppercase",
letterSpacing: "0.05em",
}}
>
{label}
</p>
<p
style={{
fontFamily: "var(--font-serif)",
fontSize: 28,
color: "var(--color-ink)",
margin: "8px 0 0 0",
}}
>
{value}
{suffix && (
<span
style={{
fontSize: 14,
color: "var(--color-ink-muted)",
marginLeft: 4,
}}
>
{suffix}
</span>
)}
</p>
</PaperCard>
);
}
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 (
<div style={{ padding: 32, maxWidth: 1200, margin: "0 auto" }}>
<PageHeader
title={t("admin.aiSettings.title")}
description="AI 服务商与用量管理"
/>
{/* AI Provider 列表 */}
<PaperCard className="p-4 mb-6">
<h3
style={{
fontFamily: "var(--font-serif)",
fontSize: 16,
color: "var(--color-ink)",
marginBottom: 12,
}}
>
{t("admin.aiSettings.providers")}
</h3>
{loading && <LoadingState />}
{error && <ErrorState message={error.message} />}
{!loading && !error && providers && (
<>
{providers.length === 0 ? (
<EmptyState message={t("admin.common.empty")} />
) : (
<Table
headers={[
t("admin.common.name"),
"类型",
t("admin.aiSettings.model"),
t("admin.aiSettings.isPublic"),
t("admin.aiSettings.isActive"),
"API Base",
t("admin.common.createdAt"),
]}
>
{providers.map((p) => (
<TableRow key={p.id}>
<TableCell>{p.name}</TableCell>
<TableCell>
<span
style={{
fontFamily: "var(--font-mono)",
fontSize: 11,
}}
>
{p.type}
</span>
</TableCell>
<TableCell>
<span
style={{
fontFamily: "var(--font-mono)",
fontSize: 11,
}}
>
{p.model}
</span>
</TableCell>
<TableCell>
<Badge status={p.isPublic ? "active" : "disabled"} />
</TableCell>
<TableCell>
<Button
variant={p.isActive ? "primary" : "secondary"}
onClick={() => handleToggle(p.id, p.isActive)}
disabled={mutationState.loading}
>
{p.isActive ? "启用" : "禁用"}
</Button>
</TableCell>
<TableCell>
<span
style={{
fontFamily: "var(--font-mono)",
fontSize: 11,
color: "var(--color-ink-muted)",
}}
>
{p.apiBase}
</span>
</TableCell>
<TableCell style={{ whiteSpace: "nowrap" }}>
{new Date(p.createdAt).toLocaleDateString("zh-CN")}
</TableCell>
</TableRow>
))}
</Table>
)}
</>
)}
</PaperCard>
{/* AI 用量统计 */}
{usage && (
<>
<div className="grid grid-cols-3 gap-4 mb-6">
<StatCard
label={t("admin.aiSettings.totalRequests")}
value={usage.totalRequests}
/>
<StatCard
label={t("admin.aiSettings.totalTokens")}
value={usage.totalTokens}
/>
<StatCard
label={t("admin.aiSettings.totalCost")}
value={`$${usage.totalCost.toFixed(2)}`}
/>
</div>
{/* Provider 用量明细 */}
<PaperCard className="p-4 mb-6">
<h3
style={{
fontFamily: "var(--font-serif)",
fontSize: 16,
color: "var(--color-ink)",
marginBottom: 12,
}}
>
{t("admin.aiSettings.usage")} - {t("admin.aiSettings.provider")}
</h3>
<Table
headers={[
t("admin.aiSettings.provider"),
t("admin.aiSettings.totalRequests"),
t("admin.aiSettings.totalTokens"),
t("admin.aiSettings.totalCost"),
]}
>
{usage.byProvider.map((row) => (
<TableRow key={row.providerName}>
<TableCell>{row.providerName}</TableCell>
<TableCell style={{ fontFamily: "var(--font-mono)" }}>
{row.requests}
</TableCell>
<TableCell style={{ fontFamily: "var(--font-mono)" }}>
{row.tokens}
</TableCell>
<TableCell style={{ fontFamily: "var(--font-mono)" }}>
${row.cost.toFixed(2)}
</TableCell>
</TableRow>
))}
</Table>
</PaperCard>
{/* 7 天趋势 */}
<PaperCard className="p-4">
<h3
style={{
fontFamily: "var(--font-serif)",
fontSize: 16,
color: "var(--color-ink)",
marginBottom: 12,
}}
>
7 {t("admin.auditLogs.trend")}
</h3>
<Table
headers={[
"日期",
t("admin.aiSettings.totalRequests"),
t("admin.aiSettings.totalTokens"),
]}
>
{usage.byDay.map((row) => (
<TableRow key={row.date}>
<TableCell style={{ whiteSpace: "nowrap" }}>
{row.date}
</TableCell>
<TableCell style={{ fontFamily: "var(--font-mono)" }}>
{row.requests}
</TableCell>
<TableCell style={{ fontFamily: "var(--font-mono)" }}>
{row.tokens}
</TableCell>
</TableRow>
))}
</Table>
</PaperCard>
</>
)}
</div>
);
}

View File

@@ -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<string, string> = {
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<string[]>(["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 (
<div style={{ padding: 32, maxWidth: 1200, margin: "0 auto" }}>
<PageHeader
title={t("admin.announcements.title")}
description="管理全校公告、发布与归档"
actions={
<Button variant="primary" onClick={openCreateModal}>
{t("admin.announcements.new")}
</Button>
}
/>
<PaperCard className="p-4 mb-4">
<div className="flex gap-3 items-center">
<Input
type="search"
placeholder={t("admin.common.search")}
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
style={{ width: 240 }}
/>
<Select
value={filter.status ?? "all"}
onChange={(e) => setStatus(e.target.value)}
>
<option value="all">{t("admin.common.all")}</option>
<option value="draft">{t("admin.announcements.draft")}</option>
<option value="published">
{t("admin.announcements.published")}
</option>
<option value="archived">
{t("admin.announcements.archived")}
</option>
</Select>
</div>
</PaperCard>
<PaperCard className="p-4">
{loading && <LoadingState />}
{error && <ErrorState message={error.message} />}
{!loading && !error && data && (
<>
{data.items.length === 0 ? (
<EmptyState message={t("admin.common.empty")} />
) : (
<>
<Table
headers={[
"标题",
t("admin.common.status"),
t("admin.announcements.audience"),
t("admin.announcements.author"),
t("admin.announcements.pin"),
t("admin.announcements.readCount"),
t("admin.announcements.publishedAt"),
t("admin.common.createdAt"),
t("admin.common.actions"),
]}
>
{data.items.map((ann) => (
<TableRow key={ann.id}>
<TableCell style={{ fontWeight: 500 }}>
{ann.title}
</TableCell>
<TableCell>
<Badge status={ann.status} />
</TableCell>
<TableCell>
{ann.audience
.map((a) => audienceLabel[a] ?? a)
.join(", ")}
</TableCell>
<TableCell>{ann.authorName}</TableCell>
<TableCell>
{ann.isPinned ? <Badge status="pinned" /> : "—"}
</TableCell>
<TableCell>
{ann.readCount}/{ann.totalCount}
</TableCell>
<TableCell style={{ whiteSpace: "nowrap" }}>
{ann.publishedAt
? new Date(ann.publishedAt).toLocaleDateString(
"zh-CN",
)
: "—"}
</TableCell>
<TableCell style={{ whiteSpace: "nowrap" }}>
{new Date(ann.createdAt).toLocaleDateString("zh-CN")}
</TableCell>
<TableCell>
<div className="flex gap-1">
{ann.status === "draft" && (
<Button
variant="primary"
onClick={() => handlePublish(ann)}
>
{t("admin.announcements.publish")}
</Button>
)}
{ann.status === "published" && (
<Button
variant="secondary"
onClick={() => handleArchive(ann)}
>
{t("admin.announcements.archive")}
</Button>
)}
<Button
variant="ghost"
onClick={() => handleTogglePin(ann)}
>
{ann.isPinned
? t("admin.announcements.unpin")
: t("admin.announcements.pin")}
</Button>
</div>
</TableCell>
</TableRow>
))}
</Table>
<Pagination
page={data.page}
pageSize={data.pageSize}
total={data.total}
hasNext={data.hasNext}
onPageChange={setPage}
/>
</>
)}
</>
)}
</PaperCard>
{modalOpen && (
<div
role="dialog"
aria-modal="true"
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.4)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 1000,
}}
>
<div
style={{
width: 480,
padding: 24,
background: "var(--bg-paper)",
borderRadius: 8,
border: "1px solid var(--color-rule)",
}}
>
<h2
style={{
fontFamily: "var(--font-serif)",
fontSize: 18,
color: "var(--color-ink)",
marginBottom: 16,
}}
>
{t("admin.announcements.new")}
</h2>
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
</span>
<Input
type="text"
value={formTitle}
onChange={(e) => setFormTitle(e.target.value)}
placeholder="请输入公告标题"
/>
</div>
<label
style={{ display: "flex", flexDirection: "column", gap: 4 }}
>
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
</span>
<textarea
value={formContent}
onChange={(e) => setFormContent(e.target.value)}
rows={5}
style={{
padding: "6px 10px",
border: "1px solid var(--color-rule)",
borderRadius: 4,
background: "var(--bg-paper)",
color: "var(--color-ink)",
fontSize: 13,
resize: "vertical",
}}
placeholder="请输入公告内容"
/>
</label>
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
{t("admin.announcements.audience")}
</span>
<div className="flex gap-3">
{AUDIENCE_OPTIONS.map((opt) => (
<label
key={opt.value}
className="flex items-center gap-1"
style={{
fontSize: 13,
color: "var(--color-ink)",
cursor: "pointer",
}}
>
<input
type="checkbox"
checked={formAudience.includes(opt.value)}
onChange={() => toggleAudience(opt.value)}
/>
{opt.label}
</label>
))}
</div>
</div>
<div
style={{
display: "flex",
justifyContent: "flex-end",
gap: 8,
marginTop: 8,
}}
>
<Button variant="secondary" onClick={() => setModalOpen(false)}>
{t("admin.common.cancel")}
</Button>
<Button
variant="primary"
onClick={handleCreate}
disabled={submitting}
>
{submitting
? t("admin.common.loading")
: t("admin.common.save")}
</Button>
</div>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,186 @@
"use client";
import { type ReactNode, useState, useEffect } from "react";
import {
useDataChangeLogs,
useDataChangeLogFilter,
exportDataChangeLogsCsv,
} from "@/hooks/use-data-change-logs";
import { useToast } from "@/providers/toast-provider";
import { AuditLogsTabs } from "@/components/audit-logs-tabs";
import {
PageHeader,
PaperCard,
Input,
Select,
Button,
LoadingState,
ErrorState,
EmptyState,
Table,
TableRow,
TableCell,
Pagination,
} from "@/components/ui";
import { t } from "@/lib/i18n";
export default function DataChangesPage(): ReactNode {
const { filter, setPage, setSearch, setTableName, setAction } =
useDataChangeLogFilter();
const { data, loading, error } = useDataChangeLogs(filter);
const { show } = useToast();
const [searchInput, setSearchInput] = useState("");
useEffect(() => {
const timer = setTimeout(() => setSearch(searchInput), 300);
return () => clearTimeout(timer);
}, [searchInput, setSearch]);
const handleExport = () => {
if (!data || data.items.length === 0) {
show("warning", "暂无数据可导出");
return;
}
exportDataChangeLogsCsv(data.items);
show("success", "已导出 CSV");
};
return (
<div style={{ padding: 32, maxWidth: 1200, margin: "0 auto" }}>
<PageHeader
title={t("admin.auditLogs.dataChanges")}
description="数据表变更记录"
actions={
<Button variant="secondary" onClick={handleExport}>
{t("admin.auditLogs.exportCsv")}
</Button>
}
/>
<AuditLogsTabs />
<PaperCard className="p-4 mb-4">
<div className="flex gap-3 items-center">
<Input
type="search"
placeholder={t("admin.common.search")}
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
style={{ width: 240 }}
/>
<Select
value={filter.tableName ?? ""}
onChange={(e) => setTableName(e.target.value)}
>
<option value="">{t("admin.common.all")}</option>
<option value="users"></option>
<option value="roles"></option>
<option value="viewports"></option>
<option value="system_settings"></option>
</Select>
<Select
value={filter.action ?? ""}
onChange={(e) => setAction(e.target.value)}
>
<option value="">{t("admin.common.all")}</option>
<option value="create"></option>
<option value="update"></option>
<option value="delete"></option>
</Select>
</div>
</PaperCard>
<PaperCard className="p-4">
{loading && <LoadingState />}
{error && <ErrorState message={error.message} />}
{!loading && !error && data && (
<>
{data.items.length === 0 ? (
<EmptyState message={t("admin.common.empty")} />
) : (
<>
<Table
headers={[
t("admin.auditLogs.time"),
t("admin.auditLogs.actor"),
t("admin.auditLogs.tableName"),
t("admin.auditLogs.action"),
t("admin.auditLogs.recordId"),
t("admin.auditLogs.beforeState"),
t("admin.auditLogs.afterState"),
t("admin.auditLogs.ip"),
]}
>
{data.items.map((log) => (
<TableRow key={log.id}>
<TableCell style={{ whiteSpace: "nowrap" }}>
{new Date(log.occurredAt).toLocaleString("zh-CN")}
</TableCell>
<TableCell>{log.actorName}</TableCell>
<TableCell
style={{ fontFamily: "var(--font-mono)", fontSize: 11 }}
>
{log.tableName}
</TableCell>
<TableCell>
<span
style={{
fontFamily: "var(--font-mono)",
fontSize: 11,
}}
>
{log.action}
</span>
</TableCell>
<TableCell
style={{ fontFamily: "var(--font-mono)", fontSize: 11 }}
>
{log.recordId}
</TableCell>
<TableCell
style={{
fontFamily: "var(--font-mono)",
fontSize: 11,
maxWidth: 200,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{log.beforeState ?? "—"}
</TableCell>
<TableCell
style={{
fontFamily: "var(--font-mono)",
fontSize: 11,
maxWidth: 200,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{log.afterState ?? "—"}
</TableCell>
<TableCell
style={{ fontFamily: "var(--font-mono)", fontSize: 11 }}
>
{log.ip}
</TableCell>
</TableRow>
))}
</Table>
<Pagination
page={data.page}
pageSize={data.pageSize}
total={data.total}
hasNext={data.hasNext}
onPageChange={setPage}
/>
</>
)}
</>
)}
</PaperCard>
</div>
);
}

View File

@@ -0,0 +1,171 @@
"use client";
import { type ReactNode, useState, useEffect } from "react";
import { useLoginLogs, useLoginLogFilter } from "@/hooks/use-login-logs";
import { AuditLogsTabs } from "@/components/audit-logs-tabs";
import {
PageHeader,
PaperCard,
Input,
Select,
LoadingState,
ErrorState,
EmptyState,
Table,
TableRow,
TableCell,
Pagination,
} from "@/components/ui";
import { t } from "@/lib/i18n";
export default function LoginLogsPage(): ReactNode {
const { filter, setPage, setSearch, setAction, setStatus } =
useLoginLogFilter();
const { data, loading, error } = useLoginLogs(filter);
const [searchInput, setSearchInput] = useState("");
useEffect(() => {
const timer = setTimeout(() => setSearch(searchInput), 300);
return () => clearTimeout(timer);
}, [searchInput, setSearch]);
return (
<div style={{ padding: 32, maxWidth: 1200, margin: "0 auto" }}>
<PageHeader
title={t("admin.auditLogs.loginLogs")}
description="用户登录与登出记录"
/>
<AuditLogsTabs />
<PaperCard className="p-4 mb-4">
<div className="flex gap-3 items-center">
<Input
type="search"
placeholder={t("admin.common.search")}
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
style={{ width: 240 }}
/>
<Select
value={filter.action ?? ""}
onChange={(e) => setAction(e.target.value)}
>
<option value="">{t("admin.common.all")}</option>
<option value="signin"></option>
<option value="signout"></option>
<option value="signup"></option>
</Select>
<Select
value={filter.status ?? ""}
onChange={(e) => setStatus(e.target.value)}
>
<option value="">{t("admin.common.all")}</option>
<option value="success"></option>
<option value="failure"></option>
</Select>
</div>
</PaperCard>
<PaperCard className="p-4">
{loading && <LoadingState />}
{error && <ErrorState message={error.message} />}
{!loading && !error && data && (
<>
{data.items.length === 0 ? (
<EmptyState message={t("admin.common.empty")} />
) : (
<>
<Table
headers={[
t("admin.auditLogs.time"),
t("admin.common.name"),
t("admin.auditLogs.loginAction"),
t("admin.auditLogs.loginStatus"),
t("admin.auditLogs.ip"),
t("admin.auditLogs.userAgent"),
t("admin.auditLogs.failureReason"),
]}
>
{data.items.map((log) => (
<TableRow key={log.id}>
<TableCell style={{ whiteSpace: "nowrap" }}>
{new Date(log.occurredAt).toLocaleString("zh-CN")}
</TableCell>
<TableCell>{log.userName}</TableCell>
<TableCell
style={{ fontFamily: "var(--font-mono)", fontSize: 11 }}
>
{log.action}
</TableCell>
<TableCell>
<span
style={{
display: "inline-block",
padding: "2px 8px",
fontSize: 11,
borderRadius: 10,
background:
log.status === "success"
? "var(--color-success)20"
: "var(--color-danger)20",
color:
log.status === "success"
? "var(--color-success)"
: "var(--color-danger)",
border: `1px solid ${
log.status === "success"
? "var(--color-success)40"
: "var(--color-danger)40"
}`,
}}
>
{log.status === "success" ? "成功" : "失败"}
</span>
</TableCell>
<TableCell
style={{ fontFamily: "var(--font-mono)", fontSize: 11 }}
>
{log.ip}
</TableCell>
<TableCell
style={{
fontFamily: "var(--font-mono)",
fontSize: 11,
maxWidth: 200,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{log.userAgent}
</TableCell>
<TableCell
style={{
fontFamily: "var(--font-mono)",
fontSize: 11,
color: log.failureReason
? "var(--color-danger)"
: "var(--color-ink-muted)",
}}
>
{log.failureReason ?? "—"}
</TableCell>
</TableRow>
))}
</Table>
<Pagination
page={data.page}
pageSize={data.pageSize}
total={data.total}
hasNext={data.hasNext}
onPageChange={setPage}
/>
</>
)}
</>
)}
</PaperCard>
</div>
);
}

View File

@@ -0,0 +1,199 @@
"use client";
import { type ReactNode } from "react";
import {
useAuditOverviewStats,
useAuditTrend,
} from "@/hooks/use-audit-overview";
import { AuditLogsTabs } from "@/components/audit-logs-tabs";
import {
PageHeader,
PaperCard,
LoadingState,
ErrorState,
EmptyState,
Table,
TableRow,
TableCell,
} from "@/components/ui";
import { t } from "@/lib/i18n";
interface StatCardProps {
label: string;
value: number | string;
}
function StatCard({ label, value }: StatCardProps): ReactNode {
return (
<PaperCard className="p-4">
<p
style={{
fontSize: 11,
color: "var(--color-ink-muted)",
textTransform: "uppercase",
letterSpacing: "0.05em",
}}
>
{label}
</p>
<p
style={{
fontFamily: "var(--font-serif)",
fontSize: 28,
color: "var(--color-ink)",
margin: "8px 0 0 0",
}}
>
{value}
</p>
</PaperCard>
);
}
export default function AuditOverviewPage(): ReactNode {
const { data: stats, loading, error } = useAuditOverviewStats();
const { data: trend } = useAuditTrend(7);
return (
<div style={{ padding: 32, maxWidth: 1200, margin: "0 auto" }}>
<PageHeader
title={t("admin.auditLogs.overview")}
description="审计事件统计与趋势"
/>
<AuditLogsTabs />
{loading && <LoadingState />}
{error && <ErrorState message={error.message} />}
{stats && (
<div className="flex flex-col gap-6">
{/* 统计卡片 */}
<div className="grid grid-cols-4 gap-4">
<StatCard
label={t("admin.auditLogs.totalEvents")}
value={stats.totalEvents}
/>
<StatCard
label={t("admin.auditLogs.todayEvents")}
value={stats.todayEvents}
/>
<StatCard
label={t("admin.auditLogs.uniqueActors")}
value={stats.uniqueActors}
/>
<StatCard
label={t("admin.auditLogs.failedActions")}
value={stats.failedActions}
/>
</div>
{/* 热门操作 & 热门资源 */}
<div className="grid grid-cols-2 gap-4">
<PaperCard className="p-4">
<h3
style={{
fontFamily: "var(--font-serif)",
fontSize: 16,
color: "var(--color-ink)",
marginBottom: 12,
}}
>
{t("admin.auditLogs.topActions")}
</h3>
{stats.topActions.length === 0 ? (
<EmptyState message={t("admin.common.empty")} />
) : (
<Table headers={["操作", "次数"]}>
{stats.topActions.map((item) => (
<TableRow key={item.action}>
<TableCell
style={{ fontFamily: "var(--font-mono)", fontSize: 11 }}
>
{item.action}
</TableCell>
<TableCell style={{ textAlign: "right" }}>
{item.count}
</TableCell>
</TableRow>
))}
</Table>
)}
</PaperCard>
<PaperCard className="p-4">
<h3
style={{
fontFamily: "var(--font-serif)",
fontSize: 16,
color: "var(--color-ink)",
marginBottom: 12,
}}
>
{t("admin.auditLogs.topResources")}
</h3>
{stats.topResources.length === 0 ? (
<EmptyState message={t("admin.common.empty")} />
) : (
<Table headers={["资源", "次数"]}>
{stats.topResources.map((item) => (
<TableRow key={item.resource}>
<TableCell
style={{ fontFamily: "var(--font-mono)", fontSize: 11 }}
>
{item.resource}
</TableCell>
<TableCell style={{ textAlign: "right" }}>
{item.count}
</TableCell>
</TableRow>
))}
</Table>
)}
</PaperCard>
</div>
{/* 7天趋势 */}
<PaperCard className="p-4">
<h3
style={{
fontFamily: "var(--font-serif)",
fontSize: 16,
color: "var(--color-ink)",
marginBottom: 12,
}}
>
{t("admin.auditLogs.trend")}
</h3>
{!trend || trend.length === 0 ? (
<EmptyState message={t("admin.common.empty")} />
) : (
<Table
headers={[
t("admin.auditLogs.time"),
t("admin.auditLogs.action"),
t("admin.common.total"),
]}
>
{trend.map((point) => (
<TableRow key={`${point.date}-${point.action}`}>
<TableCell style={{ whiteSpace: "nowrap" }}>
{point.date}
</TableCell>
<TableCell
style={{ fontFamily: "var(--font-mono)", fontSize: 11 }}
>
{point.action}
</TableCell>
<TableCell style={{ textAlign: "right" }}>
{point.count}
</TableCell>
</TableRow>
))}
</Table>
)}
</PaperCard>
</div>
)}
</div>
);
}

View File

@@ -7,6 +7,7 @@ import {
exportAuditLogsCsv,
} from "@/hooks/use-audit-logs";
import { useToast } from "@/providers/toast-provider";
import { AuditLogsTabs } from "@/components/audit-logs-tabs";
import {
PageHeader,
PaperCard,
@@ -55,6 +56,8 @@ export default function AuditLogsPage(): ReactNode {
}
/>
<AuditLogsTabs />
<PaperCard className="p-4 mb-4">
<div className="flex gap-3 items-center">
<Input

View File

@@ -0,0 +1,334 @@
"use client";
import { type ReactNode, useState, useEffect } from "react";
import {
useFiles,
useFileStats,
useDeleteFiles,
useFileFilter,
} from "@/hooks/use-files";
import { useToast } from "@/providers/toast-provider";
import {
PageHeader,
PaperCard,
Input,
Select,
Button,
LoadingState,
ErrorState,
EmptyState,
Table,
TableRow,
TableCell,
Pagination,
} from "@/components/ui";
import { t } from "@/lib/i18n";
/** 格式化文件大小 */
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024)
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
export default function FilesPage(): ReactNode {
const { filter, setPage, setSearch, setMimeType, setEntityType } =
useFileFilter();
const { data, loading, error, refetch } = useFiles(filter);
const { data: stats } = useFileStats();
const [deleteFiles] = useDeleteFiles();
const { show } = useToast();
const [searchInput, setSearchInput] = useState("");
const [selected, setSelected] = useState<Set<string>>(new Set());
useEffect(() => {
const timer = setTimeout(() => setSearch(searchInput), 300);
return () => clearTimeout(timer);
}, [searchInput, setSearch]);
const toggleSelect = (id: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const toggleSelectAll = () => {
if (!data || data.items.length === 0) return;
if (selected.size === data.items.length) {
setSelected(new Set());
} else {
setSelected(new Set(data.items.map((f) => f.id)));
}
};
const handleBatchDelete = async () => {
if (selected.size === 0) {
show("warning", "请先选择文件");
return;
}
if (!window.confirm(`确认删除选中的 ${selected.size} 个文件?`)) return;
const ok = await deleteFiles(Array.from(selected));
if (ok) {
show("success", `已删除 ${selected.size} 个文件`);
setSelected(new Set());
refetch();
} else {
show("error", "删除失败");
}
};
return (
<div style={{ padding: 32, maxWidth: 1200, margin: "0 auto" }}>
<PageHeader
title={t("admin.files.title")}
description="全校文件资源管理"
actions={
<Button
variant="danger"
onClick={handleBatchDelete}
disabled={selected.size === 0}
>
{t("admin.files.batchDelete")} ({selected.size})
</Button>
}
/>
{/* 统计摘要 */}
{stats && (
<div className="grid grid-cols-2 gap-4 mb-4">
<PaperCard className="p-4">
<p
style={{
fontSize: 11,
color: "var(--color-ink-muted)",
textTransform: "uppercase",
letterSpacing: "0.05em",
}}
>
{t("admin.files.totalFiles")}
</p>
<p
style={{
fontFamily: "var(--font-serif)",
fontSize: 28,
color: "var(--color-ink)",
margin: "8px 0 0 0",
}}
>
{stats.totalFiles}
</p>
</PaperCard>
<PaperCard className="p-4">
<p
style={{
fontSize: 11,
color: "var(--color-ink-muted)",
textTransform: "uppercase",
letterSpacing: "0.05em",
}}
>
{t("admin.files.totalSize")}
</p>
<p
style={{
fontFamily: "var(--font-serif)",
fontSize: 28,
color: "var(--color-ink)",
margin: "8px 0 0 0",
}}
>
{formatFileSize(stats.totalSize)}
</p>
</PaperCard>
</div>
)}
{/* 筛选栏 */}
<PaperCard className="p-4 mb-4">
<div className="flex gap-3 items-center">
<Input
type="search"
placeholder={t("admin.common.search")}
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
style={{ width: 240 }}
/>
<Select
value={filter.mimeType ?? ""}
onChange={(e) => setMimeType(e.target.value)}
>
<option value="">
{t("admin.common.all")}
{t("admin.files.mimeType")}
</option>
<option value="application/pdf">PDF</option>
<option value="image/jpeg">JPEG</option>
<option value="image/png">PNG</option>
<option value="application/vnd.openxmlformats-officedocument.wordprocessingml.document">
Word
</option>
<option value="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet">
Excel
</option>
<option value="application/vnd.openxmlformats-officedocument.presentationml.presentation">
PPT
</option>
<option value="video/mp4">MP4</option>
</Select>
<Select
value={filter.entityType ?? ""}
onChange={(e) => setEntityType(e.target.value)}
>
<option value="">
{t("admin.common.all")}
{t("admin.files.entityType")}
</option>
<option value="exam"></option>
<option value="lesson_plan"></option>
<option value="user_avatar"></option>
<option value="announcement"></option>
<option value="homework"></option>
</Select>
</div>
</PaperCard>
{/* 文件列表 */}
<PaperCard className="p-4">
{loading && <LoadingState />}
{error && <ErrorState message={error.message} />}
{!loading && !error && data && (
<>
{data.items.length === 0 ? (
<EmptyState message={t("admin.common.empty")} />
) : (
<>
<Table
headers={[
"",
t("admin.files.filename"),
t("admin.files.mimeType"),
t("admin.files.size"),
t("admin.files.entityType"),
t("admin.files.uploadedBy"),
t("admin.common.createdAt"),
]}
>
{data.items.map((file) => {
const isImage = file.mimeType.startsWith("image/");
const isPdf = file.mimeType === "application/pdf";
return (
<TableRow key={file.id}>
<TableCell style={{ width: 32 }}>
<input
type="checkbox"
checked={selected.has(file.id)}
onChange={() => toggleSelect(file.id)}
/>
</TableCell>
<TableCell>
{isImage ? (
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
}}
>
<img
src={file.url}
alt={file.filename}
style={{
width: 32,
height: 32,
objectFit: "cover",
borderRadius: 4,
}}
/>
<span>{file.filename}</span>
</div>
) : isPdf ? (
<a
href={file.url}
target="_blank"
rel="noopener noreferrer"
style={{ color: "var(--color-accent)" }}
>
{file.filename}
</a>
) : (
<span>{file.filename}</span>
)}
</TableCell>
<TableCell>
<span
style={{
fontFamily: "var(--font-mono)",
fontSize: 11,
}}
>
{file.mimeType}
</span>
</TableCell>
<TableCell
style={{
fontFamily: "var(--font-mono)",
fontSize: 11,
}}
>
{formatFileSize(file.size)}
</TableCell>
<TableCell>{file.entityType}</TableCell>
<TableCell>{file.uploadedByName}</TableCell>
<TableCell style={{ whiteSpace: "nowrap" }}>
{new Date(file.createdAt).toLocaleDateString("zh-CN")}
</TableCell>
</TableRow>
);
})}
</Table>
<div
style={{
marginTop: 12,
marginBottom: 8,
fontSize: 12,
color: "var(--color-ink-muted)",
}}
>
<label
style={{
display: "flex",
alignItems: "center",
gap: 6,
}}
>
<input
type="checkbox"
checked={
data.items.length > 0 &&
selected.size === data.items.length
}
onChange={toggleSelectAll}
/>
( {selected.size}/{data.items.length})
</label>
</div>
<Pagination
page={data.page}
pageSize={data.pageSize}
total={data.total}
hasNext={data.hasNext}
onPageChange={setPage}
/>
</>
)}
</>
)}
</PaperCard>
</div>
);
}

View File

@@ -0,0 +1,538 @@
"use client";
import { type ReactNode, useState, useEffect, useMemo } from "react";
import {
useInvitationCodes,
useInvitationCodeFilter,
useGenerateInvitationCodes,
useDeleteInvitationCodes,
} from "@/hooks/use-invitation-codes";
import { useRoles } from "@/hooks/use-roles";
import { useClasses } from "@/hooks/use-classes";
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";
export default function InvitationCodesPage(): ReactNode {
const { filter, setPage, setSearch, setStatus } = useInvitationCodeFilter();
const { data, loading, error } = useInvitationCodes(filter);
const { data: roles } = useRoles();
const { data: classesData } = useClasses({ page: 1, pageSize: 100 });
const { show } = useToast();
const [generateCodes] = useGenerateInvitationCodes();
const [deleteCodes] = useDeleteInvitationCodes();
const [searchInput, setSearchInput] = useState("");
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
// 生成对话框
const [generateOpen, setGenerateOpen] = useState(false);
const [genCount, setGenCount] = useState(10);
const [genRoleId, setGenRoleId] = useState("");
const [genClassId, setGenClassId] = useState("");
const [genEmail, setGenEmail] = useState("");
const [generating, setGenerating] = useState(false);
// 生成结果对话框
const [resultOpen, setResultOpen] = useState(false);
const [generatedCodes, setGeneratedCodes] = useState<string[]>([]);
const [generatedBatchId, setGeneratedBatchId] = useState("");
useEffect(() => {
const timer = setTimeout(() => setSearch(searchInput), 300);
return () => clearTimeout(timer);
}, [searchInput, setSearch]);
const classes = useMemo(() => classesData?.items ?? [], [classesData]);
const allChecked = useMemo(
() =>
data !== null &&
data.items.length > 0 &&
data.items.every((ic) => selectedIds.has(ic.id)),
[data, selectedIds],
);
const toggleSelect = (id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
};
const toggleSelectAll = () => {
if (!data) return;
if (allChecked) {
setSelectedIds(new Set());
} else {
setSelectedIds(new Set(data.items.map((ic) => ic.id)));
}
};
const openGenerateDialog = () => {
setGenCount(10);
setGenRoleId("");
setGenClassId("");
setGenEmail("");
setGenerateOpen(true);
};
const handleGenerate = async () => {
if (genCount < 1 || genCount > 1000) {
show("warning", "数量范围 1-1000");
return;
}
setGenerating(true);
try {
const result = await generateCodes({
count: genCount,
roleId: genRoleId || undefined,
classId: genClassId || undefined,
email: genEmail || undefined,
});
if (result) {
show(
"success",
t("admin.invitationCodes.generateSuccess", {
count: result.codes.length,
}),
);
setGeneratedCodes(result.codes);
setGeneratedBatchId(result.batchId);
setGenerateOpen(false);
setResultOpen(true);
}
} catch (err) {
show(
"error",
"生成失败",
err instanceof Error ? err.message : String(err),
);
} finally {
setGenerating(false);
}
};
const handleBatchDelete = async () => {
if (selectedIds.size === 0) {
show("warning", "请先选择要删除的邀请码");
return;
}
if (!window.confirm(`确认删除选中的 ${selectedIds.size} 个邀请码?`)) {
return;
}
try {
const ok = await deleteCodes(Array.from(selectedIds));
if (ok) {
show("success", "删除成功");
setSelectedIds(new Set());
window.location.reload();
}
} catch (err) {
show(
"error",
"删除失败",
err instanceof Error ? err.message : String(err),
);
}
};
const handleCopyCodes = async () => {
try {
await navigator.clipboard.writeText(generatedCodes.join("\n"));
show("success", "已复制到剪贴板");
} catch {
show("error", "复制失败");
}
};
return (
<div style={{ padding: 32, maxWidth: 1200, margin: "0 auto" }}>
<PageHeader
title={t("admin.invitationCodes.title")}
description="批量生成、管理与清理邀请码"
actions={
<Button variant="primary" onClick={openGenerateDialog}>
{t("admin.invitationCodes.generate")}
</Button>
}
/>
<PaperCard className="p-4 mb-4">
<div className="flex gap-3 items-center flex-wrap">
<Input
type="search"
placeholder={t("admin.common.search")}
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
style={{ width: 240 }}
/>
<Select
value={filter.status ?? "all"}
onChange={(e) => setStatus(e.target.value)}
>
<option value="all">{t("admin.common.all")}</option>
<option value="unused">{t("admin.invitationCodes.unused")}</option>
<option value="used">{t("admin.invitationCodes.used")}</option>
<option value="expired">
{t("admin.invitationCodes.expired")}
</option>
</Select>
{selectedIds.size > 0 && (
<Button variant="danger" onClick={handleBatchDelete}>
{t("admin.common.delete")}{selectedIds.size}
</Button>
)}
</div>
</PaperCard>
<PaperCard className="p-4">
{loading && <LoadingState />}
{error && <ErrorState message={error.message} />}
{!loading && !error && data && (
<>
{data.items.length === 0 ? (
<EmptyState message={t("admin.common.empty")} />
) : (
<>
<Table
headers={[
"",
t("admin.invitationCodes.code"),
t("admin.common.status"),
t("admin.common.role"),
t("admin.common.class"),
t("admin.common.email"),
t("admin.invitationCodes.batch"),
t("admin.invitationCodes.usedBy"),
t("admin.invitationCodes.usedAt"),
t("admin.common.createdAt"),
t("admin.invitationCodes.expiresAt"),
]}
>
{data.items.map((ic) => (
<TableRow key={ic.id}>
<TableCell style={{ width: 32 }}>
<input
type="checkbox"
checked={selectedIds.has(ic.id)}
onChange={() => toggleSelect(ic.id)}
aria-label={`select ${ic.code}`}
/>
</TableCell>
<TableCell>
<span
style={{
fontFamily: "var(--font-mono)",
fontSize: 12,
letterSpacing: "0.05em",
}}
>
{ic.code}
</span>
</TableCell>
<TableCell>
<Badge status={ic.status} />
</TableCell>
<TableCell>{ic.roleName ?? "—"}</TableCell>
<TableCell>{ic.className ?? "—"}</TableCell>
<TableCell>{ic.email ?? "—"}</TableCell>
<TableCell>
<span
style={{
fontFamily: "var(--font-mono)",
fontSize: 11,
color: "var(--color-ink-muted)",
}}
>
{ic.batchId}
</span>
</TableCell>
<TableCell>{ic.usedByName ?? "—"}</TableCell>
<TableCell style={{ whiteSpace: "nowrap" }}>
{ic.usedAt
? new Date(ic.usedAt).toLocaleDateString("zh-CN")
: "—"}
</TableCell>
<TableCell style={{ whiteSpace: "nowrap" }}>
{new Date(ic.createdAt).toLocaleDateString("zh-CN")}
</TableCell>
<TableCell style={{ whiteSpace: "nowrap" }}>
{ic.expiresAt
? new Date(ic.expiresAt).toLocaleDateString("zh-CN")
: "—"}
</TableCell>
</TableRow>
))}
</Table>
<div className="flex items-center justify-between mt-4">
<label
className="flex items-center gap-2"
style={{
fontSize: 12,
color: "var(--color-ink-muted)",
cursor: "pointer",
}}
>
<input
type="checkbox"
checked={allChecked}
onChange={toggleSelectAll}
/>
</label>
</div>
<Pagination
page={data.page}
pageSize={data.pageSize}
total={data.total}
hasNext={data.hasNext}
onPageChange={(p) => {
setSelectedIds(new Set());
setPage(p);
}}
/>
</>
)}
</>
)}
</PaperCard>
{/* 生成对话框 */}
{generateOpen && (
<div
role="dialog"
aria-modal="true"
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.4)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 1000,
}}
>
<div
style={{
width: 440,
padding: 24,
background: "var(--bg-paper)",
borderRadius: 8,
border: "1px solid var(--color-rule)",
}}
>
<h2
style={{
fontFamily: "var(--font-serif)",
fontSize: 18,
color: "var(--color-ink)",
marginBottom: 16,
}}
>
{t("admin.invitationCodes.generate")}
</h2>
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
{t("admin.invitationCodes.generateCount")}1-1000
</span>
<Input
type="number"
min={1}
max={1000}
value={genCount}
onChange={(e) => setGenCount(Number(e.target.value) || 0)}
style={{ width: 120 }}
/>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
{t("admin.invitationCodes.bindRole")}
</span>
<Select
value={genRoleId}
onChange={(e) => setGenRoleId(e.target.value)}
style={{ width: "100%" }}
>
<option value="">{t("admin.common.all")}</option>
{roles.map((role) => (
<option key={role.id} value={role.id}>
{role.name}
</option>
))}
</Select>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
{t("admin.invitationCodes.bindClass")}
</span>
<Select
value={genClassId}
onChange={(e) => setGenClassId(e.target.value)}
style={{ width: "100%" }}
>
<option value="">{t("admin.common.all")}</option>
{classes.map((cls) => (
<option key={cls.id} value={cls.id}>
{cls.name}
</option>
))}
</Select>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
{t("admin.invitationCodes.bindEmail")}
</span>
<Input
type="email"
value={genEmail}
onChange={(e) => setGenEmail(e.target.value)}
placeholder="user@edu.test"
/>
</div>
<div
style={{
display: "flex",
justifyContent: "flex-end",
gap: 8,
marginTop: 8,
}}
>
<Button
variant="secondary"
onClick={() => setGenerateOpen(false)}
>
{t("admin.common.cancel")}
</Button>
<Button
variant="primary"
onClick={handleGenerate}
disabled={generating}
>
{generating
? t("admin.common.loading")
: t("admin.invitationCodes.generate")}
</Button>
</div>
</div>
</div>
</div>
)}
{/* 生成结果对话框 */}
{resultOpen && (
<div
role="dialog"
aria-modal="true"
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.4)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 1000,
}}
>
<div
style={{
width: 480,
maxHeight: "80vh",
padding: 24,
background: "var(--bg-paper)",
borderRadius: 8,
border: "1px solid var(--color-rule)",
display: "flex",
flexDirection: "column",
}}
>
<h2
style={{
fontFamily: "var(--font-serif)",
fontSize: 18,
color: "var(--color-ink)",
marginBottom: 8,
}}
>
{t("admin.invitationCodes.generateSuccess", {
count: generatedCodes.length,
})}
</h2>
<p
style={{
fontSize: 12,
color: "var(--color-ink-muted)",
marginBottom: 12,
}}
>
{t("admin.invitationCodes.batch")}{generatedBatchId}
</p>
<div
style={{
border: "1px solid var(--color-rule)",
borderRadius: 4,
padding: 12,
background: "var(--bg-canvas, var(--bg-paper))",
overflowY: "auto",
flex: 1,
marginBottom: 12,
}}
>
<pre
style={{
margin: 0,
fontFamily: "var(--font-mono)",
fontSize: 12,
color: "var(--color-ink)",
whiteSpace: "pre-wrap",
wordBreak: "break-all",
}}
>
{generatedCodes.join("\n")}
</pre>
</div>
<div
style={{
display: "flex",
justifyContent: "flex-end",
gap: 8,
}}
>
<Button variant="secondary" onClick={handleCopyCodes}>
</Button>
<Button
variant="primary"
onClick={() => {
setResultOpen(false);
window.location.reload();
}}
>
{t("admin.common.confirm")}
</Button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,106 @@
"use client";
import { type ReactNode } from "react";
import { useAcademicYears } from "@/hooks/use-school";
import {
PaperCard,
LoadingState,
ErrorState,
EmptyState,
Table,
TableRow,
TableCell,
} from "@/components/ui";
import { t } from "@/lib/i18n";
import type { AcademicYearViewModel } from "@/types/view-models";
function CurrentBadge(): ReactNode {
const color = "var(--color-success)";
return (
<span
style={{
display: "inline-block",
padding: "2px 8px",
fontSize: 11,
borderRadius: 10,
background: `${color}20`,
color,
border: `1px solid ${color}40`,
fontWeight: 500,
}}
>
{t("admin.school.currentYear")}
</span>
);
}
export default function AcademicYearPage(): ReactNode {
const { data, loading, error } = useAcademicYears();
return (
<PaperCard className="p-4">
{loading && <LoadingState />}
{error && <ErrorState message={error.message} />}
{!loading && !error && (
<>
{data.length === 0 ? (
<EmptyState message={t("admin.common.empty")} />
) : (
<Table
headers={[
"学年",
t("admin.school.startDate"),
t("admin.school.endDate"),
"第一学期",
"第二学期",
t("admin.common.status"),
]}
>
{data.map((year: AcademicYearViewModel) => (
<TableRow key={year.id}>
<TableCell
style={{
fontWeight: year.isCurrent ? 600 : 500,
color: year.isCurrent
? "var(--color-accent)"
: "var(--color-ink)",
}}
>
{year.name}
</TableCell>
<TableCell style={{ whiteSpace: "nowrap" }}>
{year.startDate}
</TableCell>
<TableCell style={{ whiteSpace: "nowrap" }}>
{year.endDate}
</TableCell>
<TableCell
style={{
whiteSpace: "nowrap",
fontFamily: "var(--font-mono)",
fontSize: 12,
}}
>
{year.firstSemesterStart} ~ {year.firstSemesterEnd}
</TableCell>
<TableCell
style={{
whiteSpace: "nowrap",
fontFamily: "var(--font-mono)",
fontSize: 12,
}}
>
{year.secondSemesterStart} ~ {year.secondSemesterEnd}
</TableCell>
<TableCell>
{year.isCurrent ? <CurrentBadge /> : "—"}
</TableCell>
</TableRow>
))}
</Table>
)}
</>
)}
</PaperCard>
);
}

View File

@@ -0,0 +1,77 @@
"use client";
import { type ReactNode, useState } from "react";
import { useDepartments, useSchools } from "@/hooks/use-school";
import {
PaperCard,
Select,
LoadingState,
ErrorState,
EmptyState,
Table,
TableRow,
TableCell,
} from "@/components/ui";
import { t } from "@/lib/i18n";
export default function DepartmentsPage(): ReactNode {
const [schoolId, setSchoolId] = useState<string>("");
const { data: schools, loading: schoolsLoading } = useSchools();
const { data, loading, error } = useDepartments(schoolId || undefined);
return (
<>
<PaperCard className="p-4 mb-4">
<Select
value={schoolId}
onChange={(e) => setSchoolId(e.target.value)}
disabled={schoolsLoading}
>
<option value="">
{t("admin.common.all")}
{t("admin.common.school")}
</option>
{schools.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</Select>
</PaperCard>
<PaperCard className="p-4">
{loading && <LoadingState />}
{error && <ErrorState message={error.message} />}
{!loading && !error && (
<>
{data.length === 0 ? (
<EmptyState message={t("admin.common.empty")} />
) : (
<Table
headers={[
"部门",
t("admin.common.school"),
t("admin.school.leader"),
t("admin.school.memberCount"),
t("admin.school.subject"),
]}
>
{data.map((dept) => (
<TableRow key={dept.id}>
<TableCell style={{ fontWeight: 500 }}>
{dept.name}
</TableCell>
<TableCell>{dept.schoolName}</TableCell>
<TableCell>{dept.leaderName ?? "—"}</TableCell>
<TableCell>{dept.memberCount}</TableCell>
<TableCell>{dept.subject ?? "—"}</TableCell>
</TableRow>
))}
</Table>
)}
</>
)}
</PaperCard>
</>
);
}

View File

@@ -0,0 +1,250 @@
"use client";
import { type ReactNode, useState, useMemo } from "react";
import { useGrades, useGradeInsights } from "@/hooks/use-school";
import {
PaperCard,
Select,
LoadingState,
ErrorState,
EmptyState,
Table,
TableRow,
TableCell,
} from "@/components/ui";
import { t } from "@/lib/i18n";
interface SummaryCardProps {
label: string;
value: number | string;
suffix?: string;
}
function SummaryCard({ label, value, suffix }: SummaryCardProps): ReactNode {
return (
<PaperCard className="p-4">
<p
style={{
fontSize: 11,
color: "var(--color-ink-muted)",
textTransform: "uppercase",
letterSpacing: "0.05em",
}}
>
{label}
</p>
<p
style={{
fontFamily: "var(--font-serif)",
fontSize: 28,
color: "var(--color-ink)",
margin: "8px 0 0 0",
}}
>
{value}
{suffix && (
<span
style={{
fontSize: 14,
color: "var(--color-ink-muted)",
marginLeft: 4,
}}
>
{suffix}
</span>
)}
</p>
</PaperCard>
);
}
function formatPercent(rate: number): string {
return `${(rate * 100).toFixed(1)}%`;
}
export default function GradeInsightsPage(): ReactNode {
const { data: grades, loading: gradesLoading } = useGrades();
const [gradeId, setGradeId] = useState<string>("");
const selectedGradeId = gradeId || (grades[0]?.id ?? null);
const { data, loading, error } = useGradeInsights(selectedGradeId);
const ranked = useMemo(() => {
if (!data) return [];
return [...data.classRankings].sort((a, b) => a.rank - b.rank);
}, [data]);
return (
<>
<PaperCard className="p-4 mb-4">
<Select
value={selectedGradeId ?? ""}
onChange={(e) => setGradeId(e.target.value)}
disabled={gradesLoading || grades.length === 0}
>
{grades.length === 0 && <option value=""></option>}
{grades.map((g) => (
<option key={g.id} value={g.id}>
{g.name}{g.schoolName}
</option>
))}
</Select>
</PaperCard>
{loading && <LoadingState />}
{error && <ErrorState message={error.message} />}
{!loading && !error && !data && (
<PaperCard className="p-4">
<EmptyState message={t("admin.common.empty")} />
</PaperCard>
)}
{data && (
<div className="flex flex-col gap-6">
{/* 全校概览 */}
<div>
<h3
style={{
fontFamily: "var(--font-serif)",
fontSize: 16,
color: "var(--color-ink)",
marginBottom: 12,
}}
>
{t("admin.school.insights.summary")} {data.gradeName}
</h3>
<div className="grid grid-cols-4 gap-4">
<SummaryCard
label={t("admin.dashboard.totalStudents")}
value={data.schoolWideSummary.totalStudents}
/>
<SummaryCard
label={t("admin.dashboard.totalClasses")}
value={data.schoolWideSummary.totalClasses}
/>
<SummaryCard
label={t("admin.school.insights.avgScore")}
value={data.schoolWideSummary.avgScore.toFixed(1)}
/>
<SummaryCard
label={t("admin.school.insights.passingRate")}
value={formatPercent(data.schoolWideSummary.passingRate)}
/>
</div>
</div>
{/* 作业时间线 */}
<PaperCard className="p-4">
<h3
style={{
fontFamily: "var(--font-serif)",
fontSize: 16,
color: "var(--color-ink)",
marginBottom: 12,
}}
>
{t("admin.school.insights.homework")}
</h3>
{data.homeworkTimeline.length === 0 ? (
<EmptyState message={t("admin.common.empty")} />
) : (
<Table
headers={[
"作业",
t("admin.common.class"),
t("admin.school.insights.avgScore"),
"提交情况",
t("admin.school.endDate"),
]}
>
{data.homeworkTimeline.map((hw) => (
<TableRow key={hw.homeworkId}>
<TableCell style={{ fontWeight: 500 }}>
{hw.title}
</TableCell>
<TableCell>{hw.className}</TableCell>
<TableCell style={{ fontFamily: "var(--font-mono)" }}>
{hw.avgScore.toFixed(1)}
</TableCell>
<TableCell
style={{ fontFamily: "var(--font-mono)", fontSize: 12 }}
>
{hw.submittedCount}/{hw.totalCount}
</TableCell>
<TableCell style={{ whiteSpace: "nowrap" }}>
{hw.dueDate}
</TableCell>
</TableRow>
))}
</Table>
)}
</PaperCard>
{/* 班级排名 */}
<PaperCard className="p-4">
<h3
style={{
fontFamily: "var(--font-serif)",
fontSize: 16,
color: "var(--color-ink)",
marginBottom: 12,
}}
>
{t("admin.school.insights.rankings")}
</h3>
{ranked.length === 0 ? (
<EmptyState message={t("admin.common.empty")} />
) : (
<Table
headers={[
t("admin.school.insights.rank"),
t("admin.common.class"),
t("admin.school.insights.avgScore"),
t("admin.school.insights.delta"),
]}
>
{ranked.map((cls) => {
const deltaColor =
cls.delta > 0
? "var(--color-success)"
: cls.delta < 0
? "var(--color-danger)"
: "var(--color-ink-muted)";
const deltaSign = cls.delta > 0 ? "+" : "";
return (
<TableRow key={cls.classId}>
<TableCell
style={{
fontFamily: "var(--font-serif)",
fontSize: 15,
fontWeight: 500,
}}
>
{cls.rank}
</TableCell>
<TableCell style={{ fontWeight: 500 }}>
{cls.className}
</TableCell>
<TableCell style={{ fontFamily: "var(--font-mono)" }}>
{cls.avgScore.toFixed(1)}
</TableCell>
<TableCell
style={{
fontFamily: "var(--font-mono)",
color: deltaColor,
fontWeight: 500,
}}
>
{deltaSign}
{cls.delta.toFixed(1)}
</TableCell>
</TableRow>
);
})}
</Table>
)}
</PaperCard>
</div>
)}
</>
);
}

View File

@@ -0,0 +1,85 @@
"use client";
import { type ReactNode, useState, useMemo } from "react";
import { useGrades, useSchools } from "@/hooks/use-school";
import {
PaperCard,
Select,
LoadingState,
ErrorState,
EmptyState,
Table,
TableRow,
TableCell,
} from "@/components/ui";
import { t } from "@/lib/i18n";
import type { GradeViewModel } from "@/types/view-models";
export default function GradesPage(): ReactNode {
const [schoolId, setSchoolId] = useState<string>("");
const { data: schools, loading: schoolsLoading } = useSchools();
const { data, loading, error } = useGrades(schoolId || undefined);
const sorted = useMemo<GradeViewModel[]>(() => {
if (!data) return [];
return [...data].sort((a, b) => a.sortOrder - b.sortOrder);
}, [data]);
return (
<>
<PaperCard className="p-4 mb-4">
<Select
value={schoolId}
onChange={(e) => setSchoolId(e.target.value)}
disabled={schoolsLoading}
>
<option value="">
{t("admin.common.all")}
{t("admin.common.school")}
</option>
{schools.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</Select>
</PaperCard>
<PaperCard className="p-4">
{loading && <LoadingState />}
{error && <ErrorState message={error.message} />}
{!loading && !error && (
<>
{sorted.length === 0 ? (
<EmptyState message={t("admin.common.empty")} />
) : (
<Table
headers={[
t("admin.common.grade"),
t("admin.common.school"),
t("admin.school.director"),
t("admin.common.class"),
t("admin.dashboard.totalStudents"),
t("admin.viewports.sortOrder"),
]}
>
{sorted.map((grade) => (
<TableRow key={grade.id}>
<TableCell style={{ fontWeight: 500 }}>
{grade.name}
</TableCell>
<TableCell>{grade.schoolName}</TableCell>
<TableCell>{grade.directorName ?? "—"}</TableCell>
<TableCell>{grade.classCount}</TableCell>
<TableCell>{grade.studentCount}</TableCell>
<TableCell>{grade.sortOrder}</TableCell>
</TableRow>
))}
</Table>
)}
</>
)}
</PaperCard>
</>
);
}

View File

@@ -0,0 +1,87 @@
"use client";
import { type ReactNode } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { PageHeader } from "@/components/ui";
import { t } from "@/lib/i18n";
interface TabItem {
href: string;
label: string;
}
const TABS: TabItem[] = [
{ href: "/admin/school/schools", label: t("admin.school.schools") },
{ href: "/admin/school/grades", label: t("admin.school.grades") },
{
href: "/admin/school/grades/insights",
label: t("admin.school.gradeInsights"),
},
{ href: "/admin/school/departments", label: t("admin.school.departments") },
{
href: "/admin/school/academic-year",
label: t("admin.school.academicYear"),
},
];
export default function SchoolLayout({
children,
}: {
children: ReactNode;
}): ReactNode {
const pathname = usePathname();
return (
<div style={{ padding: 32, maxWidth: 1200, margin: "0 auto" }}>
<PageHeader
title={t("admin.school.title")}
description="学校、年级、部门与学年学期管理"
/>
<nav
style={{
display: "flex",
gap: 4,
borderBottom: "1px solid var(--color-rule)",
marginBottom: 24,
}}
>
{TABS.map((tab) => {
const isActive =
pathname === tab.href ||
(tab.href !== "/admin/school/grades" &&
pathname.startsWith(tab.href));
// 年级管理 / 年级洞察 都在 /admin/school/grades 下,需要精确区分
const exactActive =
tab.href === "/admin/school/grades"
? pathname === "/admin/school/grades"
: isActive;
return (
<Link
key={tab.href}
href={tab.href}
style={{
padding: "8px 16px",
fontSize: 13,
color: exactActive
? "var(--color-ink)"
: "var(--color-ink-muted)",
textDecoration: "none",
borderBottom: exactActive
? "2px solid var(--color-accent)"
: "2px solid transparent",
marginBottom: "-1px",
fontWeight: exactActive ? 500 : 400,
}}
>
{tab.label}
</Link>
);
})}
</nav>
{children}
</div>
);
}

View File

@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function SchoolPage(): never {
redirect("/admin/school/schools");
}

View File

@@ -0,0 +1,103 @@
"use client";
import { type ReactNode, useState, useMemo } from "react";
import { useSchools } from "@/hooks/use-school";
import {
PaperCard,
Input,
LoadingState,
ErrorState,
EmptyState,
Table,
TableRow,
TableCell,
} from "@/components/ui";
import { t } from "@/lib/i18n";
import type { SchoolViewModel } from "@/types/view-models";
export default function SchoolsPage(): ReactNode {
const { data, loading, error } = useSchools();
const [search, setSearch] = useState("");
const filtered = useMemo<SchoolViewModel[]>(() => {
if (!data) return [];
const q = search.trim().toLowerCase();
if (!q) return data;
return data.filter(
(s) =>
s.name.toLowerCase().includes(q) ||
s.code.toLowerCase().includes(q) ||
(s.principalName ?? "").toLowerCase().includes(q),
);
}, [data, search]);
return (
<>
<PaperCard className="p-4 mb-4">
<Input
type="search"
placeholder={t("admin.common.search")}
value={search}
onChange={(e) => setSearch(e.target.value)}
style={{ width: 240 }}
/>
</PaperCard>
<PaperCard className="p-4">
{loading && <LoadingState />}
{error && <ErrorState message={error.message} />}
{!loading && !error && (
<>
{filtered.length === 0 ? (
<EmptyState message={t("admin.common.empty")} />
) : (
<Table
headers={[
t("admin.common.school"),
t("admin.system.schoolCode"),
t("admin.system.address"),
t("admin.system.contactPhone"),
t("admin.system.contactEmail"),
t("admin.school.principal"),
t("admin.common.grade"),
t("admin.common.class"),
t("admin.dashboard.totalStudents"),
t("admin.dashboard.totalTeachers"),
]}
>
{filtered.map((school) => (
<TableRow key={school.id}>
<TableCell style={{ fontWeight: 500 }}>
{school.name}
</TableCell>
<TableCell
style={{ fontFamily: "var(--font-mono)", fontSize: 11 }}
>
{school.code}
</TableCell>
<TableCell>{school.address}</TableCell>
<TableCell
style={{ fontFamily: "var(--font-mono)", fontSize: 11 }}
>
{school.contactPhone}
</TableCell>
<TableCell
style={{ fontFamily: "var(--font-mono)", fontSize: 11 }}
>
{school.contactEmail}
</TableCell>
<TableCell>{school.principalName ?? "—"}</TableCell>
<TableCell>{school.gradeCount}</TableCell>
<TableCell>{school.classCount}</TableCell>
<TableCell>{school.studentCount}</TableCell>
<TableCell>{school.teacherCount}</TableCell>
</TableRow>
))}
</Table>
)}
</>
)}
</PaperCard>
</>
);
}

View File

@@ -16,7 +16,19 @@ import {
ErrorState,
} from "@/components/ui";
import { t } from "@/lib/i18n";
import type { SystemSettingsViewModel } from "@/types/view-models";
import {
mockSecurityPolicy,
mockFileUploadConfig,
mockNotificationConfig,
mockBrandConfig,
} from "@/mocks/fixtures";
import type {
SystemSettingsViewModel,
SecurityPolicy,
FileUploadConfig,
NotificationConfig,
BrandConfig,
} from "@/types/view-models";
export default function SystemPage(): ReactNode {
const { data, loading, error } = useSystemSettings();
@@ -24,6 +36,16 @@ export default function SystemPage(): ReactNode {
const [updateSettings] = useUpdateSystemSettings();
const [form, setForm] = useState<SystemSettingsViewModel | null>(null);
const [saving, setSaving] = useState(false);
const [securityPolicy, setSecurityPolicy] =
useState<SecurityPolicy>(mockSecurityPolicy);
const [fileUploadConfig, setFileUploadConfig] =
useState<FileUploadConfig>(mockFileUploadConfig);
const [allowedTypesText, setAllowedTypesText] = useState(
mockFileUploadConfig.allowedTypes.join(", "),
);
const [notificationConfig, setNotificationConfig] =
useState<NotificationConfig>(mockNotificationConfig);
const [brandConfig, setBrandConfig] = useState<BrandConfig>(mockBrandConfig);
useEffect(() => {
if (data && !form) {
@@ -209,6 +231,372 @@ export default function SystemPage(): ReactNode {
</form>
)}
</PaperCard>
{/* 安全策略 */}
<PaperCard className="p-6 mt-6">
<h3
style={{
fontFamily: "var(--font-serif)",
fontSize: 16,
color: "var(--color-ink)",
marginBottom: 16,
}}
>
{t("admin.system.security")}
</h3>
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<div className="grid grid-cols-2 gap-4">
<label style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
{t("admin.system.passwordMinLength")}
</span>
<Input
type="number"
min={4}
max={64}
value={securityPolicy.passwordMinLength}
onChange={(e) =>
setSecurityPolicy((prev) => ({
...prev,
passwordMinLength: Number(e.target.value),
}))
}
style={{ width: 120 }}
/>
</label>
<label style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
{t("admin.system.sessionTimeout")}
</span>
<Input
type="number"
min={1}
value={securityPolicy.sessionTimeoutMinutes}
onChange={(e) =>
setSecurityPolicy((prev) => ({
...prev,
sessionTimeoutMinutes: Number(e.target.value),
}))
}
style={{ width: 120 }}
/>
</label>
</div>
<label
style={{
display: "flex",
alignItems: "center",
gap: 8,
fontSize: 13,
color: "var(--color-ink)",
}}
>
<input
type="checkbox"
checked={securityPolicy.passwordRequireSpecial}
onChange={(e) =>
setSecurityPolicy((prev) => ({
...prev,
passwordRequireSpecial: e.target.checked,
}))
}
/>
{t("admin.system.passwordRequireSpecial")}
</label>
<label
style={{
display: "flex",
alignItems: "center",
gap: 8,
fontSize: 13,
color: "var(--color-ink)",
}}
>
<input
type="checkbox"
checked={securityPolicy.passwordRequireUppercase}
onChange={(e) =>
setSecurityPolicy((prev) => ({
...prev,
passwordRequireUppercase: e.target.checked,
}))
}
/>
{t("admin.system.passwordRequireUppercase")}
</label>
<label
style={{
display: "flex",
alignItems: "center",
gap: 8,
fontSize: 13,
color: "var(--color-ink)",
}}
>
<input
type="checkbox"
checked={securityPolicy.forcePasswordChange}
onChange={(e) =>
setSecurityPolicy((prev) => ({
...prev,
forcePasswordChange: e.target.checked,
}))
}
/>
{t("admin.system.forcePasswordChange")}
</label>
<div
style={{
display: "flex",
justifyContent: "flex-end",
marginTop: 4,
}}
>
<Button
variant="primary"
onClick={() => show("success", t("admin.system.saveSuccess"))}
>
{t("admin.common.save")}
</Button>
</div>
</div>
</PaperCard>
{/* 文件上传配置 */}
<PaperCard className="p-6 mt-6">
<h3
style={{
fontFamily: "var(--font-serif)",
fontSize: 16,
color: "var(--color-ink)",
marginBottom: 16,
}}
>
{t("admin.system.fileUpload")}
</h3>
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<label style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
{t("admin.system.maxFileSize")}
</span>
<Input
type="number"
min={1}
value={fileUploadConfig.maxFileSize}
onChange={(e) =>
setFileUploadConfig((prev) => ({
...prev,
maxFileSize: Number(e.target.value),
}))
}
style={{ width: 120 }}
/>
</label>
<label style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
{t("admin.system.allowedTypes")}
</span>
<Input
type="text"
value={allowedTypesText}
onChange={(e) => setAllowedTypesText(e.target.value)}
placeholder="pdf, jpg, png, docx"
/>
</label>
<div
style={{
display: "flex",
justifyContent: "flex-end",
marginTop: 4,
}}
>
<Button
variant="primary"
onClick={() => {
setFileUploadConfig((prev) => ({
...prev,
allowedTypes: allowedTypesText
.split(",")
.map((s) => s.trim())
.filter(Boolean),
}));
show("success", t("admin.system.saveSuccess"));
}}
>
{t("admin.common.save")}
</Button>
</div>
</div>
</PaperCard>
{/* 通知配置 */}
<PaperCard className="p-6 mt-6">
<h3
style={{
fontFamily: "var(--font-serif)",
fontSize: 16,
color: "var(--color-ink)",
marginBottom: 16,
}}
>
{t("admin.system.notification")}
</h3>
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<label
style={{
display: "flex",
alignItems: "center",
gap: 8,
fontSize: 13,
color: "var(--color-ink)",
}}
>
<input
type="checkbox"
checked={notificationConfig.notifyNewUser}
onChange={(e) =>
setNotificationConfig((prev) => ({
...prev,
notifyNewUser: e.target.checked,
}))
}
/>
{t("admin.system.notifyNewUser")}
</label>
<label
style={{
display: "flex",
alignItems: "center",
gap: 8,
fontSize: 13,
color: "var(--color-ink)",
}}
>
<input
type="checkbox"
checked={notificationConfig.notifyScheduleChange}
onChange={(e) =>
setNotificationConfig((prev) => ({
...prev,
notifyScheduleChange: e.target.checked,
}))
}
/>
{t("admin.system.notifyScheduleChange")}
</label>
<label
style={{
display: "flex",
alignItems: "center",
gap: 8,
fontSize: 13,
color: "var(--color-ink)",
}}
>
<input
type="checkbox"
checked={notificationConfig.notifyAnnouncement}
onChange={(e) =>
setNotificationConfig((prev) => ({
...prev,
notifyAnnouncement: e.target.checked,
}))
}
/>
{t("admin.system.notifyAnnouncement")}
</label>
<div
style={{
display: "flex",
justifyContent: "flex-end",
marginTop: 4,
}}
>
<Button
variant="primary"
onClick={() => show("success", t("admin.system.saveSuccess"))}
>
{t("admin.common.save")}
</Button>
</div>
</div>
</PaperCard>
{/* 品牌配置 */}
<PaperCard className="p-6 mt-6">
<h3
style={{
fontFamily: "var(--font-serif)",
fontSize: 16,
color: "var(--color-ink)",
marginBottom: 16,
}}
>
{t("admin.system.brand")}
</h3>
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<label style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
{t("admin.system.logoUrl")}
</span>
<Input
type="text"
value={brandConfig.logoUrl}
onChange={(e) =>
setBrandConfig((prev) => ({
...prev,
logoUrl: e.target.value,
}))
}
/>
</label>
<label style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
{t("admin.system.primaryColor")}
</span>
<Input
type="color"
value={brandConfig.primaryColor}
onChange={(e) =>
setBrandConfig((prev) => ({
...prev,
primaryColor: e.target.value,
}))
}
style={{ width: 60, height: 32, padding: 2 }}
/>
</label>
<label style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
{t("admin.system.schoolMotto")}
</span>
<Input
type="text"
value={brandConfig.schoolMotto}
onChange={(e) =>
setBrandConfig((prev) => ({
...prev,
schoolMotto: e.target.value,
}))
}
/>
</label>
<div
style={{
display: "flex",
justifyContent: "flex-end",
marginTop: 4,
}}
>
<Button
variant="primary"
onClick={() => show("success", t("admin.system.saveSuccess"))}
>
{t("admin.common.save")}
</Button>
</div>
</div>
</PaperCard>
</div>
);
}