chore: checkpoint admin restore state
This commit is contained in:
+14
-7
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 和模型绑定到 Goose、Aider、OpenHands</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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' ? '禁用' : '启用'}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user