feat(admin-portal): 完整实现 admin-portal 管理端微前端

包含 src 全部实现、Dockerfile、配置文件等
This commit is contained in:
SpecialX
2026-07-10 19:09:12 +08:00
parent 02d09c47fa
commit b3511910d1
69 changed files with 11955 additions and 693 deletions

View File

@@ -0,0 +1,176 @@
"use client";
import { type ReactNode } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useAuth } from "@/providers/auth-provider";
import { useToast } from "@/providers/toast-provider";
import { t } from "@/lib/i18n";
import { ROUTE_PERMISSIONS } from "@/lib/permissions";
interface NavItem {
route: string;
label: string;
}
const NAV_ITEMS: NavItem[] = [
{ route: "/admin/dashboard", label: t("admin.nav.dashboard") },
{ route: "/admin/users", label: t("admin.nav.users") },
{ route: "/admin/roles", label: t("admin.nav.roles") },
{ 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/classes", label: t("admin.nav.classes") },
{ route: "/admin/teachers", label: t("admin.nav.teachers") },
{ route: "/admin/students", label: t("admin.nav.students") },
{ route: "/admin/audit-logs", label: t("admin.nav.auditLogs") },
{ route: "/admin/system", label: t("admin.nav.system") },
];
export function AdminShell({ children }: { children: ReactNode }) {
const pathname = usePathname();
const { user, isLoading, isAuthenticated, logout, hasPermission } = useAuth();
const { show } = useToast();
if (isLoading) {
return (
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
minHeight: "100vh",
}}
>
<p style={{ color: "var(--color-ink-muted)" }}>
{t("admin.common.loading")}
</p>
</div>
);
}
if (!isAuthenticated) {
return null;
}
const visibleNavItems = NAV_ITEMS.filter((item) => {
const perm = ROUTE_PERMISSIONS[item.route];
if (!perm) return true;
return hasPermission(perm);
});
const handleLogout = () => {
logout();
show("info", t("admin.auth.logout"));
};
return (
<div
className="min-h-screen flex"
style={{ background: "var(--bg-paper)" }}
>
{/* 跳过导航链接A11y WCAG 2.2 AA*/}
<a href="#main-content" className="skip-link">
</a>
{/* 左侧栏:导航树 */}
<aside
className="w-56 flex-shrink-0 border-r relative flex flex-col"
style={{
borderColor: "var(--color-rule)",
background: "var(--bg-paper)",
}}
aria-label="管理端导航"
>
<div className="px-6 py-6">
<h1
className="text-xl"
style={{
fontFamily: "var(--font-serif)",
color: "var(--color-ink)",
}}
>
Edu
</h1>
<p
className="text-xs mt-1"
style={{ color: "var(--color-ink-muted)" }}
>
{t("admin.auth.welcome")}
</p>
</div>
<div className="rule-thin mx-6" />
<nav className="mt-4 px-3 flex-1 overflow-y-auto" aria-label="主导航">
<ul style={{ listStyle: "none", padding: 0, margin: 0 }}>
{visibleNavItems.map((item) => {
const active =
pathname === item.route ||
pathname.startsWith(item.route + "/");
return (
<li key={item.route}>
<Link
href={item.route}
className="block px-3 py-2 text-sm transition-colors"
style={{
color: active
? "var(--color-accent)"
: "var(--color-ink)",
borderLeft: active
? "2px solid var(--color-accent)"
: "2px solid transparent",
fontFamily: active
? "var(--font-serif)"
: "var(--font-sans)",
textDecoration: "none",
}}
>
{item.label}
</Link>
</li>
);
})}
</ul>
</nav>
{/* 底部:用户信息 + 登出 */}
<div
className="px-6 py-4 border-t"
style={{ borderColor: "var(--color-rule)" }}
>
{user && (
<div className="mb-2">
<p className="text-sm" style={{ color: "var(--color-ink)" }}>
{user.name}
</p>
<p
className="text-xs"
style={{ color: "var(--color-ink-muted)" }}
>
{user.roles.join(", ") || "无角色"}
</p>
</div>
)}
<button
onClick={handleLogout}
className="text-xs uppercase tracking-wide hover:opacity-70"
style={{
color: "var(--color-ink-muted)",
background: "none",
border: "none",
cursor: "pointer",
}}
>
{t("admin.auth.logout")}
</button>
</div>
</aside>
{/* 中间:内容区(纸面) */}
<main id="main-content" className="flex-1 overflow-auto" role="main">
{children}
</main>
</div>
);
}

