diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md new file mode 100644 index 0000000..5985692 --- /dev/null +++ b/docs/DEPLOY.md @@ -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 排除规则 | diff --git a/scripts/.rsync-exclude-lan b/scripts/.rsync-exclude-lan new file mode 100644 index 0000000..5ba35f6 --- /dev/null +++ b/scripts/.rsync-exclude-lan @@ -0,0 +1,10 @@ +.git +.gitignore +.DS_Store +node_modules +.env +.env.local +*.local +.vite +*.log +logs diff --git a/scripts/remote_restart.sh b/scripts/remote_restart.sh new file mode 100755 index 0000000..6f48d39 --- /dev/null +++ b/scripts/remote_restart.sh @@ -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 diff --git a/scripts/rsync_to_server.sh b/scripts/rsync_to_server.sh new file mode 100755 index 0000000..02a9713 --- /dev/null +++ b/scripts/rsync_to_server.sh @@ -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'" diff --git a/src/admin/hooks/useAdminUsers.ts b/src/admin/hooks/useAdminUsers.ts index 6f595d6..d9793da 100644 --- a/src/admin/hooks/useAdminUsers.ts +++ b/src/admin/hooks/useAdminUsers.ts @@ -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([]); 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 { diff --git a/src/admin/pages/BillingPage.tsx b/src/admin/pages/BillingPage.tsx index 68747c6..e6d27ce 100644 --- a/src/admin/pages/BillingPage.tsx +++ b/src/admin/pages/BillingPage.tsx @@ -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(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 ( +
+
setOpen(true)}> + {selected && !open ? ( + + {selected.displayName} @{selected.username} + ¥{formatYuan(selected.balanceCents)} + + ) : ( + { setQuery(e.target.value); setOpen(true); }} + onFocus={() => setOpen(true)} + /> + )} + {value && ( + + )} +
+ {open && ( +
+ {filtered.length === 0 ? ( +
无匹配用户
+ ) : ( + filtered.map((u) => ( +
select(u)}> + {u.displayName} + @{u.username} + ¥{formatYuan(u.balanceCents)} +
+ )) + )} +
+ )} +
+ ); +} + 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(null); @@ -43,32 +131,15 @@ function RechargeTab() { {error &&

{error}

} {message &&

{message}

}
- - setRecharge((s) => ({ ...s, amountYuan: e.target.value }))} - /> - setRecharge((s) => ({ ...s, note: e.target.value }))} + onChange={(id) => setRecharge((s) => ({ ...s, userId: id }))} /> + setRecharge((s) => ({ ...s, amountYuan: e.target.value }))} /> + setRecharge((s) => ({ ...s, note: e.target.value }))} /> @@ -79,16 +150,19 @@ function RechargeTab() { function UsageTab() { const { users } = useAdminUsers(); - const [usage, setUsage] = useState([]); - const [loading, setLoading] = useState(true); - const [filterUserId, setFilterUserId] = useState(''); + const [result, setResult] = useState | null>(null); + const [loading, setLoading] = useState(false); const [error, setError] = useState(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 (

用量记录

- -
{error &&

{error}

} - {loading ? ( -

加载中…

- ) : usage.length === 0 ? ( -

暂无记录

- ) : ( -
- - - - - - - - - - - - - {usage.map((row) => ( - - - - - - - - - ))} - -
时间用户输入 Token输出 Token扣费余额后
{formatTime(row.createdAt)}@{row.username}{row.inputTokens.toLocaleString()}{row.outputTokens.toLocaleString()}¥{formatYuan(row.costCents)}¥{formatYuan(row.balanceAfterCents)}
-
+ {!result && !loading && ( +

选择筛选条件后点击查询

+ )} + {loading &&

加载中…

} + {!loading && result && ( + result.items.length === 0 ? ( +

暂无记录

+ ) : ( + <> +
+ + + + + + + + + + + + + {result.items.map((row) => ( + + + + + + + + + ))} + +
时间用户输入 Token输出 Token扣费余额后
{formatTime(row.createdAt)}@{row.username}{row.inputTokens.toLocaleString()}{row.outputTokens.toLocaleString()}¥{formatYuan(row.costCents)}¥{formatYuan(row.balanceAfterCents)}
+
+ + + ) )}
); @@ -161,16 +238,19 @@ function UsageTab() { function LedgerTab() { const { users } = useAdminUsers(); - const [ledger, setLedger] = useState([]); - const [loading, setLoading] = useState(true); - const [filterUserId, setFilterUserId] = useState(''); + const [result, setResult] = useState | null>(null); + const [loading, setLoading] = useState(false); const [error, setError] = useState(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 = { - recharge: '充值', - deduct: '扣费', - refund: '退款', - adjust: '调整', - }; + const handleQuery = () => void load(1, pendingUserId); + const handlePage = (p: number) => void load(p, filterUserId); + + const TYPE_LABEL: Record = { recharge: '充值', deduct: '扣费', refund: '退款', adjust: '调整' }; return (

资金流水

- -
{error &&

{error}

} - {loading ? ( -

加载中…

- ) : ledger.length === 0 ? ( -

暂无记录

- ) : ( -
- - - - - - - - - - - - {ledger.map((row) => ( - - - - - - - - ))} - -
时间用户类型金额备注
{formatTime(row.createdAt)}@{row.username} - - {TYPE_LABEL[row.type] ?? row.type} - - - {row.amountCents >= 0 ? '+' : ''}¥{formatYuan(Math.abs(row.amountCents))} - {row.note ?? '—'}
-
+ {!result && !loading && ( +

选择筛选条件后点击查询

+ )} + {loading &&

加载中…

} + {!loading && result && ( + result.items.length === 0 ? ( +

暂无记录

+ ) : ( + <> +
+ + + + + + + + + + + + {result.items.map((row) => ( + + + + + + + + ))} + +
时间用户类型金额备注
{formatTime(row.createdAt)}@{row.username}{TYPE_LABEL[row.type] ?? row.type} + {row.amountCents >= 0 ? '+' : ''}¥{formatYuan(Math.abs(row.amountCents))} + {row.note ?? '—'}
+
+ + + ) )}
); @@ -261,22 +335,15 @@ export function BillingPage() {

计费中心

充值、用量与资金流水

-
{TABS.map((tab) => ( - ))}
-
{activeTab === 'recharge' && } {activeTab === 'usage' && } diff --git a/src/admin/pages/UsersPage.tsx b/src/admin/pages/UsersPage.tsx index a295946..5819658 100644 --- a/src/admin/pages/UsersPage.tsx +++ b/src/admin/pages/UsersPage.tsx @@ -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 | null>(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); const [message, setMessage] = useState(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() {

用户管理

-

创建账号、查看列表、进入用户详情配置权限

+

搜索账号、查看列表、进入用户详情配置权限

{error &&

{error}

} {message &&

{message}

} -
-

创建用户

- - setNewUser((s) => ({ ...s, username: e.target.value }))} - /> - setNewUser((s) => ({ ...s, password: e.target.value }))} - /> - setNewUser((s) => ({ ...s, displayName: e.target.value }))} - /> - setNewUser((s) => ({ ...s, workspaceRoot: e.target.value }))} - /> - - setNewUser((s) => ({ ...s, balanceCents: Number(e.target.value) })) - } - /> - - -
+ {showCreate && ( +
+
+

创建用户

+ +
+
+ setNewUser((s) => ({ ...s, username: e.target.value }))} /> + setNewUser((s) => ({ ...s, password: e.target.value }))} /> + setNewUser((s) => ({ ...s, displayName: e.target.value }))} /> + setNewUser((s) => ({ ...s, workspaceRoot: e.target.value }))} /> + setNewUser((s) => ({ ...s, balanceCents: Number(e.target.value) }))} /> + +
+
+ )}
-

用户列表

- -
- {loading ? ( -

加载中…

- ) : ( -
- - - - - - - - - - - - - {users.map((user) => ( - - - - - - - - - ))} - -
用户角色状态余额工作目录操作
-
{user.displayName}
-
@{user.username}
-
{user.role}{user.status}¥{formatYuan(user.balanceCents)}{user.workspaceRoot} - - 详情 - - {user.role === 'user' && ( - - )} -
+

+ 用户列表 + {result && {result.total.toLocaleString()} 人} +

+
+ setPending((s) => ({ ...s, search: e.target.value }))} + onKeyDown={handleKeyDown} + /> + + + +
+
+ + {!result && !loading && ( +

输入条件后点击查询,或直接点查询加载全部

+ )} + {loading &&

加载中…

} + {!loading && result && ( + result.items.length === 0 ? ( +

没有匹配的用户

+ ) : ( + <> +
+ + + + + + + + + + + + + {result.items.map((user) => ( + + + + + + + + + ))} + +
用户角色状态余额工作目录操作
+
{user.displayName}
+
@{user.username}
+
{user.role === 'admin' ? '管理员' : '用户'} + + {user.status === 'active' ? '正常' : user.status === 'disabled' ? '禁用' : '封禁'} + + ¥{formatYuan(user.balanceCents)}{user.workspaceRoot || '—'} + 详情 + {user.role === 'user' && ( + + )} +
+
+ + + ) )}
diff --git a/src/api/client.ts b/src/api/client.ts index 16fa028..5c68ed5 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -156,6 +156,16 @@ export async function logout(): Promise { await fetch('/auth/logout', { method: 'POST' }); } +// ── Pagination helpers ──────────────────────────────── + +export type PagedResult = { + items: T[]; + total: number; + page: number; + pageSize: number; + totalPages: number; +}; + // ── Admin dashboard ─────────────────────────────────── export async function getAdminDashboardSummary(): Promise { @@ -163,23 +173,61 @@ export async function getAdminDashboardSummary(): Promise return result.summary; } -export async function listAdminUsage(userId?: string): Promise { - 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> { + 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 { - 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> { + 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 { - 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> { + 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: { diff --git a/src/components/Pagination.tsx b/src/components/Pagination.tsx new file mode 100644 index 0000000..3bc169e --- /dev/null +++ b/src/components/Pagination.tsx @@ -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 ( +
+ + {start}–{end} / 共 {total.toLocaleString()} 条 + +
+ + {pages.map((p, i) => + p === '…' ? ( + + ) : ( + + ), + )} + +
+
+ ); +} diff --git a/src/index.css b/src/index.css index 6993296..64bab0d 100644 --- a/src/index.css +++ b/src/index.css @@ -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; +} diff --git a/vite.config.ts b/vite.config.ts index 24e4957..45ef807 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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,