Add LAN deploy scripts and improve admin billing/users UX.
Provide rsync deploy and restart tooling for the 100 server, document the workflow, and add pagination plus user filtering across billing and user management pages. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+167
-112
@@ -1,24 +1,63 @@
|
||||
import { useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { createAdminUser, updateAdminUser } from '../../api/client';
|
||||
import { useAdminUsers } from '../hooks/useAdminUsers';
|
||||
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;
|
||||
|
||||
export function UsersPage() {
|
||||
const { users, loading, error, reload, setError } = useAdminUsers();
|
||||
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);
|
||||
const [newUser, setNewUser] = useState({
|
||||
username: '',
|
||||
password: '',
|
||||
displayName: '',
|
||||
workspaceRoot: '',
|
||||
balanceCents: 500,
|
||||
});
|
||||
|
||||
// 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,
|
||||
@@ -27,17 +66,23 @@ export function UsersPage() {
|
||||
workspaceRoot: newUser.workspaceRoot || undefined,
|
||||
balanceCents: Number(newUser.balanceCents),
|
||||
});
|
||||
setNewUser({
|
||||
username: '',
|
||||
password: '',
|
||||
displayName: '',
|
||||
workspaceRoot: '',
|
||||
balanceCents: 500,
|
||||
});
|
||||
setNewUser({ username: '', password: '', displayName: '', workspaceRoot: '', balanceCents: 500 });
|
||||
setMessage('用户已创建');
|
||||
await reload();
|
||||
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 : '操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -45,108 +90,118 @@ export function UsersPage() {
|
||||
<div className="admin-page">
|
||||
<div className="admin-page-head">
|
||||
<h2>用户管理</h2>
|
||||
<p className="muted">创建账号、查看列表、进入用户详情配置权限</p>
|
||||
<p className="muted">搜索账号、查看列表、进入用户详情配置权限</p>
|
||||
</div>
|
||||
|
||||
{error && <p className="banner banner-error">{error}</p>}
|
||||
{message && <p className="banner banner-info">{message}</p>}
|
||||
|
||||
<section className="admin-card">
|
||||
<h2>创建用户</h2>
|
||||
<form className="admin-form" onSubmit={handleCreate}>
|
||||
<input
|
||||
placeholder="用户名"
|
||||
autoComplete="username"
|
||||
value={newUser.username}
|
||||
onChange={(e) => setNewUser((s) => ({ ...s, username: e.target.value }))}
|
||||
/>
|
||||
<input
|
||||
placeholder="密码"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
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">
|
||||
创建
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
{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>用户列表</h2>
|
||||
<button type="button" className="ghost-btn" onClick={() => void reload()}>
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
{loading ? (
|
||||
<p className="muted">加载中…</p>
|
||||
) : (
|
||||
<div className="admin-table-wrap">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>用户</th>
|
||||
<th>角色</th>
|
||||
<th>状态</th>
|
||||
<th>余额</th>
|
||||
<th>工作目录</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((user) => (
|
||||
<tr key={user.id}>
|
||||
<td>
|
||||
<div>{user.displayName}</div>
|
||||
<div className="muted">@{user.username}</div>
|
||||
</td>
|
||||
<td>{user.role}</td>
|
||||
<td>{user.status}</td>
|
||||
<td>¥{formatYuan(user.balanceCents)}</td>
|
||||
<td className="mono">{user.workspaceRoot}</td>
|
||||
<td className="admin-actions">
|
||||
<Link to={`/admin/users/${user.id}`} className="ghost-btn admin-inline-link">
|
||||
详情
|
||||
</Link>
|
||||
{user.role === 'user' && (
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-btn"
|
||||
onClick={() =>
|
||||
void updateAdminUser(user.id, {
|
||||
status: user.status === 'active' ? 'disabled' : 'active',
|
||||
}).then(reload)
|
||||
}
|
||||
>
|
||||
{user.status === 'active' ? '禁用' : '启用'}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<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>
|
||||
</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="admin-table-path">{user.workspaceRoot || '—'}</td>
|
||||
<td className="admin-actions">
|
||||
<Link to={`/admin/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>
|
||||
|
||||
Reference in New Issue
Block a user