View File

@@ -0,0 +1,24 @@
"use client";
import { useEffect } from "react";
/**
* MSW 初始化组件
*
* NEXT_PUBLIC_API_MOCKING=enabled 时启动 MSW worker
* 上游就绪后设为 disabled使用真实 API
*/
export function MswInitializer({ onReady }: { onReady: () => void }) {
useEffect(() => {
if (process.env.NEXT_PUBLIC_API_MOCKING === "enabled") {
import("@/mocks/browser")
.then(({ worker }) => worker.start({ onUnhandledRequest: "bypass" }))
.then(onReady)
.catch(() => onReady());
} else {
onReady();
}
}, [onReady]);
return null;
}

View File

@@ -0,0 +1,164 @@
"use client";
import { type ReactNode } from "react";
import { useWebSocket } from "@/hooks/use-websocket";
import { useToast } from "@/providers/toast-provider";
import { Badge } from "./ui";
import { t } from "@/lib/i18n";
import type { WsNotification } from "@/types/view-models";
const severityColor: Record<WsNotification["severity"], string> = {
info: "var(--color-accent)",
warning: "var(--color-warning)",
error: "var(--color-danger)",
};
const typeLabel: Record<WsNotification["type"], string> = {
audit_alert: t("admin.notification.auditAlert"),
abnormal_login: t("admin.notification.abnormalLogin"),
system_error: t("admin.notification.systemError"),
info: "系统通知",
};
export function NotificationPanel(): ReactNode {
const { notifications, connected, dismiss, clear } = useWebSocket(20);
const { show } = useToast();
return (
<div
style={{
position: "fixed",
top: 16,
right: 16,
width: 320,
maxHeight: "60vh",
overflowY: "auto",
background: "var(--bg-paper)",
border: "1px solid var(--color-rule)",
borderRadius: 8,
boxShadow: "0 4px 16px rgba(0,0,0,0.08)",
zIndex: 100,
}}
role="region"
aria-label="通知中心"
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "10px 16px",
borderBottom: "1px solid var(--color-rule)",
}}
>
<h3
style={{
fontFamily: "var(--font-serif)",
fontSize: 14,
color: "var(--color-ink)",
margin: 0,
}}
>
{t("admin.notification.title")}
</h3>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<Badge status={connected ? "healthy" : "down"} />
{notifications.length > 0 && (
<button
onClick={clear}
style={{
background: "none",
border: "none",
color: "var(--color-ink-muted)",
fontSize: 11,
cursor: "pointer",
}}
>
</button>
)}
</div>
</div>
<ul style={{ listStyle: "none", padding: 0, margin: 0 }}>
{notifications.length === 0 && (
<li
style={{
padding: "24px 16px",
textAlign: "center",
color: "var(--color-ink-muted)",
fontSize: 12,
}}
>
</li>
)}
{notifications.map((notif) => (
<li
key={notif.id}
style={{
padding: "10px 16px",
borderBottom: "1px solid var(--color-rule)",
borderLeft: `3px solid ${severityColor[notif.severity]}`,
}}
>
<button
type="button"
style={{
background: "none",
border: "none",
cursor: "pointer",
padding: 0,
width: "100%",
textAlign: "left",
}}
onClick={() => {
show(
notif.severity === "error"
? "error"
: notif.severity === "warning"
? "warning"
: "info",
notif.title,
notif.message,
);
dismiss(notif.id);
}}
aria-label={`${typeLabel[notif.type]}: ${notif.message}`}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "baseline",
}}
>
<span
style={{
fontSize: 13,
color: "var(--color-ink)",
fontWeight: 500,
}}
>
{typeLabel[notif.type]}
</span>
<span style={{ fontSize: 10, color: "var(--color-ink-muted)" }}>
{new Date(notif.timestamp).toLocaleTimeString("zh-CN")}
</span>
</div>
<p
style={{
fontSize: 12,
color: "var(--color-ink-muted)",
margin: "4px 0 0 0",
}}
>
{notif.message}
</p>
</button>
</li>
))}
</ul>
</div>
);
}

