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:
+119
@@ -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 排除规则 |
|
||||
@@ -0,0 +1,10 @@
|
||||
.git
|
||||
.gitignore
|
||||
.DS_Store
|
||||
node_modules
|
||||
.env
|
||||
.env.local
|
||||
*.local
|
||||
.vite
|
||||
*.log
|
||||
logs
|
||||
Executable
+43
@@ -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
|
||||
Executable
+90
@@ -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'"
|
||||
@@ -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
@@ -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
@@ -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
@@ -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: {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user