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
+218 -151
View File
@@ -1,9 +1,95 @@
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { listAdminLedger, listAdminUsage, rechargeUser } from '../../api/client';
import type { LedgerEntry, UsageRecord } from '../../types';
import type { PagedResult } from '../../api/client';
import type { AdminUserRow, LedgerEntry, UsageRecord } from '../../types';
import { Pagination } from '../../components/Pagination';
import { useAdminUsers } from '../hooks/useAdminUsers';
import { formatTime, formatYuan } from '../utils/format';
function UserCombobox({
users,
value,
onChange,
placeholder = '搜索用户名 / 显示名称',
}: {
users: AdminUserRow[];
value: string;
onChange: (id: string) => void;
placeholder?: string;
}) {
const [query, setQuery] = useState('');
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const selected = users.find((u) => u.id === value);
const filtered = query.trim()
? users.filter((u) => {
const q = query.toLowerCase();
return u.username.toLowerCase().includes(q) || u.displayName.toLowerCase().includes(q);
})
: users;
useEffect(() => {
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, []);
const select = (u: AdminUserRow) => {
onChange(u.id);
setQuery('');
setOpen(false);
};
const clear = () => {
onChange('');
setQuery('');
};
return (
<div ref={ref} className="user-combobox">
<div className="user-combobox-input-wrap" onClick={() => setOpen(true)}>
{selected && !open ? (
<span className="user-combobox-selected">
{selected.displayName} <span className="muted">@{selected.username}</span>
<span className="user-combobox-balance">¥{formatYuan(selected.balanceCents)}</span>
</span>
) : (
<input
className="user-combobox-input"
placeholder={selected ? `${selected.displayName} @${selected.username}` : placeholder}
value={query}
autoFocus={open}
onChange={(e) => { setQuery(e.target.value); setOpen(true); }}
onFocus={() => setOpen(true)}
/>
)}
{value && (
<button type="button" className="user-combobox-clear" onClick={(e) => { e.stopPropagation(); clear(); }}>×</button>
)}
</div>
{open && (
<div className="user-combobox-dropdown">
{filtered.length === 0 ? (
<div className="user-combobox-empty"></div>
) : (
filtered.map((u) => (
<div key={u.id} className="user-combobox-option" onMouseDown={() => select(u)}>
<span className="user-combobox-name">{u.displayName}</span>
<span className="user-combobox-meta muted">@{u.username}</span>
<span className="user-combobox-bal">¥{formatYuan(u.balanceCents)}</span>
</div>
))
)}
</div>
)}
</div>
);
}
type TabKey = 'recharge' | 'usage' | 'ledger';
const TABS: { key: TabKey; label: string }[] = [
@@ -12,6 +98,8 @@ const TABS: { key: TabKey; label: string }[] = [
{ key: 'ledger', label: '资金流水' },
];
const PAGE_SIZE = 20;
function RechargeTab() {
const { users, reload, error, setError } = useAdminUsers();
const [message, setMessage] = useState<string | null>(null);
@@ -43,32 +131,15 @@ function RechargeTab() {
{error && <p className="banner banner-error">{error}</p>}
{message && <p className="banner banner-info">{message}</p>}
<form className="admin-form" onSubmit={handleRecharge}>
<select
<UserCombobox
users={users.filter((u) => u.role === 'user')}
value={recharge.userId}
onChange={(e) => setRecharge((s) => ({ ...s, userId: e.target.value }))}
>
<option value=""></option>
{users
.filter((u) => u.role === 'user')
.map((u) => (
<option key={u.id} value={u.id}>
{u.username}¥{formatYuan(u.balanceCents)}
</option>
))}
</select>
<input
placeholder="金额(元)"
type="number"
min="0.01"
step="0.01"
value={recharge.amountYuan}
onChange={(e) => setRecharge((s) => ({ ...s, amountYuan: e.target.value }))}
/>
<input
placeholder="备注(可选)"
value={recharge.note}
onChange={(e) => setRecharge((s) => ({ ...s, note: e.target.value }))}
onChange={(id) => setRecharge((s) => ({ ...s, userId: id }))}
/>
<input placeholder="金额(元)" type="number" min="0.01" step="0.01" value={recharge.amountYuan}
onChange={(e) => setRecharge((s) => ({ ...s, amountYuan: e.target.value }))} />
<input placeholder="备注(可选)" value={recharge.note}
onChange={(e) => setRecharge((s) => ({ ...s, note: e.target.value }))} />
<button type="submit" className="send-btn" disabled={!recharge.userId || submitting}>
{submitting ? '处理中…' : '充值'}
</button>
@@ -79,16 +150,19 @@ function RechargeTab() {
function UsageTab() {
const { users } = useAdminUsers();
const [usage, setUsage] = useState<UsageRecord[]>([]);
const [loading, setLoading] = useState(true);
const [filterUserId, setFilterUserId] = useState('');
const [result, setResult] = useState<PagedResult<UsageRecord> | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [filterUserId, setFilterUserId] = useState('');
const [pendingUserId, setPendingUserId] = useState('');
const load = useCallback(async (userId?: string) => {
const load = useCallback(async (p: number, userId: string) => {
setLoading(true);
setError(null);
try {
setUsage(await listAdminUsage(userId || undefined));
const data = await listAdminUsage({ userId: userId || undefined, page: p, pageSize: PAGE_SIZE });
setResult(data);
setFilterUserId(userId);
} catch (err) {
setError(err instanceof Error ? err.message : '加载失败');
} finally {
@@ -96,64 +170,67 @@ function UsageTab() {
}
}, []);
useEffect(() => {
void load(filterUserId || undefined);
}, [load, filterUserId]);
useEffect(() => { void load(1, ''); }, [load]);
const handleQuery = () => void load(1, pendingUserId);
const handlePage = (p: number) => void load(p, filterUserId);
return (
<section className="admin-card">
<div className="admin-card-head">
<h2></h2>
<div className="billing-toolbar">
<select
className="billing-filter-select"
value={filterUserId}
onChange={(e) => setFilterUserId(e.target.value)}
>
<option value=""></option>
{users.map((u) => (
<option key={u.id} value={u.id}>
{u.username}
</option>
))}
</select>
<button type="button" className="ghost-btn" onClick={() => void load(filterUserId || undefined)}>
<UserCombobox
users={users}
value={pendingUserId}
onChange={setPendingUserId}
placeholder="全部用户"
/>
<button type="button" className="send-btn billing-query-btn" onClick={handleQuery} disabled={loading}>
</button>
</div>
</div>
{error && <p className="banner banner-error">{error}</p>}
{loading ? (
<p className="muted"></p>
) : usage.length === 0 ? (
<p className="muted billing-empty"></p>
) : (
<div className="admin-table-wrap">
<table className="admin-table">
<thead>
<tr>
<th></th>
<th></th>
<th> Token</th>
<th> Token</th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{usage.map((row) => (
<tr key={row.id}>
<td className="billing-time">{formatTime(row.createdAt)}</td>
<td>@{row.username}</td>
<td className="billing-num">{row.inputTokens.toLocaleString()}</td>
<td className="billing-num">{row.outputTokens.toLocaleString()}</td>
<td className="billing-num">¥{formatYuan(row.costCents)}</td>
<td className="billing-num">¥{formatYuan(row.balanceAfterCents)}</td>
</tr>
))}
</tbody>
</table>
</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 style={{ textAlign: 'right' }}> Token</th>
<th style={{ textAlign: 'right' }}> Token</th>
<th style={{ textAlign: 'right' }}></th>
<th style={{ textAlign: 'right' }}></th>
</tr>
</thead>
<tbody>
{result.items.map((row) => (
<tr key={row.id}>
<td className="billing-time">{formatTime(row.createdAt)}</td>
<td>@{row.username}</td>
<td className="billing-num">{row.inputTokens.toLocaleString()}</td>
<td className="billing-num">{row.outputTokens.toLocaleString()}</td>
<td className="billing-num">¥{formatYuan(row.costCents)}</td>
<td className="billing-num">¥{formatYuan(row.balanceAfterCents)}</td>
</tr>
))}
</tbody>
</table>
</div>
<Pagination page={result.page} totalPages={result.totalPages} total={result.total}
pageSize={result.pageSize} onChange={handlePage} />
</>
)
)}
</section>
);
@@ -161,16 +238,19 @@ function UsageTab() {
function LedgerTab() {
const { users } = useAdminUsers();
const [ledger, setLedger] = useState<LedgerEntry[]>([]);
const [loading, setLoading] = useState(true);
const [filterUserId, setFilterUserId] = useState('');
const [result, setResult] = useState<PagedResult<LedgerEntry> | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [filterUserId, setFilterUserId] = useState('');
const [pendingUserId, setPendingUserId] = useState('');
const load = useCallback(async (userId?: string) => {
const load = useCallback(async (p: number, userId: string) => {
setLoading(true);
setError(null);
try {
setLedger(await listAdminLedger(userId || undefined));
const data = await listAdminLedger({ userId: userId || undefined, page: p, pageSize: PAGE_SIZE });
setResult(data);
setFilterUserId(userId);
} catch (err) {
setError(err instanceof Error ? err.message : '加载失败');
} finally {
@@ -178,75 +258,69 @@ function LedgerTab() {
}
}, []);
useEffect(() => {
void load(filterUserId || undefined);
}, [load, filterUserId]);
useEffect(() => { void load(1, ''); }, [load]);
const TYPE_LABEL: Record<string, string> = {
recharge: '充值',
deduct: '扣费',
refund: '退款',
adjust: '调整',
};
const handleQuery = () => void load(1, pendingUserId);
const handlePage = (p: number) => void load(p, filterUserId);
const TYPE_LABEL: Record<string, string> = { recharge: '充值', deduct: '扣费', refund: '退款', adjust: '调整' };
return (
<section className="admin-card">
<div className="admin-card-head">
<h2></h2>
<div className="billing-toolbar">
<select
className="billing-filter-select"
value={filterUserId}
onChange={(e) => setFilterUserId(e.target.value)}
>
<option value=""></option>
{users.map((u) => (
<option key={u.id} value={u.id}>
{u.username}
</option>
))}
</select>
<button type="button" className="ghost-btn" onClick={() => void load(filterUserId || undefined)}>
<UserCombobox
users={users}
value={pendingUserId}
onChange={setPendingUserId}
placeholder="全部用户"
/>
<button type="button" className="send-btn billing-query-btn" onClick={handleQuery} disabled={loading}>
</button>
</div>
</div>
{error && <p className="banner banner-error">{error}</p>}
{loading ? (
<p className="muted"></p>
) : ledger.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>
</tr>
</thead>
<tbody>
{ledger.map((row) => (
<tr key={row.id}>
<td className="billing-time">{formatTime(row.createdAt)}</td>
<td>@{row.username}</td>
<td>
<span className={`ledger-type-tag ledger-type-${row.type}`}>
{TYPE_LABEL[row.type] ?? row.type}
</span>
</td>
<td className={`billing-num ${row.amountCents < 0 ? 'text-error' : 'text-income'}`}>
{row.amountCents >= 0 ? '+' : ''}¥{formatYuan(Math.abs(row.amountCents))}
</td>
<td className="billing-note">{row.note ?? '—'}</td>
</tr>
))}
</tbody>
</table>
</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 style={{ textAlign: 'right' }}></th>
<th></th>
</tr>
</thead>
<tbody>
{result.items.map((row) => (
<tr key={row.id}>
<td className="billing-time">{formatTime(row.createdAt)}</td>
<td>@{row.username}</td>
<td><span className={`ledger-type-tag ledger-type-${row.type}`}>{TYPE_LABEL[row.type] ?? row.type}</span></td>
<td className={`billing-num ${row.amountCents < 0 ? 'text-error' : 'text-income'}`}>
{row.amountCents >= 0 ? '+' : ''}¥{formatYuan(Math.abs(row.amountCents))}
</td>
<td className="billing-note">{row.note ?? '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
<Pagination page={result.page} totalPages={result.totalPages} total={result.total}
pageSize={result.pageSize} onChange={handlePage} />
</>
)
)}
</section>
);
@@ -261,22 +335,15 @@ export function BillingPage() {
<h2></h2>
<p className="muted"></p>
</div>
<div className="admin-tabs" role="tablist">
{TABS.map((tab) => (
<button
key={tab.key}
type="button"
role="tab"
aria-selected={activeTab === tab.key}
<button key={tab.key} type="button" role="tab" aria-selected={activeTab === tab.key}
className={`admin-tab${activeTab === tab.key ? ' active' : ''}`}
onClick={() => setActiveTab(tab.key)}
>
onClick={() => setActiveTab(tab.key)}>
{tab.label}
</button>
))}
</div>
<div className="billing-tab-content">
{activeTab === 'recharge' && <RechargeTab />}
{activeTab === 'usage' && <UsageTab />}