View File

@@ -0,0 +1,188 @@
"use client";
import { type ReactNode, useState, useEffect } from "react";
import { useOrganization } from "@/hooks/use-organization";
import type { OrganizationNode } from "@/types/view-models";
interface OrganizationTreeProps {
onSelect?: (node: OrganizationNode) => void;
}
interface TreeNodeProps {
node: OrganizationNode;
level: number;
onSelect?: (node: OrganizationNode) => void;
}
const typeLabel: Record<OrganizationNode["type"], string> = {
school: "学校",
grade: "年级",
class: "班级",
};
function TreeNode({ node, level, onSelect }: TreeNodeProps): ReactNode {
const [expanded, setExpanded] = useState(level < 2);
const [children, setChildren] = useState<OrganizationNode[]>([]);
const [loaded, setLoaded] = useState(false);
const [selected, setSelected] = useState(false);
const { data, loading } = useOrganization(expanded ? node.id : undefined);
useEffect(() => {
if (expanded && !loaded && data.length > 0) {
setChildren(data);
setLoaded(true);
}
}, [expanded, loaded, data]);
const hasChildren = node.childrenCount > 0;
void loading;
const handleKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setSelected(true);
onSelect?.(node);
}
};
return (
<div>
<button
type="button"
style={{
display: "flex",
alignItems: "center",
width: "100%",
padding: "6px 12px",
paddingLeft: 12 + level * 16,
cursor: "pointer",
color: "var(--color-ink)",
fontSize: 13,
background: selected ? "var(--color-accent-light)" : "transparent",
border: "none",
borderBottom: "1px solid var(--color-rule)",
textAlign: "left",
}}
onClick={() => {
setSelected(true);
onSelect?.(node);
}}
onKeyDown={handleKeyDown}
role="treeitem"
aria-expanded={hasChildren ? expanded : undefined}
aria-selected={selected}
>
{hasChildren ? (
<span
onClick={(e) => {
e.stopPropagation();
setExpanded((v) => !v);
}}
style={{
cursor: "pointer",
color: "var(--color-ink-muted)",
padding: 0,
marginRight: 4,
fontSize: 10,
userSelect: "none",
}}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
e.stopPropagation();
setExpanded((v) => !v);
}
}}
aria-label={expanded ? "折叠" : "展开"}
>
{expanded ? "▼" : "▶"}
</span>
) : (
<span style={{ display: "inline-block", width: 14 }} />
)}
<span
style={{
fontFamily:
node.type === "school" ? "var(--font-serif)" : "var(--font-sans)",
}}
>
{node.name}
</span>
<span
style={{
marginLeft: 8,
fontSize: 10,
color: "var(--color-ink-muted)",
padding: "1px 6px",
border: "1px solid var(--color-rule)",
borderRadius: 8,
}}
>
{typeLabel[node.type]}
</span>
{hasChildren && (
<span
style={{
marginLeft: 8,
fontSize: 11,
color: "var(--color-ink-muted)",
}}
>
({node.childrenCount})
</span>
)}
</button>
{expanded && hasChildren && (
<div role="group">
{children.map((child) => (
<TreeNode
key={child.id}
node={child}
level={level + 1}
onSelect={onSelect}
/>
))}
</div>
)}
</div>
);
}
export function OrganizationTree({
onSelect,
}: OrganizationTreeProps): ReactNode {
const { data: roots, loading, error } = useOrganization(null);
if (loading)
return (
<div
style={{ padding: 16, color: "var(--color-ink-muted)", fontSize: 13 }}
>
...
</div>
);
if (error)
return (
<div style={{ padding: 16, color: "var(--color-danger)", fontSize: 13 }}>
{error.message}
</div>
);
if (roots.length === 0)
return (
<div
style={{ padding: 16, color: "var(--color-ink-muted)", fontSize: 13 }}
>
</div>
);
return (
<div role="tree" aria-label="组织架构树">
{roots.map((node) => (
<TreeNode key={node.id} node={node} level={0} onSelect={onSelect} />
))}
</div>
);
}

View File

