441 lines
14 KiB
TypeScript
441 lines
14 KiB
TypeScript
import { useCallback, useEffect, useState } from 'react';
|
|
import {
|
|
cancelWechatDigest,
|
|
clearWechatRoute,
|
|
getWechatAdminSummary,
|
|
listWechatBindings,
|
|
listWechatDeliveries,
|
|
listWechatDigests,
|
|
listWechatMessages,
|
|
resumeWechatDigest,
|
|
} from '../../api/client';
|
|
import type {
|
|
WechatAdminSummary,
|
|
WechatBinding,
|
|
WechatDeliveryLog,
|
|
WechatDigestSubscription,
|
|
WechatMessage,
|
|
} 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,
|
|
},
|
|
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) : '—';
|
|
}
|
|
|
|
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 [search, setSearch] = useState('');
|
|
const [messageStatus, setMessageStatus] = useState('');
|
|
const [digestStatus, setDigestStatus] = useState('');
|
|
const [deliveryStatus, setDeliveryStatus] = useState('');
|
|
const [loading, setLoading] = useState(true);
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const safe = safeSummary(summary);
|
|
|
|
const load = useCallback(async () => {
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const [nextSummary, nextBindings, nextMessages, nextDigests, nextDeliveries] =
|
|
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 }),
|
|
]);
|
|
setSummary(nextSummary);
|
|
setBindings(nextBindings);
|
|
setMessages(nextMessages);
|
|
setDigests(nextDigests);
|
|
setDeliveries(nextDeliveries);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : '加载服务号管理失败');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [deliveryStatus, digestStatus, messageStatus, 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);
|
|
}
|
|
};
|
|
|
|
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>}
|
|
|
|
<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>
|
|
</dl>
|
|
</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>
|
|
);
|
|
}
|