Files
NianAIGC/components/account-manager.tsx

332 lines
18 KiB
TypeScript

"use client";
import { useEffect, useMemo, useState } from "react";
import { Building2, KeyRound, Loader2, 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;
};
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 selectedOrganization = useMemo(
() => payload?.organizations.find((organization) => organization.id === selectedOrganizationId),
[payload?.organizations, selectedOrganizationId]
);
const members = payload?.members || [];
async function loadAccounts(organizationId = selectedOrganizationId) {
if (!canManageAccounts) return;
setLoading(true);
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 (!response.ok) throw new Error(next.error || "账号列表加载失败。");
setPayload(next);
setSelectedOrganizationId(next.currentOrganizationId || "");
} catch (requestError) {
setError(requestError instanceof Error ? requestError.message : String(requestError));
} finally {
setLoading(false);
}
}
useEffect(() => {
if (canManageAccounts) void loadAccounts();
else setLoading(false);
}, [canManageAccounts]);
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="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">
<div className="account-panel-heading account-members-heading">
<div>
<h2 id="account-members-title">{selectedOrganization?.name || "全平台账号"}</h2>
</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 /> : members.length ? (
<div className="account-table" role="table" aria-label="账号列表">
<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>
{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>
) : <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) || "账";
}