@@ -0,0 +1,147 @@
"use client";
import { type ReactNode, useState, useEffect } from "react";
import type { RoleViewModel, PermissionViewModel } from "@/types/view-models";
import { Table, TableRow, TableCell, Button, Badge } from "./ui";
import { t } from "@/lib/i18n";
interface RolePermissionMatrixProps {
role: RoleViewModel;
permissions: PermissionViewModel[];
onSave: (permissionCodes: string[]) => Promise<void>;
}
export function RolePermissionMatrix({
role,
permissions,
onSave,
}: RolePermissionMatrixProps): ReactNode {
const [selected, setSelected] = useState<Set<string>>(
new Set(role.permissions.map((p) => p.code)),
);
const [saving, setSaving] = useState(false);
useEffect(() => {
setSelected(new Set(role.permissions.map((p) => p.code)));
}, [role]);
const toggle = (code: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(code)) {
next.delete(code);
} else {
next.add(code);
}
return next;
});
};
const toggleResource = (resource: string) => {
const resourcePerms = permissions.filter((p) => p.resource === resource);
const allSelected = resourcePerms.every((p) => selected.has(p.code));
setSelected((prev) => {
const next = new Set(prev);
if (allSelected) {
resourcePerms.forEach((p) => next.delete(p.code));
} else {
resourcePerms.forEach((p) => next.add(p.code));
}
return next;
});
};
const handleSave = async () => {
setSaving(true);
try {
await onSave(Array.from(selected));
} finally {
setSaving(false);
}
};
// 按资源分组
const resourceGroups = permissions.reduce<
Record<string, PermissionViewModel[]>
>((acc, p) => {
const arr = acc[p.resource] ?? (acc[p.resource] = []);
arr.push(p);
return acc;
}, {});
return (
<div>
<div className="flex items-center justify-between mb-4">
<div>
<h3
style={{
fontFamily: "var(--font-serif)",
fontSize: 16,
color: "var(--color-ink)",
}}
>
{role.name}
{role.isSystem && <Badge status="active" />}
</h3>
<p style={{ fontSize: 12, color: "var(--color-ink-muted)" }}>
{role.code} · {t("admin.roles.userCount")}: {role.userCount} ·{" "}
{role.dataScope}
</p>
</div>
<Button variant="primary" onClick={handleSave} disabled={saving}>
{saving ? t("admin.common.loading") : t("admin.common.save")}
</Button>
</div>
<Table headers={["资源", "权限点", "操作", "说明", "授权"]}>
{Object.entries(resourceGroups).map(([resource, perms]) =>
perms.map((perm, idx) => (
<TableRow key={perm.id}>
{idx === 0 && (
<TableCell
rowSpan={perms.length}
style={{ verticalAlign: "top", fontWeight: 500 }}
>
<label
style={{ display: "flex", alignItems: "center", gap: 6 }}
>
<input
type="checkbox"
checked={perms.every((p) => selected.has(p.code))}
ref={(el) => {
if (el)
el.indeterminate =
perms.some((p) => selected.has(p.code)) &&
!perms.every((p) => selected.has(p.code));
}}
onChange={() => toggleResource(resource)}
/>
{resource}
</label>
</TableCell>
)}
<TableCell
style={{ fontFamily: "var(--font-mono)", fontSize: 12 }}
>
{perm.code}
</TableCell>
<TableCell>{perm.action}</TableCell>
<TableCell
style={{ color: "var(--color-ink-muted)", fontSize: 12 }}
>
{perm.description}
</TableCell>
<TableCell>
<input
type="checkbox"
checked={selected.has(perm.code)}
onChange={() => toggle(perm.code)}
/>
</TableCell>
</TableRow>
)),
)}
</Table>
</div>
);
}

View File

