ec38ee086d
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>
961 lines
34 KiB
TypeScript
961 lines
34 KiB
TypeScript
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>
|
||
);
|
||
}
|