Files
Genarrative/apps/admin-web/src/pages/AdminAccountsPage.tsx
T
kdletters 071faa482c 统一 Rust 与 TypeScript 格式化门禁
纳入 AGC Cargo workspace 的统一 rustfmt 检查与格式化入口

完成项目 TypeScript/Prettier 与 Rust 全量格式化

修复 Pingora expected executable 门禁的空白敏感误报

同步开发运维文档与 AGC skill pack 格式化忽略规则
2026-09-01 16:28:34 +08:00

336 lines
11 KiB
TypeScript

import { Plus, RefreshCcw, Save } from 'lucide-react';
import { type FormEvent, useEffect, useState } from 'react';
import {
createAdminAccount,
listAdminAccounts,
updateAdminAccount,
} from '../api/adminApiClient';
import type { AdminAccountPayload } from '../api/adminApiTypes';
import { adminRoutes } from '../app/adminRoutes';
import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm';
import { handlePageError } from './pageUtils';
interface AdminAccountsPageProps {
token: string;
onUnauthorized: (message?: string) => void;
}
const assignableRoutes = adminRoutes.filter((route) => !route.ownerOnly);
const consumptionReconcilePermission = 'profile-wallet-consumption-reconcile';
export function AdminAccountsPage({
token,
onUnauthorized,
}: AdminAccountsPageProps) {
const [accounts, setAccounts] = useState<AdminAccountPayload[]>([]);
const [selectedAccountId, setSelectedAccountId] = useState('');
const [username, setUsername] = useState('');
const [displayName, setDisplayName] = useState('');
const [password, setPassword] = useState('');
const [enabled, setEnabled] = useState(true);
const [tabPermissions, setTabPermissions] = useState<string[]>([]);
const [actionPermissions, setActionPermissions] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
const { confirmWrite, confirmDialog } = useAdminWriteConfirm();
useEffect(() => {
void refreshAccounts();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token]);
async function refreshAccounts() {
setIsLoading(true);
setErrorMessage('');
try {
const response = await listAdminAccounts(token);
setAccounts(response.accounts);
const selected = response.accounts.find(
(account) => account.accountId === selectedAccountId,
);
if (selected) {
fillForm(selected);
}
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
setIsLoading(false);
}
}
function startCreate() {
setSelectedAccountId('');
setUsername('');
setDisplayName('');
setPassword('');
setEnabled(true);
setTabPermissions([]);
setActionPermissions([]);
setErrorMessage('');
}
function fillForm(account: AdminAccountPayload) {
setSelectedAccountId(account.accountId);
setUsername(account.username);
setDisplayName(account.displayName);
setPassword('');
setEnabled(account.enabled);
setTabPermissions(account.tabPermissions);
setActionPermissions(account.actionPermissions ?? []);
setErrorMessage('');
}
function togglePermission(permission: string, checked: boolean) {
setTabPermissions((current) =>
checked
? assignableRoutes
.map((route) => route.id)
.filter(
(routeId) => routeId === permission || current.includes(routeId),
)
: current.filter((item) => item !== permission),
);
}
async function handleSave(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (isSaving) {
return;
}
const normalizedUsername = username.trim();
const normalizedDisplayName = displayName.trim();
if (!selectedAccountId && !normalizedUsername) {
setErrorMessage('请输入用户名');
return;
}
if (!normalizedDisplayName) {
setErrorMessage('请输入显示名称');
return;
}
if (!selectedAccountId && !password) {
setErrorMessage('请输入密码');
return;
}
const confirmed = await confirmWrite({
action: selectedAccountId ? '更新后台账号' : '创建后台账号',
target: normalizedUsername,
});
if (!confirmed) {
return;
}
setIsSaving(true);
setErrorMessage('');
try {
const response = selectedAccountId
? await updateAdminAccount(token, selectedAccountId, {
displayName: normalizedDisplayName,
...(password ? { password } : {}),
tabPermissions,
actionPermissions,
enabled,
})
: await createAdminAccount(token, {
username: normalizedUsername,
displayName: normalizedDisplayName,
password,
tabPermissions,
actionPermissions,
enabled,
});
setAccounts((current) => {
const rest = current.filter(
(account) => account.accountId !== response.account.accountId,
);
return [...rest, response.account].sort((left, right) =>
left.username.localeCompare(right.username),
);
});
fillForm(response.account);
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
setIsSaving(false);
}
}
return (
<section className="admin-page admin-page-wide">
<div className="admin-page-heading">
<div>
<h2>账号管理</h2>
<p>后台成员</p>
</div>
<div className="admin-action-row">
<button
className="admin-secondary-button"
type="button"
onClick={startCreate}
>
<Plus size={17} aria-hidden="true" />
<span>添加账号</span>
</button>
<button
className="admin-secondary-button"
disabled={isLoading}
type="button"
onClick={refreshAccounts}
>
<RefreshCcw size={17} aria-hidden="true" />
<span>{isLoading ? '刷新中' : '刷新'}</span>
</button>
</div>
</div>
{errorMessage ? (
<div className="admin-alert" role="status">
{errorMessage}
</div>
) : null}
<div className="admin-accounts-layout">
<section className="admin-panel admin-account-list">
<div className="admin-panel-heading">
<h3>后台账号</h3>
<span>{accounts.length}</span>
</div>
{accounts.length ? (
<div className="admin-account-list-items">
{accounts.map((account) => (
<button
data-active={account.accountId === selectedAccountId}
disabled={account.accountRole === 'owner'}
key={account.accountId}
title={
account.accountRole === 'owner' ? 'owner' : account.username
}
type="button"
onClick={() => {
if (account.accountRole === 'member') {
fillForm(account);
}
}}
>
<span>
<strong>{account.displayName || account.username}</strong>
<small>{account.username}</small>
</span>
<small>
{account.accountRole === 'owner'
? 'owner'
: account.enabled
? '启用'
: '停用'}
</small>
</button>
))}
</div>
) : (
<div className="admin-empty-state">
{isLoading ? '加载中' : '暂无成员账号'}
</div>
)}
</section>
<form className="admin-panel admin-form" onSubmit={handleSave}>
<div className="admin-panel-heading">
<h3>{selectedAccountId ? '编辑账号' : '添加账号'}</h3>
<label className="admin-switch-field">
<input
checked={enabled}
type="checkbox"
onChange={(event) => setEnabled(event.target.checked)}
/>
<span>启用</span>
</label>
</div>
<div className="admin-form-row">
<label className="admin-field">
<span>用户名</span>
<input
disabled={Boolean(selectedAccountId)}
autoComplete="off"
value={username}
onChange={(event) => setUsername(event.target.value)}
/>
</label>
<label className="admin-field">
<span>显示名称</span>
<input
value={displayName}
onChange={(event) => setDisplayName(event.target.value)}
/>
</label>
</div>
<label className="admin-field">
<span>{selectedAccountId ? '新密码' : '密码'}</span>
<input
autoComplete="new-password"
placeholder={selectedAccountId ? '不修改' : ''}
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
/>
</label>
<fieldset className="admin-permission-fieldset">
<legend>Tab 访问权限</legend>
<div className="admin-permission-grid">
{assignableRoutes.map((route) => (
<label key={route.id}>
<input
checked={tabPermissions.includes(route.id)}
type="checkbox"
onChange={(event) =>
togglePermission(route.id, event.target.checked)
}
/>
<span>{route.label}</span>
</label>
))}
</div>
</fieldset>
<fieldset className="admin-permission-fieldset">
<legend>独立操作权限</legend>
<div className="admin-permission-grid">
<label>
<input
checked={actionPermissions.includes(
consumptionReconcilePermission,
)}
type="checkbox"
onChange={(event) =>
setActionPermissions(
event.target.checked
? [consumptionReconcilePermission]
: [],
)
}
/>
<span>手动对账用户历史花费</span>
</label>
</div>
</fieldset>
<button
className="admin-primary-button"
disabled={isSaving}
type="submit"
>
<Save size={17} aria-hidden="true" />
<span>{isSaving ? '保存中' : '保存'}</span>
</button>
</form>
</div>
{confirmDialog}
</section>
);
}