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
+119
View File
@@ -0,0 +1,119 @@
# memind_adm 部署与重启
管理后台前端(React + Vite)部署到局域网 100 服务器(Tailscale `100.99.38.66`)。
## 环境概览
| 项 | 默认值 |
|---|---|
| 部署目标 | `john@100.99.38.66:/Users/john/Project/memind_adm` |
| 服务端口 | `5174` |
| 运行方式 | `vite preview`(静态资源 + API 反代) |
| 后端 API | 远端 `.env` 中 `ADM_DEV_BACKEND`(默认 `http://127.0.0.1:8081`) |
| 日志 | `/Users/john/Project/memind_adm/adm-preview.log` |
## 前置条件
1. 本机可 SSH 到 100 服务器(Tailscale 已连接):
```bash
ssh john@100.99.38.66
```
2. 本机已安装 Node.js,项目依赖已安装(`npm install`)。
3. 100 服务器上后端(Memind portal / `server.mjs`)在 `8081` 运行,否则页面能打开但登录、API 会失败。
## 更新部署(推荐)
在本机项目根目录执行:
```bash
./scripts/rsync_to_server.sh
```
脚本会依次:
1. 本地 `npm run build` 生成 `dist/`
2. `rsync` 同步源码到远端(排除 `node_modules`、`.git`、`.env` 等)
3. 单独同步 `dist/`
4. 若远端尚无 `.env`,写入默认配置(**不覆盖已有 `.env`**)
5. 调用 `scripts/remote_restart.sh` 重启服务
### 常用参数
```bash
# 跳过本地构建(沿用当前 dist/)
./scripts/rsync_to_server.sh --skip-build
# 只同步文件,不重启
./scripts/rsync_to_server.sh --no-restart
# 环境变量覆盖
DEPLOY_HOST=john@100.99.38.66 \
REMOTE_DIR=/Users/john/Project/memind_adm \
ADM_PORT=5174 \
./scripts/rsync_to_server.sh
```
## 仅重启服务
代码已在远端、无需重新同步时:
```bash
ssh john@100.99.38.66 'ADM_ROOT=/Users/john/Project/memind_adm ADM_PORT=5174 bash -s' \
< scripts/remote_restart.sh
```
或在 100 服务器上直接执行:
```bash
cd /Users/john/Project/memind_adm
ADM_PORT=5174 ./scripts/remote_restart.sh
```
重启逻辑:结束占用 `5174` 的进程 → `nohup npm run preview` → 检查首页是否返回 200。
## 验证
本机通过 SSH 检查:
```bash
ssh john@100.99.38.66 'curl -sI http://127.0.0.1:5174/ | head -1'
# 期望: HTTP/1.1 200 OK
```
查看远端日志:
```bash
ssh john@100.99.38.66 'tail -f /Users/john/Project/memind_adm/adm-preview.log'
```
## 远端配置
编辑远端 `.env`(首次部署后路径:`/Users/john/Project/memind_adm/.env`):
```env
# preview 反代目标(/auth、/admin-api、/api)
ADM_DEV_BACKEND=http://127.0.0.1:8081
# 「返回对话」跳转地址
VITE_MAIN_APP_URL=http://127.0.0.1:8081
```
修改 `.env` 后需重启服务生效。
## 故障排查
| 现象 | 处理 |
|---|---|
| `无法 SSH 到 john@100.99.38.66` | 确认 Tailscale 在线;必要时 `ssh-copy-id john@100.99.38.66` |
| 首页 200 但登录失败 | 检查 100 上 `8081` 后端是否运行 |
| `dist 不存在` | 先在本机 `npm run build`,或完整执行 `./scripts/rsync_to_server.sh` |
| 启动失败 | 查看 `adm-preview.log`;确认远端 Node 在 PATH 中(需 Homebrew `node@22` 等) |
| 端口被占用 | `remote_restart.sh` 会自动 kill 旧进程;仍异常时可手动 `lsof -iTCP:5174 -sTCP:LISTEN` |
## 相关脚本
| 脚本 | 说明 |
|---|---|
| `scripts/rsync_to_server.sh` | 构建 + rsync + 重启(一键部署) |
| `scripts/remote_restart.sh` | 仅在远端重启 `vite preview` |
| `scripts/.rsync-exclude-lan` | rsync 排除规则 |
+10
View File
@@ -0,0 +1,10 @@
.git
.gitignore
.DS_Store
node_modules
.env
.env.local
*.local
.vite
*.log
logs
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
# 在 100 服务器上重启 memind_adm(vite preview + API 反代)
set -euo pipefail
ROOT="${ADM_ROOT:-/Users/john/Project/memind_adm}"
PORT="${ADM_PORT:-5174}"
NODE_PATH="${LAN_NODE_PATH:-/opt/homebrew/opt/node@22/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin}"
LOG="${ROOT}/adm-preview.log"
PID_FILE="${ROOT}/.adm-preview.pid"
export PATH="${NODE_PATH}:${PATH}"
command -v node >/dev/null 2>&1 || { echo "❌ 未找到 node" >&2; exit 1; }
cd "${ROOT}"
if [[ ! -d dist ]]; then
echo "❌ ${ROOT}/dist 不存在,请先构建或 rsync dist" >&2
exit 1
fi
if [[ ! -d node_modules ]]; then
echo "==> 安装依赖..."
npm install
fi
echo "==> 停止旧进程 (port ${PORT})..."
lsof -ti "TCP:${PORT}" -sTCP:LISTEN 2>/dev/null | xargs kill 2>/dev/null || true
sleep 1
lsof -ti "TCP:${PORT}" -sTCP:LISTEN 2>/dev/null | xargs kill -9 2>/dev/null || true
echo "==> 启动 vite preview @ 127.0.0.1:${PORT}..."
nohup npm run preview -- --host 127.0.0.1 --port "${PORT}" >>"${LOG}" 2>&1 &
echo $! >"${PID_FILE}"
sleep 2
if curl -sf "http://127.0.0.1:${PORT}/" >/dev/null; then
echo "✅ memind_adm 已就绪: http://127.0.0.1:${PORT}/"
curl -sf "http://127.0.0.1:${PORT}/" >/dev/null && echo " 首页 OK"
else
echo "❌ 启动失败,最近日志:" >&2
tail -n 40 "${LOG}" >&2 || true
exit 1
fi
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env bash
# memind_adm → 100 服务器 rsync 部署
#
# 用法:
# ./scripts/rsync_to_server.sh
# ./scripts/rsync_to_server.sh --skip-build
# ./scripts/rsync_to_server.sh --no-restart
# SKIP_BUILD=1 NO_RESTART=1 ./scripts/rsync_to_server.sh
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
EXCLUDE="${ROOT}/scripts/.rsync-exclude-lan"
DEPLOY_HOST="${DEPLOY_HOST:-john@100.99.38.66}"
REMOTE_DIR="${REMOTE_DIR:-/Users/john/Project/memind_adm}"
ADM_PORT="${ADM_PORT:-5174}"
SSH_OPTS=(-o ConnectTimeout=15 -o BatchMode=yes -o StrictHostKeyChecking=accept-new)
SKIP_BUILD=0
NO_RESTART=0
for arg in "$@"; do
case "$arg" in
--skip-build) SKIP_BUILD=1 ;;
--no-restart) NO_RESTART=1 ;;
-h|--help)
sed -n '2,8p' "$0"
exit 0
;;
*)
echo "未知参数: $arg" >&2
exit 1
;;
esac
done
ssh_cmd() {
ssh "${SSH_OPTS[@]}" "${DEPLOY_HOST}" "$@"
}
require_ssh() {
if ! ssh_cmd 'echo ok' >/dev/null 2>&1; then
echo "❌ 无法 SSH 到 ${DEPLOY_HOST}" >&2
echo "请确认 Tailscale 已连接,或执行: ssh-copy-id ${DEPLOY_HOST}" >&2
exit 1
fi
}
echo "======================================"
echo "memind_adm → ${DEPLOY_HOST}"
echo "时间: $(date '+%Y-%m-%d %H:%M:%S')"
echo "本地: ${ROOT}"
echo "远端: ${REMOTE_DIR}"
echo "端口: ${ADM_PORT}"
echo "======================================"
require_ssh
if [[ "${SKIP_BUILD}" -eq 0 ]]; then
echo "==> 本地构建..."
cd "${ROOT}"
npm run build
fi
echo "==> 创建远端目录..."
ssh_cmd "mkdir -p '${REMOTE_DIR}'"
echo "==> rsync 源码 (--delete)..."
rsync -az --delete --exclude-from="${EXCLUDE}" \
"${ROOT}/" \
"${DEPLOY_HOST}:${REMOTE_DIR}/"
echo "==> rsync dist..."
rsync -az --delete \
"${ROOT}/dist/" \
"${DEPLOY_HOST}:${REMOTE_DIR}/dist/"
echo "==> 写入远端 .env(不覆盖已有)..."
ssh_cmd "test -f '${REMOTE_DIR}/.env' || cat > '${REMOTE_DIR}/.env' <<'EOF'
ADM_DEV_BACKEND=http://127.0.0.1:8081
VITE_MAIN_APP_URL=http://127.0.0.1:8081
EOF"
if [[ "${NO_RESTART}" -eq 0 ]]; then
echo "==> 远端重启服务..."
ssh_cmd "ADM_ROOT='${REMOTE_DIR}' ADM_PORT='${ADM_PORT}' bash -s" <"${ROOT}/scripts/remote_restart.sh"
fi
echo ""
echo "✅ 已部署到 ${DEPLOY_HOST}:${REMOTE_DIR}"
echo " 本机访问: ssh ${DEPLOY_HOST} 'curl -sI http://127.0.0.1:${ADM_PORT}/ | head -1'"
+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;
}
+10
View File
@@ -17,6 +17,16 @@ export default defineConfig(({ mode }) => {
'/admin-api': backendTarget,
},
},
preview: {
host: '127.0.0.1',
port: 5174,
strictPort: true,
proxy: {
'/api': backendTarget,
'/auth': backendTarget,
'/admin-api': backendTarget,
},
},
build: {
outDir: 'dist',
emptyOutDir: true,