Files
NianAIGC/components/account-manager.tsx
2026-07-03 11:25:25 +08:00

641 lines
25 KiB
TypeScript
Raw Permalink 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 { FormEvent, useEffect, useMemo, useRef, useState } from "react";
import { CirclePause, KeyRound, Loader2, Pencil, Plus, RefreshCw, Save, Search, Trash2, UserCheck, UserPlus, UserX } from "lucide-react";
import { crossfadeIn, pulseFeedback, revealChildren, runScopedMotion } from "@/lib/ui/motion";
type OrganizationInfo = {
organizationId: string;
organizationName: string;
};
type OrganizationGroupInfo = {
groupId: string;
groupName: string;
};
type OrganizationRoleInfo = {
roleId: string;
roleName: string;
};
type OrganizationMemberInfo = {
memberId: string;
memberName: string;
memberStatus: string;
memberPhone: string;
organizationId: string;
memberRoleId?: string;
memberRoleName?: string;
memberGroupId?: string;
memberGroupName?: string;
memberUserId?: string;
};
type PageResult<T> = {
records: T[];
total: number;
size: number;
current: number;
pages: number;
};
type AccountPayload = {
configured: boolean;
missing: string[];
staffConfigured: boolean;
staffMissing: string[];
selectedOrganizationId: string;
organizations: OrganizationInfo[];
groups: OrganizationGroupInfo[];
roles: OrganizationRoleInfo[];
members: PageResult<OrganizationMemberInfo>;
warnings?: string[];
memberListAvailable?: boolean;
passwordManagementAvailable: boolean;
};
type MemberForm = {
organizationId: string;
username: string;
memberName: string;
memberPhone: string;
initialPassword: string;
mustChangePassword: boolean;
roleId: string;
groupId: string;
};
type DepartmentForm = {
groupName: string;
groupDesc: string;
};
const blankForm: MemberForm = {
organizationId: "",
username: "",
memberName: "",
memberPhone: "",
initialPassword: "",
mustChangePassword: true,
roleId: "",
groupId: ""
};
const blankDepartmentForm: DepartmentForm = {
groupName: "",
groupDesc: ""
};
const STATUS_OPTIONS = [
{ value: "", label: "全部状态" },
{ value: "0", label: "在岗" },
{ value: "1", label: "休假" },
{ value: "2", label: "停用" }
];
export function AccountManager() {
const [payload, setPayload] = useState<AccountPayload | null>(null);
const [organizationId, setOrganizationId] = useState("");
const [status, setStatus] = useState("");
const [query, setQuery] = useState("");
const [draftQuery, setDraftQuery] = useState("");
const [pageNum, setPageNum] = useState(1);
const [form, setForm] = useState<MemberForm>(blankForm);
const [departmentForm, setDepartmentForm] = useState<DepartmentForm>(blankDepartmentForm);
const [editingId, setEditingId] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [creatingGroup, setCreatingGroup] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const managerRef = useRef<HTMLDivElement | null>(null);
const listRef = useRef<HTMLElement | null>(null);
const feedbackRef = useRef<HTMLDivElement | null>(null);
const selectedOrganizationId = organizationId || payload?.selectedOrganizationId || "";
const members = payload?.members.records || [];
const total = payload?.members.total || 0;
const pages = payload?.members.pages || 0;
const memberListAvailable = payload?.memberListAvailable !== false;
const warnings = payload?.warnings || [];
const canSubmit = Boolean(payload?.configured && selectedOrganizationId && form.memberName.trim() &&
(editingId || (payload.staffConfigured && form.memberPhone.trim())) &&
selectedRoleId(payload, form) && selectedGroupId(payload, form));
const currentOrganization = useMemo(() => {
return payload?.organizations.find((item) => item.organizationId === selectedOrganizationId);
}, [payload?.organizations, selectedOrganizationId]);
useEffect(() => {
void loadAccounts();
}, [organizationId, pageNum, query, status]);
useEffect(() => {
return runScopedMotion(managerRef, (scope) => revealChildren(scope));
}, []);
useEffect(() => {
crossfadeIn(listRef.current);
}, [members.length, pageNum, query, status, selectedOrganizationId]);
useEffect(() => {
pulseFeedback(feedbackRef.current);
}, [error, message]);
async function loadAccounts() {
setLoading(true);
setError(null);
try {
const params = new URLSearchParams({
pageNum: String(pageNum),
pageSize: "10"
});
if (organizationId) params.set("organizationId", organizationId);
if (query) params.set("memberName", query);
if (status) params.set("memberStatus", status);
const response = await fetch(`/api/admin/accounts?${params.toString()}`, { cache: "no-store" });
const nextPayload = await response.json() as AccountPayload & { error?: string };
if (!response.ok) throw new Error(nextPayload.error || "读取账号数据失败");
setPayload(nextPayload);
syncSelectedOrganization(nextPayload);
syncFormDefaults(nextPayload);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
}
function syncSelectedOrganization(nextPayload: AccountPayload) {
if (!organizationId && nextPayload.selectedOrganizationId) {
setOrganizationId(nextPayload.selectedOrganizationId);
}
}
function syncFormDefaults(nextPayload: AccountPayload) {
setForm((current) => {
const nextOrganizationId = nextPayload.selectedOrganizationId || current.organizationId;
return {
...current,
organizationId: nextOrganizationId,
roleId: nextPayload.roles.some((role) => role.roleId === current.roleId)
? current.roleId
: nextPayload.roles[0]?.roleId || "",
groupId: nextPayload.groups.some((group) => group.groupId === current.groupId)
? current.groupId
: nextPayload.groups[0]?.groupId || ""
};
});
}
function submitSearch(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setPageNum(1);
setQuery(draftQuery.trim());
}
async function submitMember(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setSaving(true);
setError(null);
setMessage(null);
try {
const roleId = selectedRoleId(payload, form);
const groupId = selectedGroupId(payload, form);
const body = editingId ? {
memberId: editingId,
memberName: form.memberName.trim(),
roleId,
groupId
} : {
organizationId: selectedOrganizationId,
username: form.username.trim(),
phone: form.memberPhone.trim(),
name: form.memberName.trim(),
initialPassword: form.initialPassword.trim(),
mustChangePassword: form.mustChangePassword,
memberName: form.memberName.trim(),
memberPhone: form.memberPhone.trim(),
roleId,
groupId
};
const response = await fetch("/api/admin/accounts", {
method: editingId ? "PATCH" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body)
});
const result = await response.json();
if (!response.ok) throw new Error(result.error || "保存成员失败");
const initialPassword = result.user?.initialPassword;
const successMessage = editingId
? "成员信息已更新。"
: initialPassword
? `成员已创建,初始密码:${initialPassword}`
: "成员已创建。";
setMessage(!editingId && !memberListAvailable
? `${successMessage} 成员列表暂不可用,上游授权后可在列表看到。`
: successMessage);
resetForm();
await loadAccounts();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setSaving(false);
}
}
async function changeStatus(memberId: string, memberStatus: "0" | "1" | "2") {
await mutateMember("PUT", { memberId, memberStatus }, "成员状态已更新。");
}
async function removeMember(memberId: string) {
if (!window.confirm("确定删除这个成员?")) return;
await mutateMember("DELETE", { memberId }, "成员已删除。");
}
async function resetPassword(member: OrganizationMemberInfo) {
if (!member.memberUserId) {
setError("该成员还没有绑定企业端用户,不能重置密码。");
return;
}
const newPassword = window.prompt(`请输入 ${member.memberName} 的新密码`);
if (!newPassword) return;
setSaving(true);
setError(null);
setMessage(null);
try {
const response = await fetch("/api/admin/accounts/password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
userId: member.memberUserId,
newPassword,
mustChangePassword: true
})
});
const result = await response.json();
if (!response.ok) throw new Error(result.error || "重置密码失败");
setMessage("密码已重置,用户下次登录需要修改密码。");
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setSaving(false);
}
}
async function mutateMember(method: "PUT" | "DELETE", body: Record<string, string>, successMessage: string) {
setSaving(true);
setError(null);
setMessage(null);
try {
const response = await fetch("/api/admin/accounts", {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body)
});
const result = await response.json();
if (!response.ok) throw new Error(result.error || "成员操作失败");
setMessage(successMessage);
await loadAccounts();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setSaving(false);
}
}
async function submitGroup(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!selectedOrganizationId) {
setError("请先选择组织。");
return;
}
const groupName = departmentForm.groupName.trim();
if (!groupName) {
setError("部门名称不能为空。");
return;
}
setCreatingGroup(true);
setError(null);
setMessage(null);
try {
const response = await fetch("/api/admin/accounts/groups", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
organizationId: selectedOrganizationId,
groupName,
groupDesc: departmentForm.groupDesc.trim()
})
});
const result = await response.json() as { error?: string; group?: OrganizationGroupInfo | null };
if (!response.ok) throw new Error(result.error || "创建部门失败");
setDepartmentForm(blankDepartmentForm);
await loadAccounts();
if (result.group?.groupId) {
setForm((current) => ({ ...current, groupId: result.group?.groupId || current.groupId }));
}
setMessage("部门已创建。");
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setCreatingGroup(false);
}
}
function editMember(member: OrganizationMemberInfo) {
setEditingId(member.memberId);
setForm({
organizationId: member.organizationId || selectedOrganizationId,
username: "",
memberName: member.memberName || "",
memberPhone: member.memberPhone || "",
initialPassword: "",
mustChangePassword: true,
roleId: member.memberRoleId || payload?.roles[0]?.roleId || "",
groupId: member.memberGroupId || payload?.groups[0]?.groupId || ""
});
}
function resetForm() {
setEditingId(null);
setForm({
organizationId: selectedOrganizationId,
username: "",
memberName: "",
memberPhone: "",
initialPassword: "",
mustChangePassword: true,
roleId: payload?.roles[0]?.roleId || "",
groupId: payload?.groups[0]?.groupId || ""
});
}
return (
<div className="account-manager" ref={managerRef}>
<div className="workspace-head" data-animate>
<div>
<h1 className="workspace-title"></h1>
</div>
<div className="workspace-meta">
<span className="status">{payload?.organizations.length || 0} </span>
<span className="status running">{total} </span>
<span className={`status ${payload?.configured ? "" : "failed"}`}>{payload?.configured ? "已接入" : "待配置"}</span>
</div>
</div>
<section className="panel account-actions" data-animate>
<label className="field account-org-field">
<span></span>
<select
value={selectedOrganizationId}
onChange={(event) => {
setOrganizationId(event.target.value);
setPageNum(1);
setEditingId(null);
setForm({ ...blankForm, organizationId: event.target.value });
}}
disabled={!payload?.configured || loading}
>
{payload?.organizations.length ? payload.organizations.map((item) => (
<option value={item.organizationId} key={item.organizationId}>{item.organizationName || item.organizationId}</option>
)) : (
<option value="">{payload?.configured ? "暂无组织" : "组织接口未配置"}</option>
)}
</select>
</label>
<form className="log-search account-search" onSubmit={submitSearch}>
<Search aria-hidden="true" />
<input
value={draftQuery}
placeholder="搜索成员姓名"
onChange={(event) => setDraftQuery(event.target.value)}
disabled={!memberListAvailable || loading}
/>
</form>
<select className="account-status-select" value={status} onChange={(event) => {
setStatus(event.target.value);
setPageNum(1);
}} disabled={!memberListAvailable || loading}>
{STATUS_OPTIONS.map((item) => <option value={item.value} key={item.value}>{item.label}</option>)}
</select>
<div className="toolbar">
<button className="button" type="button" onClick={loadAccounts} disabled={loading || saving}>
{loading ? <Loader2 className="spin" size={18} /> : <RefreshCw size={18} />}
</button>
</div>
</section>
{error || message ? (
<div ref={feedbackRef}>
{error ? <div className="callout" role="alert">{error}</div> : null}
{message ? <div className="callout success-callout" role="status" aria-live="polite">{message}</div> : null}
</div>
) : null}
{warnings.length ? (
<div className="callout" role="status" data-animate>
{accountWarningText(warnings[0])}
</div>
) : null}
{!payload?.configured ? (
<div className="callout" role="status" data-animate>
</div>
) : !payload.passwordManagementAvailable ? (
<div className="callout" role="status" data-animate>
{payload?.staffMissing.join("、") || "ZHINIAN_STAFF_API_BASE_URL、ZHINIAN_ORG_TENANT_ID"}
</div>
) : null}
<section className="panel account-department-panel" data-animate>
<div className="settings-group-head">
<div>
<h2></h2>
<p className="muted">{currentOrganization?.organizationName || selectedOrganizationId || "请选择组织"}</p>
</div>
</div>
<form className="account-department-form" onSubmit={submitGroup}>
<label className="field">
<span></span>
<input
value={departmentForm.groupName}
onChange={(event) => setDepartmentForm((item) => ({ ...item, groupName: event.target.value }))}
disabled={!payload?.configured || !selectedOrganizationId || creatingGroup}
/>
</label>
<label className="field">
<span></span>
<input
value={departmentForm.groupDesc}
placeholder="可留空"
onChange={(event) => setDepartmentForm((item) => ({ ...item, groupDesc: event.target.value }))}
disabled={!payload?.configured || !selectedOrganizationId || creatingGroup}
/>
</label>
<button className="button" type="submit" disabled={!payload?.configured || !selectedOrganizationId || creatingGroup || !departmentForm.groupName.trim()}>
{creatingGroup ? <Loader2 className="spin" size={18} /> : <Plus size={18} />}
</button>
</form>
</section>
<section className="panel account-form-panel" data-animate>
<div className="settings-group-head">
<div>
<h2>{editingId ? "编辑成员" : "新建用户"}</h2>
<p className="muted">{currentOrganization?.organizationName || selectedOrganizationId || "请选择组织"}</p>
</div>
{editingId ? (
<button className="button" type="button" onClick={resetForm} disabled={saving}>
</button>
) : null}
</div>
<form className="account-form-grid" onSubmit={submitMember}>
<label className="field">
<span></span>
<input value={form.memberName} onChange={(event) => setForm((item) => ({ ...item, memberName: event.target.value }))} disabled={!payload?.configured || saving} />
</label>
<label className="field">
<span></span>
<input value={form.memberPhone} onChange={(event) => setForm((item) => ({ ...item, memberPhone: event.target.value }))} disabled={!payload?.configured || saving || Boolean(editingId)} />
</label>
<label className="field">
<span></span>
<input value={form.username} placeholder="默认使用手机号" onChange={(event) => setForm((item) => ({ ...item, username: event.target.value }))} disabled={!payload?.staffConfigured || saving || Boolean(editingId)} />
</label>
<label className="field">
<span></span>
<input type="password" value={form.initialPassword} placeholder="留空由服务端生成" onChange={(event) => setForm((item) => ({ ...item, initialPassword: event.target.value }))} disabled={!payload?.staffConfigured || saving || Boolean(editingId)} />
</label>
<label className="field">
<span></span>
<select value={selectedRoleId(payload, form)} onChange={(event) => setForm((item) => ({ ...item, roleId: event.target.value }))} disabled={!payload?.roles.length || saving}>
{payload?.roles.length ? payload.roles.map((role) => (
<option value={role.roleId} key={role.roleId}>{role.roleName}</option>
)) : <option value=""></option>}
</select>
</label>
<label className="field">
<span></span>
<select value={selectedGroupId(payload, form)} onChange={(event) => setForm((item) => ({ ...item, groupId: event.target.value }))} disabled={!payload?.groups.length || saving}>
{payload?.groups.length ? payload.groups.map((group) => (
<option value={group.groupId} key={group.groupId}>{group.groupName}</option>
)) : <option value=""></option>}
</select>
</label>
<label className="toggle-line account-password-toggle">
<input
type="checkbox"
checked={form.mustChangePassword}
onChange={(event) => setForm((item) => ({ ...item, mustChangePassword: event.target.checked }))}
disabled={!payload?.staffConfigured || saving || Boolean(editingId)}
/>
</label>
<div className="toolbar account-form-submit">
<button className="button primary account-create-submit" type="submit" disabled={!canSubmit || saving || creatingGroup}>
{saving ? <Loader2 className="spin" size={18} /> : editingId ? <Save size={18} /> : <UserPlus size={18} />}
{editingId ? "保存成员" : "新建用户"}
</button>
</div>
</form>
</section>
<section className="panel account-list" data-animate ref={listRef}>
{loading && !payload ? (
<div className="settings-loading">
<Loader2 className="spin" size={18} />
</div>
) : members.length ? (
<div className="account-table" role="table" aria-label="账号成员">
<div className="account-row account-head" role="row">
<span></span>
<span> / </span>
<span></span>
<span></span>
</div>
{members.map((member) => (
<div className="account-row" role="row" key={member.memberId}>
<div className="account-member-main">
<strong>{member.memberName}</strong>
<small>{member.memberPhone || member.memberId}</small>
</div>
<div className="account-member-main">
<strong>{member.memberRoleName || "-"}</strong>
<small>{member.memberGroupName || "-"}</small>
</div>
<span className={`status ${member.memberStatus === "2" ? "failed" : member.memberStatus === "1" ? "running" : ""}`}>{memberStatusLabel(member.memberStatus)}</span>
<div className="toolbar account-row-actions">
<button className="icon-button" type="button" title="编辑成员" aria-label="编辑成员" onClick={() => editMember(member)} disabled={saving}>
<Pencil aria-hidden="true" />
</button>
<button className="icon-button" type="button" title="重置密码" aria-label="重置密码" onClick={() => resetPassword(member)} disabled={saving || !payload?.staffConfigured || !member.memberUserId}>
<KeyRound aria-hidden="true" />
</button>
<button className="icon-button" type="button" title={member.memberStatus === "0" ? "设为休假" : "设为在岗"} aria-label={member.memberStatus === "0" ? "设为休假" : "设为在岗"} onClick={() => changeStatus(member.memberId, member.memberStatus === "0" ? "1" : "0")} disabled={saving}>
{member.memberStatus === "0" ? <CirclePause aria-hidden="true" /> : <UserCheck aria-hidden="true" />}
</button>
<button className="icon-button danger-icon" type="button" title="停用成员" aria-label="停用成员" onClick={() => changeStatus(member.memberId, "2")} disabled={saving || member.memberStatus === "2"}>
<UserX aria-hidden="true" />
</button>
<button className="icon-button danger-icon" type="button" title="删除成员" aria-label="删除成员" onClick={() => removeMember(member.memberId)} disabled={saving}>
<Trash2 aria-hidden="true" />
</button>
</div>
</div>
))}
</div>
) : (
<div className="log-empty">{accountEmptyText(payload)}</div>
)}
{pages > 1 ? (
<div className="pagination">
<button className="icon-button" type="button" onClick={() => setPageNum((item) => Math.max(1, item - 1))} disabled={pageNum <= 1 || loading}></button>
<span className="pager-meta">{pageNum} / {pages}</span>
<button className="icon-button" type="button" onClick={() => setPageNum((item) => Math.min(pages, item + 1))} disabled={pageNum >= pages || loading}></button>
</div>
) : null}
</section>
</div>
);
}
function selectedRoleId(payload: AccountPayload | null, form: MemberForm): string {
if (payload?.roles.some((role) => role.roleId === form.roleId)) return form.roleId;
return payload?.roles[0]?.roleId || "";
}
function selectedGroupId(payload: AccountPayload | null, form: MemberForm): string {
if (payload?.groups.some((group) => group.groupId === form.groupId)) return form.groupId;
return payload?.groups[0]?.groupId || "";
}
function memberStatusLabel(status: string): string {
if (status === "0") return "在岗";
if (status === "1") return "休假";
if (status === "2") return "停用";
return status || "未知";
}
function accountEmptyText(payload: AccountPayload | null): string {
if (!payload?.configured) return "组织接口配置后显示成员";
if (payload.memberListAvailable === false) return "成员列表暂不可用,新增账号仍可使用";
return "暂无成员";
}
function accountWarningText(message: string): string {
if (/仅管理员角色|上游.*管理员|hotelStaff 管理员|管理员角色/i.test(message)) {
return "成员列表需要认证中心管理员角色 1。当前账号已允许进入本平台后台但上游 hotelStaff 暂未授权成员列表查询。";
}
if (/成员列表|organizationMemberList|ZHINIAN_ORG_MEMBER_LIST_PATH/i.test(message)) {
return "成员列表待接入:当前组织服务还没有开放成员查询,新增账号仍可使用。";
}
return message;
}