feat: move settings creation flows into dialogs

This commit is contained in:
2026-07-02 05:49:50 -07:00
parent ba8097e583
commit f6b7924d0c
8 changed files with 590 additions and 206 deletions

View File

@@ -13,6 +13,7 @@ import type { ApiResult } from "@/modules/core/server/api";
import type { JoinRequest, Organization, PublicAccount, RoleDefinition } from "@/modules/core/types";
type UserManagementClientProps = {
canManageAccounts?: boolean;
organizations: Organization[];
roles: RoleDefinition[];
joinRequests: JoinRequest[];
@@ -27,11 +28,13 @@ function getDefaultOrganizationId(organizations: Organization[]): string {
}
export function UserManagementClient({
canManageAccounts = false,
organizations,
roles,
joinRequests,
}: UserManagementClientProps): React.ReactElement {
const router = useRouter();
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [selectedOrganizationId, setSelectedOrganizationId] = useState(() => getDefaultOrganizationId(organizations));
const [message, setMessage] = useState("");
const [isPending, setIsPending] = useState(false);
@@ -42,8 +45,26 @@ export function UserManagementClient({
);
const pendingJoinRequests = joinRequests.filter((request) => request.status === "pending");
function openCreateDialog(): void {
setMessage("");
setIsCreateOpen(true);
}
function closeCreateDialog(): void {
if (isPending) {
return;
}
setIsCreateOpen(false);
}
async function handleCreateAccount(event: FormEvent<HTMLFormElement>): Promise<void> {
event.preventDefault();
if (!canManageAccounts) {
setMessage("当前角色无权创建用户");
return;
}
setMessage("");
setIsPending(true);
@@ -68,11 +89,17 @@ export function UserManagementClient({
}
setMessage(result.reason);
setIsCreateOpen(false);
event.currentTarget.reset();
router.refresh();
}
async function reviewJoinRequest(requestId: string, decision: "approved" | "rejected", roleId: string): Promise<void> {
if (!canManageAccounts) {
setMessage("当前角色无权审批加入申请");
return;
}
setMessage("");
setIsPending(true);
const response = await fetch(`/api/settings/join-requests/${requestId}`, {
@@ -95,63 +122,37 @@ export function UserManagementClient({
return (
<section className="grid gap-4 xl:grid-cols-[0.9fr_1.1fr]">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
<CardHeader className="gap-4 md:flex-row md:items-start md:justify-between">
<div>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</div>
<Button
disabled={!canManageAccounts || organizations.length === 0}
onClick={openCreateDialog}
type="button"
>
<Plus className="size-4" aria-hidden="true" />
</Button>
</CardHeader>
<CardContent>
<form className="grid gap-3" onSubmit={handleCreateAccount}>
<label className="grid gap-2 text-sm">
<span className="font-medium"></span>
<select
className="h-11 w-full rounded-md border bg-background px-3 text-sm outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring"
name="organizationId"
onChange={(event) => setSelectedOrganizationId(event.target.value)}
value={selectedOrganizationId}
>
{organizations.map((organization) => (
<option key={organization.id} value={organization.id}>
{organization.name}
</option>
))}
</select>
</label>
<label className="grid gap-2 text-sm">
<span className="font-medium"></span>
<select
className="h-11 w-full rounded-md border bg-background px-3 text-sm outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring"
name="roleId"
required
>
{organizationRoles.map((role) => (
<option key={role.id} value={role.id}>
{role.label}
</option>
))}
</select>
</label>
<label className="grid gap-2 text-sm">
<span className="font-medium"></span>
<Input name="name" required />
</label>
<label className="grid gap-2 text-sm">
<span className="font-medium"></span>
<Input name="email" required type="email" />
</label>
<label className="grid gap-2 text-sm">
<span className="font-medium"></span>
<Input minLength={6} name="password" required type="password" />
</label>
<Button disabled={isPending || organizationRoles.length === 0} type="submit">
<Plus className="size-4" aria-hidden="true" />
</Button>
</form>
<CardContent className="grid gap-3">
<div className="grid gap-3 sm:grid-cols-3">
<div className="rounded-md border bg-secondary/20 p-3">
<p className="text-xs text-muted-foreground"></p>
<p className="mt-1 text-2xl font-semibold">{organizations.length}</p>
</div>
<div className="rounded-md border bg-secondary/20 p-3">
<p className="text-xs text-muted-foreground"></p>
<p className="mt-1 text-2xl font-semibold">{organizationRoles.length}</p>
</div>
<div className="rounded-md border bg-secondary/20 p-3">
<p className="text-xs text-muted-foreground"></p>
<p className="mt-1 text-2xl font-semibold">{pendingJoinRequests.length}</p>
</div>
</div>
{message ? (
<p className="mt-3 rounded-md bg-secondary px-3 py-2 text-sm text-secondary-foreground" role="status">
<p className="rounded-md bg-secondary px-3 py-2 text-sm text-secondary-foreground" role="status">
{message}
</p>
) : null}
@@ -191,7 +192,7 @@ export function UserManagementClient({
))}
</select>
<Button
disabled={isPending || !defaultRoleId}
disabled={isPending || !defaultRoleId || !canManageAccounts}
onClick={() => {
const element = document.getElementById(`role-${request.id}`);
const roleId = element instanceof HTMLSelectElement ? element.value : defaultRoleId;
@@ -204,7 +205,7 @@ export function UserManagementClient({
</Button>
<Button
disabled={isPending}
disabled={isPending || !canManageAccounts}
onClick={() => void reviewJoinRequest(request.id, "rejected", "")}
size="sm"
type="button"
@@ -220,6 +221,76 @@ export function UserManagementClient({
{pendingJoinRequests.length === 0 ? <p className="text-sm text-muted-foreground"></p> : null}
</CardContent>
</Card>
<Dialog
description="管理员创建账号后立即分配到机构和角色。"
onClose={closeCreateDialog}
open={isCreateOpen}
title="创建机构用户"
>
<form className="grid gap-3" onSubmit={handleCreateAccount}>
<label className="grid gap-2 text-sm">
<span className="font-medium"></span>
<select
className="h-11 w-full rounded-md border bg-background px-3 text-sm outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring"
name="organizationId"
onChange={(event) => setSelectedOrganizationId(event.target.value)}
value={selectedOrganizationId}
>
{organizations.map((organization) => (
<option key={organization.id} value={organization.id}>
{organization.name}
</option>
))}
</select>
</label>
<label className="grid gap-2 text-sm">
<span className="font-medium"></span>
<select
className="h-11 w-full rounded-md border bg-background px-3 text-sm outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring"
disabled={organizationRoles.length === 0}
key={selectedOrganizationId}
name="roleId"
required
>
{organizationRoles.map((role) => (
<option key={role.id} value={role.id}>
{role.label}
</option>
))}
</select>
</label>
<label className="grid gap-2 text-sm">
<span className="font-medium"></span>
<Input name="name" required />
</label>
<label className="grid gap-2 text-sm">
<span className="font-medium"></span>
<Input name="email" required type="email" />
</label>
<label className="grid gap-2 text-sm">
<span className="font-medium"></span>
<Input minLength={6} name="password" required type="password" />
</label>
<div className="flex flex-col-reverse gap-2 pt-1 sm:flex-row sm:justify-end">
<Button disabled={isPending} onClick={closeCreateDialog} type="button" variant="outline">
</Button>
<Button disabled={isPending || organizationRoles.length === 0} type="submit">
<Plus className="size-4" aria-hidden="true" />
</Button>
</div>
</form>
{message ? (
<p className="mt-3 rounded-md bg-secondary px-3 py-2 text-sm text-secondary-foreground" role="status">
{message}
</p>
) : null}
</Dialog>
</section>
);
}