From 7357157cb292ff7b1b87c619017d060de0f67147 Mon Sep 17 00:00:00 2001 From: john Date: Tue, 16 Jun 2026 21:11:25 +0800 Subject: [PATCH] Add super-admin section to ops SPA (role=admin gated) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New pages under /ops/admin: Summary dashboard, Users (list + edit + recharge + create), LLM provider keys management, Billing ledger and usage records. Shared AuthContext (lib/auth.tsx) fetches /auth/status once and supplies user/role to RequireOps, RequireAdmin, and OpsLayout. OpsLayout shows a ⚙ 超管 nav link for admins. RequireOps now grants admin users access without an extra ops-role probe. Co-Authored-By: Claude Opus 4.8 --- ops/src/App.tsx | 52 +++- ops/src/api/admin.ts | 207 +++++++++++++++ ops/src/components/AdminLayout.tsx | 38 +++ ops/src/components/OpsLayout.tsx | 20 +- ops/src/components/RequireAdmin.tsx | 31 +++ ops/src/components/RequireOps.tsx | 37 +-- ops/src/index.css | 17 ++ ops/src/lib/auth.tsx | 30 +++ ops/src/main.tsx | 5 +- ops/src/pages/admin/BillingPage.tsx | 181 +++++++++++++ ops/src/pages/admin/LlmPage.tsx | 354 +++++++++++++++++++++++++ ops/src/pages/admin/SummaryPage.tsx | 55 ++++ ops/src/pages/admin/UsersPage.tsx | 389 ++++++++++++++++++++++++++++ 13 files changed, 1381 insertions(+), 35 deletions(-) create mode 100644 ops/src/api/admin.ts create mode 100644 ops/src/components/AdminLayout.tsx create mode 100644 ops/src/components/RequireAdmin.tsx create mode 100644 ops/src/lib/auth.tsx create mode 100644 ops/src/pages/admin/BillingPage.tsx create mode 100644 ops/src/pages/admin/LlmPage.tsx create mode 100644 ops/src/pages/admin/SummaryPage.tsx create mode 100644 ops/src/pages/admin/UsersPage.tsx diff --git a/ops/src/App.tsx b/ops/src/App.tsx index 1e12085..189bb85 100644 --- a/ops/src/App.tsx +++ b/ops/src/App.tsx @@ -1,25 +1,53 @@ import { Navigate, Route, Routes } from 'react-router-dom'; import { OpsLayout } from './components/OpsLayout'; import { RequireOps } from './components/RequireOps'; +import { RequireAdmin } from './components/RequireAdmin'; +import { AdminLayout } from './components/AdminLayout'; import { AnalyticsPage } from './pages/AnalyticsPage'; import { CreatorsPage } from './pages/CreatorsPage'; import { FeaturedPage } from './pages/FeaturedPage'; import { ReportsPage } from './pages/ReportsPage'; import { ReviewPage } from './pages/ReviewPage'; +import { SummaryPage } from './pages/admin/SummaryPage'; +import { UsersPage } from './pages/admin/UsersPage'; +import { LlmPage } from './pages/admin/LlmPage'; +import { BillingPage } from './pages/admin/BillingPage'; export function App() { return ( - - - }> - } /> - } /> - } /> - } /> - } /> - } /> - - - + + {/* Plaza ops console */} + + + + } + > + } /> + } /> + } /> + } /> + } /> + + + {/* Super-admin console */} + + + + } + > + } /> + } /> + } /> + } /> + } /> + + + } /> + ); } diff --git a/ops/src/api/admin.ts b/ops/src/api/admin.ts new file mode 100644 index 0000000..f8f98c0 --- /dev/null +++ b/ops/src/api/admin.ts @@ -0,0 +1,207 @@ +async function adminFetch(path: string, init?: RequestInit): Promise { + const response = await fetch(path, { + ...init, + credentials: 'include', + headers: { + Accept: 'application/json', + ...(init?.body ? { 'Content-Type': 'application/json' } : {}), + ...(init?.headers ?? {}), + }, + }); + const payload = (await response.json().catch(() => ({}))) as { + data?: T; + message?: string; + error?: { code: string; message: string }; + }; + if (!response.ok) { + throw new Error(payload?.error?.message ?? payload?.message ?? `请求失败 (${response.status})`); + } + return (payload.data !== undefined ? payload.data : payload) as T; +} + +export type AdminUser = { + id: string; + username: string; + slug: string; + email: string; + displayName: string; + role: string; + status: string; + balanceCents: number; + createdAt: string; +}; + +export type AdminSummary = { + totalUsers: number; + activeUsers: number; + totalBalanceCents: number; + llm?: { + keyCount: number; + selectedKeyName: string | null; + globalModel: string | null; + }; +}; + +export type LlmKey = { + id: string; + name: string; + provider: string; + model?: string; + models?: string[]; + isSelected: boolean; + createdAt?: string; +}; + +export type LedgerEntry = { + id: string; + userId: string; + username?: string; + type: string; + amountCents: number; + note: string; + createdAt: string; +}; + +export type UsageRecord = { + id: string; + userId: string; + username?: string; + provider: string; + model: string; + inputTokens: number; + outputTokens: number; + costCents: number; + createdAt: string; +}; + +// ─── Summary ────────────────────────────────────────────────────────────────── + +export async function fetchAdminSummary() { + return adminFetch<{ summary: AdminSummary }>('/admin-api/summary'); +} + +// ─── Users ──────────────────────────────────────────────────────────────────── + +export async function fetchAdminUsers(params: { + page?: number; + pageSize?: number; + search?: string; + role?: string; + status?: string; +}) { + 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); + return adminFetch<{ users: AdminUser[]; total: number; page: number; pageSize: number; totalPages: number }>( + `/admin-api/users?${q}`, + ); +} + +export async function createAdminUser(body: { + username: string; + password: string; + displayName?: string; + role?: string; +}) { + return adminFetch<{ user: AdminUser }>('/admin-api/users', { + method: 'POST', + body: JSON.stringify(body), + }); +} + +export async function patchAdminUser( + userId: string, + patch: { role?: string; status?: string; displayName?: string; password?: string }, +) { + return adminFetch<{ user: AdminUser }>(`/admin-api/users/${userId}`, { + method: 'PATCH', + body: JSON.stringify(patch), + }); +} + +export async function rechargeUser(userId: string, amountCents: number, note = '') { + return adminFetch<{ user: AdminUser }>(`/admin-api/users/${userId}/recharge`, { + method: 'POST', + body: JSON.stringify({ amountCents, note }), + }); +} + +// ─── Billing ────────────────────────────────────────────────────────────────── + +export async function fetchAdminLedger(params: { page?: number; pageSize?: number } = {}) { + const q = new URLSearchParams(); + if (params.page) q.set('page', String(params.page)); + if (params.pageSize) q.set('pageSize', String(params.pageSize)); + return adminFetch<{ entries: LedgerEntry[]; total: number; page: number; totalPages: number }>( + `/admin-api/ledger?${q}`, + ); +} + +export async function fetchAdminUsage(params: { page?: number; pageSize?: number } = {}) { + const q = new URLSearchParams(); + if (params.page) q.set('page', String(params.page)); + if (params.pageSize) q.set('pageSize', String(params.pageSize)); + return adminFetch<{ records: UsageRecord[]; total: number; page: number; totalPages: number }>( + `/admin-api/usage?${q}`, + ); +} + +// ─── LLM Providers ─────────────────────────────────────────────────────────── + +export async function fetchLlmKeys() { + return adminFetch<{ keys: LlmKey[] }>('/admin-api/llm-providers/keys'); +} + +export async function createLlmKey(body: { + name: string; + provider: string; + apiKey: string; + model?: string; + models?: string; + baseUrl?: string; +}) { + return adminFetch<{ key: LlmKey }>('/admin-api/llm-providers/keys', { + method: 'POST', + body: JSON.stringify(body), + }); +} + +export async function patchLlmKey(keyId: string, patch: { name?: string; apiKey?: string; model?: string }) { + return adminFetch<{ key: LlmKey }>(`/admin-api/llm-providers/keys/${keyId}`, { + method: 'PATCH', + body: JSON.stringify(patch), + }); +} + +export async function selectLlmKey(keyId: string) { + return adminFetch(`/admin-api/llm-providers/keys/${keyId}/select`, { method: 'POST' }); +} + +export async function deleteLlmKey(keyId: string) { + return adminFetch(`/admin-api/llm-providers/keys/${keyId}`, { method: 'DELETE' }); +} + +export async function testLlmKey(keyId: string) { + return adminFetch<{ ok: boolean; model?: string; error?: string }>( + `/admin-api/llm-providers/keys/${keyId}/test`, + { method: 'POST' }, + ); +} + +export async function fetchLlmGlobal() { + return adminFetch<{ settings: { model: string | null } }>('/admin-api/llm-providers/global'); +} + +export async function putLlmGlobal(model: string) { + return adminFetch('/admin-api/llm-providers/global', { + method: 'PUT', + body: JSON.stringify({ model }), + }); +} + +export async function syncLlmProviders() { + return adminFetch<{ synced: number }>('/admin-api/llm-providers/sync', { method: 'POST' }); +} diff --git a/ops/src/components/AdminLayout.tsx b/ops/src/components/AdminLayout.tsx new file mode 100644 index 0000000..df94d57 --- /dev/null +++ b/ops/src/components/AdminLayout.tsx @@ -0,0 +1,38 @@ +import { NavLink, Outlet } from 'react-router-dom'; + +const links = [ + { to: '/admin', label: '概览', end: true }, + { to: '/admin/users', label: '用户管理' }, + { to: '/admin/llm', label: 'LLM 配置' }, + { to: '/admin/billing', label: '账单记录' }, +]; + +export function AdminLayout() { + return ( +
+
+

