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:
john
2026-06-16 20:46:59 +08:00
parent 8ad1a8a8da
commit 680e2c9427
11 changed files with 1079 additions and 275 deletions
+167 -112
View File
@@ -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>