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:
john
2026-06-26 15:19:03 +08:00
parent 9ed4fd48d7
commit 9b4a25799f
162 changed files with 17276 additions and 2054 deletions
-2
View File
@@ -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
View File
@@ -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' });
}
+1 -2
View File
@@ -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
+16 -7
View File
@@ -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 />
-354
View File
@@ -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>
);
}
+23
View File
@@ -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>
);
}
+230 -1
View File
@@ -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>
);
}