@@ -0,0 +1,375 @@
/**
* 通用 UI 组件(纸面风格)
*
* 复用 @edu/ui-components 中的基础组件,扩展 admin 专用组件
*/
import { type ReactNode, type ButtonHTMLAttributes } from "react";
/** 纸面卡片 */
export function PaperCard({
children,
className = "",
style,
}: {
children: ReactNode;
className?: string;
style?: React.CSSProperties;
}): ReactNode {
return (
<div
className={`bg-white border rounded-lg ${className}`}
style={{
borderColor: "var(--color-rule)",
background: "var(--bg-paper)",
...style,
}}
>
{children}
</div>
);
}
/** 页面标题 */
export function PageHeader({
title,
description,
actions,
}: {
title: string;
description?: string;
actions?: ReactNode;
}): ReactNode {
return (
<div
className="flex items-start justify-between mb-6 pb-4"
style={{ borderBottom: "1px solid var(--color-rule)" }}
>
<div>
<h1
style={{
fontFamily: "var(--font-serif)",
fontSize: 24,
color: "var(--color-ink)",
margin: 0,
}}
>
{title}
</h1>
{description && (
<p
style={{
color: "var(--color-ink-muted)",
fontSize: 13,
marginTop: 4,
}}
>
{description}
</p>
)}
</div>
{actions && <div className="flex gap-2">{actions}</div>}
</div>
);
}
/** 按钮 */
type ButtonVariant = "primary" | "secondary" | "ghost" | "danger";
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
}
const buttonStyles: Record<ButtonVariant, Record<string, string>> = {
primary: {
background: "var(--color-accent)",
color: "var(--bg-paper)",
border: "1px solid var(--color-accent)",
},
secondary: {
background: "var(--bg-paper)",
color: "var(--color-ink)",
border: "1px solid var(--color-rule)",
},
ghost: {
background: "transparent",
color: "var(--color-ink-muted)",
border: "1px solid transparent",
},
danger: {
background: "var(--color-danger)",
color: "var(--bg-paper)",
border: "1px solid var(--color-danger)",
},
};
export function Button({
variant = "secondary",
children,
style,
...props
}: ButtonProps): ReactNode {
return (
<button
{...props}
style={{
...buttonStyles[variant],
padding: "6px 14px",
fontSize: 13,
borderRadius: 4,
cursor: props.disabled ? "not-allowed" : "pointer",
opacity: props.disabled ? 0.5 : 1,
...style,
}}
>
{children}
</button>
);
}
/** 输入框 */
export function Input({
style,
...props
}: React.InputHTMLAttributes<HTMLInputElement>): ReactNode {
return (
<input
{...props}
style={{
padding: "6px 10px",
border: "1px solid var(--color-rule)",
borderRadius: 4,
background: "var(--bg-paper)",
color: "var(--color-ink)",
fontSize: 13,
...style,
}}
/>
);
}
/** 选择框 */
export function Select({
style,
children,
...props
}: React.SelectHTMLAttributes<HTMLSelectElement>): ReactNode {
return (
<select
{...props}
style={{
padding: "6px 10px",
border: "1px solid var(--color-rule)",
borderRadius: 4,
background: "var(--bg-paper)",
color: "var(--color-ink)",
fontSize: 13,
...style,
}}
>
{children}
</select>
);
}
/** 标签(状态徽章) */
export function Badge({ status }: { status: string }): ReactNode {
const colorMap: Record<string, string> = {
active: "var(--color-success)",
disabled: "var(--color-ink-muted)",
locked: "var(--color-danger)",
healthy: "var(--color-success)",
degraded: "var(--color-warning)",
down: "var(--color-danger)",
};
const labelMap: Record<string, string> = {
active: "启用",
disabled: "禁用",
locked: "锁定",
healthy: "健康",
degraded: "降级",
down: "异常",
};
const color = colorMap[status] ?? "var(--color-ink-muted)";
return (
<span
style={{
display: "inline-block",
padding: "2px 8px",
fontSize: 11,
borderRadius: 10,
background: `${color}20`,
color,
border: `1px solid ${color}40`,
}}
>
{labelMap[status] ?? status}
</span>
);
}
/** 表格 */
export function Table({
headers,
children,
}: {
headers: string[];
children: ReactNode;
}): ReactNode {
return (
<div className="overflow-x-auto">
<table
className="w-full"
style={{ borderCollapse: "collapse", fontSize: 13 }}
>
<thead>
<tr style={{ borderBottom: "2px solid var(--color-rule)" }}>
{headers.map((h, i) => (
<th
key={h}
style={{
textAlign: "left",
padding: "10px 12px",
color: "var(--color-ink-muted)",
fontWeight: 500,
fontSize: 12,
textTransform: "uppercase",
letterSpacing: "0.05em",
whiteSpace: i === 0 ? "nowrap" : "normal",
}}
>
{h}
</th>
))}
</tr>
</thead>
<tbody>{children}</tbody>
</table>
</div>
);
}
/** 表格行 */
export function TableRow({ children }: { children: ReactNode }): ReactNode {
return (
<tr style={{ borderBottom: "1px solid var(--color-rule)" }}>{children}</tr>
);
}
/** 表格单元格 */
export function TableCell({
children,
style,
rowSpan,
}: {
children: ReactNode;
style?: React.CSSProperties;
rowSpan?: number;
}): ReactNode {
return (
<td
style={{ padding: "10px 12px", color: "var(--color-ink)", ...style }}
rowSpan={rowSpan}
>
{children}
</td>
);
}
/** 空状态 */
export function EmptyState({ message }: { message: string }): ReactNode {
return (
<div
style={{
textAlign: "center",
padding: "48px 16px",
color: "var(--color-ink-muted)",
}}
>
<p style={{ fontSize: 14 }}>{message}</p>
</div>
);
}
/** 加载状态 */
export function LoadingState(): ReactNode {
return (
<div
style={{
textAlign: "center",
padding: "48px 16px",
color: "var(--color-ink-muted)",
}}
>
<p style={{ fontSize: 14 }}>...</p>
</div>
);
}
/** 错误状态 */
export function ErrorState({
message,
onRetry,
}: {
message: string;
onRetry?: () => void;
}): ReactNode {
return (
<div
style={{
textAlign: "center",
padding: "48px 16px",
color: "var(--color-danger)",
}}
>
<p style={{ fontSize: 14, marginBottom: 8 }}>{message}</p>
{onRetry && (
<Button variant="secondary" onClick={onRetry}>
</Button>
)}
</div>
);
}
/** 分页 */
export function Pagination({
page,
pageSize,
total,
hasNext,
onPageChange,
}: {
page: number;
pageSize: number;
total: number;
hasNext: boolean;
onPageChange: (page: number) => void;
}): ReactNode {
const totalPages = Math.ceil(total / pageSize);
return (
<div
className="flex items-center justify-between mt-4"
style={{ fontSize: 12, color: "var(--color-ink-muted)" }}
>
<span>
{total} {page}/{totalPages || 1}
</span>
<div className="flex gap-2">
<Button
variant="secondary"
disabled={page <= 1}
onClick={() => onPageChange(page - 1)}
>
</Button>
<Button
variant="secondary"
disabled={!hasNext}
onClick={() => onPageChange(page + 1)}
>
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,216 @@
"use client";
import { type ReactNode, useState, type FormEvent } from "react";
import type { UserViewModel, RoleViewModel } from "@/types/view-models";
import { Button, Input, Select } from "./ui";
import { t } from "@/lib/i18n";
interface UserFormModalProps {
open: boolean;
user: UserViewModel | null;
roles: RoleViewModel[];
onSubmit: (data: UserFormData) => Promise<void>;
onCancel: () => void;
}
export interface UserFormData {
email: string;
name: string;
password?: string;
roleIds: string[];
dataScope: string;
organizationId?: string;
}
export function UserFormModal({
open,
user,
roles,
onSubmit,
onCancel,
}: UserFormModalProps): ReactNode {
const [email, setEmail] = useState(user?.email ?? "");
const [name, setName] = useState(user?.name ?? "");
const [password, setPassword] = useState("");
const [roleIds, setRoleIds] = useState<string[]>(
user?.roles.map((r) => r.id) ?? [],
);
const [dataScope, setDataScope] = useState<string>(
user?.dataScope ?? "SCHOOL",
);
const [loading, setLoading] = useState(false);
if (!open) return null;
const isEdit = user !== null;
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
setLoading(true);
try {
await onSubmit({
email,
name,
password: isEdit ? undefined : password,
roleIds,
dataScope,
});
} finally {
setLoading(false);
}
};
const toggleRole = (id: string) => {
setRoleIds((prev) =>
prev.includes(id) ? prev.filter((r) => r !== id) : [...prev, id],
);
};
return (
<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,
maxHeight: "80vh",
overflowY: "auto",
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,
}}
>
{isEdit ? t("admin.users.edit") : t("admin.users.new")}
</h2>
<form
onSubmit={handleSubmit}
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.common.email")}
</span>
<Input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
disabled={isEdit}
/>
</label>
<label style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
{t("admin.common.name")}
</span>
<Input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
</label>
{!isEdit && (
<label style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
{t("admin.users.password")}
</span>
<Input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
/>
</label>
)}
<label style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
{t("admin.common.dataScope")}
</span>
<Select
value={dataScope}
onChange={(e) => setDataScope(e.target.value)}
>
<option value="ALL">ALL - </option>
<option value="SCHOOL">SCHOOL - </option>
<option value="GRADE">GRADE - </option>
<option value="CLASS">CLASS - </option>
<option value="SUBJECT">SUBJECT - </option>
<option value="SELF">SELF - </option>
</Select>
</label>
<fieldset
style={{
border: "1px solid var(--color-rule)",
padding: 12,
borderRadius: 4,
}}
>
<legend style={{ fontSize: 12, color: "var(--color-ink)" }}>
{t("admin.users.roles")}
</legend>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{roles.map((role) => (
<label
key={role.id}
style={{
display: "flex",
alignItems: "center",
gap: 6,
fontSize: 13,
}}
>
<input
type="checkbox"
checked={roleIds.includes(role.id)}
onChange={() => toggleRole(role.id)}
/>
<span>{role.name}</span>
<span
style={{ color: "var(--color-ink-muted)", fontSize: 11 }}
>
({role.code})
</span>
</label>
))}
</div>
</fieldset>
<div
style={{
display: "flex",
justifyContent: "flex-end",
gap: 8,
marginTop: 16,
}}
>
<Button variant="secondary" type="button" onClick={onCancel}>
{t("admin.common.cancel")}
</Button>
<Button variant="primary" type="submit" disabled={loading}>
{loading ? t("admin.common.loading") : t("admin.common.save")}
</Button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,72 @@
"use client";
import { type ReactNode } from "react";
import type { UserViewModel } from "@/types/view-models";
import { Badge, Button, Table, TableRow, TableCell } from "./ui";
import { t } from "@/lib/i18n";
interface UserManagementTableProps {
users: UserViewModel[];
onEdit: (user: UserViewModel) => void;
onToggleStatus: (user: UserViewModel) => void;
onCreate: () => void;
}
export function UserManagementTable({
users,
onEdit,
onToggleStatus,
onCreate,
}: UserManagementTableProps): ReactNode {
return (
<div>
<div className="flex justify-end mb-4">
<Button variant="primary" onClick={onCreate}>
{t("admin.users.new")}
</Button>
</div>
<Table
headers={[
"邮箱",
"姓名",
"角色",
"状态",
"数据范围",
"学校",
"最后登录",
"操作",
]}
>
{users.map((user) => (
<TableRow key={user.id}>
<TableCell>{user.email}</TableCell>
<TableCell>{user.name}</TableCell>
<TableCell>
{user.roles.map((r) => r.name).join(", ") || "—"}
</TableCell>
<TableCell>
<Badge status={user.status} />
</TableCell>
<TableCell>{user.dataScope}</TableCell>
<TableCell>{user.schoolName ?? "—"}</TableCell>
<TableCell>
{user.lastLoginAt
? new Date(user.lastLoginAt).toLocaleDateString("zh-CN")
: "—"}
</TableCell>
<TableCell>
<div className="flex gap-2">
<Button variant="ghost" onClick={() => onEdit(user)}>
{t("admin.common.edit")}
</Button>
<Button variant="ghost" onClick={() => onToggleStatus(user)}>
{t("admin.users.toggleStatus")}
</Button>
</div>
</TableCell>
</TableRow>
))}
</Table>
</div>
);
}

View File

@@ -0,0 +1,20 @@
"use client";
import { useEffect } from "react";
/**
* Web Vitals 采集初始化组件
*
* 仲裁:所有前端应用必须采集 Web Vitals
* 在客户端挂载时初始化 LCP/CLS/FCP/INP/TTFB 采集
*/
export function WebVitalsInitializer(): null {
useEffect(() => {
if (process.env.NODE_ENV !== "production") return;
void import("@/lib/web-vitals").then(({ initWebVitals }) =>
initWebVitals(),
);
}, []);
return null;
}