feat: add admin accounts and image templates

This commit is contained in:
inman
2026-07-03 11:25:25 +08:00
parent d98e58adfa
commit c3ea9f0eb1
61 changed files with 7016 additions and 199 deletions

View File

@@ -0,0 +1,640 @@
"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;
}