222 lines
9.4 KiB
TypeScript
222 lines
9.4 KiB
TypeScript
import { useCallback, useEffect, useState } from 'react';
|
||
import { Link } from 'react-router-dom';
|
||
import { createAdminUser, listAdminUsers, updateAdminUser } from '../../api/client';
|
||
import type { PagedResult } from '../../api/client';
|
||
import type { AdminUserRow } from '../../types';
|
||
import { Pagination } from '../../components/Pagination';
|
||
import { formatYuan } from '../utils/format';
|
||
|
||
const PAGE_SIZE = 20;
|
||
|
||
function formatBytes(bytes: number) {
|
||
if (bytes < 1024) return `${bytes} B`;
|
||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||
return `${(bytes / 1024 / 1024).toFixed(0)} MB`;
|
||
}
|
||
|
||
export function UsersPage() {
|
||
const [result, setResult] = useState<PagedResult<AdminUserRow> | null>(null);
|
||
const [loading, setLoading] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [message, setMessage] = useState<string | null>(null);
|
||
|
||
// query params committed on search click — separate from pending inputs
|
||
const [committed, setCommitted] = useState({ search: '', role: '', status: '' });
|
||
const [pending, setPending] = useState({ search: '', role: '', status: '' });
|
||
|
||
const [showCreate, setShowCreate] = useState(false);
|
||
const [newUser, setNewUser] = useState({ username: '', password: '', displayName: '', workspaceRoot: '', balanceCents: 500 });
|
||
const [creating, setCreating] = useState(false);
|
||
|
||
const load = useCallback(async (p: number, q: { search: string; role: string; status: string }) => {
|
||
setLoading(true);
|
||
setError(null);
|
||
try {
|
||
const data = await listAdminUsers({ page: p, pageSize: PAGE_SIZE, search: q.search, role: q.role, status: q.status });
|
||
setResult(data);
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : '加载失败');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
void load(1, committed);
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
|
||
const handleQuery = () => {
|
||
setCommitted(pending);
|
||
void load(1, pending);
|
||
};
|
||
|
||
const handlePage = (p: number) => void load(p, committed);
|
||
|
||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||
if (e.key === 'Enter') handleQuery();
|
||
};
|
||
|
||
const reload = () => void load(result?.page ?? 1, committed);
|
||
|
||
const handleCreate = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
setMessage(null);
|
||
setError(null);
|
||
setCreating(true);
|
||
try {
|
||
await createAdminUser({
|
||
username: newUser.username,
|
||
password: newUser.password,
|
||
displayName: newUser.displayName || undefined,
|
||
workspaceRoot: newUser.workspaceRoot || undefined,
|
||
balanceCents: Number(newUser.balanceCents),
|
||
});
|
||
setNewUser({ username: '', password: '', displayName: '', workspaceRoot: '', balanceCents: 500 });
|
||
setMessage('用户已创建');
|
||
setShowCreate(false);
|
||
reload();
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : '创建失败');
|
||
} finally {
|
||
setCreating(false);
|
||
}
|
||
};
|
||
|
||
const toggleStatus = async (user: AdminUserRow) => {
|
||
try {
|
||
await updateAdminUser(user.id, { status: user.status === 'active' ? 'disabled' : 'active' });
|
||
reload();
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : '操作失败');
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="admin-page">
|
||
<div className="admin-page-head">
|
||
<h2>用户管理</h2>
|
||
<p className="muted">搜索账号、查看列表、进入用户详情配置权限</p>
|
||
</div>
|
||
|
||
{error && <p className="banner banner-error">{error}</p>}
|
||
{message && <p className="banner banner-info">{message}</p>}
|
||
|
||
{showCreate && (
|
||
<section className="admin-card">
|
||
<div className="admin-card-head">
|
||
<h2>创建用户</h2>
|
||
<button type="button" className="ghost-btn" onClick={() => setShowCreate(false)}>取消</button>
|
||
</div>
|
||
<form className="admin-form" onSubmit={handleCreate}>
|
||
<input placeholder="用户名" autoComplete="username" required value={newUser.username} onChange={(e) => setNewUser((s) => ({ ...s, username: e.target.value }))} />
|
||
<input placeholder="密码" type="password" autoComplete="new-password" required minLength={6} value={newUser.password} onChange={(e) => setNewUser((s) => ({ ...s, password: e.target.value }))} />
|
||
<input placeholder="显示名称" value={newUser.displayName} onChange={(e) => setNewUser((s) => ({ ...s, displayName: e.target.value }))} />
|
||
<input placeholder="工作目录(可选)" value={newUser.workspaceRoot} onChange={(e) => setNewUser((s) => ({ ...s, workspaceRoot: e.target.value }))} />
|
||
<input placeholder="初始余额(分)" type="number" value={newUser.balanceCents} onChange={(e) => setNewUser((s) => ({ ...s, balanceCents: Number(e.target.value) }))} />
|
||
<button type="submit" className="send-btn" disabled={creating}>{creating ? '创建中…' : '创建'}</button>
|
||
</form>
|
||
</section>
|
||
)}
|
||
|
||
<section className="admin-card">
|
||
<div className="admin-card-head">
|
||
<h2>
|
||
用户列表
|
||
{result && <span className="users-total-badge">{result.total.toLocaleString()} 人</span>}
|
||
</h2>
|
||
<div className="users-toolbar">
|
||
<input
|
||
className="users-search-input"
|
||
placeholder="搜索用户名 / 显示名"
|
||
value={pending.search}
|
||
onChange={(e) => setPending((s) => ({ ...s, search: e.target.value }))}
|
||
onKeyDown={handleKeyDown}
|
||
/>
|
||
<select className="billing-filter-select" value={pending.role}
|
||
onChange={(e) => setPending((s) => ({ ...s, role: e.target.value }))}>
|
||
<option value="">全部角色</option>
|
||
<option value="user">普通用户</option>
|
||
<option value="admin">管理员</option>
|
||
</select>
|
||
<select className="billing-filter-select" value={pending.status}
|
||
onChange={(e) => setPending((s) => ({ ...s, status: e.target.value }))}>
|
||
<option value="">全部状态</option>
|
||
<option value="active">正常</option>
|
||
<option value="disabled">禁用</option>
|
||
<option value="suspended">封禁</option>
|
||
</select>
|
||
<button type="button" className="send-btn billing-query-btn" onClick={handleQuery} disabled={loading}>
|
||
查询
|
||
</button>
|
||
<button type="button" className="ghost-btn" onClick={() => setShowCreate((v) => !v)}>
|
||
{showCreate ? '取消' : '+ 新建'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{!result && !loading && (
|
||
<p className="muted billing-empty">输入条件后点击查询,或直接点查询加载全部</p>
|
||
)}
|
||
{loading && <p className="muted">加载中…</p>}
|
||
{!loading && result && (
|
||
result.items.length === 0 ? (
|
||
<p className="muted billing-empty">没有匹配的用户</p>
|
||
) : (
|
||
<>
|
||
<div className="admin-table-wrap">
|
||
<table className="admin-table">
|
||
<thead>
|
||
<tr>
|
||
<th>用户</th>
|
||
<th>角色</th>
|
||
<th>状态</th>
|
||
<th>余额</th>
|
||
<th>空间</th>
|
||
<th>工作目录</th>
|
||
<th>操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{result.items.map((user) => (
|
||
<tr key={user.id}>
|
||
<td>
|
||
<div>{user.displayName}</div>
|
||
<div className="muted">@{user.username}</div>
|
||
</td>
|
||
<td><span className={`role-tag role-${user.role}`}>{user.role === 'admin' ? '管理员' : '用户'}</span></td>
|
||
<td>
|
||
<span className={`status-tag status-${user.status}`}>
|
||
{user.status === 'active' ? '正常' : user.status === 'disabled' ? '禁用' : '封禁'}
|
||
</span>
|
||
</td>
|
||
<td className="billing-num">¥{formatYuan(user.balanceCents)}</td>
|
||
<td className="billing-num">
|
||
{user.spaceQuotaBytes
|
||
? `${formatBytes(user.spaceUsedBytes ?? 0)} / ${formatBytes(user.spaceQuotaBytes)}`
|
||
: '—'}
|
||
</td>
|
||
<td className="admin-table-path">{user.workspaceRoot || '—'}</td>
|
||
<td className="admin-actions">
|
||
<Link to={`/users/${user.id}`} className="ghost-btn">详情</Link>
|
||
{user.role === 'user' && (
|
||
<button type="button" className="ghost-btn" onClick={() => void toggleStatus(user)}>
|
||
{user.status === 'active' ? '禁用' : '启用'}
|
||
</button>
|
||
)}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<Pagination page={result.page} totalPages={result.totalPages} total={result.total}
|
||
pageSize={result.pageSize} onChange={handlePage} />
|
||
</>
|
||
)
|
||
)}
|
||
</section>
|
||
</div>
|
||
);
|
||
}
|