feat: split settings management pages
This commit is contained in:
7
app/(app)/app/loading.tsx
Normal file
7
app/(app)/app/loading.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
export default function AppLoading(): React.ReactElement {
|
||||
return (
|
||||
<div className="fixed inset-x-0 top-0 z-50 h-1 overflow-hidden bg-secondary">
|
||||
<div className="loading-progress h-full bg-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
100
app/(app)/app/settings/audit/page.tsx
Normal file
100
app/(app)/app/settings/audit/page.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { getCurrentAuthContext } from "@/modules/core/server/auth";
|
||||
import { hasPermission } from "@/modules/core/server/permissions";
|
||||
import { readData } from "@/modules/core/server/store";
|
||||
import { TableToolbar } from "@/modules/settings/components/TableToolbar";
|
||||
import { getPage, getPageCount, getSearchParam, paginate, type SearchParams } from "@/modules/settings/lib/pagination";
|
||||
|
||||
type AuditPageProps = {
|
||||
searchParams?: Promise<SearchParams>;
|
||||
};
|
||||
|
||||
export default async function AuditPage({ searchParams }: AuditPageProps): Promise<React.ReactElement> {
|
||||
const context = await getCurrentAuthContext();
|
||||
if (!context) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
if (!hasPermission(context.permissions, "audit:read")) {
|
||||
redirect("/app/dashboard");
|
||||
}
|
||||
|
||||
const params = (await searchParams) ?? {};
|
||||
const query = getSearchParam(params, "q");
|
||||
const page = getPage(params);
|
||||
const data = await readData();
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
const filteredLogs = data.auditLogs.filter((log) =>
|
||||
[log.actorEmail ?? "系统", log.action, log.targetType, log.targetId ?? "", log.result, log.reason]
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
.includes(normalizedQuery),
|
||||
);
|
||||
const pageCount = getPageCount(filteredLogs.length);
|
||||
const visibleLogs = paginate(filteredLogs, page);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<section>
|
||||
<Badge variant="success">Audit</Badge>
|
||||
<h2 className="mt-3 text-xl font-semibold">审计日志</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">关键操作、拒绝访问和故障处理留痕。</p>
|
||||
</section>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>审计列表</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<TableToolbar
|
||||
page={Math.min(page, pageCount)}
|
||||
pageCount={pageCount}
|
||||
pathname="/app/settings/audit"
|
||||
query={query}
|
||||
searchPlaceholder="搜索操作者、动作、对象、结果"
|
||||
total={filteredLogs.length}
|
||||
/>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[900px] text-sm">
|
||||
<thead className="bg-secondary text-left text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-4 py-3 font-medium">时间</th>
|
||||
<th className="px-4 py-3 font-medium">操作者</th>
|
||||
<th className="px-4 py-3 font-medium">动作</th>
|
||||
<th className="px-4 py-3 font-medium">对象</th>
|
||||
<th className="px-4 py-3 font-medium">结果</th>
|
||||
<th className="px-4 py-3 font-medium">原因</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y bg-card">
|
||||
{visibleLogs.map((log) => (
|
||||
<tr className="table-row-enter hover:bg-secondary/45" key={log.id}>
|
||||
<td className="px-4 py-3 text-muted-foreground">{new Date(log.timestamp).toLocaleString("zh-CN")}</td>
|
||||
<td className="px-4 py-3">{log.actorEmail ?? "系统"}</td>
|
||||
<td className="px-4 py-3">{log.action}</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">
|
||||
{log.targetType}
|
||||
{log.targetId ? ` / ${log.targetId.slice(0, 8)}` : ""}
|
||||
</td>
|
||||
<td className="px-4 py-3">{log.result}</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">{log.reason}</td>
|
||||
</tr>
|
||||
))}
|
||||
{visibleLogs.length === 0 ? (
|
||||
<tr>
|
||||
<td className="px-4 py-8 text-center text-muted-foreground" colSpan={6}>
|
||||
暂无匹配审计
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
37
app/(app)/app/settings/layout.tsx
Normal file
37
app/(app)/app/settings/layout.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import Link from "next/link";
|
||||
import { Activity, Building2, ListChecks, Settings, Users } from "lucide-react";
|
||||
|
||||
const settingsNavItems = [
|
||||
{ href: "/app/settings/users", label: "用户管理", icon: Users },
|
||||
{ href: "/app/settings/organizations", label: "机构管理", icon: Building2 },
|
||||
{ href: "/app/settings/roles", label: "角色权限", icon: Settings },
|
||||
{ href: "/app/settings/audit", label: "审计日志", icon: ListChecks },
|
||||
{ href: "/app/settings/status", label: "运行状态", icon: Activity },
|
||||
];
|
||||
|
||||
type SettingsLayoutProps = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export default function SettingsLayout({ children }: SettingsLayoutProps): React.ReactElement {
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-5">
|
||||
<section className="border-b pb-4">
|
||||
<h1 className="text-2xl font-semibold tracking-normal">系统设置</h1>
|
||||
<nav className="mt-4 flex gap-2 overflow-x-auto" aria-label="系统设置导航">
|
||||
{settingsNavItems.map((item) => (
|
||||
<Link
|
||||
className="inline-flex min-h-10 shrink-0 items-center gap-2 rounded-md border bg-card px-3 text-sm font-medium transition-colors hover:bg-secondary"
|
||||
href={item.href}
|
||||
key={item.href}
|
||||
>
|
||||
<item.icon className="size-4 text-muted-foreground" aria-hidden="true" />
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</section>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
102
app/(app)/app/settings/organizations/page.tsx
Normal file
102
app/(app)/app/settings/organizations/page.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { getCurrentAuthContext } from "@/modules/core/server/auth";
|
||||
import { hasPermission } from "@/modules/core/server/permissions";
|
||||
import { readData } from "@/modules/core/server/store";
|
||||
import {
|
||||
OrganizationManagementClient,
|
||||
OrganizationRowActions,
|
||||
} from "@/modules/settings/components/OrganizationManagementClient";
|
||||
import { TableToolbar } from "@/modules/settings/components/TableToolbar";
|
||||
import { getPage, getPageCount, getSearchParam, paginate, type SearchParams } from "@/modules/settings/lib/pagination";
|
||||
|
||||
type OrganizationsPageProps = {
|
||||
searchParams?: Promise<SearchParams>;
|
||||
};
|
||||
|
||||
export default async function OrganizationsPage({ searchParams }: OrganizationsPageProps): Promise<React.ReactElement> {
|
||||
const context = await getCurrentAuthContext();
|
||||
if (!context) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
if (!hasPermission(context.permissions, "organization:read")) {
|
||||
redirect("/app/dashboard");
|
||||
}
|
||||
|
||||
const params = (await searchParams) ?? {};
|
||||
const query = getSearchParam(params, "q");
|
||||
const page = getPage(params);
|
||||
const data = await readData();
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
const filteredOrganizations = data.organizations.filter((organization) =>
|
||||
[organization.name, organization.slug, organization.status].join(" ").toLowerCase().includes(normalizedQuery),
|
||||
);
|
||||
const pageCount = getPageCount(filteredOrganizations.length);
|
||||
const visibleOrganizations = paginate(filteredOrganizations, page);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<section>
|
||||
<Badge variant="success">Organizations</Badge>
|
||||
<h2 className="mt-3 text-xl font-semibold">机构管理</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">多机构租户、启停状态和机构角色初始化。</p>
|
||||
</section>
|
||||
|
||||
{hasPermission(context.permissions, "organization:manage") ? <OrganizationManagementClient /> : null}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>机构列表</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<TableToolbar
|
||||
page={Math.min(page, pageCount)}
|
||||
pageCount={pageCount}
|
||||
pathname="/app/settings/organizations"
|
||||
query={query}
|
||||
searchPlaceholder="搜索机构名称、标识、状态"
|
||||
total={filteredOrganizations.length}
|
||||
/>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[820px] text-sm">
|
||||
<thead className="bg-secondary text-left text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-4 py-3 font-medium">机构</th>
|
||||
<th className="px-4 py-3 font-medium">Slug</th>
|
||||
<th className="px-4 py-3 font-medium">状态</th>
|
||||
<th className="px-4 py-3 text-right font-medium">创建时间</th>
|
||||
<th className="px-4 py-3 text-right font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y bg-card">
|
||||
{visibleOrganizations.map((organization) => (
|
||||
<tr className="table-row-enter hover:bg-secondary/45" key={organization.id}>
|
||||
<td className="px-4 py-3 font-medium">{organization.name}</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">{organization.slug}</td>
|
||||
<td className="px-4 py-3">{organization.status === "active" ? "启用" : "停用"}</td>
|
||||
<td className="px-4 py-3 text-right text-muted-foreground">
|
||||
{new Date(organization.createdAt).toLocaleString("zh-CN")}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<OrganizationRowActions organization={organization} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{visibleOrganizations.length === 0 ? (
|
||||
<tr>
|
||||
<td className="px-4 py-8 text-center text-muted-foreground" colSpan={5}>
|
||||
暂无匹配机构
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,31 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getCurrentAuthContext, toPublicAccount } from "@/modules/core/server/auth";
|
||||
import { getRoleDefinitions, hasPermission } from "@/modules/core/server/permissions";
|
||||
import { readData } from "@/modules/core/server/store";
|
||||
import { SettingsOverview } from "@/modules/settings/components/SettingsOverview";
|
||||
|
||||
export default async function SettingsPage(): Promise<React.ReactElement> {
|
||||
const context = await getCurrentAuthContext();
|
||||
if (!context) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
if (!hasPermission(context.permissions, "account:read")) {
|
||||
redirect("/app/dashboard");
|
||||
}
|
||||
|
||||
const data = await readData();
|
||||
const roles = await getRoleDefinitions(context.organization?.id);
|
||||
return (
|
||||
<SettingsOverview
|
||||
accounts={data.accounts.map((account) => toPublicAccount(account))}
|
||||
roles={roles}
|
||||
auditLogs={data.auditLogs.slice(0, 100)}
|
||||
organizations={data.organizations}
|
||||
memberships={data.memberships}
|
||||
joinRequests={data.joinRequests}
|
||||
incidents={data.incidents}
|
||||
/>
|
||||
);
|
||||
redirect("/app/settings/users");
|
||||
}
|
||||
|
||||
101
app/(app)/app/settings/roles/page.tsx
Normal file
101
app/(app)/app/settings/roles/page.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { getCurrentAuthContext } from "@/modules/core/server/auth";
|
||||
import { getRoleDefinitions, hasPermission, PERMISSION_DEFINITIONS } from "@/modules/core/server/permissions";
|
||||
import { RoleManagementClient, RoleRowActions } from "@/modules/settings/components/RoleManagementClient";
|
||||
import { TableToolbar } from "@/modules/settings/components/TableToolbar";
|
||||
import { getPage, getPageCount, getSearchParam, paginate, type SearchParams } from "@/modules/settings/lib/pagination";
|
||||
|
||||
type RolesPageProps = {
|
||||
searchParams?: Promise<SearchParams>;
|
||||
};
|
||||
|
||||
export default async function RolesPage({ searchParams }: RolesPageProps): Promise<React.ReactElement> {
|
||||
const context = await getCurrentAuthContext();
|
||||
if (!context) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
if (!hasPermission(context.permissions, "role:read")) {
|
||||
redirect("/app/dashboard");
|
||||
}
|
||||
|
||||
const params = (await searchParams) ?? {};
|
||||
const query = getSearchParam(params, "q");
|
||||
const page = getPage(params);
|
||||
const roles = await getRoleDefinitions(context.organization?.id);
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
const filteredRoles = roles.filter((role) =>
|
||||
[role.label, role.key, role.description, role.scope, ...role.permissions].join(" ").toLowerCase().includes(normalizedQuery),
|
||||
);
|
||||
const pageCount = getPageCount(filteredRoles.length);
|
||||
const visibleRoles = paginate(filteredRoles, page);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<section>
|
||||
<Badge variant="success">RBAC</Badge>
|
||||
<h2 className="mt-3 text-xl font-semibold">角色权限</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">系统权限点、内置角色和机构自定义角色。</p>
|
||||
</section>
|
||||
|
||||
{hasPermission(context.permissions, "role:manage") ? <RoleManagementClient permissions={PERMISSION_DEFINITIONS} /> : null}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>角色列表</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<TableToolbar
|
||||
page={Math.min(page, pageCount)}
|
||||
pageCount={pageCount}
|
||||
pathname="/app/settings/roles"
|
||||
query={query}
|
||||
searchPlaceholder="搜索角色、权限、说明"
|
||||
total={filteredRoles.length}
|
||||
/>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[980px] text-sm">
|
||||
<thead className="bg-secondary text-left text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-4 py-3 font-medium">角色</th>
|
||||
<th className="px-4 py-3 font-medium">范围</th>
|
||||
<th className="px-4 py-3 font-medium">类型</th>
|
||||
<th className="px-4 py-3 font-medium">状态</th>
|
||||
<th className="px-4 py-3 font-medium">权限</th>
|
||||
<th className="px-4 py-3 text-right font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y bg-card">
|
||||
{visibleRoles.map((role) => (
|
||||
<tr className="table-row-enter align-top hover:bg-secondary/45" key={role.id}>
|
||||
<td className="px-4 py-3">
|
||||
<p className="font-medium">{role.label}</p>
|
||||
<p className="text-xs text-muted-foreground">{role.description}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3">{role.scope === "platform" ? "平台" : "机构"}</td>
|
||||
<td className="px-4 py-3">{role.isSystem ? "内置" : "自定义"}</td>
|
||||
<td className="px-4 py-3">{role.isEnabled ? "启用" : "停用"}</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">{role.permissions.join(", ") || "-"}</td>
|
||||
<td className="px-4 py-3">
|
||||
<RoleRowActions permissions={PERMISSION_DEFINITIONS} role={role} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{visibleRoles.length === 0 ? (
|
||||
<tr>
|
||||
<td className="px-4 py-8 text-center text-muted-foreground" colSpan={6}>
|
||||
暂无匹配角色
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
138
app/(app)/app/settings/status/page.tsx
Normal file
138
app/(app)/app/settings/status/page.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { checkDatabaseConnection } from "@/modules/core/server/db";
|
||||
import { getCurrentAuthContext } from "@/modules/core/server/auth";
|
||||
import { hasPermission } from "@/modules/core/server/permissions";
|
||||
import { readData } from "@/modules/core/server/store";
|
||||
import { IncidentStatusActions } from "@/modules/settings/components/IncidentStatusActions";
|
||||
import { TableToolbar } from "@/modules/settings/components/TableToolbar";
|
||||
import { getPage, getPageCount, getSearchParam, paginate, type SearchParams } from "@/modules/settings/lib/pagination";
|
||||
|
||||
type StatusPageProps = {
|
||||
searchParams?: Promise<SearchParams>;
|
||||
};
|
||||
|
||||
export default async function StatusPage({ searchParams }: StatusPageProps): Promise<React.ReactElement> {
|
||||
const context = await getCurrentAuthContext();
|
||||
if (!context) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
if (!hasPermission(context.permissions, "incident:read")) {
|
||||
redirect("/app/dashboard");
|
||||
}
|
||||
|
||||
const params = (await searchParams) ?? {};
|
||||
const query = getSearchParam(params, "q");
|
||||
const page = getPage(params);
|
||||
const [data, databaseHealth] = await Promise.all([readData(), checkDatabaseConnection()]);
|
||||
const canManageIncidents = hasPermission(context.permissions, "incident:manage");
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
const filteredIncidents = data.incidents.filter((incident) =>
|
||||
[incident.title, incident.description, incident.source, incident.severity, incident.status]
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
.includes(normalizedQuery),
|
||||
);
|
||||
const pageCount = getPageCount(filteredIncidents.length);
|
||||
const visibleIncidents = paginate(filteredIncidents, page);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<section>
|
||||
<Badge variant={databaseHealth.ok ? "success" : "danger"}>{databaseHealth.ok ? "Healthy" : "Degraded"}</Badge>
|
||||
<h2 className="mt-3 text-xl font-semibold">运行状态</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{databaseHealth.reason}</p>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-4 md:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-3xl">{data.organizations.length}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0 text-sm text-muted-foreground">机构</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-3xl">{data.accounts.length}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0 text-sm text-muted-foreground">账号</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-3xl">{data.beds.length}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0 text-sm text-muted-foreground">床位</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-3xl">{data.incidents.length}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0 text-sm text-muted-foreground">故障事件</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>故障中心</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<TableToolbar
|
||||
page={Math.min(page, pageCount)}
|
||||
pageCount={pageCount}
|
||||
pathname="/app/settings/status"
|
||||
query={query}
|
||||
searchPlaceholder="搜索故障标题、来源、状态"
|
||||
total={filteredIncidents.length}
|
||||
/>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[960px] text-sm">
|
||||
<thead className="bg-secondary text-left text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-4 py-3 font-medium">标题</th>
|
||||
<th className="px-4 py-3 font-medium">严重级别</th>
|
||||
<th className="px-4 py-3 font-medium">状态</th>
|
||||
<th className="px-4 py-3 font-medium">来源</th>
|
||||
<th className="px-4 py-3 text-right font-medium">创建时间</th>
|
||||
<th className="px-4 py-3 text-right font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y bg-card">
|
||||
{visibleIncidents.map((incident) => (
|
||||
<tr className="table-row-enter align-top hover:bg-secondary/45" key={incident.id}>
|
||||
<td className="px-4 py-3">
|
||||
<p className="font-medium">{incident.title}</p>
|
||||
<p className="text-xs text-muted-foreground">{incident.description}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3">{incident.severity}</td>
|
||||
<td className="px-4 py-3">{incident.status}</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">{incident.source}</td>
|
||||
<td className="px-4 py-3 text-right text-muted-foreground">
|
||||
{new Date(incident.createdAt).toLocaleString("zh-CN")}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{canManageIncidents ? (
|
||||
<IncidentStatusActions incident={incident} />
|
||||
) : (
|
||||
<span className="block text-right text-xs text-muted-foreground">只读</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{visibleIncidents.length === 0 ? (
|
||||
<tr>
|
||||
<td className="px-4 py-8 text-center text-muted-foreground" colSpan={6}>
|
||||
暂无匹配故障
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
140
app/(app)/app/settings/users/page.tsx
Normal file
140
app/(app)/app/settings/users/page.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { getCurrentAuthContext, toPublicAccount } from "@/modules/core/server/auth";
|
||||
import { getRoleDefinitions, hasPermission } from "@/modules/core/server/permissions";
|
||||
import { readData } from "@/modules/core/server/store";
|
||||
import { ROLE_LABELS } from "@/modules/core/types";
|
||||
import type { RoleId } from "@/modules/core/types";
|
||||
import { TableToolbar } from "@/modules/settings/components/TableToolbar";
|
||||
import { UserAccountActions, UserManagementClient } from "@/modules/settings/components/UserManagementClient";
|
||||
import { getPage, getPageCount, getSearchParam, paginate, type SearchParams } from "@/modules/settings/lib/pagination";
|
||||
|
||||
type UsersPageProps = {
|
||||
searchParams?: Promise<SearchParams>;
|
||||
};
|
||||
|
||||
function formatRoleLabel(role: string): string {
|
||||
if (role in ROLE_LABELS) {
|
||||
return ROLE_LABELS[role as RoleId];
|
||||
}
|
||||
|
||||
return role;
|
||||
}
|
||||
|
||||
export default async function UsersPage({ searchParams }: UsersPageProps): Promise<React.ReactElement> {
|
||||
const context = await getCurrentAuthContext();
|
||||
if (!context) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
if (!hasPermission(context.permissions, "account:read")) {
|
||||
redirect("/app/dashboard");
|
||||
}
|
||||
|
||||
const params = (await searchParams) ?? {};
|
||||
const query = getSearchParam(params, "q");
|
||||
const page = getPage(params);
|
||||
const data = await readData();
|
||||
const roles = await getRoleDefinitions(context.organization?.id);
|
||||
const roleById = new Map(roles.map((role) => [role.id, role]));
|
||||
const organizationById = new Map(data.organizations.map((organization) => [organization.id, organization]));
|
||||
const accounts = data.accounts.map((account) => {
|
||||
const membership = context.organization?.id
|
||||
? data.memberships.find(
|
||||
(item) => item.accountId === account.id && item.organizationId === context.organization?.id && item.status === "active",
|
||||
)
|
||||
: data.memberships.find((item) => item.accountId === account.id && item.status === "active");
|
||||
const organization = membership ? organizationById.get(membership.organizationId) : undefined;
|
||||
const platformRole = account.platformRoleId ? roleById.get(account.platformRoleId) : undefined;
|
||||
|
||||
return toPublicAccount(account, platformRole?.key ?? membership?.roleKey, organization);
|
||||
});
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
const filteredAccounts = accounts.filter((account) =>
|
||||
[account.name, account.email, account.status, String(account.role), account.organization ?? ""]
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
.includes(normalizedQuery),
|
||||
);
|
||||
const pageCount = getPageCount(filteredAccounts.length);
|
||||
const visibleAccounts = paginate(filteredAccounts, page);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<section>
|
||||
<Badge variant="success">Accounts</Badge>
|
||||
<h2 className="mt-3 text-xl font-semibold">用户管理</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">账号创建、机构加入审批、角色分配和账号列表。</p>
|
||||
</section>
|
||||
|
||||
<UserManagementClient organizations={data.organizations} roles={roles} joinRequests={data.joinRequests} />
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>账号列表</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<TableToolbar
|
||||
page={Math.min(page, pageCount)}
|
||||
pageCount={pageCount}
|
||||
pathname="/app/settings/users"
|
||||
query={query}
|
||||
searchPlaceholder="搜索姓名、邮箱、角色、状态"
|
||||
total={filteredAccounts.length}
|
||||
/>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[920px] text-sm">
|
||||
<thead className="bg-secondary text-left text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-4 py-3 font-medium">账号</th>
|
||||
<th className="px-4 py-3 font-medium">角色</th>
|
||||
<th className="px-4 py-3 font-medium">机构</th>
|
||||
<th className="px-4 py-3 font-medium">状态</th>
|
||||
<th className="px-4 py-3 text-right font-medium">创建时间</th>
|
||||
<th className="px-4 py-3 text-right font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y bg-card">
|
||||
{visibleAccounts.map((account) => (
|
||||
<tr className="table-row-enter hover:bg-secondary/45" key={account.id}>
|
||||
<td className="px-4 py-3">
|
||||
<p className="font-medium">{account.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{account.email}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3">{formatRoleLabel(account.role)}</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">{account.organization ?? "-"}</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant={account.status === "active" ? "success" : account.status === "pending" ? "warning" : "danger"}>
|
||||
{account.status === "active" ? "启用" : account.status === "pending" ? "待审批" : "停用"}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-muted-foreground">
|
||||
{new Date(account.createdAt).toLocaleString("zh-CN")}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<UserAccountActions
|
||||
account={account}
|
||||
currentAccountId={context.account.id}
|
||||
organizations={data.organizations}
|
||||
roles={roles}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{visibleAccounts.length === 0 ? (
|
||||
<tr>
|
||||
<td className="px-4 py-8 text-center text-muted-foreground" colSpan={6}>
|
||||
暂无匹配账号
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user