Files
memind_adm/src/admin/pages/UsersPage.tsx
T
2026-06-30 20:26:33 +08:00

222 lines
9.4 KiB
TypeScript
Raw 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.
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>
);
}