feat(admin-portal): 完整实现 admin-portal 管理端微前端
包含 src 全部实现、Dockerfile、配置文件等
This commit is contained in:
158
apps/admin-portal/src/app/admin/audit-logs/page.tsx
Normal file
158
apps/admin-portal/src/app/admin/audit-logs/page.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode, useState, useEffect } from "react";
|
||||
import {
|
||||
useAuditLogs,
|
||||
useAuditLogFilter,
|
||||
exportAuditLogsCsv,
|
||||
} from "@/hooks/use-audit-logs";
|
||||
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";
|
||||
|
||||
export default function AuditLogsPage(): ReactNode {
|
||||
const { filter, setPage, setSearch, setAction } = useAuditLogFilter();
|
||||
const { data, loading, error } = useAuditLogs(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;
|
||||
}
|
||||
exportAuditLogsCsv(data.items);
|
||||
show("success", "已导出 CSV");
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: 32, maxWidth: 1200, margin: "0 auto" }}>
|
||||
<PageHeader
|
||||
title={t("admin.auditLogs.title")}
|
||||
description="系统所有操作的审计记录"
|
||||
actions={
|
||||
<Button variant="secondary" onClick={handleExport}>
|
||||
{t("admin.auditLogs.exportCsv")}
|
||||
</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.action ?? ""}
|
||||
onChange={(e) => setAction(e.target.value)}
|
||||
>
|
||||
<option value="">{t("admin.common.all")}操作</option>
|
||||
<option value="USER_LOGIN">用户登录</option>
|
||||
<option value="USER_CREATE">创建用户</option>
|
||||
<option value="USER_UPDATE">更新用户</option>
|
||||
<option value="USER_TOGGLE_STATUS">切换状态</option>
|
||||
<option value="ROLE_CREATE">创建角色</option>
|
||||
<option value="ROLE_UPDATE">更新角色</option>
|
||||
<option value="VIEWPORT_UPDATE">更新视口</option>
|
||||
<option value="SYSTEM_SETTINGS_UPDATE">系统设置</option>
|
||||
<option value="ABNORMAL_LOGIN_BLOCKED">异常登录拦截</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.action"),
|
||||
t("admin.auditLogs.resource"),
|
||||
t("admin.auditLogs.resourceId"),
|
||||
t("admin.auditLogs.ip"),
|
||||
t("admin.auditLogs.traceId"),
|
||||
]}
|
||||
>
|
||||
{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>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 11,
|
||||
}}
|
||||
>
|
||||
{log.action}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>{log.resourceType}</TableCell>
|
||||
<TableCell
|
||||
style={{ fontFamily: "var(--font-mono)", fontSize: 11 }}
|
||||
>
|
||||
{log.resourceId}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
style={{ fontFamily: "var(--font-mono)", fontSize: 11 }}
|
||||
>
|
||||
{log.ip}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
style={{
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 11,
|
||||
color: "var(--color-ink-muted)",
|
||||
}}
|
||||
>
|
||||
{log.traceId ?? "—"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</Table>
|
||||
<Pagination
|
||||
page={data.page}
|
||||
pageSize={data.pageSize}
|
||||
total={data.total}
|
||||
hasNext={data.hasNext}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PaperCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
89
apps/admin-portal/src/app/admin/classes/page.tsx
Normal file
89
apps/admin-portal/src/app/admin/classes/page.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode, useState, useEffect } from "react";
|
||||
import { useClasses, useClassFilter } from "@/hooks/use-classes";
|
||||
import {
|
||||
PageHeader,
|
||||
PaperCard,
|
||||
Input,
|
||||
LoadingState,
|
||||
ErrorState,
|
||||
EmptyState,
|
||||
Table,
|
||||
TableRow,
|
||||
TableCell,
|
||||
Pagination,
|
||||
} from "@/components/ui";
|
||||
import { t } from "@/lib/i18n";
|
||||
|
||||
export default function ClassesPage(): ReactNode {
|
||||
const { filter, setPage, setSearch } = useClassFilter();
|
||||
const { data, loading, error } = useClasses(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.classes.title")} description="全校班级一览" />
|
||||
|
||||
<PaperCard className="p-4 mb-4">
|
||||
<Input
|
||||
type="search"
|
||||
placeholder={t("admin.common.search")}
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
style={{ width: 240 }}
|
||||
/>
|
||||
</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.classes.headTeacher"),
|
||||
t("admin.classes.studentCount"),
|
||||
t("admin.common.createdAt"),
|
||||
]}
|
||||
>
|
||||
{data.items.map((cls) => (
|
||||
<TableRow key={cls.id}>
|
||||
<TableCell>{cls.name}</TableCell>
|
||||
<TableCell>{cls.gradeName}</TableCell>
|
||||
<TableCell>{cls.schoolName}</TableCell>
|
||||
<TableCell>{cls.headTeacherName || "—"}</TableCell>
|
||||
<TableCell>{cls.studentCount}</TableCell>
|
||||
<TableCell>
|
||||
{new Date(cls.createdAt).toLocaleDateString("zh-CN")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</Table>
|
||||
<Pagination
|
||||
page={data.page}
|
||||
pageSize={data.pageSize}
|
||||
total={data.total}
|
||||
hasNext={data.hasNext}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PaperCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
267
apps/admin-portal/src/app/admin/dashboard/page.tsx
Normal file
267
apps/admin-portal/src/app/admin/dashboard/page.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode } from "react";
|
||||
import { useDashboard } from "@/hooks/use-dashboard";
|
||||
import {
|
||||
PageHeader,
|
||||
PaperCard,
|
||||
LoadingState,
|
||||
ErrorState,
|
||||
Badge,
|
||||
} from "@/components/ui";
|
||||
import { NotificationPanel } from "@/components/notification-panel";
|
||||
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 DashboardPage(): ReactNode {
|
||||
const { data, loading, error } = useDashboard();
|
||||
|
||||
return (
|
||||
<div style={{ padding: 32, maxWidth: 1200, margin: "0 auto" }}>
|
||||
<PageHeader
|
||||
title={t("admin.dashboard.title")}
|
||||
description="全局概览与系统健康"
|
||||
/>
|
||||
|
||||
<NotificationPanel />
|
||||
|
||||
{loading && <LoadingState />}
|
||||
{error && <ErrorState message={error.message} />}
|
||||
{data && (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* 统计卡片 */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
label={t("admin.dashboard.totalTeachers")}
|
||||
value={data.totalTeachers}
|
||||
/>
|
||||
<StatCard
|
||||
label={t("admin.dashboard.totalStudents")}
|
||||
value={data.totalStudents}
|
||||
/>
|
||||
<StatCard
|
||||
label={t("admin.dashboard.totalClasses")}
|
||||
value={data.totalClasses}
|
||||
/>
|
||||
<StatCard
|
||||
label={t("admin.dashboard.totalSchools")}
|
||||
value={data.totalSchools}
|
||||
/>
|
||||
<StatCard
|
||||
label={t("admin.dashboard.schoolAvgScore")}
|
||||
value={data.schoolAvgScore.toFixed(1)}
|
||||
/>
|
||||
<StatCard
|
||||
label={t("admin.dashboard.activeUsersToday")}
|
||||
value={data.activeUsersToday}
|
||||
/>
|
||||
<StatCard
|
||||
label={t("admin.dashboard.auditEventsToday")}
|
||||
value={data.auditEventsToday}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 趋势图(简化的表格) */}
|
||||
<PaperCard className="p-4">
|
||||
<h3
|
||||
style={{
|
||||
fontFamily: "var(--font-serif)",
|
||||
fontSize: 16,
|
||||
color: "var(--color-ink)",
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
{t("admin.dashboard.trend")}
|
||||
</h3>
|
||||
<div style={{ overflowX: "auto" }}>
|
||||
<table
|
||||
style={{
|
||||
width: "100%",
|
||||
borderCollapse: "collapse",
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: "1px solid var(--color-rule)" }}>
|
||||
<th
|
||||
style={{
|
||||
textAlign: "left",
|
||||
padding: "6px 8px",
|
||||
color: "var(--color-ink-muted)",
|
||||
}}
|
||||
>
|
||||
日期
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
textAlign: "right",
|
||||
padding: "6px 8px",
|
||||
color: "var(--color-ink-muted)",
|
||||
}}
|
||||
>
|
||||
教师
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
textAlign: "right",
|
||||
padding: "6px 8px",
|
||||
color: "var(--color-ink-muted)",
|
||||
}}
|
||||
>
|
||||
学生
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
textAlign: "right",
|
||||
padding: "6px 8px",
|
||||
color: "var(--color-ink-muted)",
|
||||
}}
|
||||
>
|
||||
平均分
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.trend.map((row) => (
|
||||
<tr
|
||||
key={row.date}
|
||||
style={{ borderBottom: "1px solid var(--color-rule)" }}
|
||||
>
|
||||
<td
|
||||
style={{
|
||||
padding: "6px 8px",
|
||||
color: "var(--color-ink)",
|
||||
}}
|
||||
>
|
||||
{row.date}
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
padding: "6px 8px",
|
||||
textAlign: "right",
|
||||
color: "var(--color-ink)",
|
||||
}}
|
||||
>
|
||||
{row.teachers}
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
padding: "6px 8px",
|
||||
textAlign: "right",
|
||||
color: "var(--color-ink)",
|
||||
}}
|
||||
>
|
||||
{row.students}
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
padding: "6px 8px",
|
||||
textAlign: "right",
|
||||
color: "var(--color-ink)",
|
||||
fontFamily: "var(--font-mono)",
|
||||
}}
|
||||
>
|
||||
{row.avgScore.toFixed(1)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</PaperCard>
|
||||
|
||||
{/* 服务健康 */}
|
||||
<PaperCard className="p-4">
|
||||
<h3
|
||||
style={{
|
||||
fontFamily: "var(--font-serif)",
|
||||
fontSize: 16,
|
||||
color: "var(--color-ink)",
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
{t("admin.dashboard.serviceHealth")}
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{data.serviceHealth.map((svc) => (
|
||||
<div
|
||||
key={svc.serviceName}
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
padding: "8px 12px",
|
||||
borderBottom: "1px solid var(--color-rule)",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 12,
|
||||
color: "var(--color-ink)",
|
||||
}}
|
||||
>
|
||||
{svc.serviceName}
|
||||
</span>
|
||||
<div
|
||||
style={{ display: "flex", alignItems: "center", gap: 8 }}
|
||||
>
|
||||
<span
|
||||
style={{ fontSize: 11, color: "var(--color-ink-muted)" }}
|
||||
>
|
||||
{svc.latencyMs}ms
|
||||
</span>
|
||||
<Badge status={svc.status} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</PaperCard>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
60
apps/admin-portal/src/app/admin/layout.tsx
Normal file
60
apps/admin-portal/src/app/admin/layout.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode, useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { GraphQLProvider } from "@/providers/graphql-provider";
|
||||
import { AuthProvider, useAuth } from "@/providers/auth-provider";
|
||||
import { ToastProvider } from "@/providers/toast-provider";
|
||||
import { AdminShell } from "@/components/admin-shell";
|
||||
import { MswInitializer } from "@/components/msw-initializer";
|
||||
import { WebVitalsInitializer } from "@/components/web-vitals-initializer";
|
||||
|
||||
function AuthGuard({ children }: { children: ReactNode }) {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isAuthenticated) {
|
||||
router.replace("/login");
|
||||
}
|
||||
}, [isLoading, isAuthenticated, router]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
minHeight: "100vh",
|
||||
}}
|
||||
>
|
||||
<p style={{ color: "var(--color-ink-muted)" }}>加载中...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) return null;
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
export default function AdminLayout({ children }: { children: ReactNode }) {
|
||||
const [mswReady, setMswReady] = useState(false);
|
||||
|
||||
return (
|
||||
<GraphQLProvider>
|
||||
<WebVitalsInitializer />
|
||||
<MswInitializer onReady={() => setMswReady(true)} />
|
||||
{mswReady && (
|
||||
<AuthProvider>
|
||||
<ToastProvider>
|
||||
<AuthGuard>
|
||||
<AdminShell>{children}</AdminShell>
|
||||
</AuthGuard>
|
||||
</ToastProvider>
|
||||
</AuthProvider>
|
||||
)}
|
||||
</GraphQLProvider>
|
||||
);
|
||||
}
|
||||
113
apps/admin-portal/src/app/admin/organization/page.tsx
Normal file
113
apps/admin-portal/src/app/admin/organization/page.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode, useState } from "react";
|
||||
import { OrganizationTree } from "@/components/organization-tree";
|
||||
import { PageHeader, PaperCard } from "@/components/ui";
|
||||
import { t } from "@/lib/i18n";
|
||||
import type { OrganizationNode } from "@/types/view-models";
|
||||
|
||||
export default function OrganizationPage(): ReactNode {
|
||||
const [selected, setSelected] = useState<OrganizationNode | null>(null);
|
||||
|
||||
return (
|
||||
<div style={{ padding: 32, maxWidth: 1200, margin: "0 auto" }}>
|
||||
<PageHeader
|
||||
title={t("admin.organization.title")}
|
||||
description="学校 → 年级 → 班级 三级组织架构"
|
||||
/>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<PaperCard className="p-4" style={{ flex: 1 }}>
|
||||
<h3
|
||||
style={{
|
||||
fontFamily: "var(--font-serif)",
|
||||
fontSize: 14,
|
||||
color: "var(--color-ink-muted)",
|
||||
marginBottom: 12,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
}}
|
||||
>
|
||||
组织树
|
||||
</h3>
|
||||
<OrganizationTree onSelect={setSelected} />
|
||||
</PaperCard>
|
||||
|
||||
<PaperCard className="p-4" style={{ width: 320 }}>
|
||||
<h3
|
||||
style={{
|
||||
fontFamily: "var(--font-serif)",
|
||||
fontSize: 14,
|
||||
color: "var(--color-ink-muted)",
|
||||
marginBottom: 12,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
}}
|
||||
>
|
||||
节点详情
|
||||
</h3>
|
||||
{selected ? (
|
||||
<dl style={{ fontSize: 13, color: "var(--color-ink)" }}>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<dt style={{ color: "var(--color-ink-muted)", fontSize: 11 }}>
|
||||
名称
|
||||
</dt>
|
||||
<dd
|
||||
style={{
|
||||
margin: 0,
|
||||
fontFamily: "var(--font-serif)",
|
||||
fontSize: 16,
|
||||
}}
|
||||
>
|
||||
{selected.name}
|
||||
</dd>
|
||||
</div>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<dt style={{ color: "var(--color-ink-muted)", fontSize: 11 }}>
|
||||
类型
|
||||
</dt>
|
||||
<dd style={{ margin: 0 }}>
|
||||
{selected.type === "school"
|
||||
? "学校"
|
||||
: selected.type === "grade"
|
||||
? "年级"
|
||||
: "班级"}
|
||||
</dd>
|
||||
</div>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<dt style={{ color: "var(--color-ink-muted)", fontSize: 11 }}>
|
||||
ID
|
||||
</dt>
|
||||
<dd
|
||||
style={{
|
||||
margin: 0,
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 11,
|
||||
}}
|
||||
>
|
||||
{selected.id}
|
||||
</dd>
|
||||
</div>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<dt style={{ color: "var(--color-ink-muted)", fontSize: 11 }}>
|
||||
子节点数
|
||||
</dt>
|
||||
<dd style={{ margin: 0 }}>{selected.childrenCount}</dd>
|
||||
</div>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<dt style={{ color: "var(--color-ink-muted)", fontSize: 11 }}>
|
||||
排序
|
||||
</dt>
|
||||
<dd style={{ margin: 0 }}>{selected.sortOrder}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
) : (
|
||||
<p style={{ fontSize: 13, color: "var(--color-ink-muted)" }}>
|
||||
选择左侧节点查看详情
|
||||
</p>
|
||||
)}
|
||||
</PaperCard>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
115
apps/admin-portal/src/app/admin/permissions/page.tsx
Normal file
115
apps/admin-portal/src/app/admin/permissions/page.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode, useState } from "react";
|
||||
import { usePermissions } from "@/hooks/use-permissions";
|
||||
import {
|
||||
PageHeader,
|
||||
PaperCard,
|
||||
Input,
|
||||
Select,
|
||||
LoadingState,
|
||||
ErrorState,
|
||||
EmptyState,
|
||||
Table,
|
||||
TableRow,
|
||||
TableCell,
|
||||
Badge,
|
||||
} from "@/components/ui";
|
||||
import { t } from "@/lib/i18n";
|
||||
|
||||
export default function PermissionsPage(): ReactNode {
|
||||
const { data: permissions, loading, error } = usePermissions();
|
||||
const [search, setSearch] = useState("");
|
||||
const [resourceFilter, setResourceFilter] = useState("all");
|
||||
|
||||
const resources = Array.from(new Set(permissions.map((p) => p.resource)));
|
||||
|
||||
const filtered = permissions.filter((p) => {
|
||||
if (resourceFilter !== "all" && p.resource !== resourceFilter) return false;
|
||||
if (search) {
|
||||
const q = search.toLowerCase();
|
||||
return (
|
||||
p.code.toLowerCase().includes(q) ||
|
||||
p.name.toLowerCase().includes(q) ||
|
||||
p.description.toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={{ padding: 32, maxWidth: 1200, margin: "0 auto" }}>
|
||||
<PageHeader
|
||||
title={t("admin.permissions.title")}
|
||||
description="查看所有权限点定义"
|
||||
/>
|
||||
|
||||
<PaperCard className="p-4 mb-4">
|
||||
<div className="flex gap-3 items-center">
|
||||
<Input
|
||||
type="search"
|
||||
placeholder={t("admin.common.search")}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
style={{ width: 240 }}
|
||||
/>
|
||||
<Select
|
||||
value={resourceFilter}
|
||||
onChange={(e) => setResourceFilter(e.target.value)}
|
||||
>
|
||||
<option value="all">{t("admin.common.all")}</option>
|
||||
{resources.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{r}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</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.permissions.code"),
|
||||
t("admin.permissions.resource"),
|
||||
t("admin.permissions.action"),
|
||||
"名称",
|
||||
"说明",
|
||||
"系统",
|
||||
]}
|
||||
>
|
||||
{filtered.map((perm) => (
|
||||
<TableRow key={perm.id}>
|
||||
<TableCell
|
||||
style={{ fontFamily: "var(--font-mono)", fontSize: 12 }}
|
||||
>
|
||||
{perm.code}
|
||||
</TableCell>
|
||||
<TableCell>{perm.resource}</TableCell>
|
||||
<TableCell>{perm.action}</TableCell>
|
||||
<TableCell>{perm.name}</TableCell>
|
||||
<TableCell
|
||||
style={{ color: "var(--color-ink-muted)", fontSize: 12 }}
|
||||
>
|
||||
{perm.description}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{perm.isSystem ? <Badge status="active" /> : "—"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</Table>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PaperCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
291
apps/admin-portal/src/app/admin/roles/page.tsx
Normal file
291
apps/admin-portal/src/app/admin/roles/page.tsx
Normal file
@@ -0,0 +1,291 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode, useState } from "react";
|
||||
import {
|
||||
useRoles,
|
||||
useCreateRole,
|
||||
useUpdateRolePermissions,
|
||||
} from "@/hooks/use-roles";
|
||||
import { usePermissions } from "@/hooks/use-permissions";
|
||||
import { useToast } from "@/providers/toast-provider";
|
||||
import {
|
||||
PageHeader,
|
||||
PaperCard,
|
||||
Button,
|
||||
LoadingState,
|
||||
ErrorState,
|
||||
Badge,
|
||||
} from "@/components/ui";
|
||||
import { RolePermissionMatrix } from "@/components/role-permission-matrix";
|
||||
import { t } from "@/lib/i18n";
|
||||
import type { RoleViewModel } from "@/types/view-models";
|
||||
|
||||
export default function RolesPage(): ReactNode {
|
||||
const { data: roles, loading, error } = useRoles();
|
||||
const { data: permissions } = usePermissions();
|
||||
const { show } = useToast();
|
||||
const [createRole] = useCreateRole();
|
||||
const [updateRolePermissions] = useUpdateRolePermissions();
|
||||
const [selectedRoleId, setSelectedRoleId] = useState<string | null>(null);
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const [newRoleName, setNewRoleName] = useState("");
|
||||
const [newRoleCode, setNewRoleCode] = useState("");
|
||||
const [newRoleDescription, setNewRoleDescription] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
const selectedRole: RoleViewModel | null = selectedRoleId
|
||||
? (roles.find((r) => r.id === selectedRoleId) ?? null)
|
||||
: (roles[0] ?? null);
|
||||
|
||||
const handleCreateRole = async () => {
|
||||
if (!newRoleName || !newRoleCode) {
|
||||
show("warning", "请填写角色名称和代码");
|
||||
return;
|
||||
}
|
||||
setCreating(true);
|
||||
try {
|
||||
await createRole({
|
||||
name: newRoleName,
|
||||
code: newRoleCode,
|
||||
description: newRoleDescription,
|
||||
dataScope: "SCHOOL",
|
||||
});
|
||||
show("success", "角色已创建");
|
||||
setShowCreateForm(false);
|
||||
setNewRoleName("");
|
||||
setNewRoleCode("");
|
||||
setNewRoleDescription("");
|
||||
window.location.reload();
|
||||
} catch (err) {
|
||||
show(
|
||||
"error",
|
||||
"创建失败",
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSavePermissions = async (permissionCodes: string[]) => {
|
||||
if (!selectedRole) return;
|
||||
try {
|
||||
await updateRolePermissions(selectedRole.id, permissionCodes);
|
||||
show("success", "权限已更新");
|
||||
} 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.roles.title")}
|
||||
description="管理角色、配置权限矩阵"
|
||||
actions={
|
||||
<Button variant="primary" onClick={() => setShowCreateForm(true)}>
|
||||
{t("admin.roles.new")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex gap-4">
|
||||
{/* 左侧:角色列表 */}
|
||||
<PaperCard className="p-4" style={{ width: 280, flexShrink: 0 }}>
|
||||
<h3
|
||||
style={{
|
||||
fontFamily: "var(--font-serif)",
|
||||
fontSize: 14,
|
||||
color: "var(--color-ink-muted)",
|
||||
marginBottom: 12,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
}}
|
||||
>
|
||||
角色列表
|
||||
</h3>
|
||||
{loading && <LoadingState />}
|
||||
{error && <ErrorState message={error.message} />}
|
||||
<ul style={{ listStyle: "none", padding: 0, margin: 0 }}>
|
||||
{roles.map((role) => (
|
||||
<li key={role.id}>
|
||||
<button
|
||||
onClick={() => setSelectedRoleId(role.id)}
|
||||
style={{
|
||||
display: "block",
|
||||
width: "100%",
|
||||
textAlign: "left",
|
||||
padding: "8px 12px",
|
||||
background:
|
||||
selectedRole?.id === role.id
|
||||
? "var(--color-accent-light)"
|
||||
: "transparent",
|
||||
border: "none",
|
||||
borderLeft:
|
||||
selectedRole?.id === role.id
|
||||
? "2px solid var(--color-accent)"
|
||||
: "2px solid transparent",
|
||||
cursor: "pointer",
|
||||
color: "var(--color-ink)",
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 500 }}>{role.name}</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "var(--color-ink-muted)",
|
||||
display: "flex",
|
||||
gap: 6,
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<span>{role.code}</span>
|
||||
{role.isSystem && <Badge status="active" />}
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</PaperCard>
|
||||
|
||||
{/* 右侧:权限矩阵 */}
|
||||
<PaperCard className="p-4" style={{ flex: 1 }}>
|
||||
{selectedRole && permissions.length > 0 ? (
|
||||
<RolePermissionMatrix
|
||||
role={selectedRole}
|
||||
permissions={permissions}
|
||||
onSave={handleSavePermissions}
|
||||
/>
|
||||
) : (
|
||||
<LoadingState />
|
||||
)}
|
||||
</PaperCard>
|
||||
</div>
|
||||
|
||||
{/* 新建角色对话框 */}
|
||||
{showCreateForm && (
|
||||
<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: 400,
|
||||
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.roles.new")}
|
||||
</h2>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<label
|
||||
style={{ display: "flex", flexDirection: "column", gap: 4 }}
|
||||
>
|
||||
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
|
||||
角色名称
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={newRoleName}
|
||||
onChange={(e) => setNewRoleName(e.target.value)}
|
||||
style={{
|
||||
padding: "6px 10px",
|
||||
border: "1px solid var(--color-rule)",
|
||||
borderRadius: 4,
|
||||
fontSize: 13,
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label
|
||||
style={{ display: "flex", flexDirection: "column", gap: 4 }}
|
||||
>
|
||||
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
|
||||
角色代码
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={newRoleCode}
|
||||
onChange={(e) => setNewRoleCode(e.target.value)}
|
||||
placeholder="如 grade_leader"
|
||||
style={{
|
||||
padding: "6px 10px",
|
||||
border: "1px solid var(--color-rule)",
|
||||
borderRadius: 4,
|
||||
fontSize: 13,
|
||||
fontFamily: "var(--font-mono)",
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label
|
||||
style={{ display: "flex", flexDirection: "column", gap: 4 }}
|
||||
>
|
||||
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
|
||||
描述
|
||||
</span>
|
||||
<textarea
|
||||
value={newRoleDescription}
|
||||
onChange={(e) => setNewRoleDescription(e.target.value)}
|
||||
rows={3}
|
||||
style={{
|
||||
padding: "6px 10px",
|
||||
border: "1px solid var(--color-rule)",
|
||||
borderRadius: 4,
|
||||
fontSize: 13,
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
gap: 8,
|
||||
marginTop: 8,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setShowCreateForm(false)}
|
||||
>
|
||||
{t("admin.common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleCreateRole}
|
||||
disabled={creating}
|
||||
>
|
||||
{creating
|
||||
? t("admin.common.loading")
|
||||
: t("admin.common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
112
apps/admin-portal/src/app/admin/students/page.tsx
Normal file
112
apps/admin-portal/src/app/admin/students/page.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode, useState, useEffect } from "react";
|
||||
import { useStudents, useStudentFilter } from "@/hooks/use-students";
|
||||
import {
|
||||
PageHeader,
|
||||
PaperCard,
|
||||
Input,
|
||||
Select,
|
||||
Badge,
|
||||
LoadingState,
|
||||
ErrorState,
|
||||
EmptyState,
|
||||
Table,
|
||||
TableRow,
|
||||
TableCell,
|
||||
Pagination,
|
||||
} from "@/components/ui";
|
||||
import { t } from "@/lib/i18n";
|
||||
|
||||
export default function StudentsPage(): ReactNode {
|
||||
const { filter, setPage, setSearch, setStatus } = useStudentFilter();
|
||||
const { data, loading, error } = useStudents(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.students.title")}
|
||||
description="全校学生一览"
|
||||
/>
|
||||
|
||||
<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="active">{t("admin.common.active")}</option>
|
||||
<option value="disabled">{t("admin.common.disabled")}</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.email"),
|
||||
t("admin.common.name"),
|
||||
t("admin.common.class"),
|
||||
t("admin.common.grade"),
|
||||
t("admin.common.school"),
|
||||
t("admin.students.guardian"),
|
||||
t("admin.students.guardianPhone"),
|
||||
t("admin.common.status"),
|
||||
]}
|
||||
>
|
||||
{data.items.map((student) => (
|
||||
<TableRow key={student.id}>
|
||||
<TableCell>{student.email}</TableCell>
|
||||
<TableCell>{student.name}</TableCell>
|
||||
<TableCell>{student.className}</TableCell>
|
||||
<TableCell>{student.gradeName}</TableCell>
|
||||
<TableCell>{student.schoolName}</TableCell>
|
||||
<TableCell>{student.guardianName ?? "—"}</TableCell>
|
||||
<TableCell
|
||||
style={{ fontFamily: "var(--font-mono)", fontSize: 12 }}
|
||||
>
|
||||
{student.guardianPhone ?? "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge status={student.status} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</Table>
|
||||
<Pagination
|
||||
page={data.page}
|
||||
pageSize={data.pageSize}
|
||||
total={data.total}
|
||||
hasNext={data.hasNext}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PaperCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
214
apps/admin-portal/src/app/admin/system/page.tsx
Normal file
214
apps/admin-portal/src/app/admin/system/page.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode, useState, useEffect, type FormEvent } from "react";
|
||||
import {
|
||||
useSystemSettings,
|
||||
useUpdateSystemSettings,
|
||||
} from "@/hooks/use-system-settings";
|
||||
import { useToast } from "@/providers/toast-provider";
|
||||
import {
|
||||
PageHeader,
|
||||
PaperCard,
|
||||
Button,
|
||||
Input,
|
||||
Select,
|
||||
LoadingState,
|
||||
ErrorState,
|
||||
} from "@/components/ui";
|
||||
import { t } from "@/lib/i18n";
|
||||
import type { SystemSettingsViewModel } from "@/types/view-models";
|
||||
|
||||
export default function SystemPage(): ReactNode {
|
||||
const { data, loading, error } = useSystemSettings();
|
||||
const { show } = useToast();
|
||||
const [updateSettings] = useUpdateSystemSettings();
|
||||
const [form, setForm] = useState<SystemSettingsViewModel | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (data && !form) {
|
||||
setForm(data);
|
||||
}
|
||||
}, [data, form]);
|
||||
|
||||
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
if (!form) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await updateSettings(form);
|
||||
show("success", t("admin.system.saveSuccess"));
|
||||
} catch (err) {
|
||||
show(
|
||||
"error",
|
||||
"保存失败",
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const update = (field: keyof SystemSettingsViewModel, value: string) => {
|
||||
setForm((prev) => (prev ? { ...prev, [field]: value } : prev));
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: 32, maxWidth: 720, margin: "0 auto" }}>
|
||||
<PageHeader
|
||||
title={t("admin.system.title")}
|
||||
description="学校基本信息、学期与本地化设置"
|
||||
/>
|
||||
|
||||
<PaperCard className="p-6">
|
||||
{loading && <LoadingState />}
|
||||
{error && <ErrorState message={error.message} />}
|
||||
{form && (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
style={{ display: "flex", flexDirection: "column", gap: 16 }}
|
||||
>
|
||||
<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.schoolName")}
|
||||
</span>
|
||||
<Input
|
||||
type="text"
|
||||
value={form.schoolName}
|
||||
onChange={(e) => update("schoolName", e.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label
|
||||
style={{ display: "flex", flexDirection: "column", gap: 4 }}
|
||||
>
|
||||
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
|
||||
{t("admin.system.schoolCode")}
|
||||
</span>
|
||||
<Input
|
||||
type="text"
|
||||
value={form.schoolCode}
|
||||
onChange={(e) => update("schoolCode", e.target.value)}
|
||||
required
|
||||
style={{ fontFamily: "var(--font-mono)" }}
|
||||
/>
|
||||
</label>
|
||||
<label
|
||||
style={{ display: "flex", flexDirection: "column", gap: 4 }}
|
||||
>
|
||||
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
|
||||
{t("admin.system.academicYear")}
|
||||
</span>
|
||||
<Input
|
||||
type="text"
|
||||
value={form.academicYear}
|
||||
onChange={(e) => update("academicYear", e.target.value)}
|
||||
placeholder="如 2025-2026"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label
|
||||
style={{ display: "flex", flexDirection: "column", gap: 4 }}
|
||||
>
|
||||
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
|
||||
{t("admin.system.semester")}
|
||||
</span>
|
||||
<Select
|
||||
value={form.semester}
|
||||
onChange={(e) => update("semester", e.target.value)}
|
||||
>
|
||||
<option value="first">第一学期</option>
|
||||
<option value="second">第二学期</option>
|
||||
</Select>
|
||||
</label>
|
||||
<label
|
||||
style={{ display: "flex", flexDirection: "column", gap: 4 }}
|
||||
>
|
||||
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
|
||||
{t("admin.system.contactEmail")}
|
||||
</span>
|
||||
<Input
|
||||
type="email"
|
||||
value={form.contactEmail}
|
||||
onChange={(e) => update("contactEmail", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label
|
||||
style={{ display: "flex", flexDirection: "column", gap: 4 }}
|
||||
>
|
||||
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
|
||||
{t("admin.system.contactPhone")}
|
||||
</span>
|
||||
<Input
|
||||
type="tel"
|
||||
value={form.contactPhone}
|
||||
onChange={(e) => update("contactPhone", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 4,
|
||||
gridColumn: "span 2",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
|
||||
{t("admin.system.address")}
|
||||
</span>
|
||||
<Input
|
||||
type="text"
|
||||
value={form.address}
|
||||
onChange={(e) => update("address", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label
|
||||
style={{ display: "flex", flexDirection: "column", gap: 4 }}
|
||||
>
|
||||
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
|
||||
{t("admin.system.timezone")}
|
||||
</span>
|
||||
<Select
|
||||
value={form.timezone}
|
||||
onChange={(e) => update("timezone", e.target.value)}
|
||||
>
|
||||
<option value="Asia/Shanghai">Asia/Shanghai</option>
|
||||
<option value="Asia/Hong_Kong">Asia/Hong_Kong</option>
|
||||
<option value="UTC">UTC</option>
|
||||
</Select>
|
||||
</label>
|
||||
<label
|
||||
style={{ display: "flex", flexDirection: "column", gap: 4 }}
|
||||
>
|
||||
<span style={{ fontSize: 12, color: "var(--color-ink)" }}>
|
||||
{t("admin.system.locale")}
|
||||
</span>
|
||||
<Select
|
||||
value={form.locale}
|
||||
onChange={(e) => update("locale", e.target.value)}
|
||||
>
|
||||
<option value="zh-CN">简体中文</option>
|
||||
<option value="en-US">English (US)</option>
|
||||
</Select>
|
||||
</label>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
marginTop: 8,
|
||||
}}
|
||||
>
|
||||
<Button variant="primary" type="submit" disabled={saving}>
|
||||
{saving ? t("admin.common.loading") : t("admin.common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</PaperCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
115
apps/admin-portal/src/app/admin/teachers/page.tsx
Normal file
115
apps/admin-portal/src/app/admin/teachers/page.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode, useState, useEffect } from "react";
|
||||
import { useTeachers, useTeacherFilter } from "@/hooks/use-teachers";
|
||||
import {
|
||||
PageHeader,
|
||||
PaperCard,
|
||||
Input,
|
||||
Select,
|
||||
Badge,
|
||||
LoadingState,
|
||||
ErrorState,
|
||||
EmptyState,
|
||||
Table,
|
||||
TableRow,
|
||||
TableCell,
|
||||
Pagination,
|
||||
} from "@/components/ui";
|
||||
import { t } from "@/lib/i18n";
|
||||
|
||||
export default function TeachersPage(): ReactNode {
|
||||
const { filter, setPage, setSearch, setStatus } = useTeacherFilter();
|
||||
const { data, loading, error } = useTeachers(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.teachers.title")}
|
||||
description="全校教师一览"
|
||||
/>
|
||||
|
||||
<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="active">{t("admin.common.active")}</option>
|
||||
<option value="disabled">{t("admin.common.disabled")}</option>
|
||||
<option value="locked">{t("admin.common.locked")}</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.email"),
|
||||
t("admin.common.name"),
|
||||
t("admin.common.school"),
|
||||
t("admin.teachers.subjects"),
|
||||
t("admin.teachers.classCount"),
|
||||
t("admin.common.status"),
|
||||
t("admin.common.lastLoginAt"),
|
||||
]}
|
||||
>
|
||||
{data.items.map((teacher) => (
|
||||
<TableRow key={teacher.id}>
|
||||
<TableCell>{teacher.email}</TableCell>
|
||||
<TableCell>{teacher.name}</TableCell>
|
||||
<TableCell>{teacher.schoolName}</TableCell>
|
||||
<TableCell>
|
||||
{teacher.subjects.join(", ") || "—"}
|
||||
</TableCell>
|
||||
<TableCell>{teacher.classCount}</TableCell>
|
||||
<TableCell>
|
||||
<Badge status={teacher.status} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{teacher.lastLoginAt
|
||||
? new Date(teacher.lastLoginAt).toLocaleDateString(
|
||||
"zh-CN",
|
||||
)
|
||||
: "—"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</Table>
|
||||
<Pagination
|
||||
page={data.page}
|
||||
pageSize={data.pageSize}
|
||||
total={data.total}
|
||||
hasNext={data.hasNext}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PaperCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
166
apps/admin-portal/src/app/admin/users/page.tsx
Normal file
166
apps/admin-portal/src/app/admin/users/page.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode, useState, useEffect } from "react";
|
||||
import {
|
||||
useUsers,
|
||||
useUserFilter,
|
||||
useCreateUser,
|
||||
useUpdateUser,
|
||||
useToggleUserStatus,
|
||||
} from "@/hooks/use-users";
|
||||
import { useRoles } from "@/hooks/use-roles";
|
||||
import { useToast } from "@/providers/toast-provider";
|
||||
import {
|
||||
PageHeader,
|
||||
PaperCard,
|
||||
Input,
|
||||
Select,
|
||||
LoadingState,
|
||||
ErrorState,
|
||||
EmptyState,
|
||||
Pagination,
|
||||
} from "@/components/ui";
|
||||
import { UserManagementTable } from "@/components/user-management-table";
|
||||
import { UserFormModal, type UserFormData } from "@/components/user-form-modal";
|
||||
import { t } from "@/lib/i18n";
|
||||
import type { UserViewModel } from "@/types/view-models";
|
||||
|
||||
export default function UsersPage(): ReactNode {
|
||||
const { filter, setPage, setSearch, setStatus } = useUserFilter();
|
||||
const { data, loading, error } = useUsers(filter);
|
||||
const { data: roles } = useRoles();
|
||||
const { show } = useToast();
|
||||
const [createUser] = useCreateUser();
|
||||
const [updateUser] = useUpdateUser();
|
||||
const [toggleStatus] = useToggleUserStatus();
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editingUser, setEditingUser] = useState<UserViewModel | null>(null);
|
||||
const [searchInput, setSearchInput] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setSearch(searchInput), 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchInput, setSearch]);
|
||||
|
||||
const handleCreate = () => {
|
||||
setEditingUser(null);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleEdit = (user: UserViewModel) => {
|
||||
setEditingUser(user);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleToggleStatus = async (user: UserViewModel) => {
|
||||
const newStatus = user.status === "active" ? "disabled" : "active";
|
||||
try {
|
||||
await toggleStatus(user.id, newStatus);
|
||||
show("success", "状态已更新");
|
||||
window.location.reload();
|
||||
} catch (err) {
|
||||
show(
|
||||
"error",
|
||||
"操作失败",
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (formData: UserFormData) => {
|
||||
try {
|
||||
if (editingUser) {
|
||||
await updateUser(editingUser.id, {
|
||||
name: formData.name,
|
||||
email: formData.email,
|
||||
roleIds: formData.roleIds,
|
||||
dataScope: formData.dataScope,
|
||||
});
|
||||
show("success", "用户已更新");
|
||||
} else {
|
||||
await createUser({
|
||||
email: formData.email,
|
||||
name: formData.name,
|
||||
password: formData.password ?? "",
|
||||
roleIds: formData.roleIds,
|
||||
dataScope: formData.dataScope,
|
||||
});
|
||||
show("success", "用户已创建");
|
||||
}
|
||||
setModalOpen(false);
|
||||
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.users.title")}
|
||||
description="管理所有用户账号、角色分配与状态"
|
||||
/>
|
||||
|
||||
<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="active">{t("admin.common.active")}</option>
|
||||
<option value="disabled">{t("admin.common.disabled")}</option>
|
||||
<option value="locked">{t("admin.common.locked")}</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")} />
|
||||
) : (
|
||||
<>
|
||||
<UserManagementTable
|
||||
users={data.items}
|
||||
onEdit={handleEdit}
|
||||
onToggleStatus={handleToggleStatus}
|
||||
onCreate={handleCreate}
|
||||
/>
|
||||
<Pagination
|
||||
page={data.page}
|
||||
pageSize={data.pageSize}
|
||||
total={data.total}
|
||||
hasNext={data.hasNext}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PaperCard>
|
||||
|
||||
<UserFormModal
|
||||
open={modalOpen}
|
||||
user={editingUser}
|
||||
roles={roles}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
284
apps/admin-portal/src/app/admin/viewports/page.tsx
Normal file
284
apps/admin-portal/src/app/admin/viewports/page.tsx
Normal file
@@ -0,0 +1,284 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode, useState } from "react";
|
||||
import { DndContext, type DragEndEvent, closestCenter } from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
arrayMove,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import type { ViewportConfigViewModel } from "@/types/view-models";
|
||||
import { useViewports, useUpdateViewport } from "@/hooks/use-viewports";
|
||||
import { useToast } from "@/providers/toast-provider";
|
||||
import {
|
||||
PageHeader,
|
||||
PaperCard,
|
||||
Button,
|
||||
LoadingState,
|
||||
ErrorState,
|
||||
EmptyState,
|
||||
Input,
|
||||
Select,
|
||||
} from "@/components/ui";
|
||||
import { t } from "@/lib/i18n";
|
||||
|
||||
interface SortableViewportItemProps {
|
||||
viewport: ViewportConfigViewModel;
|
||||
onToggleVisible: (id: string, isVisible: boolean) => void;
|
||||
onLabelChange: (id: string, label: string) => void;
|
||||
onPermissionChange: (id: string, perm: string | null) => void;
|
||||
}
|
||||
|
||||
function SortableViewportItem({
|
||||
viewport,
|
||||
onToggleVisible,
|
||||
onLabelChange,
|
||||
onPermissionChange,
|
||||
}: SortableViewportItemProps): ReactNode {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: viewport.id });
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
padding: "12px 16px",
|
||||
borderBottom: "1px solid var(--color-rule)",
|
||||
display: "grid",
|
||||
gridTemplateColumns: "24px 120px 1fr 180px 80px 80px",
|
||||
gap: 12,
|
||||
alignItems: "center",
|
||||
background: "var(--bg-paper)",
|
||||
fontSize: 13,
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style}>
|
||||
<button
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
aria-label="拖拽排序"
|
||||
style={{
|
||||
cursor: "grab",
|
||||
background: "none",
|
||||
border: "none",
|
||||
color: "var(--color-ink-muted)",
|
||||
}}
|
||||
>
|
||||
⋮⋮
|
||||
</button>
|
||||
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12 }}>
|
||||
{viewport.key}
|
||||
</span>
|
||||
<Input
|
||||
type="text"
|
||||
value={viewport.label}
|
||||
onChange={(e) => onLabelChange(viewport.id, e.target.value)}
|
||||
style={{ fontSize: 13 }}
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
value={viewport.requiredPermission ?? ""}
|
||||
onChange={(e) =>
|
||||
onPermissionChange(viewport.id, e.target.value || null)
|
||||
}
|
||||
placeholder="无需权限"
|
||||
style={{ fontSize: 12, fontFamily: "var(--font-mono)" }}
|
||||
/>
|
||||
<span style={{ fontSize: 11, color: "var(--color-ink-muted)" }}>
|
||||
{viewport.scope}
|
||||
</span>
|
||||
<span style={{ display: "flex", alignItems: "center", gap: 4 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={viewport.isVisible}
|
||||
onChange={(e) => onToggleVisible(viewport.id, e.target.checked)}
|
||||
aria-label="是否可见"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ViewportsPage(): ReactNode {
|
||||
const { data: viewports, loading, error } = useViewports();
|
||||
const { show } = useToast();
|
||||
const [updateViewport] = useUpdateViewport();
|
||||
const [localViewports, setLocalViewports] = useState<
|
||||
ViewportConfigViewModel[] | null
|
||||
>(null);
|
||||
const [scopeFilter, setScopeFilter] = useState<string>("all");
|
||||
|
||||
// 同步远端数据
|
||||
if (viewports.length > 0 && localViewports === null) {
|
||||
setLocalViewports(viewports);
|
||||
}
|
||||
|
||||
const filteredViewports = (localViewports ?? viewports).filter(
|
||||
(v) => scopeFilter === "all" || v.scope === scopeFilter,
|
||||
);
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
if (!localViewports) return;
|
||||
const oldIndex = localViewports.findIndex((v) => v.id === active.id);
|
||||
const newIndex = localViewports.findIndex((v) => v.id === over.id);
|
||||
if (oldIndex < 0 || newIndex < 0) return;
|
||||
const reordered = arrayMove(localViewports, oldIndex, newIndex).map(
|
||||
(v, i) => ({ ...v, sortOrder: i + 1 }),
|
||||
);
|
||||
setLocalViewports(reordered);
|
||||
};
|
||||
|
||||
const handleToggleVisible = async (id: string, isVisible: boolean) => {
|
||||
try {
|
||||
await updateViewport(id, { isVisible });
|
||||
show("success", "已更新");
|
||||
setLocalViewports(
|
||||
(prev) =>
|
||||
prev?.map((v) => (v.id === id ? { ...v, isVisible } : v)) ?? null,
|
||||
);
|
||||
} catch (err) {
|
||||
show(
|
||||
"error",
|
||||
"更新失败",
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLabelChange = async (id: string, label: string) => {
|
||||
setLocalViewports(
|
||||
(prev) => prev?.map((v) => (v.id === id ? { ...v, label } : v)) ?? null,
|
||||
);
|
||||
};
|
||||
|
||||
const handlePermissionChange = async (id: string, perm: string | null) => {
|
||||
setLocalViewports(
|
||||
(prev) =>
|
||||
prev?.map((v) =>
|
||||
v.id === id ? { ...v, requiredPermission: perm } : v,
|
||||
) ?? null,
|
||||
);
|
||||
};
|
||||
|
||||
const handleSaveOrder = async () => {
|
||||
if (!localViewports) return;
|
||||
try {
|
||||
for (const vp of localViewports) {
|
||||
await updateViewport(vp.id, {
|
||||
sortOrder: vp.sortOrder,
|
||||
label: vp.label,
|
||||
requiredPermission: vp.requiredPermission,
|
||||
isVisible: vp.isVisible,
|
||||
});
|
||||
}
|
||||
show("success", "全部已保存");
|
||||
} 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.viewports.title")}
|
||||
description="配置各端视口的路由、权限与排序"
|
||||
actions={
|
||||
<>
|
||||
<Select
|
||||
value={scopeFilter}
|
||||
onChange={(e) => setScopeFilter(e.target.value)}
|
||||
>
|
||||
<option value="all">{t("admin.common.all")}</option>
|
||||
<option value="teacher">教师端</option>
|
||||
<option value="student">学生端</option>
|
||||
<option value="parent">家长端</option>
|
||||
<option value="admin">管理端</option>
|
||||
</Select>
|
||||
<Button variant="primary" onClick={handleSaveOrder}>
|
||||
{t("admin.common.save")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<PaperCard className="p-4">
|
||||
{loading && <LoadingState />}
|
||||
{error && <ErrorState message={error.message} />}
|
||||
{!loading && !error && (
|
||||
<>
|
||||
{filteredViewports.length === 0 ? (
|
||||
<EmptyState message={t("admin.common.empty")} />
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
padding: "8px 16px",
|
||||
borderBottom: "2px solid var(--color-rule)",
|
||||
display: "grid",
|
||||
gridTemplateColumns: "24px 120px 1fr 180px 80px 80px",
|
||||
gap: 12,
|
||||
fontSize: 11,
|
||||
color: "var(--color-ink-muted)",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
}}
|
||||
>
|
||||
<span></span>
|
||||
<span>{t("admin.viewports.key")}</span>
|
||||
<span>{t("admin.viewports.label")}</span>
|
||||
<span>{t("admin.viewports.requiredPermission")}</span>
|
||||
<span>{t("admin.viewports.scope")}</span>
|
||||
<span>{t("admin.viewports.visible")}</span>
|
||||
</div>
|
||||
<DndContext
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={filteredViewports.map((v) => v.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{filteredViewports.map((vp) => (
|
||||
<SortableViewportItem
|
||||
key={vp.id}
|
||||
viewport={vp}
|
||||
onToggleVisible={handleToggleVisible}
|
||||
onLabelChange={handleLabelChange}
|
||||
onPermissionChange={handlePermissionChange}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "var(--color-ink-muted)",
|
||||
marginTop: 12,
|
||||
padding: "0 16px",
|
||||
}}
|
||||
>
|
||||
{t("admin.viewports.dragHint")}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PaperCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user