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:
+40
-12
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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_role(reviewer / 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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>,
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)}`;
|
||||
}
|
||||
Reference in New Issue
Block a user