超级管理后台

+

用户、计费与 LLM 配置

+
+ + +
+ ); +} diff --git a/ops/src/components/OpsLayout.tsx b/ops/src/components/OpsLayout.tsx index 3707b42..e18f245 100644 --- a/ops/src/components/OpsLayout.tsx +++ b/ops/src/components/OpsLayout.tsx @@ -1,7 +1,8 @@ import { NavLink, Outlet } from 'react-router-dom'; +import { useAuth } from '../lib/auth'; -const links = [ - { to: '/', label: '审核队列' }, +const opsLinks = [ + { to: '/', label: '审核队列', end: true }, { to: '/reports', label: '举报处理' }, { to: '/featured', label: '精选管理' }, { to: '/creators', label: '创作者' }, @@ -9,6 +10,8 @@ const links = [ ]; export function OpsLayout() { + const { user } = useAuth(); + return (
@@ -16,16 +19,25 @@ export function OpsLayout() {

内容审核、精选与数据概览

diff --git a/ops/src/components/RequireAdmin.tsx b/ops/src/components/RequireAdmin.tsx new file mode 100644 index 0000000..13d93b5 --- /dev/null +++ b/ops/src/components/RequireAdmin.tsx @@ -0,0 +1,31 @@ +import { useAuth } from '../lib/auth'; +import { mindSpaceLoginUrl } from '../lib/site'; + +export function RequireAdmin({ children }: { children: React.ReactNode }) { + const { loading, user } = useAuth(); + + if (loading) return

检查权限…

; + + if (!user) { + return ( +
+

需要登录

+

请先在 MindSpace 登录后再访问管理后台。

+ + 前往登录 + +
+ ); + } + + if (user.role !== 'admin') { + return ( +
+

权限不足

+

超级管理后台需要 role = admin,当前账号 ({user.username}) 无此权限。

+
+ ); + } + + return children; +} diff --git a/ops/src/components/RequireOps.tsx b/ops/src/components/RequireOps.tsx index 2631b07..8c7dad0 100644 --- a/ops/src/components/RequireOps.tsx +++ b/ops/src/components/RequireOps.tsx @@ -1,35 +1,34 @@ -import { useEffect, useState } from 'react'; -import { fetchAuthStatus, fetchReviewQueue } from '../api/client'; +import { useAuth } from '../lib/auth'; import { mindSpaceLoginUrl } from '../lib/site'; +import { fetchReviewQueue } from '../api/client'; +import { useEffect, useState } from 'react'; export function RequireOps({ children }: { children: React.ReactNode }) { - const [state, setState] = useState<'loading' | 'ok' | 'denied' | 'forbidden'>('loading'); + const { loading: authLoading, user } = useAuth(); + const [state, setState] = useState<'loading' | 'ok' | 'forbidden'>('loading'); const [message, setMessage] = useState(null); useEffect(() => { + if (authLoading) return; + if (!user) { setState('forbidden'); setMessage(null); return; } + + // Admins bypass ops-role check. + if (user.role === 'admin') { setState('ok'); return; } + void (async () => { try { - const auth = await fetchAuthStatus(); - if (!auth.authenticated) { - setState('denied'); - return; - } await fetchReviewQueue('status=pending_review&limit=1'); setState('ok'); } catch (err) { - const text = err instanceof Error ? err.message : '无运营权限'; - if (text.includes('未授权') || text.includes('登录')) { - setState('denied'); - } else { - setMessage(text); - setState('forbidden'); - } + setMessage(err instanceof Error ? err.message : '无运营权限'); + setState('forbidden'); } })(); - }, []); + }, [authLoading, user]); - if (state === 'loading') return

检查登录态…

; - if (state === 'denied') { + if (authLoading || state === 'loading') return

检查登录态…

; + + if (!user) { return (

需要登录

@@ -40,6 +39,7 @@ export function RequireOps({ children }: { children: React.ReactNode }) {
); } + if (state === 'forbidden') { return (
@@ -51,5 +51,6 @@ export function RequireOps({ children }: { children: React.ReactNode }) {
); } + return children; } diff --git a/ops/src/index.css b/ops/src/index.css index d399d18..8f096ae 100644 --- a/ops/src/index.css +++ b/ops/src/index.css @@ -88,3 +88,20 @@ textarea { background: #2f6f57; color: white; } + +input, +select, +textarea { + border: 1px solid #d6d0c3; + border-radius: 8px; + padding: 8px 12px; + background: #fffdf7; + width: 100%; +} + +input:focus, +select:focus, +textarea:focus { + outline: 2px solid #2f6f57; + outline-offset: 1px; +} diff --git a/ops/src/lib/auth.tsx b/ops/src/lib/auth.tsx new file mode 100644 index 0000000..483e121 --- /dev/null +++ b/ops/src/lib/auth.tsx @@ -0,0 +1,30 @@ +import { createContext, useContext, useEffect, useState } from 'react'; + +export type AuthUser = { + id: string; + username: string; + displayName: string; + role: string; + status?: string; +}; + +type AuthState = { loading: boolean; user: AuthUser | null }; + +const AuthContext = createContext({ loading: true, user: null }); + +export function AuthProvider({ children }: { children: React.ReactNode }) { + const [state, setState] = useState({ loading: true, user: null }); + + useEffect(() => { + void fetch('/auth/status', { credentials: 'include' }) + .then((r) => r.json()) + .then((auth: { authenticated: boolean; user?: AuthUser }) => { + setState({ loading: false, user: auth.authenticated ? (auth.user ?? null) : null }); + }) + .catch(() => setState({ loading: false, user: null })); + }, []); + + return {children}; +} + +export const useAuth = () => useContext(AuthContext); diff --git a/ops/src/main.tsx b/ops/src/main.tsx index 9d652a2..fb04a8e 100644 --- a/ops/src/main.tsx +++ b/ops/src/main.tsx @@ -1,13 +1,16 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { BrowserRouter } from 'react-router-dom'; +import { AuthProvider } from './lib/auth'; import { App } from './App'; import './index.css'; createRoot(document.getElementById('root')!).render( - + + + , ); diff --git a/ops/src/pages/admin/BillingPage.tsx b/ops/src/pages/admin/BillingPage.tsx new file mode 100644 index 0000000..ae2a945 --- /dev/null +++ b/ops/src/pages/admin/BillingPage.tsx @@ -0,0 +1,181 @@ +import { useEffect, useState } from 'react'; +import { fetchAdminLedger, fetchAdminUsage, type LedgerEntry, type UsageRecord } from '../../api/admin'; + +type Tab = 'ledger' | 'usage'; + +function yuan(cents: number) { + return `¥${(cents / 100).toFixed(2)}`; +} + +function fmtTime(ts: string) { + return new Date(ts).toLocaleString('zh-CN', { hour12: false }); +} + +export function BillingPage() { + const [tab, setTab] = useState('ledger'); + + return ( +
+
+ + +
+ {tab === 'ledger' ? : } +
+ ); +} + +function LedgerTab() { + const [entries, setEntries] = useState([]); + const [page, setPage] = useState(1); + const [totalPages, setTotalPages] = useState(1); + const [total, setTotal] = useState(0); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + const load = async (p: number) => { + setLoading(true); + setError(null); + try { + const result = await fetchAdminLedger({ page: p, pageSize: 30 }); + setEntries(result.entries); + setTotal(result.total); + setTotalPages(result.totalPages); + setPage(p); + } catch (err) { + setError(err instanceof Error ? err.message : '加载失败'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { void load(1); }, []); + + return ( + <> + {error ?

{error}

: null} +
+ + + + {['时间', '用户', '类型', '金额', '备注'].map((h) => ( + + ))} + + + + {entries.map((e) => ( + + + + + + + + ))} + {entries.length === 0 && !loading ? ( + + ) : null} + +
{h}
{fmtTime(e.createdAt)}{e.username ?? e.userId}{e.type}= 0 ? '#2f6f57' : '#b42318', + fontVariantNumeric: 'tabular-nums', + }} + > + {e.amountCents >= 0 ? '+' : ''}{yuan(e.amountCents)} + {e.note || '—'}
暂无记录
+
+ + + ); +} + +function UsageTab() { + const [records, setRecords] = useState([]); + const [page, setPage] = useState(1); + const [totalPages, setTotalPages] = useState(1); + const [total, setTotal] = useState(0); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + const load = async (p: number) => { + setLoading(true); + setError(null); + try { + const result = await fetchAdminUsage({ page: p, pageSize: 30 }); + setRecords(result.records); + setTotal(result.total); + setTotalPages(result.totalPages); + setPage(p); + } catch (err) { + setError(err instanceof Error ? err.message : '加载失败'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { void load(1); }, []); + + return ( + <> + {error ?

{error}

: null} +
+ + + + {['时间', '用户', 'Provider', '模型', '输入', '输出', '费用'].map((h) => ( + + ))} + + + + {records.map((r) => ( + + + + + + + + + + ))} + {records.length === 0 && !loading ? ( + + ) : null} + +
{h}
{fmtTime(r.createdAt)}{r.username ?? r.userId}{r.provider}{r.model}{r.inputTokens.toLocaleString()}{r.outputTokens.toLocaleString()}{yuan(r.costCents)}
暂无记录
+
+ + + ); +} + +function Pagination({ + page, totalPages, total, unit, onGo, +}: { + page: number; totalPages: number; total: number; unit: string; onGo: (p: number) => void; +}) { + if (totalPages <= 1) return null; + return ( +
+ + 第 {page} / {totalPages} 页(共 {total} {unit}) + +
+ ); +} diff --git a/ops/src/pages/admin/LlmPage.tsx b/ops/src/pages/admin/LlmPage.tsx new file mode 100644 index 0000000..27eac78 --- /dev/null +++ b/ops/src/pages/admin/LlmPage.tsx @@ -0,0 +1,354 @@ +import { useEffect, useState } from 'react'; +import { + fetchLlmKeys, + fetchLlmGlobal, + createLlmKey, + patchLlmKey, + deleteLlmKey, + selectLlmKey, + testLlmKey, + putLlmGlobal, + syncLlmProviders, + type LlmKey, +} from '../../api/admin'; + +export function LlmPage() { + const [keys, setKeys] = useState([]); + const [globalModel, setGlobalModel] = useState(''); + const [globalModelInput, setGlobalModelInput] = useState(''); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const [testResults, setTestResults] = useState>({}); + const [showCreate, setShowCreate] = useState(false); + const [editKey, setEditKey] = useState(null); + + const load = async () => { + setError(null); + try { + const [keysResult, globalResult] = await Promise.all([fetchLlmKeys(), fetchLlmGlobal()]); + setKeys(keysResult.keys); + const model = globalResult.settings?.model ?? ''; + setGlobalModel(model); + setGlobalModelInput(model); + } catch (err) { + setError(err instanceof Error ? err.message : '加载失败'); + } + }; + + useEffect(() => { void load(); }, []); + + const handleSelect = async (keyId: string) => { + setBusy(true); + try { + await selectLlmKey(keyId); + await load(); + } catch (err) { + setError(err instanceof Error ? err.message : '操作失败'); + } finally { + setBusy(false); + } + }; + + const handleDelete = async (key: LlmKey) => { + if (!window.confirm(`确认删除密钥「${key.name}」?`)) return; + setBusy(true); + try { + await deleteLlmKey(key.id); + await load(); + } catch (err) { + setError(err instanceof Error ? err.message : '操作失败'); + } finally { + setBusy(false); + } + }; + + const handleTest = async (key: LlmKey) => { + setTestResults((prev) => ({ ...prev, [key.id]: { ok: false, msg: '测试中…' } })); + try { + const result = await testLlmKey(key.id); + setTestResults((prev) => ({ + ...prev, + [key.id]: { ok: result.ok, msg: result.ok ? `OK (${result.model ?? ''})` : (result.error ?? '失败') }, + })); + } catch (err) { + setTestResults((prev) => ({ + ...prev, + [key.id]: { ok: false, msg: err instanceof Error ? err.message : '连接失败' }, + })); + } + }; + + const handleSaveGlobal = async () => { + setBusy(true); + try { + await putLlmGlobal(globalModelInput.trim()); + setGlobalModel(globalModelInput.trim()); + } catch (err) { + setError(err instanceof Error ? err.message : '保存失败'); + } finally { + setBusy(false); + } + }; + + const handleSync = async () => { + setBusy(true); + try { + const result = await syncLlmProviders(); + alert(`同步完成,更新 ${result.synced} 条`); + await load(); + } catch (err) { + setError(err instanceof Error ? err.message : '同步失败'); + } finally { + setBusy(false); + } + }; + + return ( +
+ {error ?

{error}

: null} + + {/* Global model */} +
+

全局模型设置

+
+ setGlobalModelInput(e.target.value)} + placeholder="如 deepseek-chat" + style={{ flex: '1 1 200px', padding: '8px 12px', borderRadius: 999, border: '1px solid #d6d0c3' }} + /> + +
+ {globalModel ? ( +

当前:{globalModel}

+ ) : ( +

未设置(使用选中密钥自带的 model)

+ )} +
+ + {/* Keys list */} +
+
+

API 密钥({keys.length})

+
+ + +
+
+ + {keys.length === 0 ? ( +

暂无配置密钥

+ ) : ( + keys.map((key) => ( +
+
+
+ {key.name} + {key.isSelected ? ( + ✓ 当前选中 + ) : null} +

+ {key.provider} {key.model ? `· ${key.model}` : ''} + {key.models?.length ? ` · ${key.models.join(', ')}` : ''} +

+
+
+ {!key.isSelected ? ( + + ) : null} + + + +
+
+ {testResults[key.id] ? ( +

+ {testResults[key.id].msg} +

+ ) : null} +
+ )) + )} +
+ + {showCreate ? ( + setShowCreate(false)} + onSuccess={() => { setShowCreate(false); void load(); }} + /> + ) : null} + + {editKey ? ( + setEditKey(null)} + onSuccess={() => { setEditKey(null); void load(); }} + /> + ) : null} +
+ ); +} + +function CreateKeyModal({ onClose, onSuccess }: { onClose: () => void; onSuccess: () => void }) { + const [name, setName] = useState(''); + const [provider, setProvider] = useState('openai'); + const [apiKey, setApiKey] = useState(''); + const [model, setModel] = useState(''); + const [baseUrl, setBaseUrl] = useState(''); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const handleSubmit = async () => { + if (!name.trim() || !apiKey.trim()) { setError('名称和 API Key 必填'); return; } + setBusy(true); + try { + await createLlmKey({ name: name.trim(), provider, apiKey, model: model.trim() || undefined, baseUrl: baseUrl.trim() || undefined }); + onSuccess(); + } catch (err) { + setError(err instanceof Error ? err.message : '创建失败'); + } finally { + setBusy(false); + } + }; + + return ( + +
+ setName(e.target.value)} placeholder="DeepSeek Default" /> + + + + setApiKey(e.target.value)} type="password" placeholder="sk-..." /> + setModel(e.target.value)} placeholder="deepseek-chat" /> + setBaseUrl(e.target.value)} placeholder="https://api.deepseek.com/v1" /> + {error ?

{error}

: null} + void handleSubmit()} busy={busy} /> +
+
+ ); +} + +function EditKeyModal({ llmKey, onClose, onSuccess }: { llmKey: LlmKey; onClose: () => void; onSuccess: () => void }) { + const [name, setName] = useState(llmKey.name); + const [apiKey, setApiKey] = useState(''); + const [model, setModel] = useState(llmKey.model ?? ''); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const handleSubmit = async () => { + setBusy(true); + try { + const patch: Record = {}; + if (name !== llmKey.name) patch.name = name.trim(); + if (apiKey) patch.apiKey = apiKey; + if (model !== (llmKey.model ?? '')) patch.model = model.trim(); + if (Object.keys(patch).length === 0) { onClose(); return; } + await patchLlmKey(llmKey.id, patch); + onSuccess(); + } catch (err) { + setError(err instanceof Error ? err.message : '更新失败'); + } finally { + setBusy(false); + } + }; + + return ( + +
+ setName(e.target.value)} /> + setApiKey(e.target.value)} type="password" placeholder="留空不修改" /> + setModel(e.target.value)} /> + {error ?

{error}

: null} + void handleSubmit()} busy={busy} /> +
+
+ ); +} + +function Modal({ title, onClose, children }: { title: string; onClose: () => void; children: React.ReactNode }) { + return ( +
e.target === e.currentTarget && onClose()} + > +
+

{title}

+ {children} +
+
+ ); +} + +function LlmField({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ + {children} +
+ ); +} + +function ModalActions({ onClose, onSubmit, busy }: { onClose: () => void; onSubmit: () => void; busy: boolean }) { + return ( +
+ + +
+ ); +} diff --git a/ops/src/pages/admin/SummaryPage.tsx b/ops/src/pages/admin/SummaryPage.tsx new file mode 100644 index 0000000..c608a7b --- /dev/null +++ b/ops/src/pages/admin/SummaryPage.tsx @@ -0,0 +1,55 @@ +import { useEffect, useState } from 'react'; +import { fetchAdminSummary, type AdminSummary } from '../../api/admin'; + +function yuan(cents: number) { + return `¥${(cents / 100).toFixed(2)}`; +} + +export function SummaryPage() { + const [summary, setSummary] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + void fetchAdminSummary() + .then((r) => setSummary(r.summary)) + .catch((err) => setError(err instanceof Error ? err.message : '加载失败')); + }, []); + + if (error) return

