Files
NianAIGC/components/account-manager.tsx
T
2026-10-02 19:56:32 +08:00

381 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { Building2, KeyRound, Pencil, Plus, RefreshCw, ShieldCheck, Trash2, UserPlus } from "lucide-react";
import { AccountSecurityPanel } from "@/components/account-security-panel";
type Role = "super_admin" | "organization_admin" | "user";
type Status = "active" | "disabled";
type Organization = { id: string; name: string; status: Status };
type Member = {
id: string;
phone: string;
displayName: string;
role: Role;
organizationId: string | null;
status: Status;
createdAt: string;
lastLoginAt: string | null;
lockedUntil: string | null;
};
type AccountPayload = {
currentOrganizationId: string | null;
organizations: Organization[];
members: Member[];
canManageOrganizations: boolean;
canAssignOrganizationAdmin: boolean;
};
type AccountManagerProps = {
canManageAccounts: boolean;
};
type MemberGroup = { id: string; name: string; status?: Status; members: Member[] };
const roleLabels: Record<Role, string> = {
super_admin: "超级管理员",
organization_admin: "组织管理员",
user: "普通用户"
};
export function AccountManager({ canManageAccounts }: AccountManagerProps) {
const [payload, setPayload] = useState<AccountPayload | null>(null);
const [loading, setLoading] = useState(canManageAccounts);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [selectedOrganizationId, setSelectedOrganizationId] = useState("");
const [form, setForm] = useState({ phone: "", displayName: "", password: "", role: "user" as Role });
const [organizationName, setOrganizationName] = useState("");
const [accountsLoadFailed, setAccountsLoadFailed] = useState(false);
const accountsRequestVersion = useRef(0);
const membersPanelRef = useRef<HTMLElement | null>(null);
const selectedOrganization = useMemo(
() => payload?.organizations.find((organization) => organization.id === selectedOrganizationId),
[payload?.organizations, selectedOrganizationId]
);
const memberGroups = useMemo(() => {
if (!payload) return [];
const groups: MemberGroup[] = payload.organizations
.filter((organization) => !payload.currentOrganizationId || organization.id === payload.currentOrganizationId)
.map((organization) => ({ ...organization, members: [] }));
const byOrganization = new Map(groups.map((group) => [group.id, group]));
for (const member of payload.members) {
const organizationId = member.organizationId || "";
let group = byOrganization.get(organizationId);
if (!group) {
group = { id: organizationId, name: organizationId ? `未知组织(${organizationId})` : "未归属组织", members: [] };
byOrganization.set(organizationId, group);
groups.push(group);
}
group.members.push(member);
}
return groups;
}, [payload]);
async function loadAccounts(organizationId = selectedOrganizationId) {
if (!canManageAccounts) return;
const requestVersion = ++accountsRequestVersion.current;
setLoading(true);
setAccountsLoadFailed(false);
setError(null);
try {
const query = organizationId ? `?organizationId=${encodeURIComponent(organizationId)}` : "";
const response = await fetch(`/api/admin/accounts${query}`, { cache: "no-store" });
const next = await response.json() as AccountPayload & { error?: string };
if (requestVersion !== accountsRequestVersion.current) return;
if (!response.ok) throw new Error(next.error || "账号列表加载失败。");
setPayload(next);
setSelectedOrganizationId(next.currentOrganizationId || "");
} catch (requestError) {
if (requestVersion !== accountsRequestVersion.current) return;
setAccountsLoadFailed(true);
setError(requestError instanceof Error ? requestError.message : String(requestError));
} finally {
if (requestVersion === accountsRequestVersion.current) setLoading(false);
}
}
useEffect(() => {
if (canManageAccounts) void loadAccounts();
else setLoading(false);
return () => { accountsRequestVersion.current += 1; };
}, [canManageAccounts]);
function viewOrganizationAccounts(organizationId: string) {
setSelectedOrganizationId(organizationId);
void loadAccounts(organizationId);
membersPanelRef.current?.scrollIntoView({ block: "start", behavior: "smooth" });
}
async function createUser(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setSaving(true);
setMessage(null);
setError(null);
try {
const response = await fetch("/api/admin/accounts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
phone: form.phone,
displayName: form.displayName,
password: form.password,
role: form.role,
organizationId: selectedOrganization?.id
})
});
const result = await response.json().catch(() => ({})) as { error?: string };
if (!response.ok) throw new Error(result.error || "账号创建失败。");
setMessage("账号已创建。");
setForm({ phone: "", displayName: "", password: "", role: "user" });
await loadAccounts(selectedOrganization?.id || "");
} catch (requestError) {
setError(requestError instanceof Error ? requestError.message : String(requestError));
} finally {
setSaving(false);
}
}
async function createOrganization(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!organizationName.trim()) return;
setSaving(true);
setMessage(null);
setError(null);
try {
const response = await fetch("/api/admin/organizations", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: organizationName })
});
const result = await response.json().catch(() => ({})) as { error?: string };
if (!response.ok) throw new Error(result.error || "组织创建失败。");
setOrganizationName("");
setMessage("组织已创建。");
await loadAccounts();
} catch (requestError) {
setError(requestError instanceof Error ? requestError.message : String(requestError));
} finally {
setSaving(false);
}
}
async function renameOrganization(organization: Organization) {
const name = window.prompt("请输入新的组织名称", organization.name)?.trim();
if (!name || name === organization.name) return;
await mutateOrganization("PATCH", { organizationId: organization.id, name }, "组织名称已更新。");
}
async function toggleOrganization(organization: Organization) {
const status: Status = organization.status === "active" ? "disabled" : "active";
await mutateOrganization("PATCH", { organizationId: organization.id, status }, status === "active" ? "组织已启用。" : "组织已停用。登录和新账号创建会受到限制。");
}
async function deleteOrganization(organization: Organization) {
if (!window.confirm(`确定删除“${organization.name}”吗?只有没有任何账号的组织才可以删除。`)) return;
await mutateOrganization("DELETE", { organizationId: organization.id }, "组织已删除。");
}
async function mutateOrganization(method: string, body: Record<string, unknown>, successMessage: string) {
setError(null);
setMessage(null);
try {
const response = await fetch("/api/admin/organizations", {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body)
});
const result = await response.json().catch(() => ({})) as { error?: string };
if (!response.ok) throw new Error(result.error || "组织操作失败。");
setMessage(successMessage);
if (method === "DELETE") {
setSelectedOrganizationId("");
await loadAccounts("");
} else {
await loadAccounts(selectedOrganizationId);
}
} catch (requestError) {
setError(requestError instanceof Error ? requestError.message : String(requestError));
}
}
async function changeStatus(member: Member) {
const nextStatus: Status = member.status === "active" ? "disabled" : "active";
await mutateMember("PUT", { userId: member.id, status: nextStatus }, nextStatus === "active" ? "账号已启用。" : "账号已停用。");
}
async function resetPassword(member: Member) {
const password = window.prompt(`为 ${member.displayName} 设置新密码(至少 8 位)`);
if (!password) return;
await mutateMember("POST", { userId: member.id, newPassword: password }, "密码已重置。", "/api/admin/accounts/password");
}
async function changeRole(member: Member, nextRole: Role) {
await mutateMember("PATCH", { userId: member.id, role: nextRole }, "账号角色已更新。");
}
async function deleteMember(member: Member) {
if (!window.confirm(`确定彻底删除“${member.displayName}”吗?素材和任务会转移到组织归档账号,用量记录保留。`)) return;
await mutateMember("DELETE", { userId: member.id }, "账号已删除。");
}
async function mutateMember(method: string, body: Record<string, unknown>, successMessage: string, endpoint = "/api/admin/accounts") {
setError(null);
setMessage(null);
try {
const response = await fetch(endpoint, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body)
});
const result = await response.json().catch(() => ({})) as { error?: string };
if (!response.ok) throw new Error(result.error || "操作失败。");
setMessage(successMessage);
await loadAccounts(selectedOrganizationId);
} catch (requestError) {
setError(requestError instanceof Error ? requestError.message : String(requestError));
}
}
return (
<div className="account-directory">
<header className="account-directory-head">
<div className="account-directory-intro">
<h1>账户与访问</h1>
</div>
</header>
{message ? <div className="account-feedback success" role="status">{message}</div> : null}
{error ? <div className="account-feedback error" role="alert">{error}</div> : null}
<section className="account-workspace" aria-label="账户工作区">
<AccountSecurityPanel />
{canManageAccounts ? (
<>
<section className="account-management-section" aria-labelledby="account-management-title">
<div className="account-workspace-heading">
<div>
<h2 id="account-management-title">组织与成员管理</h2>
</div>
<ShieldCheck aria-hidden="true" />
</div>
<div className="account-management-grid">
<div className="account-management-form">
<div className="account-subsection-heading">
<h3>创建账号</h3>
</div>
<form className="account-form-grid" onSubmit={createUser}>
<label className="field"><span>手机号</span><input inputMode="tel" value={form.phone} onChange={(event) => setForm((current) => ({ ...current, phone: event.target.value }))} placeholder="请输入手机号" /></label>
<label className="field"><span>显示名称</span><input value={form.displayName} onChange={(event) => setForm((current) => ({ ...current, displayName: event.target.value }))} placeholder="请输入名称" /></label>
<label className="field"><span>初始密码</span><input type="password" autoComplete="new-password" value={form.password} onChange={(event) => setForm((current) => ({ ...current, password: event.target.value }))} placeholder="至少 8 位" /></label>
{payload?.canAssignOrganizationAdmin ? <label className="field"><span>角色</span><select value={form.role} onChange={(event) => setForm((current) => ({ ...current, role: event.target.value as Role }))}><option value="user">普通用户</option><option value="organization_admin">组织管理员</option><option value="super_admin">超级管理员</option></select></label> : null}
<label className="field account-form-wide"><span>组织</span><select value={selectedOrganization?.id || ""} onChange={(event) => { setSelectedOrganizationId(event.target.value); void loadAccounts(event.target.value); }} disabled={!payload?.canManageOrganizations && Boolean(selectedOrganization)}><option value="">{payload?.canManageOrganizations ? "全平台 / 未归属" : "选择组织"}</option>{payload?.organizations.map((organization) => <option key={organization.id} value={organization.id}>{organization.name}</option>)}</select></label>
<button className="button primary account-create-submit account-form-wide" type="submit" disabled={saving || loading || (form.role !== "super_admin" && !selectedOrganization?.id) || !form.phone || !form.displayName || !form.password}><UserPlus size={17} />{saving ? "创建中" : "创建账号"}</button>
</form>
</div>
{payload?.canManageOrganizations ? (
<div className="account-management-form account-organization-form">
<div className="account-subsection-heading">
<h3>组织目录</h3>
</div>
<form className="account-department-form" onSubmit={createOrganization}>
<label className="field"><span>新组织名称</span><input value={organizationName} onChange={(event) => setOrganizationName(event.target.value)} placeholder="例如:华东运营组" /></label>
<button className="button secondary" type="submit" disabled={saving || !organizationName.trim()}><Plus size={17} />创建组织</button>
</form>
<div className="account-org-list">{payload.organizations.map((organization) => (
<div className="account-org-item" key={organization.id}>
<div><strong>{organization.name}</strong><small className={organization.status === "active" ? "org-active" : "org-disabled"}>{organization.status === "active" ? "启用" : "停用"}</small></div>
<div className="account-org-actions">
<button className="account-org-view" type="button" onClick={() => viewOrganizationAccounts(organization.id)} aria-label={`查看 ${organization.name} 的账号`}>查看账号</button>
<button className="icon-button" type="button" onClick={() => void renameOrganization(organization)} title="重命名" aria-label={`重命名 ${organization.name}`}><Pencil size={15} /></button>
<button className="icon-button" type="button" onClick={() => void toggleOrganization(organization)} title={organization.status === "active" ? "停用组织" : "启用组织"} aria-label={`${organization.status === "active" ? "停用" : "启用"} ${organization.name}`}><RefreshCw size={15} /></button>
<button className="icon-button danger-icon" type="button" onClick={() => void deleteOrganization(organization)} title="删除组织" aria-label={`删除 ${organization.name}`}><Trash2 size={15} /></button>
</div>
</div>
))}</div>
</div>
) : null}
</div>
</section>
<section className="account-members-panel" aria-labelledby="account-members-title" ref={membersPanelRef}>
<div className="account-panel-heading account-members-heading">
<div>
<h2 id="account-members-title">{selectedOrganization ? `${selectedOrganization.name}的账号` : "按组织查看账号"}</h2>
<p className="account-members-description">{payload?.canManageOrganizations ? "按组织展示账号,包含已停用账号。" : "显示本组织内可管理的普通用户账号。"}</p>
</div>
<div className="account-members-tools">
<label className="account-filter-field">
<span>组织</span>
<select value={selectedOrganization?.id || ""} onChange={(event) => { setSelectedOrganizationId(event.target.value); void loadAccounts(event.target.value); }} disabled={!payload?.canManageOrganizations && Boolean(selectedOrganization)} aria-label="按组织筛选账号">
<option value="">{payload?.canManageOrganizations ? "全平台" : "当前组织"}</option>
{payload?.organizations.map((organization) => <option key={organization.id} value={organization.id}>{organization.name}</option>)}
</select>
</label>
<button className="icon-button" type="button" onClick={() => void loadAccounts(selectedOrganizationId)} title="刷新账号列表" aria-label="刷新账号列表"><RefreshCw size={17} /></button>
</div>
</div>
{loading ? <AccountLoading /> : accountsLoadFailed ? (
<div className="account-empty"><span>账号列表加载失败。</span><button className="button secondary" type="button" onClick={() => void loadAccounts(selectedOrganizationId)}>重新加载</button></div>
) : memberGroups.length ? (
<div className="account-member-groups">
{memberGroups.map((group) => (
<section className="account-member-group" key={group.id} aria-label={`${group.name}的账号`}>
<header className="account-member-group-heading">
<h3><Building2 size={17} aria-hidden="true" />{group.name}</h3>
{group.status === "disabled" ? <span className="account-status-badge disabled">组织已停用</span> : null}
<span className="account-member-count">{group.members.length} 个{payload?.canManageOrganizations ? "账号" : "普通账号"}</span>
</header>
{group.members.length ? <div className="account-table" role="table" aria-label={`${group.name}账号列表`}>
<div className="account-row account-head" role="row"><span role="columnheader">账号</span><span role="columnheader">角色</span><span role="columnheader">状态</span><span role="columnheader">操作</span></div>
{group.members.map((member) => (
<div className="account-row" role="row" key={member.id}>
<div className="account-member-identity" role="cell">
<span className="account-avatar" aria-hidden="true">{accountInitial(member.displayName)}</span>
<div className="account-member-main"><strong>{member.displayName}</strong><small>{member.phone}</small></div>
</div>
<div className="account-role-cell" role="cell">{payload?.canAssignOrganizationAdmin ? <select className="account-status-select" value={member.role} onChange={(event) => void changeRole(member, event.target.value as Role)} aria-label={`${member.displayName}的角色`}><option value="user">{roleLabels.user}</option><option value="organization_admin">{roleLabels.organization_admin}</option><option value="super_admin">{roleLabels.super_admin}</option></select> : <span>{roleLabels[member.role]}</span>}</div>
<div role="cell"><span className={`account-status-badge ${member.status === "active" ? "active" : "disabled"}`}>{member.status === "active" ? "启用" : "停用"}</span></div>
<div className="account-row-actions" role="cell">
<button className="icon-button" type="button" onClick={() => void resetPassword(member)} title="重置密码" aria-label={`重置 ${member.displayName} 密码`}><KeyRound size={16} /></button>
<button className="icon-button" type="button" onClick={() => void changeStatus(member)} title={member.status === "active" ? "停用" : "启用"} aria-label={`${member.status === "active" ? "停用" : "启用"} ${member.displayName}`}><RefreshCw size={16} /></button>
<button className="icon-button danger-icon" type="button" onClick={() => void deleteMember(member)} title="彻底删除" aria-label={`删除 ${member.displayName}`}><Trash2 size={16} /></button>
</div>
</div>
))}
</div> : <p className="account-member-group-empty">{payload?.canManageOrganizations ? "该组织暂无账号。" : "本组织暂无可管理的普通用户账号。"}</p>}
</section>
))}
</div>
) : <div className="account-empty">暂无组织或账号。</div>}
</section>
</>
) : null}
</section>
</div>
);
}
function AccountLoading() {
return (
<div className="account-loading" role="status" aria-live="polite">
<div className="account-skeleton-list" aria-hidden="true">
<span /><span /><span />
</div>
<span>正在加载账号</span>
</div>
);
}
function accountInitial(value: string) {
return value.trim().slice(0, 2) || "账";
}