381 lines
21 KiB
TypeScript
381 lines
21 KiB
TypeScript
"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) || "账";
|
||
}
|