Files
memind_adm/src/admin/pages/WechatPage.tsx
T
john ec38ee086d Add dedicated admin page for TKMind Cursor experience channel (H5 + WeChat).
Centralize whitelist and channel toggles away from the WeChat page so Cursor stays an opt-in path for selected users only.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 15:27:10 +08:00

961 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import {
cancelWechatDigest,
clearWechatRoute,
createWechatWebNotification,
getWechatAdminSummary,
getWechatIntentRouterConfig,
getWechatIntentRouterRuntime,
listAdminUsers,
listLlmProviderKeys,
listWechatBindings,
listWechatDeliveries,
listWechatDigests,
listWechatMessages,
listWechatWebNotifications,
patchWechatIntentRouterConfig,
resumeWechatDigest,
updateWechatScheduleLlmConfig,
} from '../../api/client';
import type {
AdminUserRow,
LlmProviderKeyRow,
WechatAdminSummary,
WechatBinding,
WechatDeliveryLog,
WechatDigestSubscription,
WechatIntentRouterAdminConfig,
WechatIntentRouterRuntimeState,
WechatMessage,
WechatWebNotification,
} from '../../types';
import { formatTime } from '../utils/format';
function count(record: Record<string, number>, key: string) {
return record[key] ?? 0;
}
function safeSummary(summary: WechatAdminSummary | null) {
return {
config: summary?.config ?? {
mpEnabled: false,
scheduleEnabled: false,
reminderWorkerEnabled: false,
appId: null,
publicBaseUrl: null,
bindPath: null,
tokenEndpointConfigured: false,
customerServiceEndpointConfigured: false,
scheduleLlmEnabled: false,
},
counts: summary?.counts ?? {
boundUsers: 0,
routes: { total: 0, active: 0 },
recentMessages: {},
digests: {},
recentDeliveries: {},
},
};
}
function statusLabel(status?: string | null) {
if (!status) return '—';
return status;
}
function statusClass(status?: string | null) {
if (status === 'failed') return 'text-error';
if (status === 'active' || status === 'success' || status === 'done') return 'wechat-ok';
return 'muted';
}
function userLabel(row: { displayName?: string | null; username?: string | null }) {
return row.displayName || row.username || '—';
}
function dateLabel(value?: number | null) {
return value ? formatTime(value) : '—';
}
type IntentRouterForm = {
enabled: boolean;
shadowMode: boolean;
modelProviderKeyId: string;
model: string;
minConfidence: string;
timeoutMs: string;
canaryOpenids: string;
};
function intentRouterToForm(
config: WechatIntentRouterAdminConfig | undefined,
keys: LlmProviderKeyRow[],
): IntentRouterForm {
const keyId = String(config?.modelProviderKeyId ?? '').trim();
const selectedKey = keys.find((item) => item.id === keyId) ?? null;
const model = String(config?.model ?? '').trim()
|| selectedKey?.defaultModel
|| selectedKey?.models?.[0]
|| '';
return {
enabled: Boolean(config?.enabled),
shadowMode: config?.shadowMode !== false,
modelProviderKeyId: keyId,
model,
minConfidence: String(config?.minConfidence ?? 0.65),
timeoutMs: String(config?.timeoutMs ?? 4000),
canaryOpenids: Array.isArray(config?.canaryOpenids) ? config.canaryOpenids.join('\n') : '',
};
}
function intentRouterRuntimeMode(overrides: Record<string, string>) {
const enabled = ['1', 'true', 'yes', 'on'].includes(
String(overrides.MEMIND_WECHAT_INTENT_LLM_ENABLED ?? '').toLowerCase(),
);
const shadow = ['1', 'true', 'yes', 'on'].includes(
String(overrides.MEMIND_WECHAT_INTENT_LLM_SHADOW ?? '').toLowerCase(),
);
if (!enabled) return '关闭(仅规则意图)';
if (shadow) return 'Shadow 观测(行为不变)';
return '已激活(chat.general 可升级为 page.generate';
}
export function WechatPage() {
const [summary, setSummary] = useState<WechatAdminSummary | null>(null);
const [bindings, setBindings] = useState<WechatBinding[]>([]);
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 [scheduleLlmEnabled, setScheduleLlmEnabled] = useState(false);
const [llmKeys, setLlmKeys] = useState<LlmProviderKeyRow[]>([]);
const [intentForm, setIntentForm] = useState<IntentRouterForm | null>(null);
const [savedIntentForm, setSavedIntentForm] = useState<IntentRouterForm | null>(null);
const [intentRuntime, setIntentRuntime] = useState<WechatIntentRouterRuntimeState | null>(null);
const [intentSaving, setIntentSaving] = useState(false);
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 intentDirty = useMemo(() => {
if (!intentForm || !savedIntentForm) return false;
return JSON.stringify(intentForm) !== JSON.stringify(savedIntentForm);
}, [intentForm, savedIntentForm]);
const selectedIntentKey = useMemo(
() => llmKeys.find((item) => item.id === intentForm?.modelProviderKeyId) ?? null,
[llmKeys, intentForm?.modelProviderKeyId],
);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [
nextSummary,
nextBindings,
nextMessages,
nextDigests,
nextDeliveries,
nextNotifications,
nextUsers,
nextLlmKeys,
nextIntentConfig,
nextIntentRuntime,
] =
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' }),
listLlmProviderKeys().catch(() => [] as LlmProviderKeyRow[]),
getWechatIntentRouterConfig().catch(() => null),
getWechatIntentRouterRuntime().catch(() => null),
]);
setSummary(nextSummary);
setScheduleLlmEnabled(nextSummary.config.scheduleLlmEnabled ?? false);
setBindings(nextBindings);
setMessages(nextMessages);
setDigests(nextDigests);
setDeliveries(nextDeliveries);
setWebNotifications(nextNotifications);
setUsers(nextUsers.items);
setNotifyUserId((current) => current || nextUsers.items[0]?.id || '');
setLlmKeys(nextLlmKeys);
if (nextIntentConfig) {
const nextForm = intentRouterToForm(nextIntentConfig, nextLlmKeys);
setIntentForm(nextForm);
setSavedIntentForm(nextForm);
}
setIntentRuntime(nextIntentRuntime);
} catch (err) {
setError(err instanceof Error ? err.message : '加载服务号管理失败');
} finally {
setLoading(false);
}
}, [deliveryStatus, digestStatus, messageStatus, notificationStatus, search]);
useEffect(() => {
void load();
}, [load]);
const runAction = async (action: () => Promise<unknown>) => {
setBusy(true);
setError(null);
try {
await action();
await load();
} catch (err) {
setError(err instanceof Error ? err.message : '操作失败');
} finally {
setBusy(false);
}
};
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);
}
};
const handleSaveScheduleLlm = async () => {
await runAction(async () => {
await updateWechatScheduleLlmConfig({ scheduleLlmEnabled });
setNotice(`提醒待办 LLM 已${scheduleLlmEnabled ? '开启' : '关闭'}`);
});
};
const handleSaveIntentRouter = async () => {
if (!intentForm) return;
setIntentSaving(true);
setError(null);
setNotice(null);
try {
const result = await patchWechatIntentRouterConfig({
enabled: intentForm.enabled,
shadowMode: intentForm.shadowMode,
modelProviderKeyId: intentForm.modelProviderKeyId || null,
model: intentForm.model || null,
minConfidence: Number(intentForm.minConfidence) || 0.65,
timeoutMs: Number(intentForm.timeoutMs) || 4000,
canaryOpenids: intentForm.canaryOpenids
.split(/[\s,]+/u)
.map((item) => item.trim())
.filter(Boolean),
});
const nextForm = intentRouterToForm(result, llmKeys);
setIntentForm(nextForm);
setSavedIntentForm(nextForm);
setIntentRuntime(await getWechatIntentRouterRuntime().catch(() => null));
setNotice('微信 LLM 意图路由配置已保存。Portal 会在下次请求时自动热加载。');
} catch (err) {
setError(err instanceof Error ? err.message : '保存失败');
} finally {
setIntentSaving(false);
}
};
return (
<div className="admin-page">
<div className="admin-page-head">
<h2></h2>
<p className="muted"></p>
</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 ? '已启用' : '未启用'} />
<Stat label="绑定用户" value={loading ? '—' : safe.counts.boundUsers} />
<Stat
label="活跃路由"
value={
loading
? '—'
: `${safe.counts.routes.active}/${safe.counts.routes.total}`
}
/>
<Stat label="每日推送" value={loading ? '—' : count(safe.counts.digests, 'active')} />
<Stat
label="24h 消息失败"
value={loading ? '—' : count(safe.counts.recentMessages, 'failed')}
/>
<Stat
label="24h 投递失败"
value={loading ? '—' : count(safe.counts.recentDeliveries, 'failed')}
/>
</div>
{summary && (
<section className="admin-card">
<h2></h2>
<dl className="admin-dl">
<div>
<dt>AppID</dt>
<dd>{summary.config.appId ?? '未配置'}</dd>
</div>
<div>
<dt></dt>
<dd>{summary.config.publicBaseUrl ?? '未配置'}</dd>
</div>
<div>
<dt></dt>
<dd>{summary.config.bindPath ?? '未配置'}</dd>
</div>
<div>
<dt></dt>
<dd>{summary.config.scheduleEnabled ? '已启用' : '未启用'}</dd>
</div>
<div>
<dt> worker</dt>
<dd>{summary.config.reminderWorkerEnabled ? '已启用' : '未启用'}</dd>
</div>
<div>
<dt> LLM</dt>
<dd>{summary.config.scheduleLlmEnabled ? '已开启' : '已关闭'}</dd>
</div>
</dl>
<div className="wechat-toolbar" style={{ marginTop: 16, justifyContent: 'space-between', alignItems: 'center' }}>
<label>
<input
type="checkbox"
checked={scheduleLlmEnabled}
onChange={(event) => setScheduleLlmEnabled(event.target.checked)}
disabled={busy}
/>{' '}
LLM
</label>
<button type="button" className="ghost-btn" onClick={() => void handleSaveScheduleLlm()} disabled={busy}>
{busy ? '保存中…' : '保存开关'}
</button>
</div>
<p className="muted" style={{ marginTop: 8 }}>
LLM
</p>
</section>
)}
{intentForm ? (
<section className="admin-card">
<div className="admin-card-head">
<div>
<h2> LLM </h2>
<p className="muted" style={{ marginTop: 6 }}>
H5 Router chat.general LLM page.generate vs chat.general
</p>
</div>
<button
type="button"
className="ghost-btn"
onClick={() => void handleSaveIntentRouter()}
disabled={busy || intentSaving || !intentDirty}
>
{intentSaving ? '保存中…' : intentDirty ? '保存配置' : '已保存'}
</button>
</div>
<div className="admin-form" style={{ marginTop: 16 }}>
<label>
<input
type="checkbox"
checked={intentForm.enabled}
disabled={busy || intentSaving}
onChange={(event) =>
setIntentForm((current) => current && { ...current, enabled: event.target.checked })
}
/>{' '}
LLM
</label>
<label>
<input
type="checkbox"
checked={intentForm.shadowMode}
disabled={busy || intentSaving || !intentForm.enabled}
onChange={(event) =>
setIntentForm((current) => current && { ...current, shadowMode: event.target.checked })
}
/>{' '}
Shadow
</label>
<label>
LLM Provider Key
<select
value={intentForm.modelProviderKeyId}
disabled={busy || intentSaving}
onChange={(event) => {
const keyId = event.target.value;
const key = llmKeys.find((item) => item.id === keyId) ?? null;
setIntentForm((current) =>
current
? {
...current,
modelProviderKeyId: keyId,
model: key?.defaultModel || key?.models?.[0] || current.model,
}
: current,
);
}}
>
<option value="">使 Key</option>
{llmKeys.map((key) => (
<option key={key.id} value={key.id}>
{key.name} ({key.providerLabel})
</option>
))}
</select>
</label>
<label>
<input
list="wechat-intent-models"
value={intentForm.model}
disabled={busy || intentSaving}
placeholder={selectedIntentKey?.defaultModel || 'deepseek-v4-pro'}
onChange={(event) =>
setIntentForm((current) => current && { ...current, model: event.target.value })
}
/>
<datalist id="wechat-intent-models">
{(selectedIntentKey?.models ?? []).map((model) => (
<option key={model} value={model} />
))}
</datalist>
</label>
<label>
<input
type="number"
min={0}
max={1}
step={0.05}
value={intentForm.minConfidence}
disabled={busy || intentSaving}
onChange={(event) =>
setIntentForm((current) => current && { ...current, minConfidence: event.target.value })
}
/>
</label>
<label>
(ms)
<input
type="number"
min={500}
max={30000}
step={100}
value={intentForm.timeoutMs}
disabled={busy || intentSaving}
onChange={(event) =>
setIntentForm((current) => current && { ...current, timeoutMs: event.target.value })
}
/>
</label>
<label>
Canary OpenID=
<textarea
value={intentForm.canaryOpenids}
disabled={busy || intentSaving}
rows={3}
placeholder="oXXXX..."
onChange={(event) =>
setIntentForm((current) => current && { ...current, canaryOpenids: event.target.value })
}
/>
</label>
</div>
{intentRuntime ? (
<p className="muted" style={{ marginTop: 12 }}>
{intentRouterRuntimeMode(intentRuntime.overrides)} · {intentRuntime.source}
{intentRuntime.updatedAt ? ` · 更新 ${dateLabel(intentRuntime.updatedAt)}` : ''}
</p>
) : null}
</section>
) : null}
<section className="admin-card">
<h2>TKMind </h2>
<p className="muted" style={{ marginTop: 6 }}>
Cursor H5
</p>
<p style={{ marginTop: 12 }}>
<Link to="/cursor-channel" className="ghost-btn">
</Link>
</p>
</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>
<div className="wechat-toolbar">
<input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="搜索用户、昵称、openid"
/>
<button type="button" className="ghost-btn" onClick={() => void load()} disabled={busy}>
</button>
</div>
</div>
<div className="admin-table-wrap">
<table className="admin-table">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{bindings.length === 0 ? (
<tr><td colSpan={5} className="muted"></td></tr>
) : (
bindings.map((binding) => (
<tr key={`${binding.userId}-${binding.openidMasked}`}>
<td>
<div>{userLabel(binding)}</div>
<div className="muted">@{binding.username}</div>
</td>
<td>
<div>{binding.nickname || '—'}</div>
<div className="mono">{binding.openidMasked}</div>
</td>
<td>
<div className={statusClass(binding.routeStatus)}>
{statusLabel(binding.routeStatus)}
</div>
<div className="mono">{binding.agentSessionId || '无会话'}</div>
</td>
<td>{dateLabel(binding.lastLoginAt)}</td>
<td className="admin-actions">
<button
type="button"
className="ghost-btn"
disabled={busy || !binding.routeId}
onClick={() => {
if (!window.confirm(`确认清除「${userLabel(binding)}」的服务号会话路由?`)) return;
void runAction(() => clearWechatRoute(binding.userId));
}}
>
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</section>
<section className="admin-card">
<TableHead
title="每日待办推送"
value={digestStatus}
options={['', 'active', 'locked', 'failed', 'cancelled']}
onChange={setDigestStatus}
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>
<th></th>
</tr>
</thead>
<tbody>
{digests.length === 0 ? (
<tr><td colSpan={6} className="muted"></td></tr>
) : (
digests.map((digest) => (
<tr key={digest.id}>
<td>
<div>{userLabel(digest)}</div>
<div className="muted">@{digest.username}</div>
</td>
<td>
{String(digest.hour).padStart(2, '0')}:{String(digest.minute).padStart(2, '0')}
<div className="muted">{digest.timezone}</div>
</td>
<td className={statusClass(digest.status)}>{digest.status}</td>
<td>{dateLabel(digest.nextRunAt)}</td>
<td>{digest.lastError || '—'}</td>
<td className="admin-actions">
{digest.status === 'active' || digest.status === 'locked' ? (
<button
type="button"
className="ghost-btn"
disabled={busy}
onClick={() => {
if (!window.confirm(`确认暂停「${userLabel(digest)}」的每日待办推送?`)) return;
void runAction(() => cancelWechatDigest(digest.id));
}}
>
</button>
) : (
<button
type="button"
className="send-btn"
disabled={busy}
onClick={() => void runAction(() => resumeWechatDigest(digest.id))}
>
</button>
)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</section>
<section className="admin-card">
<TableHead
title="最近服务号消息"
value={messageStatus}
options={['', 'processing', 'done', 'failed']}
onChange={setMessageStatus}
onRefresh={() => void load()}
busy={busy}
/>
<div className="admin-table-wrap">
<table className="admin-table">
<thead>
<tr>
<th></th>
<th>OpenID</th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{messages.length === 0 ? (
<tr><td colSpan={5} className="muted"></td></tr>
) : (
messages.map((message) => (
<tr key={`${message.openidMasked}-${message.msgId}`}>
<td>{userLabel(message)}</td>
<td className="mono">{message.openidMasked}</td>
<td className={statusClass(message.status)}>{message.status}</td>
<td className="mono">{message.agentSessionId || '—'}</td>
<td>{dateLabel(message.updatedAt)}</td>
</tr>
))
)}
</tbody>
</table>
</div>
</section>
<section className="admin-card">
<TableHead
title="推送投递日志"
value={deliveryStatus}
options={['', 'success', 'failed']}
onChange={setDeliveryStatus}
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>
{deliveries.length === 0 ? (
<tr><td colSpan={5} className="muted"></td></tr>
) : (
deliveries.map((delivery) => (
<tr key={delivery.id}>
<td>{userLabel(delivery)}</td>
<td>{delivery.digestType ?? (delivery.reminderId ? 'reminder' : '—')}</td>
<td className={statusClass(delivery.status)}>{delivery.status}</td>
<td>{delivery.errorMessage || delivery.errorCode || delivery.providerMessageId || '—'}</td>
<td>{dateLabel(delivery.createdAt)}</td>
</tr>
))
)}
</tbody>
</table>
</div>
</section>
</div>
);
}
function Stat({ label, value }: { label: string; value: string | number }) {
return (
<div className="admin-stat-card">
<div className="admin-stat-label">{label}</div>
<div className="admin-stat-value">{value}</div>
</div>
);
}
function TableHead({
title,
value,
options,
onChange,
onRefresh,
busy,
}: {
title: string;
value: string;
options: string[];
onChange: (value: string) => void;
onRefresh: () => void;
busy: boolean;
}) {
return (
<div className="admin-card-head">
<h2>{title}</h2>
<div className="wechat-toolbar">
<select value={value} onChange={(event) => onChange(event.target.value)}>
{options.map((option) => (
<option key={option || 'all'} value={option}>
{option || '全部状态'}
</option>
))}
</select>
<button type="button" className="ghost-btn" onClick={onRefresh} disabled={busy}>
</button>
</div>
</div>
);
}