Extract memind_adm admin server, add local dev tooling, and remove image-generation.

Split platform admin and ops APIs into standalone admin-server.mjs with network guards; simplify billing to RMB token pricing, refactor user auth, and add rsync deploy plus local-test scripts and docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Your Name
2026-06-17 16:39:39 -07:00
parent ab0718938e
commit b0f5d6a51c
98 changed files with 5394 additions and 3010 deletions
+40 -12
View File
@@ -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 (
<RequireOps>
<Routes>
<Route element={<OpsLayout />}>
<Route index element={<ReviewPage />} />
<Route path="reports" element={<ReportsPage />} />
<Route path="featured" element={<FeaturedPage />} />
<Route path="creators" element={<CreatorsPage />} />
<Route path="analytics" element={<AnalyticsPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
</Routes>
</RequireOps>
<Routes>
{/* Plaza ops console */}
<Route
element={
<RequireOps>
<OpsLayout />
</RequireOps>
}
>
<Route index element={<ReviewPage />} />
<Route path="reports" element={<ReportsPage />} />
<Route path="featured" element={<FeaturedPage />} />
<Route path="creators" element={<CreatorsPage />} />
<Route path="analytics" element={<AnalyticsPage />} />
</Route>
{/* Super-admin console */}
<Route
path="admin"
element={
<RequireAdmin>
<AdminLayout />
</RequireAdmin>
}
>
<Route index element={<SummaryPage />} />
<Route path="users" element={<UsersPage />} />
<Route path="llm" element={<LlmPage />} />
<Route path="billing" element={<BillingPage />} />
<Route path="*" element={<Navigate to="/admin" replace />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
}
+207
View File
@@ -0,0 +1,207 @@
async function adminFetch<T>(path: string, init?: RequestInit): Promise<T> {
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' });
}
+38
View File
@@ -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 (
<div className="layout">
<header>
<h1></h1>
<p style={{ color: '#68716c' }}> LLM </p>
</header>
<nav className="nav">
<NavLink
to="/"
style={{ opacity: 0.6 }}
>
</NavLink>
{links.map((link) => (
<NavLink
key={link.to}
to={link.to}
end={link.end}
className={({ isActive }) => (isActive ? 'active' : undefined)}
>
{link.label}
</NavLink>
))}
</nav>
<Outlet />
</div>
);
}
+16 -4
View File
@@ -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 (
<div className="layout">
<header>
@@ -16,16 +19,25 @@ export function OpsLayout() {
<p style={{ color: '#68716c' }}></p>
</header>
<nav className="nav">
{links.map((link) => (
{opsLinks.map((link) => (
<NavLink
key={link.to}
to={link.to}
end={link.to === '/'}
end={link.end}
className={({ isActive }) => (isActive ? 'active' : undefined)}
>
{link.label}
</NavLink>
))}
{user?.role === 'admin' ? (
<NavLink
to="/admin"
className={({ isActive }) => (isActive ? 'active' : undefined)}
style={{ marginLeft: 'auto', opacity: 0.75 }}
>
</NavLink>
) : null}
</nav>
<Outlet />
</div>
+31
View File
@@ -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 <p></p>;
if (!user) {
return (
<div className="card">
<h2></h2>
<p> MindSpace 访</p>
<a className="btn" href={mindSpaceLoginUrl()}>
</a>
</div>
);
}
if (user.role !== 'admin') {
return (
<div className="card">
<h2></h2>
<p> role = admin ({user.username}) </p>
</div>
);
}
return children;
}
+23 -19
View File
@@ -1,45 +1,48 @@
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<string | null>(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 <p></p>;
if (state === 'denied') {
if (authLoading || state === 'loading') return <p></p>;
if (!user) {
return (
<div className="card">
<h2></h2>
<p> MindSpace ops_rolereviewer / editor / ops_admin</p>
<p> MindSpace Ops 使 127.0.0.1 *.localhost</p>
<p style={{ color: '#68716c' }}>
Ops <code>http://127.0.0.1:3002/ops/</code>
</p>
<a className="btn" href={mindSpaceLoginUrl()}>
</a>
</div>
);
}
if (state === 'forbidden') {
return (
<div className="card">
@@ -51,5 +54,6 @@ export function RequireOps({ children }: { children: React.ReactNode }) {
</div>
);
}
return children;
}
+17
View File
@@ -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;
}
+30
View File
@@ -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<AuthState>({ loading: true, user: null });
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [state, setState] = useState<AuthState>({ 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 <AuthContext.Provider value={state}>{children}</AuthContext.Provider>;
}
export const useAuth = () => useContext(AuthContext);
+4 -1
View File
@@ -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(
<StrictMode>
<BrowserRouter basename="/ops">
<App />
<AuthProvider>
<App />
</AuthProvider>
</BrowserRouter>
</StrictMode>,
);
+181
View File
@@ -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<Tab>('ledger');
return (
<div className="grid">
<div className="card" style={{ display: 'flex', gap: 8 }}>
<button
type="button"
className={tab === 'ledger' ? 'btn' : 'btn secondary'}
onClick={() => setTab('ledger')}
>
</button>
<button
type="button"
className={tab === 'usage' ? 'btn' : 'btn secondary'}
onClick={() => setTab('usage')}
>
</button>
</div>
{tab === 'ledger' ? <LedgerTab /> : <UsageTab />}
</div>
);
}
function LedgerTab() {
const [entries, setEntries] = useState<LedgerEntry[]>([]);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [total, setTotal] = useState(0);
const [error, setError] = useState<string | null>(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 ? <p className="alert">{error}</p> : null}
<div className="card" style={{ overflowX: 'auto', padding: 0 }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ background: '#f5f0e5', borderBottom: '1px solid #d6d0c3' }}>
{['时间', '用户', '类型', '金额', '备注'].map((h) => (
<th key={h} style={{ padding: '10px 12px', textAlign: 'left', fontWeight: 600 }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{entries.map((e) => (
<tr key={e.id} style={{ borderBottom: '1px solid #ebe4d6' }}>
<td style={{ padding: '10px 12px', whiteSpace: 'nowrap', color: '#68716c' }}>{fmtTime(e.createdAt)}</td>
<td style={{ padding: '10px 12px' }}><code style={{ fontSize: 11 }}>{e.username ?? e.userId}</code></td>
<td style={{ padding: '10px 12px' }}>{e.type}</td>
<td
style={{
padding: '10px 12px',
color: e.amountCents >= 0 ? '#2f6f57' : '#b42318',
fontVariantNumeric: 'tabular-nums',
}}
>
{e.amountCents >= 0 ? '+' : ''}{yuan(e.amountCents)}
</td>
<td style={{ padding: '10px 12px', color: '#68716c', maxWidth: 240, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{e.note || '—'}</td>
</tr>
))}
{entries.length === 0 && !loading ? (
<tr><td colSpan={5} style={{ padding: 24, textAlign: 'center', color: '#68716c' }}></td></tr>
) : null}
</tbody>
</table>
</div>
<Pagination page={page} totalPages={totalPages} total={total} unit="条" onGo={load} />
</>
);
}
function UsageTab() {
const [records, setRecords] = useState<UsageRecord[]>([]);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [total, setTotal] = useState(0);
const [error, setError] = useState<string | null>(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 ? <p className="alert">{error}</p> : null}
<div className="card" style={{ overflowX: 'auto', padding: 0 }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ background: '#f5f0e5', borderBottom: '1px solid #d6d0c3' }}>
{['时间', '用户', 'Provider', '模型', '输入', '输出', '费用'].map((h) => (
<th key={h} style={{ padding: '10px 12px', textAlign: 'left', fontWeight: 600 }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{records.map((r) => (
<tr key={r.id} style={{ borderBottom: '1px solid #ebe4d6' }}>
<td style={{ padding: '10px 12px', whiteSpace: 'nowrap', color: '#68716c' }}>{fmtTime(r.createdAt)}</td>
<td style={{ padding: '10px 12px' }}><code style={{ fontSize: 11 }}>{r.username ?? r.userId}</code></td>
<td style={{ padding: '10px 12px' }}>{r.provider}</td>
<td style={{ padding: '10px 12px', maxWidth: 160, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.model}</td>
<td style={{ padding: '10px 12px', fontVariantNumeric: 'tabular-nums' }}>{r.inputTokens.toLocaleString()}</td>
<td style={{ padding: '10px 12px', fontVariantNumeric: 'tabular-nums' }}>{r.outputTokens.toLocaleString()}</td>
<td style={{ padding: '10px 12px', color: '#b54708', fontVariantNumeric: 'tabular-nums' }}>{yuan(r.costCents)}</td>
</tr>
))}
{records.length === 0 && !loading ? (
<tr><td colSpan={7} style={{ padding: 24, textAlign: 'center', color: '#68716c' }}></td></tr>
) : null}
</tbody>
</table>
</div>
<Pagination page={page} totalPages={totalPages} total={total} unit="条" onGo={load} />
</>
);
}
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 (
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<button type="button" className="btn secondary" disabled={page <= 1} onClick={() => onGo(page - 1)}></button>
<span style={{ fontSize: 13, color: '#68716c' }}> {page} / {totalPages} {total} {unit}</span>
<button type="button" className="btn secondary" disabled={page >= totalPages} onClick={() => onGo(page + 1)}></button>
</div>
);
}
+354
View File
@@ -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<LlmKey[]>([]);
const [globalModel, setGlobalModel] = useState<string>('');
const [globalModelInput, setGlobalModelInput] = useState('');
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [testResults, setTestResults] = useState<Record<string, { ok: boolean; msg: string }>>({});
const [showCreate, setShowCreate] = useState(false);
const [editKey, setEditKey] = useState<LlmKey | null>(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 (
<div className="grid">
{error ? <p className="alert">{error}</p> : null}
{/* Global model */}
<div className="card grid">
<h3 style={{ margin: 0 }}></h3>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
<input
value={globalModelInput}
onChange={(e) => setGlobalModelInput(e.target.value)}
placeholder="如 deepseek-chat"
style={{ flex: '1 1 200px', padding: '8px 12px', borderRadius: 999, border: '1px solid #d6d0c3' }}
/>
<button type="button" className="btn" onClick={() => void handleSaveGlobal()} disabled={busy || globalModelInput === globalModel}>
</button>
</div>
{globalModel ? (
<p style={{ fontSize: 12, color: '#68716c', margin: 0 }}>{globalModel}</p>
) : (
<p style={{ fontSize: 12, color: '#68716c', margin: 0 }}>使 model</p>
)}
</div>
{/* Keys list */}
<div className="card grid">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h3 style={{ margin: 0 }}>API {keys.length}</h3>
<div style={{ display: 'flex', gap: 8 }}>
<button type="button" className="btn secondary" onClick={() => void handleSync()} disabled={busy}>
Providers
</button>
<button type="button" className="btn" onClick={() => setShowCreate(true)}>
</button>
</div>
</div>
{keys.length === 0 ? (
<p style={{ color: '#68716c' }}></p>
) : (
keys.map((key) => (
<div
key={key.id}
style={{
border: `1px solid ${key.isSelected ? '#2f6f57' : '#d6d0c3'}`,
borderRadius: 12,
padding: 12,
background: key.isSelected ? '#f0f9f4' : undefined,
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', flexWrap: 'wrap', gap: 8 }}>
<div>
<strong>{key.name}</strong>
{key.isSelected ? (
<span style={{ marginLeft: 8, fontSize: 11, color: '#2f6f57', fontWeight: 600 }}> </span>
) : null}
<p style={{ margin: '4px 0 0', fontSize: 12, color: '#68716c' }}>
{key.provider} {key.model ? `· ${key.model}` : ''}
{key.models?.length ? ` · ${key.models.join(', ')}` : ''}
</p>
</div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{!key.isSelected ? (
<button
type="button"
className="btn"
style={{ padding: '4px 10px', fontSize: 12 }}
onClick={() => void handleSelect(key.id)}
disabled={busy}
>
</button>
) : null}
<button
type="button"
className="btn secondary"
style={{ padding: '4px 10px', fontSize: 12 }}
onClick={() => void handleTest(key)}
>
</button>
<button
type="button"
className="btn secondary"
style={{ padding: '4px 10px', fontSize: 12 }}
onClick={() => setEditKey(key)}
>
</button>
<button
type="button"
className="btn danger"
style={{ padding: '4px 10px', fontSize: 12 }}
onClick={() => void handleDelete(key)}
disabled={busy}
>
</button>
</div>
</div>
{testResults[key.id] ? (
<p
style={{
margin: '8px 0 0',
fontSize: 12,
color: testResults[key.id].ok ? '#2f6f57' : '#b42318',
}}
>
{testResults[key.id].msg}
</p>
) : null}
</div>
))
)}
</div>
{showCreate ? (
<CreateKeyModal
onClose={() => setShowCreate(false)}
onSuccess={() => { setShowCreate(false); void load(); }}
/>
) : null}
{editKey ? (
<EditKeyModal
llmKey={editKey}
onClose={() => setEditKey(null)}
onSuccess={() => { setEditKey(null); void load(); }}
/>
) : null}
</div>
);
}
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<string | null>(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 (
<Modal title="添加 API 密钥" onClose={onClose}>
<div className="grid">
<LlmField label="名称"><input value={name} onChange={(e) => setName(e.target.value)} placeholder="DeepSeek Default" /></LlmField>
<LlmField label="Provider">
<select value={provider} onChange={(e) => setProvider(e.target.value)}>
<option value="openai">openai</option>
<option value="deepseek">deepseek</option>
<option value="anthropic">anthropic</option>
<option value="ollama">ollama</option>
<option value="openrouter">openrouter</option>
</select>
</LlmField>
<LlmField label="API Key"><input value={apiKey} onChange={(e) => setApiKey(e.target.value)} type="password" placeholder="sk-..." /></LlmField>
<LlmField label="默认模型(可选)"><input value={model} onChange={(e) => setModel(e.target.value)} placeholder="deepseek-chat" /></LlmField>
<LlmField label="Base URL(可选)"><input value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} placeholder="https://api.deepseek.com/v1" /></LlmField>
{error ? <p className="alert">{error}</p> : null}
<ModalActions onClose={onClose} onSubmit={() => void handleSubmit()} busy={busy} />
</div>
</Modal>
);
}
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<string | null>(null);
const handleSubmit = async () => {
setBusy(true);
try {
const patch: Record<string, string> = {};
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 (
<Modal title={`编辑:${llmKey.name}`} onClose={onClose}>
<div className="grid">
<LlmField label="名称"><input value={name} onChange={(e) => setName(e.target.value)} /></LlmField>
<LlmField label="新 API Key(留空不修改)"><input value={apiKey} onChange={(e) => setApiKey(e.target.value)} type="password" placeholder="留空不修改" /></LlmField>
<LlmField label="默认模型"><input value={model} onChange={(e) => setModel(e.target.value)} /></LlmField>
{error ? <p className="alert">{error}</p> : null}
<ModalActions onClose={onClose} onSubmit={() => void handleSubmit()} busy={busy} />
</div>
</Modal>
);
}
function Modal({ title, onClose, children }: { title: string; onClose: () => void; children: React.ReactNode }) {
return (
<div
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,.35)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 100 }}
onClick={(e) => e.target === e.currentTarget && onClose()}
>
<div className="card grid" style={{ width: '100%', maxWidth: 480, margin: 16, maxHeight: '90vh', overflowY: 'auto' }}>
<h3 style={{ margin: 0 }}>{title}</h3>
{children}
</div>
</div>
);
}
function LlmField({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div style={{ display: 'grid', gap: 4 }}>
<label style={{ fontSize: 12, color: '#68716c' }}>{label}</label>
{children}
</div>
);
}
function ModalActions({ onClose, onSubmit, busy }: { onClose: () => void; onSubmit: () => void; busy: boolean }) {
return (
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button type="button" className="btn secondary" onClick={onClose} disabled={busy}></button>
<button type="button" className="btn" onClick={onSubmit} disabled={busy}>{busy ? '处理中…' : '确认'}</button>
</div>
);
}
+55
View File
@@ -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<AdminSummary | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
void fetchAdminSummary()
.then((r) => setSummary(r.summary))
.catch((err) => setError(err instanceof Error ? err.message : '加载失败'));
}, []);
if (error) return <p className="alert">{error}</p>;
if (!summary) return <p></p>;
return (
<div className="grid">
<div
className="card"
style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 12 }}
>
<div>
<p style={{ color: '#68716c', marginBottom: 4 }}></p>
<strong style={{ fontSize: 28 }}>{summary.totalUsers}</strong>
</div>
<div>
<p style={{ color: '#68716c', marginBottom: 4 }}></p>
<strong style={{ fontSize: 28 }}>{summary.activeUsers}</strong>
</div>
<div>
<p style={{ color: '#68716c', marginBottom: 4 }}></p>
<strong style={{ fontSize: 28 }}>{yuan(summary.totalBalanceCents)}</strong>
</div>
</div>
{summary.llm ? (
<div className="card">
<h3 style={{ marginTop: 0 }}>LLM </h3>
<p>{summary.llm.keyCount} </p>
<p>{summary.llm.selectedKeyName ?? '(无)'}</p>
<p>{summary.llm.globalModel ?? '(未设置)'}</p>
</div>
) : (
<div className="card">
<p style={{ color: '#68716c' }}>LLM </p>
</div>
)}
</div>
);
}
+389
View File
@@ -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<AdminUser[]>([]);
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<string | null>(null);
const [editTarget, setEditTarget] = useState<EditTarget | null>(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 (
<div className="grid">
{/* Filters */}
<div className="card" style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
<input
type="search"
placeholder="用户名 / 昵称 / 邮箱"
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
style={{ flex: '1 1 200px', padding: '8px 12px', borderRadius: 999, border: '1px solid #d6d0c3' }}
/>
<select
value={roleFilter}
onChange={(e) => setRoleFilter(e.target.value)}
style={{ padding: '8px 12px', borderRadius: 999, border: '1px solid #d6d0c3' }}
>
<option value=""></option>
<option value="user">user</option>
<option value="admin">admin</option>
</select>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
style={{ padding: '8px 12px', borderRadius: 999, border: '1px solid #d6d0c3' }}
>
<option value=""></option>
<option value="active">active</option>
<option value="suspended">suspended</option>
</select>
<button type="button" className="btn" onClick={handleSearch} disabled={loading}>
</button>
<button
type="button"
className="btn secondary"
onClick={() =>
setEditTarget({
user: { id: '', username: '', slug: '', email: '', displayName: '', role: 'user', status: 'active', balanceCents: 0, createdAt: '' },
mode: 'create',
})
}
>
</button>
<span style={{ color: '#68716c', fontSize: 13, marginLeft: 'auto' }}> {total} </span>
</div>
{error ? <p className="alert">{error}</p> : null}
{/* Table */}
<div className="card" style={{ overflowX: 'auto', padding: 0 }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ background: '#f5f0e5', borderBottom: '1px solid #d6d0c3' }}>
{['用户名', '昵称', '角色', '状态', '余额', '注册时间', '操作'].map((h) => (
<th key={h} style={{ padding: '10px 12px', textAlign: 'left', fontWeight: 600 }}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id} style={{ borderBottom: '1px solid #ebe4d6' }}>
<td style={{ padding: '10px 12px' }}>
<code style={{ fontSize: 12 }}>{u.username}</code>
</td>
<td style={{ padding: '10px 12px' }}>{u.displayName}</td>
<td style={{ padding: '10px 12px' }}>
<span
style={{
padding: '2px 8px',
borderRadius: 999,
fontSize: 11,
background: u.role === 'admin' ? '#2f6f57' : '#ebe4d6',
color: u.role === 'admin' ? 'white' : 'inherit',
}}
>
{u.role}
</span>
</td>
<td style={{ padding: '10px 12px' }}>
<span style={{ color: u.status === 'suspended' ? '#b42318' : '#2f6f57', fontSize: 12 }}>
{u.status}
</span>
</td>
<td style={{ padding: '10px 12px' }}>{yuan(u.balanceCents)}</td>
<td style={{ padding: '10px 12px', color: '#68716c', whiteSpace: 'nowrap' }}>
{u.createdAt ? new Date(u.createdAt).toLocaleDateString('zh-CN') : '—'}
</td>
<td style={{ padding: '10px 12px' }}>
<div style={{ display: 'flex', gap: 6 }}>
<button
type="button"
className="btn secondary"
style={{ padding: '4px 10px', fontSize: 12 }}
onClick={() => setEditTarget({ user: u, mode: 'edit' })}
>
</button>
<button
type="button"
className="btn secondary"
style={{ padding: '4px 10px', fontSize: 12 }}
onClick={() => setEditTarget({ user: u, mode: 'recharge' })}
>
</button>
</div>
</td>
</tr>
))}
{users.length === 0 && !loading ? (
<tr>
<td colSpan={7} style={{ padding: 24, textAlign: 'center', color: '#68716c' }}>
</td>
</tr>
) : null}
</tbody>
</table>
</div>
{/* Pagination */}
{totalPages > 1 ? (
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<button
type="button"
className="btn secondary"
disabled={page <= 1}
onClick={() => void load(page - 1)}
>
</button>
<span style={{ fontSize: 13, color: '#68716c' }}>
{page} / {totalPages}
</span>
<button
type="button"
className="btn secondary"
disabled={page >= totalPages}
onClick={() => void load(page + 1)}
>
</button>
</div>
) : null}
{/* Modal */}
{editTarget ? (
<EditModal
target={editTarget}
onClose={() => setEditTarget(null)}
onSuccess={() => {
setEditTarget(null);
void load(page);
}}
/>
) : null}
</div>
);
}
function EditModal({
target,
onClose,
onSuccess,
}: {
target: EditTarget;
onClose: () => void;
onSuccess: () => void;
}) {
const { user, mode } = target;
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(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<HTMLDivElement>(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<string, string> = {};
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<EditTarget['mode'], string> = {
edit: `编辑用户:${user.username}`,
recharge: `充值:${user.username}(当前 ${yuan(user.balanceCents)}`,
create: '新建用户',
};
return (
<div
style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,.35)',
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 100,
}}
onClick={(e) => e.target === e.currentTarget && onClose()}
>
<div
ref={dialogRef}
className="card grid"
style={{ width: '100%', maxWidth: 480, margin: 16, maxHeight: '90vh', overflowY: 'auto' }}
>
<h3 style={{ margin: 0 }}>{titles[mode]}</h3>
{mode === 'create' ? (
<>
<Field label="用户名">
<input value={newUsername} onChange={(e) => setNewUsername(e.target.value)} placeholder="login username" />
</Field>
<Field label="密码">
<input type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} />
</Field>
<Field label="昵称(可选)">
<input value={newDisplayName} onChange={(e) => setNewDisplayName(e.target.value)} />
</Field>
<Field label="角色">
<select value={newRole} onChange={(e) => setNewRole(e.target.value)}>
<option value="user">user</option>
<option value="admin">admin</option>
</select>
</Field>
</>
) : mode === 'edit' ? (
<>
<Field label="昵称">
<input value={displayName} onChange={(e) => setDisplayName(e.target.value)} />
</Field>
<Field label="角色">
<select value={role} onChange={(e) => setRole(e.target.value)}>
<option value="user">user</option>
<option value="admin">admin</option>
</select>
</Field>
<Field label="状态">
<select value={status} onChange={(e) => setStatus(e.target.value)}>
<option value="active">active</option>
<option value="suspended">suspended</option>
</select>
</Field>
<Field label="新密码(不改留空)">
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder="留空不修改" />
</Field>
</>
) : (
<>
<Field label="充值金额(元)">
<input
type="number"
min="0.01"
step="0.01"
value={amountYuan}
onChange={(e) => setAmountYuan(e.target.value)}
placeholder="例:5.00"
/>
</Field>
<Field label="备注">
<input value={note} onChange={(e) => setNote(e.target.value)} placeholder="管理员赠送" />
</Field>
</>
)}
{error ? <p className="alert">{error}</p> : null}
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button type="button" className="btn secondary" onClick={onClose} disabled={busy}></button>
<button type="button" className="btn" onClick={() => void handleSubmit()} disabled={busy}>
{busy ? '处理中…' : '确认'}
</button>
</div>
</div>
</div>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div style={{ display: 'grid', gap: 4 }}>
<label style={{ fontSize: 12, color: '#68716c' }}>{label}</label>
<div
style={{
display: 'contents',
}}
// apply common input styles via CSS
>
{children}
</div>
</div>
);
}
function yuan(cents: number) {
return `¥${(cents / 100).toFixed(2)}`;
}