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
+3 -1
View File
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react';
import { listAdminUsers } from '../../api/client';
import type { AdminUserRow } from '../../types';
// Lightweight hook for user dropdowns — loads up to 100 users, no pagination.
export function useAdminUsers() {
const [users, setUsers] = useState<AdminUserRow[]>([]);
const [loading, setLoading] = useState(true);
@@ -11,7 +12,8 @@ export function useAdminUsers() {
setLoading(true);
setError(null);
try {
setUsers(await listAdminUsers());
const result = await listAdminUsers({ pageSize: 100 });
setUsers(result.items);
} catch (err) {
setError(err instanceof Error ? err.message : '加载用户失败');
} finally {
+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 />}
+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>
+59 -11
View File
@@ -156,6 +156,16 @@ export async function logout(): Promise<void> {
await fetch('/auth/logout', { method: 'POST' });
}
// ── Pagination helpers ────────────────────────────────
export type PagedResult<T> = {
items: T[];
total: number;
page: number;
pageSize: number;
totalPages: number;
};
// ── Admin dashboard ───────────────────────────────────
export async function getAdminDashboardSummary(): Promise<AdminDashboardSummary> {
@@ -163,23 +173,61 @@ export async function getAdminDashboardSummary(): Promise<AdminDashboardSummary>
return result.summary;
}
export async function listAdminUsage(userId?: string): Promise<UsageRecord[]> {
const query = userId ? `?userId=${encodeURIComponent(userId)}` : '';
const result = await portalFetch<{ records: UsageRecord[] }>(`/admin-api/usage${query}`);
return result.records ?? [];
export async function listAdminUsage(params?: {
userId?: string;
page?: number;
pageSize?: number;
}): Promise<PagedResult<UsageRecord>> {
const q = new URLSearchParams();
if (params?.userId) q.set('userId', params.userId);
if (params?.page) q.set('page', String(params.page));
if (params?.pageSize) q.set('pageSize', String(params.pageSize));
const result = await portalFetch<{ records: UsageRecord[]; total: number; page: number; pageSize: number }>(
`/admin-api/usage${q.toString() ? `?${q}` : ''}`,
);
const total = result.total ?? 0;
const pageSize = result.pageSize ?? 50;
return { items: result.records ?? [], total, page: result.page ?? 1, pageSize, totalPages: Math.ceil(total / pageSize) };
}
export async function listAdminLedger(userId?: string): Promise<LedgerEntry[]> {
const query = userId ? `?userId=${encodeURIComponent(userId)}` : '';
const result = await portalFetch<{ entries: LedgerEntry[] }>(`/admin-api/ledger${query}`);
return result.entries ?? [];
export async function listAdminLedger(params?: {
userId?: string;
page?: number;
pageSize?: number;
}): Promise<PagedResult<LedgerEntry>> {
const q = new URLSearchParams();
if (params?.userId) q.set('userId', params.userId);
if (params?.page) q.set('page', String(params.page));
if (params?.pageSize) q.set('pageSize', String(params.pageSize));
const result = await portalFetch<{ entries: LedgerEntry[]; total: number; page: number; pageSize: number }>(
`/admin-api/ledger${q.toString() ? `?${q}` : ''}`,
);
const total = result.total ?? 0;
const pageSize = result.pageSize ?? 50;
return { items: result.entries ?? [], total, page: result.page ?? 1, pageSize, totalPages: Math.ceil(total / pageSize) };
}
// ── Admin users ───────────────────────────────────────
export async function listAdminUsers(): Promise<AdminUserRow[]> {
const result = await portalFetch<{ users: AdminUserRow[] }>('/admin-api/users');
return result.users ?? [];
export async function listAdminUsers(params?: {
page?: number;
pageSize?: number;
search?: string;
role?: string;
status?: string;
}): Promise<PagedResult<AdminUserRow>> {
const q = new URLSearchParams();
if (params?.page) q.set('page', String(params.page));
if (params?.pageSize) q.set('pageSize', String(params.pageSize));
if (params?.search) q.set('search', params.search);
if (params?.role) q.set('role', params.role);
if (params?.status) q.set('status', params.status);
const result = await portalFetch<{ users: AdminUserRow[]; total: number; page: number; pageSize: number }>(
`/admin-api/users${q.toString() ? `?${q}` : ''}`,
);
const total = result.total ?? 0;
const pageSize = result.pageSize ?? 20;
return { items: result.users ?? [], total, page: result.page ?? 1, pageSize, totalPages: Math.ceil(total / pageSize) };
}
export async function createAdminUser(payload: {
+62
View File
@@ -0,0 +1,62 @@
type Props = {
page: number;
totalPages: number;
total: number;
pageSize: number;
onChange: (page: number) => void;
};
export function Pagination({ page, totalPages, total, pageSize, onChange }: Props) {
if (totalPages <= 1) return null;
const start = (page - 1) * pageSize + 1;
const end = Math.min(page * pageSize, total);
const pages: (number | '…')[] = [];
if (totalPages <= 7) {
for (let i = 1; i <= totalPages; i++) pages.push(i);
} else {
pages.push(1);
if (page > 3) pages.push('…');
for (let i = Math.max(2, page - 1); i <= Math.min(totalPages - 1, page + 1); i++) pages.push(i);
if (page < totalPages - 2) pages.push('…');
pages.push(totalPages);
}
return (
<div className="pagination">
<span className="pagination-info">
{start}{end} / {total.toLocaleString()}
</span>
<div className="pagination-controls">
<button
className="pagination-btn"
disabled={page <= 1}
onClick={() => onChange(page - 1)}
>
</button>
{pages.map((p, i) =>
p === '…' ? (
<span key={`ellipsis-${i}`} className="pagination-ellipsis"></span>
) : (
<button
key={p}
className={`pagination-btn${p === page ? ' active' : ''}`}
onClick={() => onChange(p)}
>
{p}
</button>
),
)}
<button
className="pagination-btn"
disabled={page >= totalPages}
onClick={() => onChange(page + 1)}
>
</button>
</div>
</div>
);
}
+298
View File
@@ -461,6 +461,11 @@ body,
font-family: inherit;
}
.admin-form button[type="submit"] {
align-self: end;
justify-self: stretch;
}
.global-model-card {
margin-bottom: 16px;
padding: 14px;
@@ -729,6 +734,150 @@ body,
margin-bottom: 0.75rem;
}
/* ── Pagination ──────────────────────────────────────── */
.pagination {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 0 4px;
flex-wrap: wrap;
}
.pagination-info {
font-size: 12px;
color: var(--color-text-muted);
}
.pagination-controls {
display: flex;
align-items: center;
gap: 4px;
}
.pagination-btn {
min-width: 32px;
height: 32px;
padding: 0 6px;
border: 1px solid var(--color-border-input);
border-radius: var(--radius-sm);
background: var(--color-bg-base);
color: var(--color-text-secondary);
font-size: 13px;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.pagination-btn:hover:not(:disabled) {
background: var(--color-bg-elevated);
border-color: var(--color-accent);
}
.pagination-btn.active {
background: var(--color-accent);
border-color: var(--color-accent);
color: white;
font-weight: 600;
}
.pagination-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.pagination-ellipsis {
padding: 0 4px;
color: var(--color-text-muted);
font-size: 13px;
}
/* ── Users page ──────────────────────────────────────── */
.users-toolbar {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.users-search-input {
padding: 6px 10px;
border-radius: var(--radius-md);
border: 1px solid var(--color-border-input);
background: var(--color-bg-base);
color: var(--color-text-primary);
font-size: 13px;
width: 180px;
}
.users-search-input:focus {
outline: none;
border-color: var(--color-border-input-focus);
}
.users-total-badge {
margin-left: 8px;
font-size: 12px;
font-weight: 400;
color: var(--color-text-muted);
}
.admin-table-path {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 11px;
color: var(--color-text-muted);
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.table-row-dimmed {
opacity: 0.5;
}
.role-tag {
display: inline-block;
padding: 2px 8px;
border-radius: var(--radius-pill);
font-size: 11px;
font-weight: 600;
}
.role-user {
background: var(--color-bg-elevated);
color: var(--color-text-muted);
}
.role-admin {
background: rgba(61, 139, 253, 0.15);
color: #6ab0ff;
}
.status-tag {
display: inline-block;
padding: 2px 8px;
border-radius: var(--radius-pill);
font-size: 11px;
font-weight: 600;
}
.status-active {
background: rgba(34, 197, 94, 0.12);
color: #16a34a;
}
.status-disabled {
background: rgba(239, 68, 68, 0.1);
color: #dc2626;
}
.status-suspended {
background: rgba(234, 179, 8, 0.12);
color: #ca8a04;
}
/* ── Billing page ────────────────────────────────────── */
.billing-toolbar {
@@ -750,6 +899,12 @@ body,
padding-top: 4px;
}
.billing-query-btn {
padding: 6px 14px;
min-width: unset;
font-size: 13px;
}
.billing-time {
color: var(--color-text-muted);
font-size: 12px;
@@ -802,3 +957,146 @@ body,
background: rgba(234, 179, 8, 0.15);
color: #ca8a04;
}
/* UserCombobox */
.user-combobox {
position: relative;
}
/* Full-width row when inside a vertical form */
.admin-form .user-combobox {
grid-column: 1 / -1;
}
/* Compact width when inside a horizontal toolbar */
.billing-toolbar .user-combobox {
min-width: 0;
width: 200px;
flex-shrink: 0;
}
.billing-toolbar .user-combobox .user-combobox-input-wrap {
min-height: 32px;
padding: 0 6px;
}
.billing-toolbar .user-combobox .user-combobox-input {
font-size: 13px;
padding: 4px 0;
}
.billing-toolbar .user-combobox .user-combobox-selected {
font-size: 13px;
padding: 4px 0;
}
.user-combobox-input-wrap {
display: flex;
align-items: center;
border: 1px solid var(--color-border-input);
border-radius: var(--radius-md);
background: var(--color-bg-base);
padding: 0 8px;
cursor: text;
min-height: 40px;
gap: 6px;
}
.user-combobox-input-wrap:focus-within {
border-color: var(--color-border-input-focus);
}
.user-combobox-input {
flex: 1;
border: none;
background: transparent;
color: var(--color-text-primary);
font-size: 13px;
outline: none;
padding: 6px 0;
min-width: 0;
}
.user-combobox-input::placeholder {
color: var(--color-text-faint);
}
.user-combobox-selected {
flex: 1;
font-size: 13px;
display: flex;
align-items: center;
gap: 6px;
padding: 6px 0;
white-space: nowrap;
overflow: hidden;
}
.user-combobox-balance {
margin-left: auto;
color: var(--color-text-muted);
font-size: 12px;
}
.user-combobox-clear {
background: none;
border: none;
color: var(--color-text-faint);
cursor: pointer;
font-size: 16px;
line-height: 1;
padding: 0 2px;
flex-shrink: 0;
}
.user-combobox-clear:hover {
color: var(--color-text-primary);
}
.user-combobox-dropdown {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
background: var(--color-bg-elevated);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
max-height: 240px;
overflow-y: auto;
z-index: 200;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
}
.user-combobox-option {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
cursor: pointer;
font-size: 13px;
}
.user-combobox-option:hover {
background: var(--color-bg-active);
}
.user-combobox-name {
font-weight: 500;
}
.user-combobox-meta {
font-size: 12px;
}
.user-combobox-bal {
margin-left: auto;
color: var(--color-text-muted);
font-size: 12px;
white-space: nowrap;
}
.user-combobox-empty {
padding: 10px 12px;
color: var(--color-text-faint);
font-size: 13px;
}