Add smart ACK provider for WeChat MP replies
Replace fixed ackText with a rule-based AckProvider that picks response templates by message type and intent (translate, summary, rewrite, poster, ppt, mindmap, code, search, schedule). Pure sync, zero I/O, auto-falls back to config.ackText on any error. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -10,7 +10,6 @@ import { ReportsPage } from './pages/ReportsPage';
|
||||
import { ReviewPage } from './pages/ReviewPage';
|
||||
import { SummaryPage } from './pages/admin/SummaryPage';
|
||||
import { UsersPage } from './pages/admin/UsersPage';
|
||||
import { LlmPage } from './pages/admin/LlmPage';
|
||||
import { BillingPage } from './pages/admin/BillingPage';
|
||||
import { WechatPage } from './pages/admin/WechatPage';
|
||||
|
||||
@@ -43,7 +42,6 @@ export function App() {
|
||||
>
|
||||
<Route index element={<SummaryPage />} />
|
||||
<Route path="users" element={<UsersPage />} />
|
||||
<Route path="llm" element={<LlmPage />} />
|
||||
<Route path="billing" element={<BillingPage />} />
|
||||
<Route path="wechat" element={<WechatPage />} />
|
||||
<Route path="*" element={<Navigate to="/admin" replace />} />
|
||||
|
||||
+49
-67
@@ -42,16 +42,6 @@ export type AdminSummary = {
|
||||
};
|
||||
};
|
||||
|
||||
export type LlmKey = {
|
||||
id: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
model?: string;
|
||||
models?: string[];
|
||||
isSelected: boolean;
|
||||
createdAt?: string;
|
||||
};
|
||||
|
||||
export type LedgerEntry = {
|
||||
id: string;
|
||||
userId: string;
|
||||
@@ -160,6 +150,33 @@ export type WechatDeliveryLog = {
|
||||
digestType: string | null;
|
||||
};
|
||||
|
||||
export type WechatWebNotification = {
|
||||
id: string;
|
||||
userId: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
channel: string;
|
||||
notificationType: string;
|
||||
title: string;
|
||||
body: string;
|
||||
status: string;
|
||||
readAt: number | null;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
export type CreateWechatNotificationPayload = {
|
||||
userId?: string;
|
||||
userIds?: string[];
|
||||
audience?: 'all';
|
||||
allUsers?: boolean;
|
||||
title: string;
|
||||
body: string;
|
||||
notificationType?: string;
|
||||
channel?: 'web' | 'wechat';
|
||||
channels?: Array<'web' | 'wechat'>;
|
||||
};
|
||||
|
||||
// ─── Summary ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function fetchAdminSummary() {
|
||||
@@ -269,6 +286,28 @@ export async function fetchWechatDeliveries(params: { status?: string; limit?: n
|
||||
return adminFetch<{ deliveries: WechatDeliveryLog[] }>(`/admin-api/wechat/deliveries?${q}`);
|
||||
}
|
||||
|
||||
export async function fetchWechatWebNotifications(params: { status?: string; limit?: number } = {}) {
|
||||
const q = new URLSearchParams();
|
||||
if (params.status) q.set('status', params.status);
|
||||
if (params.limit) q.set('limit', String(params.limit));
|
||||
return adminFetch<{ notifications: WechatWebNotification[] }>(
|
||||
`/admin-api/wechat/web-notifications?${q}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function createWechatWebNotification(body: CreateWechatNotificationPayload) {
|
||||
return adminFetch<{
|
||||
ok: boolean;
|
||||
created: number;
|
||||
wechatSent: number;
|
||||
wechatFailures: Array<{ userId: string; message: string }>;
|
||||
targets: number;
|
||||
}>('/admin-api/wechat/web-notifications', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
export async function clearWechatRoute(userId: string) {
|
||||
return adminFetch<{ ok: boolean; deleted: number; openidMasked: string }>(
|
||||
`/admin-api/wechat/users/${userId}/route/clear`,
|
||||
@@ -285,60 +324,3 @@ export async function resumeWechatDigest(id: string) {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
// ─── LLM Providers ───────────────────────────────────────────────────────────
|
||||
|
||||
export async function fetchLlmKeys() {
|
||||
return adminFetch<{ keys: LlmKey[] }>('/admin-api/llm-providers/keys');
|
||||
}
|
||||
|
||||
export async function createLlmKey(body: {
|
||||
name: string;
|
||||
provider: string;
|
||||
apiKey: string;
|
||||
model?: string;
|
||||
models?: string;
|
||||
baseUrl?: string;
|
||||
}) {
|
||||
return adminFetch<{ key: LlmKey }>('/admin-api/llm-providers/keys', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
export async function patchLlmKey(keyId: string, patch: { name?: string; apiKey?: string; model?: string }) {
|
||||
return adminFetch<{ key: LlmKey }>(`/admin-api/llm-providers/keys/${keyId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
}
|
||||
|
||||
export async function selectLlmKey(keyId: string) {
|
||||
return adminFetch(`/admin-api/llm-providers/keys/${keyId}/select`, { method: 'POST' });
|
||||
}
|
||||
|
||||
export async function deleteLlmKey(keyId: string) {
|
||||
return adminFetch(`/admin-api/llm-providers/keys/${keyId}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export async function testLlmKey(keyId: string) {
|
||||
return adminFetch<{ ok: boolean; model?: string; error?: string }>(
|
||||
`/admin-api/llm-providers/keys/${keyId}/test`,
|
||||
{ method: 'POST' },
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchLlmGlobal() {
|
||||
return adminFetch<{ settings: { model: string | null } }>('/admin-api/llm-providers/global');
|
||||
}
|
||||
|
||||
export async function putLlmGlobal(model: string) {
|
||||
return adminFetch('/admin-api/llm-providers/global', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ model }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function syncLlmProviders() {
|
||||
return adminFetch<{ synced: number }>('/admin-api/llm-providers/sync', { method: 'POST' });
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { NavLink, Outlet } from 'react-router-dom';
|
||||
const links = [
|
||||
{ to: '/admin', label: '概览', end: true },
|
||||
{ to: '/admin/users', label: '用户管理' },
|
||||
{ to: '/admin/llm', label: 'LLM 配置' },
|
||||
{ to: '/admin/billing', label: '账单记录' },
|
||||
{ to: '/admin/wechat', label: '服务号管理' },
|
||||
];
|
||||
@@ -13,7 +12,7 @@ export function AdminLayout() {
|
||||
<div className="layout">
|
||||
<header>
|
||||
<h1>超级管理后台</h1>
|
||||
<p style={{ color: '#68716c' }}>用户、计费、LLM 与服务号</p>
|
||||
<p style={{ color: '#68716c' }}>用户、计费与服务号</p>
|
||||
</header>
|
||||
<nav className="nav">
|
||||
<NavLink
|
||||
|
||||
@@ -30,13 +30,22 @@ export function OpsLayout() {
|
||||
</NavLink>
|
||||
))}
|
||||
{user?.role === 'admin' ? (
|
||||
<NavLink
|
||||
to="/admin"
|
||||
className={({ isActive }) => (isActive ? 'active' : undefined)}
|
||||
style={{ marginLeft: 'auto', opacity: 0.75 }}
|
||||
>
|
||||
⚙ 超管
|
||||
</NavLink>
|
||||
<>
|
||||
<NavLink
|
||||
to="/admin/wechat"
|
||||
className={({ isActive }) => (isActive ? 'active' : undefined)}
|
||||
style={{ marginLeft: 'auto' }}
|
||||
>
|
||||
通知推送
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="/admin"
|
||||
className={({ isActive }) => (isActive ? 'active' : undefined)}
|
||||
style={{ opacity: 0.75 }}
|
||||
>
|
||||
⚙ 超管
|
||||
</NavLink>
|
||||
</>
|
||||
) : null}
|
||||
</nav>
|
||||
<Outlet />
|
||||
|
||||
@@ -1,354 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
fetchLlmKeys,
|
||||
fetchLlmGlobal,
|
||||
createLlmKey,
|
||||
patchLlmKey,
|
||||
deleteLlmKey,
|
||||
selectLlmKey,
|
||||
testLlmKey,
|
||||
putLlmGlobal,
|
||||
syncLlmProviders,
|
||||
type LlmKey,
|
||||
} from '../../api/admin';
|
||||
|
||||
export function LlmPage() {
|
||||
const [keys, setKeys] = useState<LlmKey[]>([]);
|
||||
const [globalModel, setGlobalModel] = useState<string>('');
|
||||
const [globalModelInput, setGlobalModelInput] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [testResults, setTestResults] = useState<Record<string, { ok: boolean; msg: string }>>({});
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [editKey, setEditKey] = useState<LlmKey | null>(null);
|
||||
|
||||
const load = async () => {
|
||||
setError(null);
|
||||
try {
|
||||
const [keysResult, globalResult] = await Promise.all([fetchLlmKeys(), fetchLlmGlobal()]);
|
||||
setKeys(keysResult.keys);
|
||||
const model = globalResult.settings?.model ?? '';
|
||||
setGlobalModel(model);
|
||||
setGlobalModelInput(model);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载失败');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { void load(); }, []);
|
||||
|
||||
const handleSelect = async (keyId: string) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await selectLlmKey(keyId);
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '操作失败');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (key: LlmKey) => {
|
||||
if (!window.confirm(`确认删除密钥「${key.name}」?`)) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await deleteLlmKey(key.id);
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '操作失败');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTest = async (key: LlmKey) => {
|
||||
setTestResults((prev) => ({ ...prev, [key.id]: { ok: false, msg: '测试中…' } }));
|
||||
try {
|
||||
const result = await testLlmKey(key.id);
|
||||
setTestResults((prev) => ({
|
||||
...prev,
|
||||
[key.id]: { ok: result.ok, msg: result.ok ? `OK (${result.model ?? ''})` : (result.error ?? '失败') },
|
||||
}));
|
||||
} catch (err) {
|
||||
setTestResults((prev) => ({
|
||||
...prev,
|
||||
[key.id]: { ok: false, msg: err instanceof Error ? err.message : '连接失败' },
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveGlobal = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await putLlmGlobal(globalModelInput.trim());
|
||||
setGlobalModel(globalModelInput.trim());
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '保存失败');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSync = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const result = await syncLlmProviders();
|
||||
alert(`同步完成,更新 ${result.synced} 条`);
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '同步失败');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid">
|
||||
{error ? <p className="alert">{error}</p> : null}
|
||||
|
||||
{/* Global model */}
|
||||
<div className="card grid">
|
||||
<h3 style={{ margin: 0 }}>全局模型设置</h3>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<input
|
||||
value={globalModelInput}
|
||||
onChange={(e) => setGlobalModelInput(e.target.value)}
|
||||
placeholder="如 deepseek-chat"
|
||||
style={{ flex: '1 1 200px', padding: '8px 12px', borderRadius: 999, border: '1px solid #d6d0c3' }}
|
||||
/>
|
||||
<button type="button" className="btn" onClick={() => void handleSaveGlobal()} disabled={busy || globalModelInput === globalModel}>
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
{globalModel ? (
|
||||
<p style={{ fontSize: 12, color: '#68716c', margin: 0 }}>当前:{globalModel}</p>
|
||||
) : (
|
||||
<p style={{ fontSize: 12, color: '#68716c', margin: 0 }}>未设置(使用选中密钥自带的 model)</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Keys list */}
|
||||
<div className="card grid">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<h3 style={{ margin: 0 }}>API 密钥({keys.length})</h3>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button type="button" className="btn secondary" onClick={() => void handleSync()} disabled={busy}>
|
||||
同步 Providers
|
||||
</button>
|
||||
<button type="button" className="btn" onClick={() => setShowCreate(true)}>
|
||||
添加密钥
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{keys.length === 0 ? (
|
||||
<p style={{ color: '#68716c' }}>暂无配置密钥</p>
|
||||
) : (
|
||||
keys.map((key) => (
|
||||
<div
|
||||
key={key.id}
|
||||
style={{
|
||||
border: `1px solid ${key.isSelected ? '#2f6f57' : '#d6d0c3'}`,
|
||||
borderRadius: 12,
|
||||
padding: 12,
|
||||
background: key.isSelected ? '#f0f9f4' : undefined,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', flexWrap: 'wrap', gap: 8 }}>
|
||||
<div>
|
||||
<strong>{key.name}</strong>
|
||||
{key.isSelected ? (
|
||||
<span style={{ marginLeft: 8, fontSize: 11, color: '#2f6f57', fontWeight: 600 }}>✓ 当前选中</span>
|
||||
) : null}
|
||||
<p style={{ margin: '4px 0 0', fontSize: 12, color: '#68716c' }}>
|
||||
{key.provider} {key.model ? `· ${key.model}` : ''}
|
||||
{key.models?.length ? ` · ${key.models.join(', ')}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
{!key.isSelected ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
style={{ padding: '4px 10px', fontSize: 12 }}
|
||||
onClick={() => void handleSelect(key.id)}
|
||||
disabled={busy}
|
||||
>
|
||||
选为当前
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
style={{ padding: '4px 10px', fontSize: 12 }}
|
||||
onClick={() => void handleTest(key)}
|
||||
>
|
||||
测试
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
style={{ padding: '4px 10px', fontSize: 12 }}
|
||||
onClick={() => setEditKey(key)}
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn danger"
|
||||
style={{ padding: '4px 10px', fontSize: 12 }}
|
||||
onClick={() => void handleDelete(key)}
|
||||
disabled={busy}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{testResults[key.id] ? (
|
||||
<p
|
||||
style={{
|
||||
margin: '8px 0 0',
|
||||
fontSize: 12,
|
||||
color: testResults[key.id].ok ? '#2f6f57' : '#b42318',
|
||||
}}
|
||||
>
|
||||
{testResults[key.id].msg}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showCreate ? (
|
||||
<CreateKeyModal
|
||||
onClose={() => setShowCreate(false)}
|
||||
onSuccess={() => { setShowCreate(false); void load(); }}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{editKey ? (
|
||||
<EditKeyModal
|
||||
llmKey={editKey}
|
||||
onClose={() => setEditKey(null)}
|
||||
onSuccess={() => { setEditKey(null); void load(); }}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateKeyModal({ onClose, onSuccess }: { onClose: () => void; onSuccess: () => void }) {
|
||||
const [name, setName] = useState('');
|
||||
const [provider, setProvider] = useState('openai');
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
const [model, setModel] = useState('');
|
||||
const [baseUrl, setBaseUrl] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!name.trim() || !apiKey.trim()) { setError('名称和 API Key 必填'); return; }
|
||||
setBusy(true);
|
||||
try {
|
||||
await createLlmKey({ name: name.trim(), provider, apiKey, model: model.trim() || undefined, baseUrl: baseUrl.trim() || undefined });
|
||||
onSuccess();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '创建失败');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal title="添加 API 密钥" onClose={onClose}>
|
||||
<div className="grid">
|
||||
<LlmField label="名称"><input value={name} onChange={(e) => setName(e.target.value)} placeholder="DeepSeek Default" /></LlmField>
|
||||
<LlmField label="Provider">
|
||||
<select value={provider} onChange={(e) => setProvider(e.target.value)}>
|
||||
<option value="openai">openai</option>
|
||||
<option value="deepseek">deepseek</option>
|
||||
<option value="anthropic">anthropic</option>
|
||||
<option value="ollama">ollama</option>
|
||||
<option value="openrouter">openrouter</option>
|
||||
</select>
|
||||
</LlmField>
|
||||
<LlmField label="API Key"><input value={apiKey} onChange={(e) => setApiKey(e.target.value)} type="password" placeholder="sk-..." /></LlmField>
|
||||
<LlmField label="默认模型(可选)"><input value={model} onChange={(e) => setModel(e.target.value)} placeholder="deepseek-chat" /></LlmField>
|
||||
<LlmField label="Base URL(可选)"><input value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} placeholder="https://api.deepseek.com/v1" /></LlmField>
|
||||
{error ? <p className="alert">{error}</p> : null}
|
||||
<ModalActions onClose={onClose} onSubmit={() => void handleSubmit()} busy={busy} />
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function EditKeyModal({ llmKey, onClose, onSuccess }: { llmKey: LlmKey; onClose: () => void; onSuccess: () => void }) {
|
||||
const [name, setName] = useState(llmKey.name);
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
const [model, setModel] = useState(llmKey.model ?? '');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const patch: Record<string, string> = {};
|
||||
if (name !== llmKey.name) patch.name = name.trim();
|
||||
if (apiKey) patch.apiKey = apiKey;
|
||||
if (model !== (llmKey.model ?? '')) patch.model = model.trim();
|
||||
if (Object.keys(patch).length === 0) { onClose(); return; }
|
||||
await patchLlmKey(llmKey.id, patch);
|
||||
onSuccess();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '更新失败');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal title={`编辑:${llmKey.name}`} onClose={onClose}>
|
||||
<div className="grid">
|
||||
<LlmField label="名称"><input value={name} onChange={(e) => setName(e.target.value)} /></LlmField>
|
||||
<LlmField label="新 API Key(留空不修改)"><input value={apiKey} onChange={(e) => setApiKey(e.target.value)} type="password" placeholder="留空不修改" /></LlmField>
|
||||
<LlmField label="默认模型"><input value={model} onChange={(e) => setModel(e.target.value)} /></LlmField>
|
||||
{error ? <p className="alert">{error}</p> : null}
|
||||
<ModalActions onClose={onClose} onSubmit={() => void handleSubmit()} busy={busy} />
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function Modal({ title, onClose, children }: { title: string; onClose: () => void; children: React.ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,.35)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 100 }}
|
||||
onClick={(e) => e.target === e.currentTarget && onClose()}
|
||||
>
|
||||
<div className="card grid" style={{ width: '100%', maxWidth: 480, margin: 16, maxHeight: '90vh', overflowY: 'auto' }}>
|
||||
<h3 style={{ margin: 0 }}>{title}</h3>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LlmField({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div style={{ display: 'grid', gap: 4 }}>
|
||||
<label style={{ fontSize: 12, color: '#68716c' }}>{label}</label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ModalActions({ onClose, onSubmit, busy }: { onClose: () => void; onSubmit: () => void; busy: boolean }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||
<button type="button" className="btn secondary" onClick={onClose} disabled={busy}>取消</button>
|
||||
<button type="button" className="btn" onClick={onSubmit} disabled={busy}>{busy ? '处理中…' : '确认'}</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { fetchAdminSummary, type AdminSummary } from '../../api/admin';
|
||||
|
||||
function yuan(cents: number) {
|
||||
@@ -50,6 +51,28 @@ export function SummaryPage() {
|
||||
<p style={{ color: '#68716c' }}>LLM 服务未启用</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="card"
|
||||
style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 12 }}
|
||||
>
|
||||
<div>
|
||||
<h3 style={{ marginTop: 0, marginBottom: 8 }}>常用入口</h3>
|
||||
<p style={{ color: '#68716c', marginTop: 0 }}>
|
||||
这里可以直接进入用户充值、服务号通知和网页端通知管理。
|
||||
</p>
|
||||
</div>
|
||||
<Link className="btn" to="/admin/wechat" style={{ textAlign: 'center', textDecoration: 'none' }}>
|
||||
进入通知推送
|
||||
</Link>
|
||||
<Link
|
||||
className="btn secondary"
|
||||
to="/admin/users"
|
||||
style={{ textAlign: 'center', textDecoration: 'none' }}
|
||||
>
|
||||
进入用户管理
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
import { useEffect, useState, type CSSProperties, type ReactNode } from 'react';
|
||||
import {
|
||||
cancelWechatDigest,
|
||||
createWechatWebNotification,
|
||||
clearWechatRoute,
|
||||
fetchAdminUsers,
|
||||
fetchWechatBindings,
|
||||
fetchWechatDeliveries,
|
||||
fetchWechatDigests,
|
||||
fetchWechatMessages,
|
||||
fetchWechatSummary,
|
||||
fetchWechatWebNotifications,
|
||||
resumeWechatDigest,
|
||||
type AdminUser,
|
||||
type WechatAdminSummary,
|
||||
type WechatBinding,
|
||||
type WechatDeliveryLog,
|
||||
type WechatDigestSubscription,
|
||||
type WechatMessage,
|
||||
type WechatWebNotification,
|
||||
} from '../../api/admin';
|
||||
|
||||
function time(value?: number | null) {
|
||||
@@ -66,29 +71,55 @@ 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<AdminUser[]>([]);
|
||||
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 [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
|
||||
const load = async () => {
|
||||
setError(null);
|
||||
try {
|
||||
const [summaryResult, bindingResult, messageResult, digestResult, deliveryResult] =
|
||||
const [
|
||||
summaryResult,
|
||||
bindingResult,
|
||||
messageResult,
|
||||
digestResult,
|
||||
deliveryResult,
|
||||
notificationResult,
|
||||
userResult,
|
||||
] =
|
||||
await Promise.all([
|
||||
fetchWechatSummary(),
|
||||
fetchWechatBindings({ search, limit: 80 }),
|
||||
fetchWechatMessages({ status: messageStatus || undefined, limit: 80 }),
|
||||
fetchWechatDigests({ status: digestStatus || undefined, limit: 80 }),
|
||||
fetchWechatDeliveries({ status: deliveryStatus || undefined, limit: 80 }),
|
||||
fetchWechatWebNotifications({ status: notificationStatus || undefined, limit: 80 }),
|
||||
fetchAdminUsers({ page: 1, pageSize: 200, status: 'active' }),
|
||||
]);
|
||||
setSummary(summaryResult);
|
||||
setBindings(bindingResult.bindings);
|
||||
setMessages(messageResult.messages);
|
||||
setDigests(digestResult.digests);
|
||||
setDeliveries(deliveryResult.deliveries);
|
||||
setWebNotifications(notificationResult.notifications);
|
||||
setUsers(userResult.users);
|
||||
setNotifyUserId((current) =>
|
||||
current || userResult.users[0]?.id || '',
|
||||
);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载失败');
|
||||
}
|
||||
@@ -136,11 +167,80 @@ 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 handleSubmitNotification = async () => {
|
||||
const title = notifyTitle.trim();
|
||||
const body = notifyBody.trim();
|
||||
if (!title) {
|
||||
setError('请填写通知标题');
|
||||
return;
|
||||
}
|
||||
if (!body) {
|
||||
setError('请填写通知内容');
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
try {
|
||||
const payload =
|
||||
notifyAudience === 'all'
|
||||
? {
|
||||
audience: 'all' as const,
|
||||
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 result = await createWechatWebNotification(payload);
|
||||
const failureCount = result.wechatFailures?.length ?? 0;
|
||||
setNotice(
|
||||
`发送完成:目标 ${result.targets} 人,网页通知 ${result.created} 条,公众号成功 ${result.wechatSent} 条${
|
||||
failureCount ? `,失败 ${failureCount} 条` : ''
|
||||
}。`,
|
||||
);
|
||||
setNotifyTitle('');
|
||||
setNotifyBody('');
|
||||
if (notifyAudience === 'multi') setNotifyUserIds([]);
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '发送失败');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!summary && !error) return <p>加载中...</p>;
|
||||
|
||||
return (
|
||||
<div className="grid">
|
||||
{error ? <p className="alert">{error}</p> : null}
|
||||
{notice ? <p className="alert" style={{ background: '#edf7f1', color: '#2f6f57' }}>{notice}</p> : null}
|
||||
|
||||
{summary ? (
|
||||
<div
|
||||
@@ -160,6 +260,88 @@ export function WechatPage() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<section className="card grid">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
|
||||
<h3 style={{ margin: 0 }}>主动通知推送</h3>
|
||||
<button type="button" className="btn secondary" onClick={() => void load()} disabled={busy}>
|
||||
刷新用户
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))',
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<Field label="发送对象">
|
||||
<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>
|
||||
</Field>
|
||||
<Field label="通知类型">
|
||||
<input value={notifyType} onChange={(event) => setNotifyType(event.target.value)} placeholder="manual / recharge / balance_low" />
|
||||
</Field>
|
||||
<Field label="发送渠道">
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', minHeight: 38, alignItems: 'center' }}>
|
||||
<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>
|
||||
</Field>
|
||||
{notifyAudience === 'single' ? (
|
||||
<Field label="目标用户">
|
||||
<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>
|
||||
</Field>
|
||||
) : null}
|
||||
{notifyAudience === 'multi' ? (
|
||||
<Field label="目标用户(可多选)">
|
||||
<select
|
||||
multiple
|
||||
value={notifyUserIds}
|
||||
onChange={(event) =>
|
||||
setNotifyUserIds(Array.from(event.target.selectedOptions).map((item) => item.value))
|
||||
}
|
||||
style={{ minHeight: 120 }}
|
||||
>
|
||||
{users.map((user) => (
|
||||
<option key={user.id} value={user.id}>
|
||||
{user.displayName || user.username} ({user.username})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
) : null}
|
||||
</div>
|
||||
<Field label="标题">
|
||||
<input value={notifyTitle} onChange={(event) => setNotifyTitle(event.target.value)} placeholder="例如:系统维护提醒" />
|
||||
</Field>
|
||||
<Field label="内容">
|
||||
<textarea
|
||||
value={notifyBody}
|
||||
onChange={(event) => setNotifyBody(event.target.value)}
|
||||
placeholder="填写要发送给用户的通知内容"
|
||||
rows={4}
|
||||
/>
|
||||
</Field>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<p style={{ margin: 0, color: '#68716c', fontSize: 12 }}>
|
||||
支持单发、批量、多通道;全部用户会发送给所有非 disabled 用户。
|
||||
</p>
|
||||
<button type="button" className="btn" onClick={() => void handleSubmitNotification()} disabled={busy}>
|
||||
{busy ? '发送中…' : '立即发送'}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card grid">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
|
||||
<h3 style={{ margin: 0 }}>绑定与会话路由</h3>
|
||||
@@ -220,6 +402,44 @@ export function WechatPage() {
|
||||
</TableShell>
|
||||
</section>
|
||||
|
||||
<section className="card grid">
|
||||
<SectionHead
|
||||
title="最近网页通知"
|
||||
value={notificationStatus}
|
||||
options={['', 'unread', 'read']}
|
||||
onChange={setNotificationStatus}
|
||||
onRefresh={() => void load()}
|
||||
busy={busy}
|
||||
/>
|
||||
<TableShell empty={webNotifications.length === 0}>
|
||||
<table style={tableStyle}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={thStyle}>用户</th>
|
||||
<th style={thStyle}>类型</th>
|
||||
<th style={thStyle}>标题</th>
|
||||
<th style={thStyle}>状态</th>
|
||||
<th style={thStyle}>创建时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{webNotifications.map((notification) => (
|
||||
<tr key={notification.id}>
|
||||
<td style={tdStyle}>{userName(notification)}</td>
|
||||
<td style={tdStyle}>{notification.notificationType}</td>
|
||||
<td style={{ ...tdStyle, maxWidth: 420 }}>
|
||||
<strong>{notification.title}</strong>
|
||||
<div style={{ color: '#68716c', fontSize: 12, marginTop: 4 }}>{notification.body}</div>
|
||||
</td>
|
||||
<td style={tdStyle}>{badge(notification.status)}</td>
|
||||
<td style={tdStyle}>{time(notification.createdAt)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableShell>
|
||||
</section>
|
||||
|
||||
<section className="card grid">
|
||||
<SectionHead
|
||||
title="每日待办推送"
|
||||
@@ -407,3 +627,12 @@ function TableShell({ empty, children }: { empty: boolean; children: ReactNode }
|
||||
if (empty) return <p style={{ color: '#68716c', margin: 0 }}>暂无记录</p>;
|
||||
return <div style={{ overflowX: 'auto' }}>{children}</div>;
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<label style={{ display: 'grid', gap: 6 }}>
|
||||
<span style={{ color: '#68716c', fontSize: 12 }}>{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user