From a555c9dd23f50de7c0151d7eec244719c2c5896c Mon Sep 17 00:00:00 2001 From: TalexDreamSoul Date: Thu, 2 Jul 2026 04:28:35 -0700 Subject: [PATCH] feat: split settings management pages --- app/(app)/app/loading.tsx | 7 + app/(app)/app/settings/audit/page.tsx | 100 +++++++ app/(app)/app/settings/layout.tsx | 37 +++ app/(app)/app/settings/organizations/page.tsx | 102 +++++++ app/(app)/app/settings/page.tsx | 28 +- app/(app)/app/settings/roles/page.tsx | 101 +++++++ app/(app)/app/settings/status/page.tsx | 138 ++++++++++ app/(app)/app/settings/users/page.tsx | 140 ++++++++++ app/api/organizations/[id]/route.ts | 145 ++++++++++ app/api/settings/accounts/[id]/route.ts | 240 +++++++++++++++++ app/api/settings/accounts/route.ts | 17 +- app/api/settings/roles/[id]/route.ts | 218 +++++++++++++++ app/globals.css | 44 +++ modules/core/server/auth.ts | 5 +- modules/core/server/permissions.ts | 6 +- .../components/IncidentStatusActions.tsx | 84 ++++++ .../OrganizationManagementClient.tsx | 194 ++++++++++++++ .../components/RoleManagementClient.tsx | 251 ++++++++++++++++++ modules/settings/components/TableToolbar.tsx | 62 +++++ .../components/UserManagementClient.tsx | 186 ++++++++++++- modules/settings/lib/pagination.ts | 35 +++ modules/shared/components/AppShell.tsx | 28 +- 22 files changed, 2131 insertions(+), 37 deletions(-) create mode 100644 app/(app)/app/loading.tsx create mode 100644 app/(app)/app/settings/audit/page.tsx create mode 100644 app/(app)/app/settings/layout.tsx create mode 100644 app/(app)/app/settings/organizations/page.tsx create mode 100644 app/(app)/app/settings/roles/page.tsx create mode 100644 app/(app)/app/settings/status/page.tsx create mode 100644 app/(app)/app/settings/users/page.tsx create mode 100644 app/api/organizations/[id]/route.ts create mode 100644 app/api/settings/accounts/[id]/route.ts create mode 100644 app/api/settings/roles/[id]/route.ts create mode 100644 modules/settings/components/IncidentStatusActions.tsx create mode 100644 modules/settings/components/OrganizationManagementClient.tsx create mode 100644 modules/settings/components/RoleManagementClient.tsx create mode 100644 modules/settings/components/TableToolbar.tsx create mode 100644 modules/settings/lib/pagination.ts diff --git a/app/(app)/app/loading.tsx b/app/(app)/app/loading.tsx new file mode 100644 index 0000000..4e088b3 --- /dev/null +++ b/app/(app)/app/loading.tsx @@ -0,0 +1,7 @@ +export default function AppLoading(): React.ReactElement { + return ( +
+
+
+ ); +} diff --git a/app/(app)/app/settings/audit/page.tsx b/app/(app)/app/settings/audit/page.tsx new file mode 100644 index 0000000..cc1fe6a --- /dev/null +++ b/app/(app)/app/settings/audit/page.tsx @@ -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; +}; + +export default async function AuditPage({ searchParams }: AuditPageProps): Promise { + 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 ( +
+
+ Audit +

审计日志

+

关键操作、拒绝访问和故障处理留痕。

+
+ + + + 审计列表 + + + +
+ + + + + + + + + + + + + {visibleLogs.map((log) => ( + + + + + + + + + ))} + {visibleLogs.length === 0 ? ( + + + + ) : null} + +
时间操作者动作对象结果原因
{new Date(log.timestamp).toLocaleString("zh-CN")}{log.actorEmail ?? "系统"}{log.action} + {log.targetType} + {log.targetId ? ` / ${log.targetId.slice(0, 8)}` : ""} + {log.result}{log.reason}
+ 暂无匹配审计 +
+
+
+
+
+ ); +} diff --git a/app/(app)/app/settings/layout.tsx b/app/(app)/app/settings/layout.tsx new file mode 100644 index 0000000..62ad950 --- /dev/null +++ b/app/(app)/app/settings/layout.tsx @@ -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 ( +
+
+

系统设置

+ +
+ {children} +
+ ); +} diff --git a/app/(app)/app/settings/organizations/page.tsx b/app/(app)/app/settings/organizations/page.tsx new file mode 100644 index 0000000..987d37c --- /dev/null +++ b/app/(app)/app/settings/organizations/page.tsx @@ -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; +}; + +export default async function OrganizationsPage({ searchParams }: OrganizationsPageProps): Promise { + 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 ( +
+
+ Organizations +

机构管理

+

多机构租户、启停状态和机构角色初始化。

+
+ + {hasPermission(context.permissions, "organization:manage") ? : null} + + + + 机构列表 + + + +
+ + + + + + + + + + + + {visibleOrganizations.map((organization) => ( + + + + + + + + ))} + {visibleOrganizations.length === 0 ? ( + + + + ) : null} + +
机构Slug状态创建时间操作
{organization.name}{organization.slug}{organization.status === "active" ? "启用" : "停用"} + {new Date(organization.createdAt).toLocaleString("zh-CN")} + + +
+ 暂无匹配机构 +
+
+
+
+
+ ); +} diff --git a/app/(app)/app/settings/page.tsx b/app/(app)/app/settings/page.tsx index 468cf40..289ce3d 100644 --- a/app/(app)/app/settings/page.tsx +++ b/app/(app)/app/settings/page.tsx @@ -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 { - 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 ( - 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"); } diff --git a/app/(app)/app/settings/roles/page.tsx b/app/(app)/app/settings/roles/page.tsx new file mode 100644 index 0000000..635caf5 --- /dev/null +++ b/app/(app)/app/settings/roles/page.tsx @@ -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; +}; + +export default async function RolesPage({ searchParams }: RolesPageProps): Promise { + 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 ( +
+
+ RBAC +

角色权限

+

系统权限点、内置角色和机构自定义角色。

+
+ + {hasPermission(context.permissions, "role:manage") ? : null} + + + + 角色列表 + + + +
+ + + + + + + + + + + + + {visibleRoles.map((role) => ( + + + + + + + + + ))} + {visibleRoles.length === 0 ? ( + + + + ) : null} + +
角色范围类型状态权限操作
+

{role.label}

+

{role.description}

+
{role.scope === "platform" ? "平台" : "机构"}{role.isSystem ? "内置" : "自定义"}{role.isEnabled ? "启用" : "停用"}{role.permissions.join(", ") || "-"} + +
+ 暂无匹配角色 +
+
+
+
+
+ ); +} diff --git a/app/(app)/app/settings/status/page.tsx b/app/(app)/app/settings/status/page.tsx new file mode 100644 index 0000000..a77f054 --- /dev/null +++ b/app/(app)/app/settings/status/page.tsx @@ -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; +}; + +export default async function StatusPage({ searchParams }: StatusPageProps): Promise { + 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 ( +
+
+ {databaseHealth.ok ? "Healthy" : "Degraded"} +

运行状态

+

{databaseHealth.reason}

+
+ +
+ + + {data.organizations.length} + + 机构 + + + + {data.accounts.length} + + 账号 + + + + {data.beds.length} + + 床位 + + + + {data.incidents.length} + + 故障事件 + +
+ + + + 故障中心 + + + +
+ + + + + + + + + + + + + {visibleIncidents.map((incident) => ( + + + + + + + + + ))} + {visibleIncidents.length === 0 ? ( + + + + ) : null} + +
标题严重级别状态来源创建时间操作
+

{incident.title}

+

{incident.description}

+
{incident.severity}{incident.status}{incident.source} + {new Date(incident.createdAt).toLocaleString("zh-CN")} + + {canManageIncidents ? ( + + ) : ( + 只读 + )} +
+ 暂无匹配故障 +
+
+
+
+
+ ); +} diff --git a/app/(app)/app/settings/users/page.tsx b/app/(app)/app/settings/users/page.tsx new file mode 100644 index 0000000..d10602b --- /dev/null +++ b/app/(app)/app/settings/users/page.tsx @@ -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; +}; + +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 { + 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 ( +
+
+ Accounts +

用户管理

+

账号创建、机构加入审批、角色分配和账号列表。

+
+ + + + + + 账号列表 + + + +
+ + + + + + + + + + + + + {visibleAccounts.map((account) => ( + + + + + + + + + ))} + {visibleAccounts.length === 0 ? ( + + + + ) : null} + +
账号角色机构状态创建时间操作
+

{account.name}

+

{account.email}

+
{formatRoleLabel(account.role)}{account.organization ?? "-"} + + {account.status === "active" ? "启用" : account.status === "pending" ? "待审批" : "停用"} + + + {new Date(account.createdAt).toLocaleString("zh-CN")} + + +
+ 暂无匹配账号 +
+
+
+
+
+ ); +} diff --git a/app/api/organizations/[id]/route.ts b/app/api/organizations/[id]/route.ts new file mode 100644 index 0000000..bbf734e --- /dev/null +++ b/app/api/organizations/[id]/route.ts @@ -0,0 +1,145 @@ +import { eq } from "drizzle-orm"; + +import { jsonFailure, jsonSuccess, readJsonBody } from "@/modules/core/server/api"; +import { recordAuditLog } from "@/modules/core/server/audit"; +import { requirePermission } from "@/modules/core/server/auth"; +import { getDatabase } from "@/modules/core/server/db"; +import { organizations } from "@/modules/core/server/schema"; +import type { OrganizationStatus } from "@/modules/core/types"; + +type RouteContext = { + params: Promise<{ + id: string; + }>; +}; + +const ORGANIZATION_STATUSES: OrganizationStatus[] = ["active", "disabled"]; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function readString(source: Record, key: string): string { + const value = source[key]; + return typeof value === "string" ? value.trim() : ""; +} + +function readStatus(value: unknown): OrganizationStatus | null { + if (typeof value !== "string") { + return null; + } + + return ORGANIZATION_STATUSES.find((status) => status === value) ?? null; +} + +export async function PATCH(request: Request, context: RouteContext): Promise { + const { id } = await context.params; + const auth = await requirePermission("organization:manage", { + action: "organization.update", + targetType: "organization", + targetId: id, + }); + + if (!auth.success) { + return auth.response; + } + + const body = await readJsonBody(request); + if (!isRecord(body)) { + return jsonFailure("请求数据格式无效"); + } + + const name = readString(body, "name"); + const slug = readString(body, "slug"); + const status = "status" in body ? readStatus(body.status) : null; + if ("status" in body && !status) { + return jsonFailure("机构状态无效"); + } + + if (!name && !slug && !status) { + return jsonFailure("请至少填写一个要更新的字段"); + } + + const updateValues: { + name?: string; + slug?: string; + status?: OrganizationStatus; + updatedAt: Date; + } = { + updatedAt: new Date(), + }; + + if (name) { + updateValues.name = name; + } + if (slug) { + updateValues.slug = slug; + } + if (status) { + updateValues.status = status; + } + + const database = getDatabase(); + try { + const rows = await database.update(organizations).set(updateValues).where(eq(organizations.id, id)).returning(); + const organization = rows[0]; + if (!organization) { + return jsonFailure("机构不存在", 404); + } + + await recordAuditLog({ + actor: auth.context.account, + organizationId: organization.id, + action: "organization.update", + targetType: "organization", + targetId: organization.id, + result: "success", + reason: `更新机构:${organization.name}`, + }); + + return jsonSuccess("机构已更新", { organization }); + } catch (error) { + const message = error instanceof Error ? error.message : ""; + if (message.includes("organizations_slug_unique")) { + return jsonFailure("机构标识已存在", 409); + } + + return jsonFailure("机构更新失败", 500); + } +} + +export async function DELETE(_request: Request, context: RouteContext): Promise { + const { id } = await context.params; + const auth = await requirePermission("organization:manage", { + action: "organization.disable", + targetType: "organization", + targetId: id, + }); + + if (!auth.success) { + return auth.response; + } + + const database = getDatabase(); + const rows = await database + .update(organizations) + .set({ status: "disabled", updatedAt: new Date() }) + .where(eq(organizations.id, id)) + .returning(); + const organization = rows[0]; + if (!organization) { + return jsonFailure("机构不存在", 404); + } + + await recordAuditLog({ + actor: auth.context.account, + organizationId: organization.id, + action: "organization.disable", + targetType: "organization", + targetId: organization.id, + result: "success", + reason: `停用机构:${organization.name}`, + }); + + return jsonSuccess("机构已停用", { organization }); +} diff --git a/app/api/settings/accounts/[id]/route.ts b/app/api/settings/accounts/[id]/route.ts new file mode 100644 index 0000000..d06319d --- /dev/null +++ b/app/api/settings/accounts/[id]/route.ts @@ -0,0 +1,240 @@ +import { and, eq } from "drizzle-orm"; + +import { jsonFailure, jsonSuccess, readJsonBody } from "@/modules/core/server/api"; +import { recordAuditLog } from "@/modules/core/server/audit"; +import { requirePermission, toPublicAccount } from "@/modules/core/server/auth"; +import { getDatabase } from "@/modules/core/server/db"; +import { hasPermission } from "@/modules/core/server/permissions"; +import { accounts, memberships, organizations, roles, sessions } from "@/modules/core/server/schema"; +import type { AccountStatus, Organization } from "@/modules/core/types"; + +type RouteContext = { + params: Promise<{ + id: string; + }>; +}; + +const ACCOUNT_STATUSES: AccountStatus[] = ["active", "disabled", "pending"]; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function readString(source: Record, key: string): string { + const value = source[key]; + return typeof value === "string" ? value.trim() : ""; +} + +function readStatus(value: unknown): AccountStatus | null { + if (typeof value !== "string") { + return null; + } + + return ACCOUNT_STATUSES.find((status) => status === value) ?? null; +} + +function toOrganization(row: typeof organizations.$inferSelect): Organization { + return { + id: row.id, + name: row.name, + slug: row.slug, + status: row.status, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; +} + +export async function PATCH(request: Request, context: RouteContext): Promise { + const { id } = await context.params; + const auth = await requirePermission("account:manage", { + action: "account.update", + targetType: "account", + targetId: id, + }); + + if (!auth.success) { + return auth.response; + } + + const body = await readJsonBody(request); + if (!isRecord(body)) { + return jsonFailure("请求数据格式无效"); + } + + const name = readString(body, "name"); + const status = "status" in body ? readStatus(body.status) : null; + const roleId = readString(body, "roleId"); + const requestedOrganizationId = readString(body, "organizationId"); + const organizationId = requestedOrganizationId || auth.context.organization?.id; + + if ("status" in body && !status) { + return jsonFailure("账号状态无效"); + } + + if (!name && !status && !roleId) { + return jsonFailure("请至少填写一个要更新的字段"); + } + + if (id === auth.context.account.id && status === "disabled") { + return jsonFailure("不能停用当前登录账号", 400); + } + + const canManagePlatform = hasPermission(auth.context.permissions, "platform:manage"); + if (!canManagePlatform && requestedOrganizationId && requestedOrganizationId !== auth.context.organization?.id) { + return jsonFailure("权限不足", 403); + } + + const database = getDatabase(); + const targetAccountRows = await database.select().from(accounts).where(eq(accounts.id, id)).limit(1); + const targetAccount = targetAccountRows[0]; + if (!targetAccount) { + return jsonFailure("账号不存在", 404); + } + + if (roleId && !organizationId) { + return jsonFailure("请选择机构后分配角色", 400); + } + + const organizationRows = organizationId + ? await database.select().from(organizations).where(eq(organizations.id, organizationId)).limit(1) + : []; + const organization = organizationRows[0]; + if (roleId && !organization) { + return jsonFailure("机构不存在", 404); + } + + try { + await database.transaction(async (transaction) => { + if (name || status) { + const updateValues: { + name?: string; + status?: AccountStatus; + updatedAt: Date; + } = { + updatedAt: new Date(), + }; + if (name) { + updateValues.name = name; + } + if (status) { + updateValues.status = status; + } + + await transaction.update(accounts).set(updateValues).where(eq(accounts.id, id)); + if (status === "disabled") { + await transaction.delete(sessions).where(eq(sessions.accountId, id)); + } + } + + if (roleId && organizationId) { + const roleRows = await transaction + .select() + .from(roles) + .where(and(eq(roles.id, roleId), eq(roles.organizationId, organizationId), eq(roles.isEnabled, true))) + .limit(1); + const role = roleRows[0]; + if (!role) { + throw new Error("角色不存在"); + } + + const membershipRows = await transaction + .select() + .from(memberships) + .where(and(eq(memberships.accountId, id), eq(memberships.organizationId, organizationId))) + .limit(1); + const membership = membershipRows[0]; + + if (membership) { + await transaction + .update(memberships) + .set({ roleId, status: "active", updatedAt: new Date() }) + .where(eq(memberships.id, membership.id)); + } else { + await transaction.insert(memberships).values({ + accountId: id, + organizationId, + roleId, + status: "active", + }); + } + } + }); + } catch (error) { + const reason = error instanceof Error ? error.message : "账号更新失败"; + return jsonFailure(reason, reason === "角色不存在" ? 404 : 400); + } + + const updatedAccountRows = await database.select().from(accounts).where(eq(accounts.id, id)).limit(1); + const updatedAccount = updatedAccountRows[0]; + if (!updatedAccount) { + return jsonFailure("账号不存在", 404); + } + + const membershipRows = organizationId + ? await database + .select({ membership: memberships, role: roles }) + .from(memberships) + .innerJoin(roles, eq(roles.id, memberships.roleId)) + .where(and(eq(memberships.accountId, id), eq(memberships.organizationId, organizationId))) + .limit(1) + : []; + const membership = membershipRows[0]; + const publicAccount = toPublicAccount( + updatedAccount, + membership?.role.key, + organization ? toOrganization(organization) : undefined, + ); + + await recordAuditLog({ + actor: auth.context.account, + organizationId, + action: "account.update", + targetType: "account", + targetId: updatedAccount.id, + result: "success", + reason: `更新账号:${updatedAccount.email}`, + }); + + return jsonSuccess("账号已更新", { account: publicAccount }); +} + +export async function DELETE(_request: Request, context: RouteContext): Promise { + const { id } = await context.params; + const auth = await requirePermission("account:manage", { + action: "account.disable", + targetType: "account", + targetId: id, + }); + + if (!auth.success) { + return auth.response; + } + + if (id === auth.context.account.id) { + return jsonFailure("不能停用当前登录账号", 400); + } + + const database = getDatabase(); + const rows = await database + .update(accounts) + .set({ status: "disabled", updatedAt: new Date() }) + .where(eq(accounts.id, id)) + .returning(); + const account = rows[0]; + if (!account) { + return jsonFailure("账号不存在", 404); + } + await database.delete(sessions).where(eq(sessions.accountId, id)); + + await recordAuditLog({ + actor: auth.context.account, + organizationId: auth.context.organization?.id, + action: "account.disable", + targetType: "account", + targetId: account.id, + result: "success", + reason: `停用账号:${account.email}`, + }); + + return jsonSuccess("账号已停用", { account: toPublicAccount(account) }); +} diff --git a/app/api/settings/accounts/route.ts b/app/api/settings/accounts/route.ts index f4b6160..8b8656e 100644 --- a/app/api/settings/accounts/route.ts +++ b/app/api/settings/accounts/route.ts @@ -4,7 +4,7 @@ import { jsonFailure, jsonSuccess, readJsonBody } from "@/modules/core/server/ap import { hashPassword, normalizeEmail, requirePermission, toPublicAccount } from "@/modules/core/server/auth"; import { recordAuditLog } from "@/modules/core/server/audit"; import { getDatabase } from "@/modules/core/server/db"; -import { hasPermission } from "@/modules/core/server/permissions"; +import { getRoleDefinitions, hasPermission } from "@/modules/core/server/permissions"; import { accounts, memberships, organizations, roles } from "@/modules/core/server/schema"; import { readData } from "@/modules/core/server/store"; @@ -30,6 +30,9 @@ export async function GET(): Promise { const data = await readData(); const canReadAllAccounts = hasPermission(auth.context.permissions, "platform:manage"); const organizationId = auth.context.organization?.id; + const roleDefinitions = await getRoleDefinitions(organizationId); + const roleById = new Map(roleDefinitions.map((role) => [role.id, role])); + const organizationById = new Map(data.organizations.map((organization) => [organization.id, organization])); const scopedAccountIds = organizationId && !canReadAllAccounts ? new Set( @@ -42,7 +45,17 @@ export async function GET(): Promise { return jsonSuccess("账号列表已加载", { accounts: data.accounts .filter((account) => !scopedAccountIds || scopedAccountIds.has(account.id)) - .map((account) => toPublicAccount(account)), + .map((account) => { + const membership = organizationId + ? data.memberships.find( + (item) => item.accountId === account.id && item.organizationId === organizationId && 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); + }), }); } diff --git a/app/api/settings/roles/[id]/route.ts b/app/api/settings/roles/[id]/route.ts new file mode 100644 index 0000000..dbd0b1a --- /dev/null +++ b/app/api/settings/roles/[id]/route.ts @@ -0,0 +1,218 @@ +import { eq } from "drizzle-orm"; + +import { jsonFailure, jsonSuccess, readJsonBody } from "@/modules/core/server/api"; +import { recordAuditLog } from "@/modules/core/server/audit"; +import { requirePermission } from "@/modules/core/server/auth"; +import { getDatabase } from "@/modules/core/server/db"; +import { getRoleDefinitions, hasPermission } from "@/modules/core/server/permissions"; +import { rolePermissions, roles } from "@/modules/core/server/schema"; +import { PERMISSIONS } from "@/modules/core/types"; +import type { Permission } from "@/modules/core/types"; + +type RouteContext = { + params: Promise<{ + id: string; + }>; +}; + +const PERMISSION_SET = new Set(PERMISSIONS); + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function readString(source: Record, key: string): string { + const value = source[key]; + return typeof value === "string" ? value.trim() : ""; +} + +function readBoolean(source: Record, key: string): boolean | null { + const value = source[key]; + return typeof value === "boolean" ? value : null; +} + +function readPermissionArray(source: Record): Permission[] | null { + const value = source.permissions; + if (!Array.isArray(value)) { + return null; + } + + const permissions = value.filter((item): item is string => typeof item === "string"); + if (permissions.length !== value.length || permissions.some((permission) => !PERMISSION_SET.has(permission))) { + return null; + } + + return permissions as Permission[]; +} + +function createRoleKey(label: string): string { + return label.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_|_$/g, ""); +} + +async function getEditableRole(id: string, organizationId: string | undefined, canManagePlatform: boolean) { + const database = getDatabase(); + const rows = await database.select().from(roles).where(eq(roles.id, id)).limit(1); + const role = rows[0]; + if (!role) { + return { success: false as const, response: jsonFailure("角色不存在", 404) }; + } + + if (role.isSystem) { + return { success: false as const, response: jsonFailure("内置角色不可编辑", 400) }; + } + + if (!canManagePlatform && role.organizationId !== organizationId) { + return { success: false as const, response: jsonFailure("角色不存在", 404) }; + } + + return { success: true as const, role }; +} + +export async function PATCH(request: Request, context: RouteContext): Promise { + const { id } = await context.params; + const auth = await requirePermission("role:manage", { + action: "role.update", + targetType: "role", + targetId: id, + }); + + if (!auth.success) { + return auth.response; + } + + const body = await readJsonBody(request); + if (!isRecord(body)) { + return jsonFailure("请求数据格式无效"); + } + + const organizationId = auth.context.organization?.id; + const editable = await getEditableRole(id, organizationId, hasPermission(auth.context.permissions, "platform:manage")); + if (!editable.success) { + return editable.response; + } + + const label = readString(body, "label"); + const key = readString(body, "key") || (label ? createRoleKey(label) : ""); + const description = readString(body, "description"); + const isEnabled = "isEnabled" in body ? readBoolean(body, "isEnabled") : null; + if ("isEnabled" in body && isEnabled === null) { + return jsonFailure("角色状态无效"); + } + + const permissions = "permissions" in body ? readPermissionArray(body) : null; + if ("permissions" in body && permissions === null) { + return jsonFailure("权限列表无效"); + } + + if (!label && !key && !description && isEnabled === null && permissions === null) { + return jsonFailure("请至少填写一个要更新的字段"); + } + + const updateValues: { + label?: string; + key?: string; + description?: string; + isEnabled?: boolean; + updatedAt: Date; + } = { + updatedAt: new Date(), + }; + + if (label) { + updateValues.label = label; + } + if (key) { + updateValues.key = key; + } + if (description) { + updateValues.description = description; + } + if (isEnabled !== null) { + updateValues.isEnabled = isEnabled; + } + + const database = getDatabase(); + try { + await database.transaction(async (transaction) => { + await transaction.update(roles).set(updateValues).where(eq(roles.id, id)); + if (permissions) { + await transaction.delete(rolePermissions).where(eq(rolePermissions.roleId, id)); + if (permissions.length > 0) { + await transaction.insert(rolePermissions).values( + permissions.map((permissionId) => ({ + roleId: id, + permissionId, + })), + ); + } + } + }); + } catch (error) { + const message = error instanceof Error ? error.message : ""; + if (message.includes("roles_key_organization_unique")) { + return jsonFailure("角色标识已存在", 409); + } + + return jsonFailure("角色更新失败", 500); + } + + await recordAuditLog({ + actor: auth.context.account, + organizationId: editable.role.organizationId ?? organizationId, + action: "role.update", + targetType: "role", + targetId: id, + result: "success", + reason: `更新角色:${label || editable.role.label}`, + }); + + const roleRows = await getRoleDefinitions(editable.role.organizationId ?? organizationId); + const role = roleRows.find((item) => item.id === id); + if (!role) { + return jsonFailure("角色更新后读取失败", 500); + } + + return jsonSuccess("角色已更新", { role }); +} + +export async function DELETE(_request: Request, context: RouteContext): Promise { + const { id } = await context.params; + const auth = await requirePermission("role:manage", { + action: "role.disable", + targetType: "role", + targetId: id, + }); + + if (!auth.success) { + return auth.response; + } + + const organizationId = auth.context.organization?.id; + const editable = await getEditableRole(id, organizationId, hasPermission(auth.context.permissions, "platform:manage")); + if (!editable.success) { + return editable.response; + } + + const database = getDatabase(); + const rows = await database + .update(roles) + .set({ isEnabled: false, updatedAt: new Date() }) + .where(eq(roles.id, id)) + .returning(); + const role = rows[0]; + if (!role) { + return jsonFailure("角色不存在", 404); + } + + await recordAuditLog({ + actor: auth.context.account, + organizationId: role.organizationId ?? organizationId, + action: "role.disable", + targetType: "role", + targetId: role.id, + result: "success", + reason: `停用角色:${role.label}`, + }); + + return jsonSuccess("角色已停用", { role }); +} diff --git a/app/globals.css b/app/globals.css index c7f1a28..acc98f4 100644 --- a/app/globals.css +++ b/app/globals.css @@ -79,3 +79,47 @@ a, ::selection { background: color-mix(in oklch, var(--primary) 22%, transparent); } + +@keyframes page-enter { + from { + opacity: 0; + transform: translateY(6px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes loading-progress { + 0% { + transform: translateX(-100%); + } + 55% { + transform: translateX(-18%); + } + 100% { + transform: translateX(100%); + } +} + +.page-enter { + animation: page-enter 180ms ease-out both; +} + +.table-row-enter { + animation: page-enter 160ms ease-out both; +} + +.loading-progress { + width: 55%; + animation: loading-progress 900ms ease-in-out infinite; +} + +@media (prefers-reduced-motion: reduce) { + .page-enter, + .table-row-enter, + .loading-progress { + animation: none; + } +} diff --git a/modules/core/server/auth.ts b/modules/core/server/auth.ts index f2fa758..b0ab2d9 100644 --- a/modules/core/server/auth.ts +++ b/modules/core/server/auth.ts @@ -341,7 +341,7 @@ export async function loginWithPassword(input: { .from(memberships) .innerJoin(organizations, eq(organizations.id, memberships.organizationId)) .innerJoin(roles, eq(roles.id, memberships.roleId)) - .where(and(eq(memberships.accountId, account.id), eq(memberships.status, "active"))) + .where(and(eq(memberships.accountId, account.id), eq(memberships.status, "active"), eq(roles.isEnabled, true))) .limit(1); const membership = membershipRows[0]; const expiresAt = new Date(Date.now() + SESSION_MAX_AGE_SECONDS * 1000); @@ -401,7 +401,7 @@ export async function getCurrentAuthContext(): Promise { const session = toSession(row.session); const platformRoleRows = row.account.platformRoleId - ? await database.select().from(roles).where(eq(roles.id, row.account.platformRoleId)).limit(1) + ? await database.select().from(roles).where(and(eq(roles.id, row.account.platformRoleId), eq(roles.isEnabled, true))).limit(1) : []; const platformRole = platformRoleRows[0]; const organizationRows = row.session.activeOrganizationId @@ -418,6 +418,7 @@ export async function getCurrentAuthContext(): Promise { eq(memberships.accountId, row.account.id), eq(memberships.organizationId, organizationRow.id), eq(memberships.status, "active"), + eq(roles.isEnabled, true), ), ) .limit(1) diff --git a/modules/core/server/permissions.ts b/modules/core/server/permissions.ts index 38cb861..79be301 100644 --- a/modules/core/server/permissions.ts +++ b/modules/core/server/permissions.ts @@ -249,7 +249,11 @@ export async function getRoleDefinitions(organizationId?: string): Promise { const database = getDatabase(); - const rows = await database.select().from(rolePermissions).where(eq(rolePermissions.roleId, roleId)); + const rows = await database + .select({ permissionId: rolePermissions.permissionId }) + .from(rolePermissions) + .innerJoin(roles, eq(roles.id, rolePermissions.roleId)) + .where(and(eq(rolePermissions.roleId, roleId), eq(roles.isEnabled, true))); return rows.map((row) => row.permissionId as Permission); } diff --git a/modules/settings/components/IncidentStatusActions.tsx b/modules/settings/components/IncidentStatusActions.tsx new file mode 100644 index 0000000..1438656 --- /dev/null +++ b/modules/settings/components/IncidentStatusActions.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { CheckCheck, CircleCheck, CircleDot } from "lucide-react"; +import type { LucideIcon } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; + +import { Button } from "@/components/ui/button"; +import type { ApiResult } from "@/modules/core/server/api"; +import type { IncidentStatus, SystemIncident } from "@/modules/core/types"; + +type IncidentStatusActionsProps = { + incident: SystemIncident; +}; + +type StatusAction = { + label: string; + status: IncidentStatus; + icon: LucideIcon; +}; + +const STATUS_ACTIONS: Record = { + open: [ + { label: "确认", status: "acknowledged", icon: CircleDot }, + { label: "解决", status: "resolved", icon: CircleCheck }, + ], + acknowledged: [ + { label: "解决", status: "resolved", icon: CircleCheck }, + { label: "关闭", status: "closed", icon: CheckCheck }, + ], + resolved: [{ label: "关闭", status: "closed", icon: CheckCheck }], + closed: [], +}; + +export function IncidentStatusActions({ incident }: IncidentStatusActionsProps): React.ReactElement { + const router = useRouter(); + const [message, setMessage] = useState(""); + const [isPending, setIsPending] = useState(false); + const actions = STATUS_ACTIONS[incident.status]; + + async function updateStatus(status: IncidentStatus): Promise { + setMessage(""); + setIsPending(true); + const response = await fetch(`/api/system/incidents/${incident.id}`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ status }), + }); + const result = (await response.json()) as ApiResult<{ incident: SystemIncident }>; + setIsPending(false); + + if (!result.success) { + setMessage(result.reason); + return; + } + + router.refresh(); + } + + if (actions.length === 0) { + return 已关闭; + } + + return ( +
+
+ {actions.map((action) => ( + + ))} +
+ {message ?

{message}

: null} +
+ ); +} diff --git a/modules/settings/components/OrganizationManagementClient.tsx b/modules/settings/components/OrganizationManagementClient.tsx new file mode 100644 index 0000000..a81d658 --- /dev/null +++ b/modules/settings/components/OrganizationManagementClient.tsx @@ -0,0 +1,194 @@ +"use client"; + +import { FormEvent, useState } from "react"; +import { Pencil, Plus, Power } from "lucide-react"; +import { useRouter } from "next/navigation"; + +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Dialog } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import type { ApiResult } from "@/modules/core/server/api"; +import type { Organization } from "@/modules/core/types"; + +export function OrganizationManagementClient(): React.ReactElement { + const router = useRouter(); + const [message, setMessage] = useState(""); + const [isPending, setIsPending] = useState(false); + + async function handleSubmit(event: FormEvent): Promise { + event.preventDefault(); + setMessage(""); + setIsPending(true); + const formData = new FormData(event.currentTarget); + const response = await fetch("/api/organizations", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + name: String(formData.get("name") ?? ""), + slug: String(formData.get("slug") ?? ""), + }), + }); + const result = (await response.json()) as ApiResult<{ organization: Organization }>; + setIsPending(false); + + if (!result.success) { + setMessage(result.reason); + return; + } + + setMessage(result.reason); + event.currentTarget.reset(); + router.refresh(); + } + + return ( + + + 新增机构 + 创建后会自动初始化机构内置角色。 + + +
+ + + +
+ {message ? ( +

+ {message} +

+ ) : null} +
+
+ ); +} + +type OrganizationRowActionsProps = { + organization: Organization; +}; + +export function OrganizationRowActions({ organization }: OrganizationRowActionsProps): React.ReactElement { + const router = useRouter(); + const [isEditOpen, setIsEditOpen] = useState(false); + const [message, setMessage] = useState(""); + const [isPending, setIsPending] = useState(false); + + async function updateOrganization(payload: Record): Promise { + setMessage(""); + setIsPending(true); + const response = await fetch(`/api/organizations/${organization.id}`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify(payload), + }); + const result = (await response.json()) as ApiResult<{ organization: Organization }>; + setIsPending(false); + + if (!result.success) { + setMessage(result.reason); + return; + } + + setMessage(result.reason); + setIsEditOpen(false); + router.refresh(); + } + + async function disableOrganization(): Promise { + setMessage(""); + setIsPending(true); + const response = await fetch(`/api/organizations/${organization.id}`, { + method: "DELETE", + }); + const result = (await response.json()) as ApiResult<{ organization: Organization }>; + setIsPending(false); + + if (!result.success) { + setMessage(result.reason); + return; + } + + router.refresh(); + } + + async function handleSubmit(event: FormEvent): Promise { + event.preventDefault(); + const formData = new FormData(event.currentTarget); + await updateOrganization({ + name: String(formData.get("name") ?? ""), + slug: String(formData.get("slug") ?? ""), + status: String(formData.get("status") ?? ""), + }); + } + + return ( +
+ + {organization.status === "disabled" ? ( + + ) : ( + + )} + setIsEditOpen(false)} + open={isEditOpen} + title="编辑机构" + > +
+ + + + {message ? ( +

+ {message} +

+ ) : null} +
+ + +
+
+
+
+ ); +} diff --git a/modules/settings/components/RoleManagementClient.tsx b/modules/settings/components/RoleManagementClient.tsx new file mode 100644 index 0000000..f1abb5e --- /dev/null +++ b/modules/settings/components/RoleManagementClient.tsx @@ -0,0 +1,251 @@ +"use client"; + +import { FormEvent, useState } from "react"; +import { Pencil, Plus, Power } from "lucide-react"; +import { useRouter } from "next/navigation"; + +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Dialog } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import type { ApiResult } from "@/modules/core/server/api"; +import type { Permission, RoleDefinition } from "@/modules/core/types"; + +type PermissionDefinition = { + id: Permission; + label: string; + category: string; + description: string; +}; + +type RoleManagementClientProps = { + permissions: PermissionDefinition[]; +}; + +export function RoleManagementClient({ permissions }: RoleManagementClientProps): React.ReactElement { + const router = useRouter(); + const [message, setMessage] = useState(""); + const [isPending, setIsPending] = useState(false); + + async function handleSubmit(event: FormEvent): Promise { + event.preventDefault(); + setMessage(""); + setIsPending(true); + const formData = new FormData(event.currentTarget); + const response = await fetch("/api/settings/roles", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + label: String(formData.get("label") ?? ""), + key: String(formData.get("key") ?? ""), + description: String(formData.get("description") ?? ""), + permissions: formData.getAll("permissions").map(String), + }), + }); + const result = (await response.json()) as ApiResult<{ role: RoleDefinition }>; + setIsPending(false); + + if (!result.success) { + setMessage(result.reason); + return; + } + + setMessage(result.reason); + event.currentTarget.reset(); + router.refresh(); + } + + return ( + + + 新增角色 + 角色由系统注册权限点组合而成。 + + +
+
+ + + +
+
+ {permissions.map((permission) => ( + + ))} +
+ +
+ {message ? ( +

+ {message} +

+ ) : null} +
+
+ ); +} + +type RoleRowActionsProps = { + permissions: PermissionDefinition[]; + role: RoleDefinition; +}; + +export function RoleRowActions({ permissions, role }: RoleRowActionsProps): React.ReactElement { + const router = useRouter(); + const [isEditOpen, setIsEditOpen] = useState(false); + const [message, setMessage] = useState(""); + const [isPending, setIsPending] = useState(false); + + async function updateRole(payload: Record): Promise { + setMessage(""); + setIsPending(true); + const response = await fetch(`/api/settings/roles/${role.id}`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify(payload), + }); + const result = (await response.json()) as ApiResult<{ role: RoleDefinition }>; + setIsPending(false); + + if (!result.success) { + setMessage(result.reason); + return; + } + + setMessage(result.reason); + setIsEditOpen(false); + router.refresh(); + } + + async function disableRole(): Promise { + setMessage(""); + setIsPending(true); + const response = await fetch(`/api/settings/roles/${role.id}`, { + method: "DELETE", + }); + const result = (await response.json()) as ApiResult<{ role: RoleDefinition }>; + setIsPending(false); + + if (!result.success) { + setMessage(result.reason); + return; + } + + router.refresh(); + } + + async function handleSubmit(event: FormEvent): Promise { + event.preventDefault(); + const formData = new FormData(event.currentTarget); + await updateRole({ + label: String(formData.get("label") ?? ""), + key: String(formData.get("key") ?? ""), + description: String(formData.get("description") ?? ""), + isEnabled: String(formData.get("isEnabled") ?? "") === "true", + permissions: formData.getAll("permissions").map(String), + }); + } + + if (role.isSystem) { + return 内置只读; + } + + return ( +
+ + {role.isEnabled ? ( + + ) : ( + + )} + setIsEditOpen(false)} + open={isEditOpen} + title="编辑角色" + > +
+
+ + +
+ + +
+ {permissions.map((permission) => ( + + ))} +
+ {message ? ( +

+ {message} +

+ ) : null} +
+ + +
+
+
+
+ ); +} diff --git a/modules/settings/components/TableToolbar.tsx b/modules/settings/components/TableToolbar.tsx new file mode 100644 index 0000000..6bd62fa --- /dev/null +++ b/modules/settings/components/TableToolbar.tsx @@ -0,0 +1,62 @@ +import Link from "next/link"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { buildPageHref } from "@/modules/settings/lib/pagination"; + +type TableToolbarProps = { + actionLabel?: string; + page: number; + pageCount: number; + pathname: string; + query: string; + searchPlaceholder: string; + total: number; +}; + +export function TableToolbar({ + actionLabel, + page, + pageCount, + pathname, + query, + searchPlaceholder, + total, +}: TableToolbarProps): React.ReactElement { + return ( +
+
+ + +
+
+ {actionLabel ?? `共 ${total} 条`} +
+ {page <= 1 ? ( + + ) : ( + + )} + + {page}/{pageCount} + + {page >= pageCount ? ( + + ) : ( + + )} +
+
+
+ ); +} diff --git a/modules/settings/components/UserManagementClient.tsx b/modules/settings/components/UserManagementClient.tsx index 3fffd61..a4053bc 100644 --- a/modules/settings/components/UserManagementClient.tsx +++ b/modules/settings/components/UserManagementClient.tsx @@ -1,15 +1,16 @@ "use client"; import { FormEvent, useMemo, useState } from "react"; -import { Check, Plus, X } from "lucide-react"; +import { Check, Pencil, Plus, Power, X } from "lucide-react"; import { useRouter } from "next/navigation"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Dialog } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import type { ApiResult } from "@/modules/core/server/api"; -import type { JoinRequest, Organization, RoleDefinition } from "@/modules/core/types"; +import type { JoinRequest, Organization, PublicAccount, RoleDefinition } from "@/modules/core/types"; type UserManagementClientProps = { organizations: Organization[]; @@ -222,3 +223,184 @@ export function UserManagementClient({ ); } + +type UserAccountActionsProps = { + account: PublicAccount; + currentAccountId: string; + organizations: Organization[]; + roles: RoleDefinition[]; +}; + +export function UserAccountActions({ + account, + currentAccountId, + organizations, + roles, +}: UserAccountActionsProps): React.ReactElement { + const router = useRouter(); + const [isEditOpen, setIsEditOpen] = useState(false); + const [selectedOrganizationId, setSelectedOrganizationId] = useState( + () => account.organizationId ?? getDefaultOrganizationId(organizations), + ); + const [message, setMessage] = useState(""); + const [isPending, setIsPending] = useState(false); + const organizationRoles = useMemo( + () => getOrganizationRoles(roles, selectedOrganizationId), + [roles, selectedOrganizationId], + ); + const defaultRoleId = + organizationRoles.find((role) => role.key === account.role || role.id === account.role)?.id ?? organizationRoles[0]?.id ?? ""; + const isCurrentAccount = account.id === currentAccountId; + + async function updateAccount(payload: Record): Promise { + setMessage(""); + setIsPending(true); + const response = await fetch(`/api/settings/accounts/${account.id}`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify(payload), + }); + const result = (await response.json()) as ApiResult<{ account: PublicAccount }>; + setIsPending(false); + + if (!result.success) { + setMessage(result.reason); + return; + } + + setMessage(result.reason); + setIsEditOpen(false); + router.refresh(); + } + + async function disableAccount(): Promise { + setMessage(""); + setIsPending(true); + const response = await fetch(`/api/settings/accounts/${account.id}`, { + method: "DELETE", + }); + const result = (await response.json()) as ApiResult<{ account: PublicAccount }>; + setIsPending(false); + + if (!result.success) { + setMessage(result.reason); + return; + } + + router.refresh(); + } + + async function handleSubmit(event: FormEvent): Promise { + event.preventDefault(); + const formData = new FormData(event.currentTarget); + await updateAccount({ + name: String(formData.get("name") ?? ""), + status: String(formData.get("status") ?? ""), + organizationId: String(formData.get("organizationId") ?? ""), + roleId: String(formData.get("roleId") ?? ""), + }); + } + + return ( +
+ + {account.status === "disabled" ? ( + + ) : ( + + )} + setIsEditOpen(false)} + open={isEditOpen} + title="编辑用户" + > +
+ + +
+ + +
+ {message ? ( +

+ {message} +

+ ) : null} +
+ + +
+
+
+
+ ); +} diff --git a/modules/settings/lib/pagination.ts b/modules/settings/lib/pagination.ts new file mode 100644 index 0000000..a7c6857 --- /dev/null +++ b/modules/settings/lib/pagination.ts @@ -0,0 +1,35 @@ +export const SETTINGS_PAGE_SIZE = 8; + +export type SearchParams = Record; + +export function getSearchParam(params: SearchParams, key: string): string { + const value = params[key]; + if (Array.isArray(value)) { + return value[0] ?? ""; + } + + return value ?? ""; +} + +export function getPage(params: SearchParams): number { + const page = Number(getSearchParam(params, "page")); + return Number.isInteger(page) && page > 0 ? page : 1; +} + +export function getPageCount(total: number, pageSize = SETTINGS_PAGE_SIZE): number { + return Math.max(1, Math.ceil(total / pageSize)); +} + +export function paginate(items: T[], page: number, pageSize = SETTINGS_PAGE_SIZE): T[] { + const safePage = Math.min(page, getPageCount(items.length, pageSize)); + return items.slice((safePage - 1) * pageSize, safePage * pageSize); +} + +export function buildPageHref(pathname: string, query: string, page: number): string { + const params = new URLSearchParams(); + if (query.trim()) { + params.set("q", query.trim()); + } + params.set("page", String(page)); + return `${pathname}?${params.toString()}`; +} diff --git a/modules/shared/components/AppShell.tsx b/modules/shared/components/AppShell.tsx index f42a6c0..294215b 100644 --- a/modules/shared/components/AppShell.tsx +++ b/modules/shared/components/AppShell.tsx @@ -2,10 +2,12 @@ import Link from "next/link"; import { Bell, BedDouble, + Building2, ClipboardCheck, HeartPulse, Home, LayoutDashboard, + ListChecks, Radio, Settings, ShieldAlert, @@ -78,10 +80,30 @@ const navItems: NavItem[] = [ icon: TabletSmartphone, }, { - href: "/app/settings", - label: "权限设置", + href: "/app/settings/organizations", + label: "机构管理", + icon: Building2, + }, + { + href: "/app/settings/users", + label: "用户管理", + icon: Users, + }, + { + href: "/app/settings/roles", + label: "角色权限", icon: Settings, }, + { + href: "/app/settings/audit", + label: "审计日志", + icon: ListChecks, + }, + { + href: "/app/settings/status", + label: "运行状态", + icon: Wrench, + }, ]; type AppShellProps = { @@ -150,7 +172,7 @@ export function AppShell({ children, account, permissions }: AppShellProps): Rea
-
{children}
+
{children}
);