071faa482c
纳入 AGC Cargo workspace 的统一 rustfmt 检查与格式化入口 完成项目 TypeScript/Prettier 与 Rust 全量格式化 修复 Pingora expected executable 门禁的空白敏感误报 同步开发运维文档与 AGC skill pack 格式化忽略规则
336 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|