chore: checkpoint admin restore state

This commit is contained in:
john
2026-06-30 20:26:33 +08:00
parent f2172322fd
commit f9a7584ad4
63 changed files with 7555 additions and 1030 deletions
+14 -7
View File
@@ -13,25 +13,32 @@ type NavSection = {
const NAV_SECTIONS: NavSection[] = [
{
items: [{ to: '/admin', label: '概览', end: true }],
items: [{ to: '/', label: '概览', end: true }],
},
{
label: '用户与账户',
items: [{ to: '/admin/users', label: '用户管理' }],
items: [{ to: '/users', label: '用户管理' }],
},
{
label: '计费',
items: [{ to: '/admin/billing', label: '计费中心', end: false }],
items: [{ to: '/billing', label: '计费中心', end: false }],
},
{
label: '平台配置',
items: [
{ to: '/admin/capabilities', label: '能力' },
{ to: '/admin/skills', label: '技能' },
{ to: '/admin/policies', label: '策略' },
{ to: '/admin/providers', label: 'LLM Provider' },
{ to: '/wechat', label: '服务号' },
{ to: '/mindspace', label: 'MindSpace 配置' },
{ to: '/capabilities', label: '能力' },
{ to: '/skills', label: '技能' },
{ to: '/policies', label: '策略' },
{ to: '/providers', label: '统一模型中心' },
{ to: '/blocked-words', label: '违禁词管理' },
],
},
{
label: 'Plaza',
items: [{ to: '/ops', label: '运营后台', end: false }],
},
];
export function AdminNav() {
+600 -4
View File
@@ -1,7 +1,19 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { listAdminLedger, listAdminUsage, rechargeUser } from '../../api/client';
import {
cancelUserSubscription,
createSubscriptionPlan,
deleteSubscriptionPlan,
grantUserSubscription,
listAdminLedger,
listAdminSubscriptions,
listAdminUsage,
listSubscriptionPlans,
rechargeUser,
syncSubscriptionPlansToProduction,
updateSubscriptionPlan,
} from '../../api/client';
import type { PagedResult } from '../../api/client';
import type { AdminUserRow, LedgerEntry, UsageRecord } from '../../types';
import type { AdminSubscription, AdminUserRow, LedgerEntry, PlanDefinition, UsageRecord } from '../../types';
import { Pagination } from '../../components/Pagination';
import { useAdminUsers } from '../hooks/useAdminUsers';
import { formatTime, formatYuan } from '../utils/format';
@@ -90,9 +102,10 @@ function UserCombobox({
);
}
type TabKey = 'recharge' | 'usage' | 'ledger';
type TabKey = 'recharge' | 'usage' | 'ledger' | 'subscriptions';
const TABS: { key: TabKey; label: string }[] = [
{ key: 'subscriptions', label: '订阅记录' },
{ key: 'recharge', label: '充值' },
{ key: 'usage', label: '用量记录' },
{ key: 'ledger', label: '资金流水' },
@@ -326,8 +339,590 @@ function LedgerTab() {
);
}
const PLAN_TIER_LABEL: Record<string, string> = {
basic: '基础',
standard: '标准',
premium: '旗舰',
};
const EMPTY_PLAN_FORM = {
planType: '',
name: '',
priceCents: '0',
periodDays: '30',
periodTokens: '0',
periodImages: '0',
modelTier: 'basic',
overageRate: '1.00',
sortOrder: '0',
isActive: true,
description: '',
};
type PlanForm = typeof EMPTY_PLAN_FORM;
function planToForm(p: PlanDefinition): PlanForm {
return {
planType: p.planType,
name: p.name,
priceCents: String(p.priceCents),
periodDays: String(p.periodDays),
periodTokens: String(p.periodTokens),
periodImages: String(p.periodImages),
modelTier: p.modelTier,
overageRate: String(p.overageRate),
sortOrder: String(p.sortOrder),
isActive: p.isActive,
description: p.description ?? '',
};
}
function PlanFormModal({
initial,
onSave,
onClose,
}: {
initial: PlanForm;
onSave: (form: PlanForm) => Promise<void>;
onClose: () => void;
}) {
const [form, setForm] = useState<PlanForm>(initial);
const [saving, setSaving] = useState(false);
const [err, setErr] = useState<string | null>(null);
const isNew = !initial.planType;
const set = (key: keyof PlanForm, val: string | boolean) =>
setForm((s) => ({ ...s, [key]: val }));
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
setErr(null);
try {
await onSave(form);
} catch (ex) {
setErr(ex instanceof Error ? ex.message : '保存失败');
} finally {
setSaving(false);
}
};
return (
<div className="modal-backdrop" onClick={(e) => e.target === e.currentTarget && onClose()}>
<div className="modal-box" style={{ maxWidth: 520 }}>
<div className="modal-head">
<h3>{isNew ? '新建套餐' : `编辑套餐 · ${initial.name}`}</h3>
<button type="button" className="modal-close" onClick={onClose}>×</button>
</div>
{err && <p className="banner banner-error">{err}</p>}
<form className="admin-form plan-form" onSubmit={handleSubmit}>
{isNew && (
<label className="plan-form-row">
<span> <span className="muted">(plan_type)</span></span>
<input
placeholder="如 basic / pro_plus"
value={form.planType}
onChange={(e) => set('planType', e.target.value.toLowerCase().replace(/[^a-z0-9_]/g, ''))}
required
/>
</label>
)}
<label className="plan-form-row">
<span></span>
<input placeholder="如 专业版" value={form.name} onChange={(e) => set('name', e.target.value)} required />
</label>
<label className="plan-form-row">
<span></span>
<input type="number" min="0" step="1" value={form.priceCents}
onChange={(e) => set('priceCents', e.target.value)} />
<span className="plan-form-hint">¥{(Number(form.priceCents) / 100).toFixed(2)} / </span>
</label>
<label className="plan-form-row">
<span></span>
<input type="number" min="1" step="1" value={form.periodDays}
onChange={(e) => set('periodDays', e.target.value)} />
</label>
<label className="plan-form-row">
<span>Token </span>
<input type="number" min="0" step="10000" value={form.periodTokens}
onChange={(e) => set('periodTokens', e.target.value)} />
<span className="plan-form-hint">
{Number(form.periodTokens) === 0 ? '不限' : `${(Number(form.periodTokens) / 10000).toFixed(0)} 万 Token`}
</span>
</label>
<label className="plan-form-row">
<span></span>
<input type="number" min="0" step="1" value={form.periodImages}
onChange={(e) => set('periodImages', e.target.value)} />
<span className="plan-form-hint">
{Number(form.periodImages) === 0 ? '不限' : `${form.periodImages} 张/周期`}
</span>
</label>
<label className="plan-form-row">
<span></span>
<select className="admin-select" value={form.modelTier}
onChange={(e) => set('modelTier', e.target.value)}>
<option value="basic">basic</option>
<option value="standard">standard</option>
<option value="premium">premium</option>
</select>
</label>
<label className="plan-form-row">
<span></span>
<input type="number" min="0" max="2" step="0.01" value={form.overageRate}
onChange={(e) => set('overageRate', e.target.value)} />
<span className="plan-form-hint">1.0 = </span>
</label>
<label className="plan-form-row">
<span></span>
<input type="number" step="1" value={form.sortOrder}
onChange={(e) => set('sortOrder', e.target.value)} />
</label>
<label className="plan-form-row">
<span></span>
<input placeholder="套餐说明文字" value={form.description}
onChange={(e) => set('description', e.target.value)} />
</label>
<label className="plan-form-row plan-form-check">
<input type="checkbox" checked={form.isActive}
onChange={(e) => set('isActive', e.target.checked)} />
<span></span>
</label>
<div className="plan-form-actions">
<button type="button" className="admin-btn-secondary" onClick={onClose}></button>
<button type="submit" className="send-btn" disabled={saving}>
{saving ? '保存中…' : '保存套餐'}
</button>
</div>
</form>
</div>
</div>
);
}
function PlanCatalogSection({
plans,
onPlansChange,
}: {
plans: PlanDefinition[];
onPlansChange: () => void;
}) {
const [editing, setEditing] = useState<PlanForm | null>(null);
const [err, setErr] = useState<string | null>(null);
const [msg, setMsg] = useState<string | null>(null);
const [deleting, setDeleting] = useState<string | null>(null);
const [syncingProd, setSyncingProd] = useState(false);
const openNew = () => setEditing({ ...EMPTY_PLAN_FORM });
const openEdit = (p: PlanDefinition) => setEditing(planToForm(p));
const closeModal = () => setEditing(null);
const handleSave = async (form: PlanForm) => {
const data = {
name: form.name,
priceCents: Number(form.priceCents),
periodDays: Number(form.periodDays),
periodTokens: Number(form.periodTokens),
periodImages: Number(form.periodImages),
modelTier: form.modelTier,
overageRate: Number(form.overageRate),
sortOrder: Number(form.sortOrder),
isActive: form.isActive,
description: form.description || null,
};
const isNew = !editing?.planType || editing.planType !== form.planType;
if (isNew) {
const result = await createSubscriptionPlan(form.planType, data);
setMsg(
result.sync?.ok === false
? `已创建套餐「${form.name}」,但生产后台同步失败:${result.sync.message}`
: result.sync?.message
? `已创建套餐「${form.name}」,${result.sync.message}`
: `已创建套餐「${form.name}`,
);
} else {
const result = await updateSubscriptionPlan(form.planType, data);
setMsg(
result.sync?.ok === false
? `已更新套餐「${form.name}」,但生产后台同步失败:${result.sync.message}`
: result.sync?.message
? `已更新套餐「${form.name}」,${result.sync.message}`
: `已更新套餐「${form.name}`,
);
}
closeModal();
onPlansChange();
};
const handleDelete = async (p: PlanDefinition) => {
if (!window.confirm(`确认删除套餐「${p.name}」?已购用户不受影响,但新用户无法选择此套餐。`)) return;
setDeleting(p.planType);
setErr(null);
try {
const result = await deleteSubscriptionPlan(p.planType);
setMsg(
result.sync?.ok === false
? `已删除套餐「${p.name}」,但生产后台同步失败:${result.sync.message}`
: result.sync?.message
? `已删除套餐「${p.name}」,${result.sync.message}`
: `已删除套餐「${p.name}`,
);
onPlansChange();
} catch (ex) {
setErr(ex instanceof Error ? ex.message : '删除失败');
} finally {
setDeleting(null);
}
};
const handleSyncProduction = async () => {
setSyncingProd(true);
setErr(null);
try {
const result = await syncSubscriptionPlansToProduction();
if (result.ok) setMsg(result.message);
else setErr(result.message);
} catch (ex) {
setErr(ex instanceof Error ? ex.message : '同步生产后台失败');
} finally {
setSyncingProd(false);
}
};
return (
<section className="admin-card">
<div className="admin-card-head">
<h2></h2>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<button type="button" className="admin-btn-secondary" onClick={handleSyncProduction} disabled={syncingProd}>
{syncingProd ? '同步中…' : '同步生产后台'}
</button>
<button type="button" className="send-btn" onClick={openNew}>+ </button>
</div>
</div>
{err && <p className="banner banner-error">{err}</p>}
{msg && <p className="banner banner-info">{msg}</p>}
{plans.length === 0 ? (
<p className="muted"></p>
) : (
<div className="admin-table-wrap">
<table className="admin-table">
<thead>
<tr>
<th></th>
<th style={{ textAlign: 'right' }}></th>
<th style={{ textAlign: 'right' }}>Token </th>
<th style={{ textAlign: 'right' }}></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{plans.map((p) => (
<tr key={p.planType} style={{ opacity: p.isActive ? 1 : 0.5 }}>
<td>
<strong>{p.name}</strong>
<span className="muted"> ({p.planType})</span>
{p.description && <div className="plan-desc muted">{p.description}</div>}
</td>
<td className="billing-num">
{p.priceCents === 0 ? '免费' : `¥${formatYuan(p.priceCents)}/月`}
</td>
<td className="billing-num">
{p.periodTokens === 0 ? '不限' : `${(p.periodTokens / 10000).toFixed(0)}`}
</td>
<td className="billing-num">
{p.periodImages === 0 ? '不限' : `${p.periodImages}`}
</td>
<td>{PLAN_TIER_LABEL[p.modelTier] ?? p.modelTier}</td>
<td>{p.periodDays} </td>
<td>{p.overageRate === 1 ? '—' : `×${p.overageRate}`}</td>
<td>
<span className={`ledger-type-tag ledger-type-${p.isActive ? 'active' : 'cancelled'}`}>
{p.isActive ? '上架' : '下架'}
</span>
</td>
<td className="plan-actions">
<button type="button" className="admin-btn-secondary" onClick={() => openEdit(p)}></button>
{p.planType !== 'free' && (
<button type="button" className="admin-btn-danger"
disabled={deleting === p.planType}
onClick={() => void handleDelete(p)}>
{deleting === p.planType ? '删除中…' : '删除'}
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{editing !== null && (
<PlanFormModal initial={editing} onSave={handleSave} onClose={closeModal} />
)}
</section>
);
}
function SubscriptionsTab() {
const { users, reload: reloadUsers } = useAdminUsers();
const [plans, setPlans] = useState<PlanDefinition[]>([]);
const [result, setResult] = useState<{
items: AdminSubscription[];
total: number;
page: number;
totalPages: number;
pageSize: number;
} | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [message, setMessage] = useState<string | null>(null);
const [filterUserId, setFilterUserId] = useState('');
const [pendingUserId, setPendingUserId] = useState('');
const [filterStatus, setFilterStatus] = useState('active');
const [grant, setGrant] = useState({ userId: '', planType: '', durationDays: '', note: '' });
const [submitting, setSubmitting] = useState(false);
const [cancelling, setCancelling] = useState<string | null>(null);
const loadPlans = useCallback(async () => {
try {
const p = await listSubscriptionPlans();
setPlans(p);
} catch { /* ignore */ }
}, []);
useEffect(() => { void loadPlans(); }, [loadPlans]);
const load = useCallback(async (p: number, userId: string, status: string) => {
setLoading(true);
setError(null);
try {
const data = await listAdminSubscriptions({
userId: userId || undefined,
status: status || undefined,
page: p,
pageSize: 20,
});
setResult(data);
setFilterUserId(userId);
setFilterStatus(status);
} catch (err) {
setError(err instanceof Error ? err.message : '加载失败');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { void load(1, '', 'active'); }, [load]);
const handleQuery = () => void load(1, pendingUserId, filterStatus);
const handlePage = (p: number) => void load(p, filterUserId, filterStatus);
const activePlans = plans.filter((p) => p.isActive && p.priceCents > 0);
const handleGrant = async (e: React.FormEvent) => {
e.preventDefault();
if (!grant.userId || !grant.planType) return;
setSubmitting(true);
setError(null);
setMessage(null);
try {
const days = grant.durationDays ? Number(grant.durationDays) : undefined;
await grantUserSubscription(grant.userId, grant.planType, days, grant.note || undefined);
const planName = plans.find((p) => p.planType === grant.planType)?.name ?? grant.planType;
const user = users.find((u) => u.id === grant.userId);
setMessage(`已为 ${user?.displayName ?? grant.userId} 授予 ${planName} 套餐`);
setGrant((s) => ({ ...s, userId: '', note: '' }));
await reloadUsers();
void load(1, filterUserId, filterStatus);
} catch (err) {
setError(err instanceof Error ? err.message : '授予失败');
} finally {
setSubmitting(false);
}
};
const handleCancel = async (userId: string, displayName: string) => {
if (!window.confirm(`确认取消 ${displayName} 的套餐?`)) return;
setCancelling(userId);
setError(null);
setMessage(null);
try {
await cancelUserSubscription(userId);
setMessage(`已取消 ${displayName} 的套餐`);
void load(1, filterUserId, filterStatus);
} catch (err) {
setError(err instanceof Error ? err.message : '取消失败');
} finally {
setCancelling(null);
}
};
const STATUS_LABEL: Record<string, string> = { active: '有效', expired: '已到期', cancelled: '已取消' };
return (
<>
<PlanCatalogSection plans={plans} onPlansChange={loadPlans} />
{/* Grant subscription */}
<section className="admin-card">
<h2></h2>
{error && <p className="banner banner-error">{error}</p>}
{message && <p className="banner banner-info">{message}</p>}
<form className="admin-form" onSubmit={handleGrant}>
<UserCombobox
users={users.filter((u) => u.role === 'user')}
value={grant.userId}
onChange={(id) => setGrant((s) => ({ ...s, userId: id }))}
/>
<select
className="admin-select"
value={grant.planType}
onChange={(e) => setGrant((s) => ({ ...s, planType: e.target.value }))}
required
>
<option value=""></option>
{plans.map((p) => (
<option key={p.planType} value={p.planType}>
{p.name} {p.priceCents === 0 ? '免费' : `¥${formatYuan(p.priceCents)}/月`}
{!p.isActive ? ' [下架]' : ''}
</option>
))}
</select>
<input
placeholder="有效天数(留空用套餐默认)"
type="number"
min="1"
value={grant.durationDays}
onChange={(e) => setGrant((s) => ({ ...s, durationDays: e.target.value }))}
/>
<input
placeholder="备注(可选)"
value={grant.note}
onChange={(e) => setGrant((s) => ({ ...s, note: e.target.value }))}
/>
<button type="submit" className="send-btn" disabled={!grant.userId || !grant.planType || submitting}>
{submitting ? '处理中…' : '授予'}
</button>
</form>
</section>
{/* Subscription list */}
<section className="admin-card">
<div className="admin-card-head">
<h2></h2>
<div className="billing-toolbar">
<UserCombobox
users={users}
value={pendingUserId}
onChange={setPendingUserId}
placeholder="全部用户"
/>
<select
className="admin-select billing-status-select"
value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value)}
>
<option value=""></option>
<option value="active"></option>
<option value="expired"></option>
<option value="cancelled"></option>
</select>
<button
type="button"
className="send-btn billing-query-btn"
onClick={handleQuery}
disabled={loading}
>
</button>
</div>
</div>
{loading && <p className="muted"></p>}
{!loading && result && (
result.items.length === 0 ? (
<p className="muted billing-empty"></p>
) : (
<>
<div className="admin-table-wrap">
<table className="admin-table">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th style={{ textAlign: 'right' }}>Token </th>
<th style={{ textAlign: 'right' }}></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{result.items.map((row) => (
<tr key={row.id}>
<td>
<span>{row.displayName}</span>
<span className="muted"> @{row.username}</span>
</td>
<td><strong>{row.planType}</strong></td>
<td>
<span className={`ledger-type-tag ledger-type-${row.status}`}>
{STATUS_LABEL[row.status] ?? row.status}
</span>
</td>
<td className="billing-num">
{row.periodTokensUsed.toLocaleString()}
{row.periodTokensLimit > 0 && (
<span className="muted"> / {row.periodTokensLimit.toLocaleString()}</span>
)}
</td>
<td className="billing-num">
{row.periodImagesUsed}
{row.periodImagesLimit > 0 && (
<span className="muted"> / {row.periodImagesLimit}</span>
)}
</td>
<td className="billing-time">{formatTime(row.expiresAt)}</td>
<td className="billing-note">{row.note ?? '—'}</td>
<td>
{row.status === 'active' && (
<button
type="button"
className="admin-btn-danger"
disabled={cancelling === row.userId}
onClick={() => void handleCancel(row.userId, row.displayName)}
>
{cancelling === row.userId ? '取消中…' : '取消套餐'}
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<Pagination
page={result.page}
totalPages={result.totalPages}
total={result.total}
pageSize={result.pageSize}
onChange={handlePage}
/>
</>
)
)}
</section>
</>
);
}
export function BillingPage() {
const [activeTab, setActiveTab] = useState<TabKey>('recharge');
const [activeTab, setActiveTab] = useState<TabKey>('subscriptions');
return (
<div className="admin-page">
@@ -348,6 +943,7 @@ export function BillingPage() {
{activeTab === 'recharge' && <RechargeTab />}
{activeTab === 'usage' && <UsageTab />}
{activeTab === 'ledger' && <LedgerTab />}
{activeTab === 'subscriptions' && <SubscriptionsTab />}
</div>
</div>
);
+318
View File
@@ -0,0 +1,318 @@
import { useCallback, useEffect, useState } from 'react';
import {
listBlockedWords,
createBlockedWord,
updateBlockedWord,
deleteBlockedWord,
} from '../../api/client';
import type { BlockedWord } from '../../types';
const DEFAULT_WORDS = ['goose', 'aider', 'openhands'];
export function BlockedWordsPage() {
const [words, setWords] = useState<BlockedWord[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [message, setMessage] = useState<string | null>(null);
const [newWord, setNewWord] = useState('');
const [newReplacement, setNewReplacement] = useState('***');
const [newNote, setNewNote] = useState('');
const [adding, setAdding] = useState(false);
const [editId, setEditId] = useState<string | null>(null);
const [editWord, setEditWord] = useState('');
const [editReplacement, setEditReplacement] = useState('');
const [editNote, setEditNote] = useState('');
const [editBusy, setEditBusy] = useState(false);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
setWords(await listBlockedWords());
} catch (err) {
setError(err instanceof Error ? err.message : '加载失败');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { void load(); }, [load]);
const handleAdd = async (e: React.FormEvent) => {
e.preventDefault();
if (!newWord.trim()) return;
setAdding(true);
setError(null);
setMessage(null);
try {
const created = await createBlockedWord({
word: newWord.trim(),
replacement: newReplacement || '***',
note: newNote.trim() || undefined,
});
setWords((prev) => [created, ...prev]);
setNewWord('');
setNewReplacement('***');
setNewNote('');
setMessage(`已添加:${created.word}`);
} catch (err) {
setError(err instanceof Error ? err.message : '添加失败');
} finally {
setAdding(false);
}
};
const startEdit = (w: BlockedWord) => {
setEditId(w.id);
setEditWord(w.word);
setEditReplacement(w.replacement);
setEditNote(w.note ?? '');
};
const cancelEdit = () => setEditId(null);
const handleSaveEdit = async (id: string) => {
setEditBusy(true);
setError(null);
setMessage(null);
try {
const updated = await updateBlockedWord(id, {
word: editWord.trim(),
replacement: editReplacement || '***',
note: editNote.trim() || '',
});
setWords((prev) => prev.map((w) => (w.id === id ? updated : w)));
setEditId(null);
setMessage('已更新');
} catch (err) {
setError(err instanceof Error ? err.message : '更新失败');
} finally {
setEditBusy(false);
}
};
const handleToggleStatus = async (w: BlockedWord) => {
setError(null);
try {
const updated = await updateBlockedWord(w.id, {
status: w.status === 'active' ? 'disabled' : 'active',
});
setWords((prev) => prev.map((item) => (item.id === w.id ? updated : item)));
} catch (err) {
setError(err instanceof Error ? err.message : '状态切换失败');
}
};
const handleDelete = async (id: string, word: string) => {
if (!window.confirm(`确认删除词语「${word}」?`)) return;
setError(null);
try {
await deleteBlockedWord(id);
setWords((prev) => prev.filter((w) => w.id !== id));
setMessage(`已删除:${word}`);
} catch (err) {
setError(err instanceof Error ? err.message : '删除失败');
}
};
const handleQuickAdd = async (word: string) => {
setError(null);
setMessage(null);
try {
const created = await createBlockedWord({ word, replacement: '***' });
setWords((prev) => [created, ...prev]);
setMessage(`已添加:${created.word}`);
} catch (err) {
setError(err instanceof Error ? err.message : '添加失败');
}
};
const existingWords = new Set(words.map((w) => w.word.toLowerCase()));
const quickAddable = DEFAULT_WORDS.filter((w) => !existingWords.has(w.toLowerCase()));
return (
<div className="admin-page">
<div className="admin-page-head">
<h2></h2>
<p className="muted">H5 AI </p>
</div>
{error && <p className="banner banner-error">{error}</p>}
{message && <p className="banner banner-info">{message}</p>}
{quickAddable.length > 0 && (
<section className="admin-card">
<h2></h2>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap', marginTop: '8px' }}>
{quickAddable.map((w) => (
<button
key={w}
type="button"
className="ghost-btn"
onClick={() => void handleQuickAdd(w)}
>
+ {w}
</button>
))}
</div>
</section>
)}
<section className="admin-card">
<h2></h2>
<form className="admin-form" onSubmit={handleAdd}>
<label className="admin-form-row">
<span></span>
<input
type="text"
placeholder="输入要屏蔽的词语"
value={newWord}
onChange={(e) => setNewWord(e.target.value)}
required
/>
</label>
<label className="admin-form-row">
<span></span>
<input
type="text"
placeholder="***"
value={newReplacement}
onChange={(e) => setNewReplacement(e.target.value)}
/>
</label>
<label className="admin-form-row">
<span></span>
<input
type="text"
placeholder="可选"
value={newNote}
onChange={(e) => setNewNote(e.target.value)}
/>
</label>
<div className="admin-actions">
<button type="submit" className="send-btn" disabled={adding || !newWord.trim()}>
{adding ? '添加中...' : '添加'}
</button>
</div>
</form>
</section>
<section className="admin-card">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h2>{loading ? '' : `${words.length}`}</h2>
<button type="button" className="ghost-btn" onClick={() => void load()} disabled={loading}>
</button>
</div>
{loading && <p className="muted"></p>}
{!loading && words.length === 0 && <p className="muted"></p>}
{!loading && words.length > 0 && (
<table className="admin-table" style={{ marginTop: '12px' }}>
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{words.map((w) => (
<tr key={w.id} style={{ opacity: w.status === 'disabled' ? 0.5 : 1 }}>
{editId === w.id ? (
<>
<td>
<input
type="text"
value={editWord}
onChange={(e) => setEditWord(e.target.value)}
style={{ width: '120px' }}
/>
</td>
<td>
<input
type="text"
value={editReplacement}
onChange={(e) => setEditReplacement(e.target.value)}
style={{ width: '80px' }}
/>
</td>
<td>
<input
type="text"
value={editNote}
onChange={(e) => setEditNote(e.target.value)}
style={{ width: '120px' }}
/>
</td>
<td></td>
<td style={{ display: 'flex', gap: '6px' }}>
<button
type="button"
className="send-btn"
style={{ fontSize: '12px', padding: '2px 10px' }}
disabled={editBusy}
onClick={() => void handleSaveEdit(w.id)}
>
</button>
<button
type="button"
className="ghost-btn"
style={{ fontSize: '12px', padding: '2px 10px' }}
onClick={cancelEdit}
>
</button>
</td>
</>
) : (
<>
<td><code>{w.word}</code></td>
<td><code>{w.replacement}</code></td>
<td className="muted">{w.note || '—'}</td>
<td>
<span style={{ color: w.status === 'active' ? 'var(--color-success, #16a34a)' : 'var(--color-muted, #888)' }}>
{w.status === 'active' ? '启用' : '禁用'}
</span>
</td>
<td style={{ display: 'flex', gap: '6px' }}>
<button
type="button"
className="ghost-btn"
style={{ fontSize: '12px', padding: '2px 10px' }}
onClick={() => startEdit(w)}
>
</button>
<button
type="button"
className="ghost-btn"
style={{ fontSize: '12px', padding: '2px 10px' }}
onClick={() => void handleToggleStatus(w)}
>
{w.status === 'active' ? '禁用' : '启用'}
</button>
<button
type="button"
className="ghost-btn"
style={{ fontSize: '12px', padding: '2px 10px', color: 'var(--color-danger, #dc2626)' }}
onClick={() => void handleDelete(w.id, w.word)}
>
</button>
</td>
</>
)}
</tr>
))}
</tbody>
</table>
)}
</section>
</div>
);
}
+63 -3
View File
@@ -1,13 +1,15 @@
import { Link } from 'react-router-dom';
import { useCallback, useEffect, useState } from 'react';
import { getAdminDashboardSummary } from '../../api/client';
import { getAdminDashboardSummary, restartAdminService } from '../../api/client';
import type { AdminServiceRestartAction } from '../../types';
import type { AdminDashboardSummary } from '../../types';
import { formatTime, formatYuan } from '../utils/format';
const QUICK_LINKS = [
{ to: '/users', label: '用户管理', desc: '创建账号、启用禁用' },
{ to: '/billing/recharge', label: '充值', desc: '为用户账户充值' },
{ to: '/providers', label: 'LLM Provider', desc: '模型 Key 与全局配置' },
{ to: '/providers', label: '统一模型中心', desc: 'Provider、执行器模型与启动控制' },
{ to: '/mindspace', label: 'MindSpace 配置', desc: '公开页上限与空间发布参数' },
{ to: '/capabilities', label: '能力权限', desc: '扩展与工具开关' },
] as const;
@@ -15,6 +17,9 @@ export function DashboardPage() {
const [summary, setSummary] = useState<AdminDashboardSummary | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [serviceBusy, setServiceBusy] = useState<AdminServiceRestartAction | null>(null);
const [serviceMessage, setServiceMessage] = useState<string | null>(null);
const [serviceError, setServiceError] = useState<string | null>(null);
const load = useCallback(async () => {
setLoading(true);
@@ -32,6 +37,24 @@ export function DashboardPage() {
void load();
}, [load]);
const handleServiceRestart = useCallback(async (action: AdminServiceRestartAction) => {
setServiceBusy(action);
setServiceMessage(null);
setServiceError(null);
try {
const result = await restartAdminService(action);
const suffix = result.logFile ? ` 日志: ${result.logFile}` : '';
setServiceMessage(`${result.message}${suffix}`);
if (action === 'local_restart') {
window.setTimeout(() => window.location.reload(), 6000);
}
} catch (err) {
setServiceError(err instanceof Error ? err.message : `${action} 失败`);
} finally {
setServiceBusy(null);
}
}, []);
const safeSummary: AdminDashboardSummary = summary ?? {
users: { total: 0, active: 0, lowBalance: 0, totalBalanceCents: 0 },
usage24h: { count: 0, costCents: 0 },
@@ -49,6 +72,8 @@ export function DashboardPage() {
</div>
{error && <p className="banner banner-error">{error}</p>}
{serviceMessage && <p className="banner banner-info">{serviceMessage}</p>}
{serviceError && <p className="banner banner-error">{serviceError}</p>}
<div className="admin-stat-grid">
<div className="admin-stat-card">
@@ -75,7 +100,7 @@ export function DashboardPage() {
</div>
</div>
<div className="admin-stat-card">
<div className="admin-stat-label">LLM Provider</div>
<div className="admin-stat-label"></div>
<div className="admin-stat-value">{loading ? '—' : (safeSummary.llm?.keyCount ?? '—')}</div>
<div className="muted">
{loading
@@ -87,6 +112,41 @@ export function DashboardPage() {
</div>
</div>
<section className="admin-card">
<div className="admin-card-head">
<div>
<h2></h2>
<p className="muted">`local_restart` = `pro_restart` = </p>
</div>
</div>
<div className="admin-service-grid">
<article className="admin-service-card">
<h3></h3>
<p className="muted"> `127.0.0.1:8085` `127.0.0.1:5174`</p>
<button
type="button"
className="send-btn"
disabled={serviceBusy !== null}
onClick={() => void handleServiceRestart('local_restart')}
>
{serviceBusy === 'local_restart' ? '重启中...' : '执行本机重启'}
</button>
</article>
<article className="admin-service-card">
<h3></h3>
<p className="muted"> `remote_restart.sh`</p>
<button
type="button"
className="ghost-btn"
disabled={serviceBusy !== null}
onClick={() => void handleServiceRestart('pro_restart')}
>
{serviceBusy === 'pro_restart' ? '重启中...' : '执行生产重启'}
</button>
</article>
</div>
</section>
{!loading && safeSummary.lowBalanceUsers.length > 0 && (
<section className="admin-card">
<div className="admin-card-head">
+95
View File
@@ -0,0 +1,95 @@
import { useCallback, useEffect, useState, type FormEvent } from 'react';
import { getMindSpaceAdminConfig, updateMindSpaceAdminConfig } from '../../api/client';
import type { MindSpaceAdminConfig } from '../../types';
function safeConfig(config: MindSpaceAdminConfig | null): MindSpaceAdminConfig {
return config ?? { publicPageLimit: 10 };
}
export function MindSpacePage() {
const [config, setConfig] = useState<MindSpaceAdminConfig | null>(null);
const [limit, setLimit] = useState('10');
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [message, setMessage] = useState<string | null>(null);
const load = useCallback(async () => {
setLoading(true);
setError(null);
setMessage(null);
try {
const nextConfig = await getMindSpaceAdminConfig();
setConfig(nextConfig);
setLimit(String(nextConfig.publicPageLimit));
} catch (err) {
setError(err instanceof Error ? err.message : '加载 MindSpace 配置失败');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
const handleSave = async (event: FormEvent) => {
event.preventDefault();
setBusy(true);
setError(null);
setMessage(null);
try {
const nextConfig = await updateMindSpaceAdminConfig({ publicPageLimit: Number(limit) });
setConfig(nextConfig);
setLimit(String(nextConfig.publicPageLimit));
setMessage(`已更新为 ${nextConfig.publicPageLimit},主站会立即按新值校验。`);
} catch (err) {
setError(err instanceof Error ? err.message : '保存 MindSpace 配置失败');
} finally {
setBusy(false);
}
};
const current = safeConfig(config);
return (
<div className="admin-page">
<div className="admin-page-head">
<h2>MindSpace </h2>
<p className="muted"></p>
</div>
{error && <p className="banner banner-error">{error}</p>}
{message && <p className="banner banner-info">{message}</p>}
<section className="admin-card">
<h2></h2>
<p className="muted">
{loading ? '—' : current.publicPageLimit}
</p>
<form className="admin-form" onSubmit={handleSave}>
<label className="admin-form-row">
<span>线</span>
<input
type="number"
min={1}
step={1}
value={limit}
onChange={(e) => setLimit(e.target.value)}
inputMode="numeric"
required
/>
</label>
<div className="admin-actions">
<button type="submit" className="send-btn" disabled={busy || loading}>
{busy ? '保存中...' : '保存配置'}
</button>
<button type="button" className="ghost-btn" onClick={() => void load()} disabled={busy}>
</button>
</div>
</form>
</section>
</div>
);
}
+859 -7
View File
@@ -1,13 +1,865 @@
import { ProviderKeySettings } from '../../components/ProviderKeySettings';
import type React from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
clearLlmVisionKey,
createLlmProviderKey,
deleteLlmProviderKey,
getLlmGlobalSettings,
getLlmVisionSettings,
launchLlmExecutor,
listLlmExecutorBindings,
listLlmExecutorLaunchPlans,
listLlmExecutorLaunchStatus,
listLlmExecutorRuntime,
listLlmProviderCatalog,
listLlmProviderKeys,
restartLlmExecutor,
selectLlmProviderKey,
setLlmExecutorBinding,
setLlmVisionKey,
stopLlmExecutor,
syncLlmProviderToGoosed,
testLlmProviderKey,
updateLlmProviderKey,
} from '../../api/client';
import type {
LlmExecutorBinding,
LlmExecutorLaunchPlan,
LlmExecutorLaunchState,
LlmExecutorRuntime,
LlmGlobalSettings,
LlmProviderDefinition,
LlmProviderKeyRow,
LlmVisionSettings,
} from '../../types';
export function ProvidersPage() {
const CUSTOM_PROVIDER_ID = '__custom__';
const defaultProviderForm = {
providerId: CUSTOM_PROVIDER_ID,
name: '',
apiKey: '',
apiUrl: '',
modelsText: '',
defaultModel: '',
relayProvider: '',
};
type ProviderFormState = typeof defaultProviderForm;
type ProviderDialogState =
| { mode: 'create'; form: ProviderFormState }
| { mode: 'edit'; row: LlmProviderKeyRow; form: ProviderFormState };
function parseModelsText(text: string) {
return [...new Set(text.split(/[\n,]/).map((item) => item.trim()).filter(Boolean))];
}
function statusText(status?: LlmExecutorLaunchState | null) {
if (!status) return '未启动';
if (status.running) return `运行中${status.pid ? ` · pid ${status.pid}` : ''}`;
return status.stoppedAt ? '已停止' : '未运行';
}
function ProviderSelect({
keys,
value,
onChange,
}: {
keys: LlmProviderKeyRow[];
value: string;
onChange: (value: string) => void;
}) {
return (
<div className="admin-page">
<div className="admin-page-head">
<h2>LLM Provider</h2>
<p className="muted"> Key</p>
<select value={value} onChange={(event) => onChange(event.target.value)}>
<option value=""> Provider</option>
{keys.map((key) => (
<option key={key.id} value={key.id}>
{key.name} · {key.providerLabel}
</option>
))}
</select>
);
}
function providerFormFromRow(row: LlmProviderKeyRow): ProviderFormState {
return {
providerId: row.providerId,
name: row.name,
apiKey: '',
apiUrl: row.apiUrl ?? '',
modelsText: row.models.join('\n'),
defaultModel: row.defaultModel,
relayProvider: row.relayProvider ?? '',
};
}
function ProviderDialog({
state,
catalog,
busy,
onChange,
onClose,
onSubmit,
}: {
state: ProviderDialogState;
catalog: LlmProviderDefinition[];
busy: boolean;
onChange: (form: ProviderFormState) => void;
onClose: () => void;
onSubmit: (event: React.FormEvent) => void;
}) {
const form = state.form;
const isCreate = state.mode === 'create';
const isCustom = form.providerId === CUSTOM_PROVIDER_ID;
const customModels = parseModelsText(form.modelsText);
const selectedCatalog = catalog.find((item) => item.id === form.providerId) ?? null;
const modelOptions = isCustom ? customModels : selectedCatalog?.models ?? [];
return (
<div className="modal-backdrop" role="presentation" onMouseDown={onClose}>
<div className="modal-panel provider-modal" role="dialog" aria-modal="true" aria-labelledby="provider-dialog-title" onMouseDown={(event) => event.stopPropagation()}>
<div className="modal-head">
<div>
<h3 id="provider-dialog-title">{isCreate ? '添加 Provider' : '编辑 Provider'}</h3>
<p className="muted">{isCreate ? '新增一组可用于执行器绑定的 LLM 配置' : '调整名称、模型列表或连接参数'}</p>
</div>
<button type="button" className="ghost-btn" onClick={onClose} disabled={busy}>
</button>
</div>
<form className="admin-form provider-dialog-form" onSubmit={onSubmit}>
<label>
<span>Provider </span>
<select
value={form.providerId}
disabled={!isCreate || busy}
onChange={(event) => {
const nextProvider = event.target.value;
const nextCatalog = catalog.find((item) => item.id === nextProvider);
onChange({
...form,
providerId: nextProvider,
defaultModel:
nextProvider === CUSTOM_PROVIDER_ID
? parseModelsText(form.modelsText)[0] ?? ''
: nextCatalog?.defaultModel ?? '',
});
}}
>
{catalog.map((item) => (
<option key={item.id} value={item.id}>
{item.label}
</option>
))}
</select>
</label>
<label>
<span></span>
<input placeholder="例如:OpenRouter 主账号" value={form.name} onChange={(event) => onChange({ ...form, name: event.target.value })} />
</label>
{isCustom ? (
<>
<label>
<span>API </span>
<input placeholder="https://api.example.com/v1/chat/completions" value={form.apiUrl} onChange={(event) => onChange({ ...form, apiUrl: event.target.value })} />
</label>
<label>
<span>Relay Provider</span>
<input placeholder="可选,例如 ollama" value={form.relayProvider} onChange={(event) => onChange({ ...form, relayProvider: event.target.value })} />
</label>
<label className="form-span-all">
<span></span>
<textarea
placeholder="每行一个模型,例如 qwen2.5:3b"
rows={5}
value={form.modelsText}
onChange={(event) => {
const models = parseModelsText(event.target.value);
onChange({
...form,
modelsText: event.target.value,
defaultModel: models.includes(form.defaultModel) ? form.defaultModel : models[0] ?? '',
});
}}
/>
</label>
</>
) : null}
<label>
<span>{isCreate ? 'API Key / Bearer Token' : '替换 API Key'}</span>
<input
placeholder={isCreate ? '必填' : '留空则保持原 Key'}
type="password"
value={form.apiKey}
onChange={(event) => onChange({ ...form, apiKey: event.target.value })}
/>
</label>
<label>
<span></span>
<select value={form.defaultModel} onChange={(event) => onChange({ ...form, defaultModel: event.target.value })}>
<option value=""></option>
{modelOptions.map((model) => (
<option key={model} value={model}>
{model}
</option>
))}
</select>
</label>
<div className="modal-actions">
<button type="button" className="ghost-btn" onClick={onClose} disabled={busy}>
</button>
<button type="submit" className="send-btn" disabled={busy}>
{isCreate ? '添加' : '保存'}
</button>
</div>
</form>
</div>
<ProviderKeySettings />
</div>
);
}
function ExecutorCard({
binding,
keys,
runtime,
plan,
launchStatus,
busy,
onSave,
onLaunch,
onStop,
onRestart,
}: {
binding: LlmExecutorBinding;
keys: LlmProviderKeyRow[];
runtime?: LlmExecutorRuntime;
plan?: LlmExecutorLaunchPlan;
launchStatus?: LlmExecutorLaunchState | null;
busy: boolean;
onSave: (binding: LlmExecutorBinding, keyId: string | null, model: string, enabled: boolean) => Promise<void>;
onLaunch: (binding: LlmExecutorBinding, instruction: string) => Promise<void>;
onStop: (binding: LlmExecutorBinding) => Promise<void>;
onRestart: (binding: LlmExecutorBinding) => Promise<void>;
}) {
const [enabled, setEnabled] = useState(binding.enabled);
const [keyId, setKeyId] = useState(binding.providerKeyId ?? '');
const [model, setModel] = useState(binding.model);
const [instruction, setInstruction] = useState(binding.executor === 'aider' ? '请根据当前仓库继续执行常规代码维护任务。' : '');
const selectedKey = keys.find((item) => item.id === keyId) ?? null;
const modelOptions = selectedKey?.models?.length ? selectedKey.models : binding.availableModels;
const effectiveInstruction =
instruction.trim() || (binding.executor === 'aider' ? '请根据当前仓库继续执行常规代码维护任务。' : '');
useEffect(() => {
setEnabled(binding.enabled);
setKeyId(binding.providerKeyId ?? '');
setModel(binding.model);
}, [binding]);
useEffect(() => {
if (!selectedKey) return;
if (!model || !selectedKey.models.includes(model)) {
setModel(selectedKey.defaultModel || selectedKey.models[0] || '');
}
}, [model, selectedKey]);
return (
<article className="executor-card">
<div className="admin-card-head">
<div>
<h3>{binding.executorLabel}</h3>
<p className="muted">{binding.executorDescription}</p>
</div>
<label className="inline-check">
<input type="checkbox" checked={enabled} onChange={(event) => setEnabled(event.target.checked)} />
</label>
</div>
<div className="executor-form-grid">
<ProviderSelect keys={keys} value={keyId} onChange={setKeyId} />
<select value={model} onChange={(event) => setModel(event.target.value)} disabled={!keyId}>
<option value=""></option>
{modelOptions.map((item) => (
<option key={item} value={item}>
{item}
</option>
))}
</select>
<button
type="button"
className="send-btn"
disabled={busy || (enabled && (!keyId || !model))}
onClick={() => void onSave(binding, keyId || null, model, enabled)}
>
</button>
</div>
<div className="executor-footer">
<div className="executor-meta">
<span>{statusText(launchStatus)}</span>
{runtime?.ok ? <span>{runtime.providerName} · {runtime.model}</span> : <span>{runtime?.message ?? '运行配置未就绪'}</span>}
{plan?.ok ? <span className="mono">{plan.command} {plan.args?.join(' ')}</span> : <span>{plan?.message}</span>}
</div>
{binding.executor === 'goose' ? (
<p className="executor-note">Goose </p>
) : (
<>
<input
className="executor-instruction"
value={instruction}
onChange={(event) => setInstruction(event.target.value)}
placeholder={binding.executor === 'openhands' ? 'OpenHands serve 模式可留空' : 'Aider 启动任务指令'}
/>
<div className="admin-actions executor-actions">
<button
type="button"
className="ghost-btn"
disabled={busy || !plan?.ok || !enabled}
onClick={() => void onLaunch(binding, effectiveInstruction)}
>
</button>
<button
type="button"
className="ghost-btn"
disabled={busy || !launchStatus?.running}
onClick={() => void onStop(binding)}
>
</button>
<button
type="button"
className="ghost-btn"
disabled={busy || !plan?.ok}
onClick={() => void onRestart(binding)}
>
</button>
</div>
</>
)}
</div>
{launchStatus?.logFile ? <span className="mono executor-log">{launchStatus.logFile}</span> : null}
</article>
);
}
function VisionSection({
keys,
settings,
busy,
onSave,
onClear,
}: {
keys: LlmProviderKeyRow[];
settings: LlmVisionSettings | null;
busy: boolean;
onSave: (keyId: string, model: string) => Promise<void>;
onClear: () => Promise<void>;
}) {
const [keyId, setKeyId] = useState(settings?.keyId ?? '');
const [model, setModel] = useState(settings?.visionModel ?? '');
const selectedKey = keys.find((item) => item.id === keyId) ?? null;
const modelOptions = selectedKey?.models?.length ? selectedKey.models : [];
useEffect(() => {
setKeyId(settings?.keyId ?? '');
setModel(settings?.visionModel ?? '');
}, [settings]);
useEffect(() => {
if (!selectedKey) return;
if (!model || !selectedKey.models.includes(model)) {
setModel(selectedKey.defaultModel || selectedKey.models[0] || '');
}
}, [model, selectedKey]);
return (
<section className="admin-card providers-panel">
<div className="admin-card-head providers-panel-head">
<div>
<h3></h3>
<p className="muted"> Qwen VL</p>
</div>
{settings?.keyId ? (
<button type="button" className="ghost-btn" disabled={busy} onClick={() => void onClear()}>
</button>
) : null}
</div>
{settings?.keyId ? (
<p className="muted" style={{ marginBottom: '0.75rem' }}>
<strong>{settings.keyName}</strong> · <span className="mono">{settings.visionModel}</span>
</p>
) : (
<p className="muted" style={{ marginBottom: '0.75rem' }}></p>
)}
<div className="executor-form-grid">
<ProviderSelect keys={keys} value={keyId} onChange={setKeyId} />
<select value={model} onChange={(event) => setModel(event.target.value)} disabled={!keyId}>
<option value=""></option>
{modelOptions.map((item) => (
<option key={item} value={item}>{item}</option>
))}
</select>
<button
type="button"
className="send-btn"
disabled={busy || !keyId || !model}
onClick={() => void onSave(keyId, model)}
>
</button>
</div>
</section>
);
}
export function ProvidersPage() {
const [catalog, setCatalog] = useState<LlmProviderDefinition[]>([]);
const [keys, setKeys] = useState<LlmProviderKeyRow[]>([]);
const [globalSettings, setGlobalSettings] = useState<LlmGlobalSettings | null>(null);
const [visionSettings, setVisionSettings] = useState<LlmVisionSettings | null>(null);
const [bindings, setBindings] = useState<LlmExecutorBinding[]>([]);
const [runtimes, setRuntimes] = useState<LlmExecutorRuntime[]>([]);
const [plans, setPlans] = useState<LlmExecutorLaunchPlan[]>([]);
const [launchStatuses, setLaunchStatuses] = useState<Record<string, LlmExecutorLaunchState>>({});
const [providerDialog, setProviderDialog] = useState<ProviderDialogState | null>(null);
const [busy, setBusy] = useState(false);
const [loading, setLoading] = useState(true);
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const activeKeys = useMemo(() => keys.filter((item) => item.status === 'active'), [keys]);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [catalogRows, keyRows, global, vision, bindingRows, runtimeRows, planRows, statusRows] = await Promise.all([
listLlmProviderCatalog(),
listLlmProviderKeys(),
getLlmGlobalSettings(),
getLlmVisionSettings(),
listLlmExecutorBindings(),
listLlmExecutorRuntime(),
listLlmExecutorLaunchPlans({ mode: 'serve' }),
listLlmExecutorLaunchStatus(),
]);
setCatalog(catalogRows);
setKeys(keyRows);
setGlobalSettings(global);
setVisionSettings(vision);
setBindings(bindingRows);
setRuntimes(runtimeRows);
setPlans(planRows);
setLaunchStatuses(
Object.fromEntries(statusRows.filter(Boolean).map((item) => [item!.executor, item!])) as Record<string, LlmExecutorLaunchState>,
);
} catch (err) {
setError(err instanceof Error ? err.message : '加载统一模型中心失败');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
const openCreateProvider = () => {
const customProvider = catalog.find((item) => item.id === CUSTOM_PROVIDER_ID);
const firstProvider = customProvider ?? catalog[0];
setProviderDialog({
mode: 'create',
form: {
...defaultProviderForm,
providerId: firstProvider?.id ?? CUSTOM_PROVIDER_ID,
defaultModel: firstProvider?.defaultModel ?? '',
},
});
};
const handleSaveProvider = async (event: React.FormEvent) => {
event.preventDefault();
if (!providerDialog) return;
const form = providerDialog.form;
const isCustom = form.providerId === CUSTOM_PROVIDER_ID;
const customModels = parseModelsText(form.modelsText);
const selectedCatalog = catalog.find((item) => item.id === form.providerId) ?? null;
setBusy(true);
setMessage(null);
setError(null);
try {
if (providerDialog.mode === 'create') {
await createLlmProviderKey(
isCustom
? {
providerId: CUSTOM_PROVIDER_ID,
name: form.name,
apiKey: form.apiKey,
apiUrl: form.apiUrl,
models: customModels,
defaultModel: form.defaultModel || customModels[0],
relayProvider: form.relayProvider || undefined,
}
: {
providerId: form.providerId,
name: form.name,
apiKey: form.apiKey,
defaultModel: form.defaultModel || selectedCatalog?.defaultModel,
},
);
} else {
await updateLlmProviderKey(
providerDialog.row.id,
isCustom
? {
name: form.name,
...(form.apiKey ? { apiKey: form.apiKey } : {}),
apiUrl: form.apiUrl,
models: customModels,
defaultModel: form.defaultModel || customModels[0],
relayProvider: form.relayProvider || undefined,
}
: {
name: form.name,
...(form.apiKey ? { apiKey: form.apiKey } : {}),
defaultModel: form.defaultModel || selectedCatalog?.defaultModel,
},
);
}
setMessage(providerDialog.mode === 'create' ? 'Provider 已添加' : 'Provider 已保存');
setProviderDialog(null);
await load();
} catch (err) {
setError(err instanceof Error ? err.message : '保存 Provider 失败');
} finally {
setBusy(false);
}
};
const handleToggleProviderStatus = async (row: LlmProviderKeyRow) => {
setBusy(true);
setMessage(null);
setError(null);
try {
await updateLlmProviderKey(row.id, { status: row.status === 'active' ? 'disabled' : 'active' });
await load();
} catch (err) {
setError(err instanceof Error ? err.message : '状态更新失败');
} finally {
setBusy(false);
}
};
const handleSelectProvider = async (keyId: string) => {
setBusy(true);
setMessage(null);
setError(null);
try {
await selectLlmProviderKey(keyId);
setMessage('默认 Provider 已切换');
await load();
} catch (err) {
setError(err instanceof Error ? err.message : '切换失败');
} finally {
setBusy(false);
}
};
const handleDeleteProvider = async (row: LlmProviderKeyRow) => {
if (!window.confirm(`确定删除配置「${row.name}」?`)) return;
setBusy(true);
try {
await deleteLlmProviderKey(row.id);
await load();
} catch (err) {
setError(err instanceof Error ? err.message : '删除失败');
} finally {
setBusy(false);
}
};
const handleTestProvider = async (row: LlmProviderKeyRow) => {
setBusy(true);
setMessage(null);
setError(null);
try {
const result = await testLlmProviderKey(row.id, row.defaultModel);
if (result.ok) setMessage(`${row.name}」联通成功`);
else setError(`${row.name}」联通失败:${result.message ?? '未知错误'}`);
} catch (err) {
setError(err instanceof Error ? err.message : '联通测试失败');
} finally {
setBusy(false);
}
};
const handleSaveBinding = async (
binding: LlmExecutorBinding,
keyId: string | null,
model: string,
enabled: boolean,
) => {
setBusy(true);
setMessage(null);
setError(null);
try {
await setLlmExecutorBinding(binding.executor, { keyId, model, enabled, purpose: binding.purpose });
setMessage(`${binding.executorLabel} 绑定已保存`);
await load();
} catch (err) {
setError(err instanceof Error ? err.message : '保存执行器绑定失败');
} finally {
setBusy(false);
}
};
const handleLaunch = async (binding: LlmExecutorBinding, instruction: string) => {
setBusy(true);
setMessage(null);
setError(null);
try {
const launch = await launchLlmExecutor(binding.executor, {
mode: binding.executor === 'openhands' ? 'serve' : 'headless',
instruction,
purpose: binding.purpose,
});
setMessage(`${binding.executorLabel} 已启动${launch.pid ? ` · pid ${launch.pid}` : ''}`);
await load();
} catch (err) {
setError(err instanceof Error ? err.message : '启动失败');
} finally {
setBusy(false);
}
};
const handleStop = async (binding: LlmExecutorBinding) => {
setBusy(true);
setMessage(null);
setError(null);
try {
await stopLlmExecutor(binding.executor, { purpose: binding.purpose });
setMessage(`${binding.executorLabel} 已停止`);
await load();
} catch (err) {
setError(err instanceof Error ? err.message : '停止失败');
} finally {
setBusy(false);
}
};
const handleRestart = async (binding: LlmExecutorBinding) => {
setBusy(true);
setMessage(null);
setError(null);
try {
const launch = await restartLlmExecutor(binding.executor, { purpose: binding.purpose });
setMessage(`${binding.executorLabel} 已重启${launch.pid ? ` · pid ${launch.pid}` : ''}`);
await load();
} catch (err) {
setError(err instanceof Error ? err.message : '重启失败');
} finally {
setBusy(false);
}
};
const handleSync = async () => {
setBusy(true);
setMessage(null);
setError(null);
try {
const result = await syncLlmProviderToGoosed();
setMessage(result.synced ? '已同步 Goose 绑定' : '未同步:请检查 Goose 执行器绑定');
} catch (err) {
setError(err instanceof Error ? err.message : '同步失败');
} finally {
setBusy(false);
}
};
const handleSetVisionKey = async (keyId: string, model: string) => {
setBusy(true);
setMessage(null);
setError(null);
try {
const vision = await setLlmVisionKey(keyId, model);
setVisionSettings(vision);
setMessage('图片任务模型已保存');
} catch (err) {
setError(err instanceof Error ? err.message : '保存失败');
} finally {
setBusy(false);
}
};
const handleClearVisionKey = async () => {
if (!window.confirm('确定清除图片任务模型?清除后所有图片消息将使用默认模型处理。')) return;
setBusy(true);
setMessage(null);
setError(null);
try {
await clearLlmVisionKey();
setVisionSettings((prev) => prev ? { ...prev, keyId: null, keyName: null, providerLabel: null, visionModel: null, availableModels: [] } : prev);
setMessage('图片任务模型已清除');
} catch (err) {
setError(err instanceof Error ? err.message : '清除失败');
} finally {
setBusy(false);
}
};
return (
<div className="admin-page providers-page">
<div className="admin-page-head providers-hero">
<div>
<h2></h2>
<p className="muted"> Provider</p>
</div>
<div className="providers-hero-meta">
<div className="providers-hero-stat">
<span className="providers-hero-label"></span>
<strong>{globalSettings?.globalModel ?? '未设置'}</strong>
</div>
<button type="button" className="send-btn" disabled={busy || loading} onClick={openCreateProvider}>
Provider
</button>
</div>
</div>
{message && <p className="banner banner-info">{message}</p>}
{error && <p className="banner banner-error">{error}</p>}
<section className="admin-card providers-panel providers-panel-primary">
<div className="admin-card-head providers-panel-head">
<div>
<h3></h3>
<p className="muted"> Provider GooseAiderOpenHands</p>
</div>
<button type="button" className="ghost-btn" disabled={busy} onClick={() => void handleSync()}>
Goose
</button>
</div>
{loading ? (
<p className="muted">...</p>
) : (
<div className="executor-grid">
{bindings.map((binding) => (
<ExecutorCard
key={binding.executor}
binding={binding}
keys={activeKeys}
runtime={runtimes.find((item) => item.executor === binding.executor)}
plan={plans.find((item) => item.executor === binding.executor)}
launchStatus={launchStatuses[binding.executor]}
busy={busy}
onSave={handleSaveBinding}
onLaunch={handleLaunch}
onStop={handleStop}
onRestart={handleRestart}
/>
))}
</div>
)}
</section>
<section className="admin-card providers-panel providers-panel-secondary">
<div className="admin-card-head providers-panel-head">
<div>
<h3>Provider </h3>
<p className="muted"> list</p>
</div>
<button type="button" className="send-btn" disabled={busy || loading} onClick={openCreateProvider}>
Provider
</button>
</div>
<div className="providers-list">
{keys.map((row) => (
<article key={row.id} className="provider-list-item">
<div className="provider-list-main">
<div className="provider-list-top">
<div className="providers-name">
<span>{row.name}</span>
{row.isSelected ? <span className="risk-pill risk-low"></span> : null}
</div>
<span className={`provider-state ${row.status === 'active' ? 'is-active' : 'is-disabled'}`}>
{row.status === 'active' ? '可用' : '禁用'}
</span>
</div>
<div className="provider-list-meta">
<span>{row.providerKind === 'custom' ? row.apiUrl : row.providerLabel}</span>
<span className="mono">{row.defaultModel}</span>
<span className="mono">{row.apiKeyMasked}</span>
</div>
</div>
<div className="admin-actions providers-actions provider-list-actions">
<button
type="button"
className="ghost-btn"
disabled={busy}
onClick={() => setProviderDialog({ mode: 'edit', row, form: providerFormFromRow(row) })}
>
</button>
<button type="button" className="ghost-btn" disabled={busy} onClick={() => void handleTestProvider(row)}>
</button>
{!row.isSelected && row.status === 'active' ? (
<button type="button" className="ghost-btn" disabled={busy} onClick={() => void handleSelectProvider(row.id)}>
</button>
) : null}
<button
type="button"
className="ghost-btn"
disabled={busy || row.isSelected}
onClick={() => void handleToggleProviderStatus(row)}
>
{row.status === 'active' ? '禁用' : '恢复'}
</button>
<button type="button" className="danger-btn" disabled={busy || row.isSelected} onClick={() => void handleDeleteProvider(row)}>
</button>
</div>
</article>
))}
</div>
</section>
<VisionSection
keys={activeKeys}
settings={visionSettings}
busy={busy}
onSave={handleSetVisionKey}
onClear={handleClearVisionKey}
/>
{providerDialog ? (
<ProviderDialog
state={providerDialog}
catalog={catalog}
busy={busy}
onChange={(form) => setProviderDialog((current) => (current ? { ...current, form } : current))}
onClose={() => {
if (!busy) setProviderDialog(null);
}}
onSubmit={handleSaveProvider}
/>
) : null}
</div>
);
}
+116 -12
View File
@@ -1,44 +1,109 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { Link, Navigate, useParams } from 'react-router-dom';
import { rechargeUser, updateAdminUser } from '../../api/client';
import { getAdminUser, rechargeUser, updateAdminUser } from '../../api/client';
import { CapabilitySettings } from '../../components/CapabilitySettings';
import { PolicySettings } from '../../components/PolicySettings';
import { SkillSettings } from '../../components/SkillSettings';
import { useAdminUsers } from '../hooks/useAdminUsers';
import { formatYuan } from '../utils/format';
import type { PortalUser } from '../../types';
function formatBytes(bytes: number) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
export function UserDetailPage() {
const { userId = '' } = useParams();
const { users, loading, error, reload, setError } = useAdminUsers();
const { users, reload, setError } = useAdminUsers();
const [user, setUser] = useState<PortalUser | null>(null);
const [loading, setLoading] = useState(true);
const [error, setLocalError] = useState<string | null>(null);
const [message, setMessage] = useState<string | null>(null);
const [recharge, setRecharge] = useState({ amountYuan: '10', note: '' });
const [spaceQuotaMb, setSpaceQuotaMb] = useState('5');
const user = users.find((row) => row.id === userId);
useEffect(() => {
if (!user?.spaceQuotaBytes) return;
setSpaceQuotaMb(String(Math.max(1, Math.round(user.spaceQuotaBytes / 1024 / 1024))));
}, [user?.spaceQuotaBytes]);
useEffect(() => {
let cancelled = false;
if (!userId) return;
setLoading(true);
setLocalError(null);
void getAdminUser(userId)
.then((nextUser) => {
if (cancelled) return;
setUser(nextUser);
})
.catch((err) => {
if (cancelled) return;
const fallbackUser = users.find((row) => row.id === userId) ?? null;
if (fallbackUser) {
setUser(fallbackUser);
setLocalError(null);
return;
}
setLocalError(err instanceof Error ? err.message : '加载用户失败');
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [userId, users]);
const handleRecharge = async (e: React.FormEvent) => {
e.preventDefault();
if (!user) return;
setMessage(null);
setLocalError(null);
setError(null);
try {
const amountCents = Math.round(Number(recharge.amountYuan) * 100);
await rechargeUser(user.id, amountCents, recharge.note || undefined);
const nextUser = await rechargeUser(user.id, amountCents, recharge.note || undefined);
setUser(nextUser);
setMessage('充值成功');
setRecharge({ amountYuan: '10', note: '' });
await reload();
} catch (err) {
setError(err instanceof Error ? err.message : '充值失败');
setLocalError(err instanceof Error ? err.message : '充值失败');
}
};
if (!loading && !user) {
return <Navigate to="/admin/users" replace />;
if (!loading && !user && !error) {
return <Navigate to="/users" replace />;
}
const currentQuotaMb = Math.max(1, Math.round((user?.spaceQuotaBytes ?? 0) / 1024 / 1024));
const handleSpaceQuotaSave = async (e: React.FormEvent) => {
e.preventDefault();
if (!user) return;
setMessage(null);
setLocalError(null);
setError(null);
try {
const quotaMb = Math.floor(Number(spaceQuotaMb));
const nextUser = await updateAdminUser(user.id, {
spaceQuotaBytes: quotaMb * 1024 * 1024,
});
setUser(nextUser);
setMessage('空间已更新');
await reload();
} catch (err) {
setLocalError(err instanceof Error ? err.message : '空间更新失败');
}
};
return (
<div className="admin-page">
<div className="admin-page-head">
<Link to="/admin/users" className="ghost-btn admin-back-inline">
<Link to="/users" className="ghost-btn admin-back-inline">
</Link>
<h2>{user?.displayName ?? '用户详情'}</h2>
@@ -59,11 +124,17 @@ export function UserDetailPage() {
<button
type="button"
className="ghost-btn"
onClick={() =>
onClick={() => {
setLocalError(null);
void updateAdminUser(user.id, {
status: user.status === 'active' ? 'disabled' : 'active',
}).then(reload)
}
}).then((nextUser) => {
setUser(nextUser);
void reload();
}).catch((err) => {
setLocalError(err instanceof Error ? err.message : '状态更新失败');
});
}}
>
{user.status === 'active' ? '禁用' : '启用'}
</button>
@@ -74,6 +145,19 @@ export function UserDetailPage() {
<dt></dt>
<dd>¥{formatYuan(user.balanceCents)}</dd>
</div>
<div>
<dt></dt>
<dd>
{user.spaceQuotaBytes ? formatBytes(user.spaceQuotaBytes) : '—'}
{user.spaceUsedBytes !== undefined && (
<span className="muted">
{' '}
· {formatBytes(user.spaceUsedBytes)} · {' '}
{formatBytes(user.spaceAvailableBytes ?? 0)}
</span>
)}
</dd>
</div>
<div>
<dt></dt>
<dd className="mono">{user.workspaceRoot}</dd>
@@ -102,6 +186,26 @@ export function UserDetailPage() {
</form>
</section>
<section className="admin-card">
<h2></h2>
<p className="muted">
MB
</p>
<form className="admin-form" onSubmit={handleSpaceQuotaSave}>
<input
placeholder="空间总额(MB"
type="number"
min="1"
value={spaceQuotaMb}
onChange={(e) => setSpaceQuotaMb(e.target.value)}
/>
<button type="submit" className="send-btn">
</button>
</form>
<p className="muted"> {currentQuotaMb} MB</p>
</section>
<CapabilitySettings users={users} userId={user.id} userOnly />
<SkillSettings users={users} userId={user.id} userOnly />
<PolicySettings users={users} userId={user.id} userOnly />
+13 -1
View File
@@ -8,6 +8,12 @@ import { formatYuan } from '../utils/format';
const PAGE_SIZE = 20;
function formatBytes(bytes: number) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(0)} MB`;
}
export function UsersPage() {
const [result, setResult] = useState<PagedResult<AdminUserRow> | null>(null);
const [loading, setLoading] = useState(false);
@@ -166,6 +172,7 @@ export function UsersPage() {
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
@@ -184,9 +191,14 @@ export function UsersPage() {
</span>
</td>
<td className="billing-num">¥{formatYuan(user.balanceCents)}</td>
<td className="billing-num">
{user.spaceQuotaBytes
? `${formatBytes(user.spaceUsedBytes ?? 0)} / ${formatBytes(user.spaceQuotaBytes)}`
: '—'}
</td>
<td className="admin-table-path">{user.workspaceRoot || '—'}</td>
<td className="admin-actions">
<Link to={`/admin/users/${user.id}`} className="ghost-btn"></Link>
<Link to={`/users/${user.id}`} className="ghost-btn"></Link>
{user.role === 'user' && (
<button type="button" className="ghost-btn" onClick={() => void toggleStatus(user)}>
{user.status === 'active' ? '禁用' : '启用'}
+234 -2
View File
@@ -2,19 +2,24 @@ import { useCallback, useEffect, useState } from 'react';
import {
cancelWechatDigest,
clearWechatRoute,
createWechatWebNotification,
getWechatAdminSummary,
listAdminUsers,
listWechatBindings,
listWechatDeliveries,
listWechatDigests,
listWechatMessages,
listWechatWebNotifications,
resumeWechatDigest,
} from '../../api/client';
import type {
AdminUserRow,
WechatAdminSummary,
WechatBinding,
WechatDeliveryLog,
WechatDigestSubscription,
WechatMessage,
WechatWebNotification,
} from '../../types';
import { formatTime } from '../utils/format';
@@ -69,38 +74,62 @@ export function WechatPage() {
const [messages, setMessages] = useState<WechatMessage[]>([]);
const [digests, setDigests] = useState<WechatDigestSubscription[]>([]);
const [deliveries, setDeliveries] = useState<WechatDeliveryLog[]>([]);
const [webNotifications, setWebNotifications] = useState<WechatWebNotification[]>([]);
const [users, setUsers] = useState<AdminUserRow[]>([]);
const [search, setSearch] = useState('');
const [messageStatus, setMessageStatus] = useState('');
const [digestStatus, setDigestStatus] = useState('');
const [deliveryStatus, setDeliveryStatus] = useState('');
const [notificationStatus, setNotificationStatus] = useState('');
const [notifyAudience, setNotifyAudience] = useState<'single' | 'multi' | 'all'>('single');
const [notifyUserId, setNotifyUserId] = useState('');
const [notifyUserIds, setNotifyUserIds] = useState<string[]>([]);
const [notifyTitle, setNotifyTitle] = useState('');
const [notifyBody, setNotifyBody] = useState('');
const [notifyType, setNotifyType] = useState('manual');
const [notifyChannels, setNotifyChannels] = useState<Array<'web' | 'wechat'>>(['web']);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const safe = safeSummary(summary);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [nextSummary, nextBindings, nextMessages, nextDigests, nextDeliveries] =
const [
nextSummary,
nextBindings,
nextMessages,
nextDigests,
nextDeliveries,
nextNotifications,
nextUsers,
] =
await Promise.all([
getWechatAdminSummary(),
listWechatBindings({ search, limit: 80 }),
listWechatMessages({ status: messageStatus || undefined, limit: 80 }),
listWechatDigests({ status: digestStatus || undefined, limit: 80 }),
listWechatDeliveries({ status: deliveryStatus || undefined, limit: 80 }),
listWechatWebNotifications({ status: notificationStatus || undefined, limit: 80 }),
listAdminUsers({ page: 1, pageSize: 200, status: 'active' }),
]);
setSummary(nextSummary);
setBindings(nextBindings);
setMessages(nextMessages);
setDigests(nextDigests);
setDeliveries(nextDeliveries);
setWebNotifications(nextNotifications);
setUsers(nextUsers.items);
setNotifyUserId((current) => current || nextUsers.items[0]?.id || '');
} catch (err) {
setError(err instanceof Error ? err.message : '加载服务号管理失败');
} finally {
setLoading(false);
}
}, [deliveryStatus, digestStatus, messageStatus, search]);
}, [deliveryStatus, digestStatus, messageStatus, notificationStatus, search]);
useEffect(() => {
void load();
@@ -119,6 +148,82 @@ export function WechatPage() {
}
};
const toggleChannel = (channel: 'web' | 'wechat') => {
setNotifyChannels((current) => {
if (current.includes(channel)) {
if (current.length === 1) return current;
return current.filter((item) => item !== channel);
}
return [...current, channel];
});
};
const handleSendNotification = async () => {
const title = notifyTitle.trim();
const body = notifyBody.trim();
if (!title) {
setError('请填写通知标题');
return;
}
if (!body) {
setError('请填写通知内容');
return;
}
if (notifyAudience === 'single' && !notifyUserId) {
setError('请选择目标用户');
return;
}
if (notifyAudience === 'multi' && notifyUserIds.length === 0) {
setError('请至少选择一个用户');
return;
}
setBusy(true);
setError(null);
setNotice(null);
try {
const result = await createWechatWebNotification(
notifyAudience === 'all'
? {
audience: 'all',
allUsers: true,
title,
body,
notificationType: notifyType.trim() || 'manual',
channels: notifyChannels,
}
: notifyAudience === 'multi'
? {
userIds: notifyUserIds,
title,
body,
notificationType: notifyType.trim() || 'manual',
channels: notifyChannels,
}
: {
userId: notifyUserId,
title,
body,
notificationType: notifyType.trim() || 'manual',
channels: notifyChannels,
},
);
const failed = result.wechatFailures?.length ?? 0;
setNotice(
`发送完成:目标 ${result.targets} 人,网页通知 ${result.created} 条,公众号成功 ${result.wechatSent}${
failed ? `,失败 ${failed}` : ''
}`,
);
setNotifyTitle('');
setNotifyBody('');
if (notifyAudience === 'multi') setNotifyUserIds([]);
await load();
} catch (err) {
setError(err instanceof Error ? err.message : '发送失败');
} finally {
setBusy(false);
}
};
return (
<div className="admin-page">
<div className="admin-page-head">
@@ -127,6 +232,7 @@ export function WechatPage() {
</div>
{error && <p className="banner banner-error">{error}</p>}
{notice && <p className="banner banner-info">{notice}</p>}
<div className="admin-stat-grid">
<Stat label="服务号" value={loading ? '—' : safe.config.mpEnabled ? '已启用' : '未启用'} />
@@ -178,6 +284,132 @@ export function WechatPage() {
</section>
)}
<section className="admin-card">
<div className="admin-card-head">
<h2></h2>
<button type="button" className="ghost-btn" onClick={() => void load()} disabled={busy}>
</button>
</div>
<div className="admin-form">
<select value={notifyAudience} onChange={(event) => setNotifyAudience(event.target.value as 'single' | 'multi' | 'all')}>
<option value="single"></option>
<option value="multi"></option>
<option value="all"></option>
</select>
<input
value={notifyType}
onChange={(event) => setNotifyType(event.target.value)}
placeholder="通知类型,例如 manual / recharge / balance_low"
/>
{notifyAudience === 'single' ? (
<select value={notifyUserId} onChange={(event) => setNotifyUserId(event.target.value)}>
<option value=""></option>
{users.map((user) => (
<option key={user.id} value={user.id}>
{(user.displayName || user.username) + ` (@${user.username})`}
</option>
))}
</select>
) : null}
{notifyAudience === 'multi' ? (
<select
multiple
value={notifyUserIds}
onChange={(event) =>
setNotifyUserIds(Array.from(event.target.selectedOptions).map((item) => item.value))
}
style={{ minHeight: 140 }}
>
{users.map((user) => (
<option key={user.id} value={user.id}>
{(user.displayName || user.username) + ` (@${user.username})`}
</option>
))}
</select>
) : null}
<input
value={notifyTitle}
onChange={(event) => setNotifyTitle(event.target.value)}
placeholder="通知标题"
/>
<textarea
value={notifyBody}
onChange={(event) => setNotifyBody(event.target.value)}
placeholder="通知内容"
rows={4}
/>
</div>
<div className="wechat-toolbar" style={{ justifyContent: 'space-between', alignItems: 'center' }}>
<div className="wechat-toolbar">
<label>
<input
type="checkbox"
checked={notifyChannels.includes('web')}
onChange={() => toggleChannel('web')}
/>{' '}
</label>
<label>
<input
type="checkbox"
checked={notifyChannels.includes('wechat')}
onChange={() => toggleChannel('wechat')}
/>{' '}
</label>
</div>
<button type="button" className="send-btn" onClick={() => void handleSendNotification()} disabled={busy}>
{busy ? '发送中…' : '立即发送'}
</button>
</div>
</section>
<section className="admin-card">
<TableHead
title="最近网页通知"
value={notificationStatus}
options={['', 'unread', 'read']}
onChange={setNotificationStatus}
onRefresh={() => void load()}
busy={busy}
/>
<div className="admin-table-wrap">
<table className="admin-table">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{webNotifications.length === 0 ? (
<tr><td colSpan={5} className="muted"></td></tr>
) : (
webNotifications.map((notification) => (
<tr key={notification.id}>
<td>
<div>{userLabel(notification)}</div>
<div className="muted">@{notification.username}</div>
</td>
<td>{notification.notificationType}</td>
<td>
<div>{notification.title}</div>
<div className="muted">{notification.body}</div>
</td>
<td className={statusClass(notification.status)}>{notification.status}</td>
<td>{dateLabel(notification.createdAt)}</td>
</tr>
))
)}
</tbody>
</table>
</div>
</section>
<section className="admin-card">
<div className="admin-card-head">
<h2></h2>