{error}

; + if (!summary) return

加载中…

; + + return ( +
+
+
+

总用户

+ {summary.totalUsers} +
+
+

活跃用户

+ {summary.activeUsers} +
+
+

平台余额总计

+ {yuan(summary.totalBalanceCents)} +
+
+ + {summary.llm ? ( +
+

LLM 状态

+

已配置密钥:{summary.llm.keyCount} 条

+

当前选中:{summary.llm.selectedKeyName ?? '(无)'}

+

全局模型:{summary.llm.globalModel ?? '(未设置)'}

+
+ ) : ( +
+

LLM 服务未启用

+
+ )} +
+ ); +} diff --git a/ops/src/pages/admin/UsersPage.tsx b/ops/src/pages/admin/UsersPage.tsx new file mode 100644 index 0000000..05a8396 --- /dev/null +++ b/ops/src/pages/admin/UsersPage.tsx @@ -0,0 +1,389 @@ +import { useEffect, useRef, useState } from 'react'; +import { + fetchAdminUsers, + patchAdminUser, + rechargeUser, + createAdminUser, + type AdminUser, +} from '../../api/admin'; + +type EditTarget = { user: AdminUser; mode: 'edit' | 'recharge' | 'create' }; + +const PAGE_SIZE = 20; + +export function UsersPage() { + const [users, setUsers] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [totalPages, setTotalPages] = useState(1); + const [search, setSearch] = useState(''); + const [roleFilter, setRoleFilter] = useState(''); + const [statusFilter, setStatusFilter] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [editTarget, setEditTarget] = useState(null); + + const load = async (p = page) => { + setLoading(true); + setError(null); + try { + const result = await fetchAdminUsers({ + page: p, + pageSize: PAGE_SIZE, + search: search.trim() || undefined, + role: roleFilter || undefined, + status: statusFilter || undefined, + }); + setUsers(result.users); + setTotal(result.total); + setTotalPages(result.totalPages); + setPage(p); + } catch (err) { + setError(err instanceof Error ? err.message : '加载失败'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + void load(1); + }, []); + + const handleSearch = () => void load(1); + + return ( +
+ {/* Filters */} +
+ setSearch(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleSearch()} + style={{ flex: '1 1 200px', padding: '8px 12px', borderRadius: 999, border: '1px solid #d6d0c3' }} + /> + + + + + 共 {total} 个用户 +
+ + {error ?

{error}

: null} + + {/* Table */} +
+ + + + {['用户名', '昵称', '角色', '状态', '余额', '注册时间', '操作'].map((h) => ( + + ))} + + + + {users.map((u) => ( + + + + + + + + + + ))} + {users.length === 0 && !loading ? ( + + + + ) : null} + +
+ {h} +
+ {u.username} + {u.displayName} + + {u.role} + + + + {u.status} + + {yuan(u.balanceCents)} + {u.createdAt ? new Date(u.createdAt).toLocaleDateString('zh-CN') : '—'} + +
+ + +
+
+ 暂无用户 +
+
+ + {/* Pagination */} + {totalPages > 1 ? ( +
+ + + 第 {page} / {totalPages} 页 + + +
+ ) : null} + + {/* Modal */} + {editTarget ? ( + setEditTarget(null)} + onSuccess={() => { + setEditTarget(null); + void load(page); + }} + /> + ) : null} +
+ ); +} + +function EditModal({ + target, + onClose, + onSuccess, +}: { + target: EditTarget; + onClose: () => void; + onSuccess: () => void; +}) { + const { user, mode } = target; + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + // edit fields + const [role, setRole] = useState(user.role); + const [status, setStatus] = useState(user.status ?? 'active'); + const [displayName, setDisplayName] = useState(user.displayName); + const [password, setPassword] = useState(''); + + // create fields + const [newUsername, setNewUsername] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [newDisplayName, setNewDisplayName] = useState(''); + const [newRole, setNewRole] = useState('user'); + + // recharge fields + const [amountYuan, setAmountYuan] = useState(''); + const [note, setNote] = useState(''); + + const dialogRef = useRef(null); + + const handleSubmit = async () => { + setBusy(true); + setError(null); + try { + if (mode === 'create') { + if (!newUsername.trim() || !newPassword.trim()) { + setError('用户名和密码必填'); + return; + } + await createAdminUser({ username: newUsername.trim(), password: newPassword, displayName: newDisplayName.trim() || undefined, role: newRole }); + } else if (mode === 'edit') { + const patch: Record = {}; + if (role !== user.role) patch.role = role; + if (status !== user.status) patch.status = status; + if (displayName !== user.displayName) patch.displayName = displayName; + if (password) patch.password = password; + if (Object.keys(patch).length === 0) { onClose(); return; } + await patchAdminUser(user.id, patch); + } else { + const cents = Math.round(parseFloat(amountYuan) * 100); + if (!cents || cents <= 0) { setError('请输入有效金额'); return; } + await rechargeUser(user.id, cents, note.trim()); + } + onSuccess(); + } catch (err) { + setError(err instanceof Error ? err.message : '操作失败'); + } finally { + setBusy(false); + } + }; + + const titles: Record = { + edit: `编辑用户:${user.username}`, + recharge: `充值:${user.username}(当前 ${yuan(user.balanceCents)})`, + create: '新建用户', + }; + + return ( +
e.target === e.currentTarget && onClose()} + > +
+

{titles[mode]}

+ + {mode === 'create' ? ( + <> + + setNewUsername(e.target.value)} placeholder="login username" /> + + + setNewPassword(e.target.value)} /> + + + setNewDisplayName(e.target.value)} /> + + + + + + ) : mode === 'edit' ? ( + <> + + setDisplayName(e.target.value)} /> + + + + + + + + + setPassword(e.target.value)} placeholder="留空不修改" /> + + + ) : ( + <> + + setAmountYuan(e.target.value)} + placeholder="例:5.00" + /> + + + setNote(e.target.value)} placeholder="管理员赠送" /> + + + )} + + {error ?

{error}

: null} + +
+ + +
+
+
+ ); +} + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ +
+ {children} +
+
+ ); +} + +function yuan(cents: number) { + return `¥${(cents / 100).toFixed(2)}`; +}