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:
@@ -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