Improve WeChat MP replies and ship MindSpace/H5 production updates.
Add WeChat service account routing with sync acks, connectivity tests, and context isolation; document deploy runbooks; and bundle related MindSpace, voice, Plaza, and server gateway changes for production rollout. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Executable
+26
@@ -0,0 +1,26 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="/Users/john/Project/Memind"
|
||||
BACKUP="/Users/john/Project/.codex-baselines/Memind/wechat-service-agent-20260618-084500/.env.before-prod-mp-20260618-092652"
|
||||
NODE_BIN="/opt/homebrew/opt/node@24/bin/node"
|
||||
|
||||
cd "$ROOT"
|
||||
|
||||
if [[ ! -f "$BACKUP" ]]; then
|
||||
echo "backup env not found: $BACKUP" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cp "$BACKUP" .env
|
||||
|
||||
PORT_PID="$(lsof -tiTCP:8081 -sTCP:LISTEN || true)"
|
||||
if [[ -n "$PORT_PID" ]]; then
|
||||
kill "$PORT_PID"
|
||||
sleep 2
|
||||
fi
|
||||
|
||||
nohup "$NODE_BIN" server.mjs >> h5.log 2>&1 &
|
||||
echo $! > .h5.pid
|
||||
sleep 3
|
||||
curl -s http://127.0.0.1:8081/api/status
|
||||
@@ -12,6 +12,7 @@ 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';
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
@@ -44,6 +45,7 @@ export function App() {
|
||||
<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 />} />
|
||||
</Route>
|
||||
|
||||
|
||||
@@ -74,6 +74,92 @@ export type UsageRecord = {
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type WechatAdminSummary = {
|
||||
config: {
|
||||
mpEnabled: boolean;
|
||||
scheduleEnabled: boolean;
|
||||
reminderWorkerEnabled: boolean;
|
||||
appId: string | null;
|
||||
publicBaseUrl: string | null;
|
||||
bindPath: string | null;
|
||||
tokenEndpointConfigured: boolean;
|
||||
customerServiceEndpointConfigured: boolean;
|
||||
};
|
||||
counts: {
|
||||
boundUsers: number;
|
||||
routes: { total: number; active: number };
|
||||
recentMessages: Record<string, number>;
|
||||
digests: Record<string, number>;
|
||||
recentDeliveries: Record<string, number>;
|
||||
};
|
||||
};
|
||||
|
||||
export type WechatBinding = {
|
||||
userId: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
status: string;
|
||||
appId: string | null;
|
||||
openidMasked: string;
|
||||
nickname: string | null;
|
||||
avatarUrl: string | null;
|
||||
lastLoginAt: number;
|
||||
boundAt: number;
|
||||
routeId: string | null;
|
||||
routeStatus: string | null;
|
||||
agentSessionId: string | null;
|
||||
routeUpdatedAt: number | null;
|
||||
};
|
||||
|
||||
export type WechatMessage = {
|
||||
appId: string | null;
|
||||
openidMasked: string;
|
||||
msgId: string;
|
||||
status: string;
|
||||
agentSessionId: string | null;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
userId: string | null;
|
||||
username: string | null;
|
||||
displayName: string | null;
|
||||
};
|
||||
|
||||
export type WechatDigestSubscription = {
|
||||
id: string;
|
||||
userId: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
digestType: string;
|
||||
hour: number;
|
||||
minute: number;
|
||||
timezone: string;
|
||||
channel: string;
|
||||
status: string;
|
||||
nextRunAt: number;
|
||||
lastRunAt: number | null;
|
||||
attempts: number;
|
||||
lastError: string | null;
|
||||
sourceText: string | null;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
export type WechatDeliveryLog = {
|
||||
id: string;
|
||||
reminderId: string | null;
|
||||
subscriptionId: string | null;
|
||||
userId: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
channel: string;
|
||||
status: string;
|
||||
providerMessageId: string | null;
|
||||
errorCode: string | null;
|
||||
errorMessage: string | null;
|
||||
createdAt: number;
|
||||
digestType: string | null;
|
||||
};
|
||||
|
||||
// ─── Summary ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function fetchAdminSummary() {
|
||||
@@ -149,6 +235,57 @@ export async function fetchAdminUsage(params: { page?: number; pageSize?: number
|
||||
);
|
||||
}
|
||||
|
||||
// ─── WeChat MP ────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function fetchWechatSummary() {
|
||||
return adminFetch<WechatAdminSummary>('/admin-api/wechat/summary');
|
||||
}
|
||||
|
||||
export async function fetchWechatBindings(params: { search?: string; limit?: number } = {}) {
|
||||
const q = new URLSearchParams();
|
||||
if (params.search) q.set('search', params.search);
|
||||
if (params.limit) q.set('limit', String(params.limit));
|
||||
return adminFetch<{ bindings: WechatBinding[] }>(`/admin-api/wechat/bindings?${q}`);
|
||||
}
|
||||
|
||||
export async function fetchWechatMessages(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<{ messages: WechatMessage[] }>(`/admin-api/wechat/messages?${q}`);
|
||||
}
|
||||
|
||||
export async function fetchWechatDigests(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<{ digests: WechatDigestSubscription[] }>(`/admin-api/wechat/digests?${q}`);
|
||||
}
|
||||
|
||||
export async function fetchWechatDeliveries(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<{ deliveries: WechatDeliveryLog[] }>(`/admin-api/wechat/deliveries?${q}`);
|
||||
}
|
||||
|
||||
export async function clearWechatRoute(userId: string) {
|
||||
return adminFetch<{ ok: boolean; deleted: number; openidMasked: string }>(
|
||||
`/admin-api/wechat/users/${userId}/route/clear`,
|
||||
{ method: 'POST' },
|
||||
);
|
||||
}
|
||||
|
||||
export async function cancelWechatDigest(id: string) {
|
||||
return adminFetch<{ ok: boolean }>(`/admin-api/wechat/digests/${id}/cancel`, { method: 'POST' });
|
||||
}
|
||||
|
||||
export async function resumeWechatDigest(id: string) {
|
||||
return adminFetch<{ ok: boolean; nextRunAt: number }>(`/admin-api/wechat/digests/${id}/resume`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
// ─── LLM Providers ───────────────────────────────────────────────────────────
|
||||
|
||||
export async function fetchLlmKeys() {
|
||||
|
||||
@@ -5,6 +5,7 @@ const links = [
|
||||
{ to: '/admin/users', label: '用户管理' },
|
||||
{ to: '/admin/llm', label: 'LLM 配置' },
|
||||
{ to: '/admin/billing', label: '账单记录' },
|
||||
{ to: '/admin/wechat', label: '服务号管理' },
|
||||
];
|
||||
|
||||
export function AdminLayout() {
|
||||
@@ -12,7 +13,7 @@ export function AdminLayout() {
|
||||
<div className="layout">
|
||||
<header>
|
||||
<h1>超级管理后台</h1>
|
||||
<p style={{ color: '#68716c' }}>用户、计费与 LLM 配置</p>
|
||||
<p style={{ color: '#68716c' }}>用户、计费、LLM 与服务号</p>
|
||||
</header>
|
||||
<nav className="nav">
|
||||
<NavLink
|
||||
|
||||
@@ -0,0 +1,409 @@
|
||||
import { useEffect, useState, type CSSProperties, type ReactNode } from 'react';
|
||||
import {
|
||||
cancelWechatDigest,
|
||||
clearWechatRoute,
|
||||
fetchWechatBindings,
|
||||
fetchWechatDeliveries,
|
||||
fetchWechatDigests,
|
||||
fetchWechatMessages,
|
||||
fetchWechatSummary,
|
||||
resumeWechatDigest,
|
||||
type WechatAdminSummary,
|
||||
type WechatBinding,
|
||||
type WechatDeliveryLog,
|
||||
type WechatDigestSubscription,
|
||||
type WechatMessage,
|
||||
} from '../../api/admin';
|
||||
|
||||
function time(value?: number | null) {
|
||||
if (!value) return '—';
|
||||
return new Date(value).toLocaleString('zh-CN', { hour12: false });
|
||||
}
|
||||
|
||||
function count(record: Record<string, number>, key: string) {
|
||||
return record[key] ?? 0;
|
||||
}
|
||||
|
||||
function badge(status?: string | null) {
|
||||
if (!status) return <span style={{ color: '#68716c' }}>—</span>;
|
||||
const color =
|
||||
status === 'active' || status === 'success' || status === 'done'
|
||||
? '#2f6f57'
|
||||
: status === 'failed'
|
||||
? '#b42318'
|
||||
: '#68716c';
|
||||
return <strong style={{ color, fontSize: 12 }}>{status}</strong>;
|
||||
}
|
||||
|
||||
function userName(row: { displayName?: string | null; username?: string | null }) {
|
||||
return row.displayName || row.username || '—';
|
||||
}
|
||||
|
||||
const tableStyle = {
|
||||
width: '100%',
|
||||
borderCollapse: 'collapse',
|
||||
fontSize: 13,
|
||||
} satisfies CSSProperties;
|
||||
|
||||
const thStyle = {
|
||||
textAlign: 'left',
|
||||
color: '#68716c',
|
||||
fontWeight: 600,
|
||||
borderBottom: '1px solid #e7dfd1',
|
||||
padding: '8px 6px',
|
||||
whiteSpace: 'nowrap',
|
||||
} satisfies CSSProperties;
|
||||
|
||||
const tdStyle = {
|
||||
borderBottom: '1px solid #eee7da',
|
||||
padding: '9px 6px',
|
||||
verticalAlign: 'top',
|
||||
} satisfies CSSProperties;
|
||||
|
||||
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 [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const load = async () => {
|
||||
setError(null);
|
||||
try {
|
||||
const [summaryResult, bindingResult, messageResult, digestResult, deliveryResult] =
|
||||
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 }),
|
||||
]);
|
||||
setSummary(summaryResult);
|
||||
setBindings(bindingResult.bindings);
|
||||
setMessages(messageResult.messages);
|
||||
setDigests(digestResult.digests);
|
||||
setDeliveries(deliveryResult.deliveries);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载失败');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
const handleClearRoute = async (binding: WechatBinding) => {
|
||||
if (!window.confirm(`确认清除「${userName(binding)}」的服务号会话路由?下一次用户发消息会重建路由。`)) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await clearWechatRoute(binding.userId);
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '清除失败');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelDigest = async (digest: WechatDigestSubscription) => {
|
||||
if (!window.confirm(`确认暂停「${userName(digest)}」的每日待办推送?`)) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await cancelWechatDigest(digest.id);
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '暂停失败');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResumeDigest = async (digest: WechatDigestSubscription) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await resumeWechatDigest(digest.id);
|
||||
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}
|
||||
|
||||
{summary ? (
|
||||
<div
|
||||
className="card"
|
||||
style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(170px, 1fr))', gap: 12 }}
|
||||
>
|
||||
<Metric label="服务号" value={summary.config.mpEnabled ? '已启用' : '未启用'} />
|
||||
<Metric label="绑定用户" value={summary.counts.boundUsers} />
|
||||
<Metric label="活跃路由" value={`${summary.counts.routes.active}/${summary.counts.routes.total}`} />
|
||||
<Metric label="每日订阅" value={count(summary.counts.digests, 'active')} />
|
||||
<Metric label="24h 消息失败" value={count(summary.counts.recentMessages, 'failed')} danger />
|
||||
<Metric label="24h 投递失败" value={count(summary.counts.recentDeliveries, 'failed')} danger />
|
||||
<div style={{ gridColumn: '1 / -1', color: '#68716c', fontSize: 12 }}>
|
||||
AppID {summary.config.appId ?? '未配置'} · {summary.config.publicBaseUrl ?? '未配置公网地址'}
|
||||
{summary.config.bindPath ? ` · 绑定路径 ${summary.config.bindPath}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<section className="card grid">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
|
||||
<h3 style={{ margin: 0 }}>绑定与会话路由</h3>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
<input
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder="搜索用户、昵称、openid"
|
||||
style={{ width: 220 }}
|
||||
/>
|
||||
<button type="button" className="btn secondary" onClick={() => void load()} disabled={busy}>
|
||||
查询
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<TableShell empty={bindings.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>
|
||||
{bindings.map((binding) => (
|
||||
<tr key={`${binding.userId}-${binding.openidMasked}`}>
|
||||
<td style={tdStyle}>
|
||||
<strong>{userName(binding)}</strong>
|
||||
<div style={{ color: '#68716c', fontSize: 12 }}>{binding.username}</div>
|
||||
</td>
|
||||
<td style={tdStyle}>
|
||||
<div>{binding.nickname || '—'}</div>
|
||||
<div style={{ color: '#68716c', fontSize: 12 }}>{binding.openidMasked}</div>
|
||||
</td>
|
||||
<td style={tdStyle}>
|
||||
{badge(binding.routeStatus)}
|
||||
<div style={{ color: '#68716c', fontSize: 12 }}>{binding.agentSessionId || '无会话'}</div>
|
||||
</td>
|
||||
<td style={tdStyle}>{time(binding.lastLoginAt)}</td>
|
||||
<td style={tdStyle}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
style={{ padding: '4px 10px', fontSize: 12 }}
|
||||
onClick={() => void handleClearRoute(binding)}
|
||||
disabled={busy || !binding.routeId}
|
||||
>
|
||||
清除路由
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableShell>
|
||||
</section>
|
||||
|
||||
<section className="card grid">
|
||||
<SectionHead
|
||||
title="每日待办推送"
|
||||
value={digestStatus}
|
||||
options={['', 'active', 'locked', 'failed', 'cancelled']}
|
||||
onChange={setDigestStatus}
|
||||
onRefresh={() => void load()}
|
||||
busy={busy}
|
||||
/>
|
||||
<TableShell empty={digests.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>
|
||||
<th style={thStyle}>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{digests.map((digest) => (
|
||||
<tr key={digest.id}>
|
||||
<td style={tdStyle}>
|
||||
<strong>{userName(digest)}</strong>
|
||||
<div style={{ color: '#68716c', fontSize: 12 }}>{digest.username}</div>
|
||||
</td>
|
||||
<td style={tdStyle}>
|
||||
{String(digest.hour).padStart(2, '0')}:{String(digest.minute).padStart(2, '0')}
|
||||
<div style={{ color: '#68716c', fontSize: 12 }}>{digest.timezone}</div>
|
||||
</td>
|
||||
<td style={tdStyle}>{badge(digest.status)}</td>
|
||||
<td style={tdStyle}>{time(digest.nextRunAt)}</td>
|
||||
<td style={{ ...tdStyle, maxWidth: 260 }}>{digest.lastError || '—'}</td>
|
||||
<td style={tdStyle}>
|
||||
{digest.status === 'active' || digest.status === 'locked' ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
style={{ padding: '4px 10px', fontSize: 12 }}
|
||||
onClick={() => void handleCancelDigest(digest)}
|
||||
disabled={busy}
|
||||
>
|
||||
暂停
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
style={{ padding: '4px 10px', fontSize: 12 }}
|
||||
onClick={() => void handleResumeDigest(digest)}
|
||||
disabled={busy}
|
||||
>
|
||||
恢复
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableShell>
|
||||
</section>
|
||||
|
||||
<section className="card grid">
|
||||
<SectionHead
|
||||
title="最近服务号消息"
|
||||
value={messageStatus}
|
||||
options={['', 'processing', 'done', 'failed']}
|
||||
onChange={setMessageStatus}
|
||||
onRefresh={() => void load()}
|
||||
busy={busy}
|
||||
/>
|
||||
<TableShell empty={messages.length === 0}>
|
||||
<table style={tableStyle}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={thStyle}>用户</th>
|
||||
<th style={thStyle}>OpenID</th>
|
||||
<th style={thStyle}>状态</th>
|
||||
<th style={thStyle}>会话</th>
|
||||
<th style={thStyle}>更新时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{messages.map((message) => (
|
||||
<tr key={`${message.openidMasked}-${message.msgId}`}>
|
||||
<td style={tdStyle}>{userName(message)}</td>
|
||||
<td style={tdStyle}>{message.openidMasked}</td>
|
||||
<td style={tdStyle}>{badge(message.status)}</td>
|
||||
<td style={tdStyle}>{message.agentSessionId || '—'}</td>
|
||||
<td style={tdStyle}>{time(message.updatedAt)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableShell>
|
||||
</section>
|
||||
|
||||
<section className="card grid">
|
||||
<SectionHead
|
||||
title="推送投递日志"
|
||||
value={deliveryStatus}
|
||||
options={['', 'success', 'failed']}
|
||||
onChange={setDeliveryStatus}
|
||||
onRefresh={() => void load()}
|
||||
busy={busy}
|
||||
/>
|
||||
<TableShell empty={deliveries.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>
|
||||
{deliveries.map((delivery) => (
|
||||
<tr key={delivery.id}>
|
||||
<td style={tdStyle}>{userName(delivery)}</td>
|
||||
<td style={tdStyle}>{delivery.digestType ?? (delivery.reminderId ? 'reminder' : '—')}</td>
|
||||
<td style={tdStyle}>{badge(delivery.status)}</td>
|
||||
<td style={{ ...tdStyle, maxWidth: 320 }}>
|
||||
{delivery.errorMessage || delivery.errorCode || delivery.providerMessageId || '—'}
|
||||
</td>
|
||||
<td style={tdStyle}>{time(delivery.createdAt)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableShell>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Metric({ label, value, danger = false }: { label: string; value: string | number; danger?: boolean }) {
|
||||
return (
|
||||
<div>
|
||||
<p style={{ color: '#68716c', marginBottom: 4 }}>{label}</p>
|
||||
<strong style={{ fontSize: 26, color: danger && Number(value) > 0 ? '#b42318' : undefined }}>{value}</strong>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionHead({
|
||||
title,
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
onRefresh,
|
||||
busy,
|
||||
}: {
|
||||
title: string;
|
||||
value: string;
|
||||
options: string[];
|
||||
onChange: (value: string) => void;
|
||||
onRefresh: () => void;
|
||||
busy: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
|
||||
<h3 style={{ margin: 0 }}>{title}</h3>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
<select value={value} onChange={(event) => onChange(event.target.value)} style={{ width: 160 }}>
|
||||
{options.map((option) => (
|
||||
<option key={option || 'all'} value={option}>
|
||||
{option || '全部状态'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="button" className="btn secondary" onClick={onRefresh} disabled={busy}>
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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>;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
[Unit]
|
||||
Description=TKMind WeChat MP egress proxy
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
Environment=WECHAT_EGRESS_HOST=127.0.0.1
|
||||
Environment=WECHAT_EGRESS_PORT=19090
|
||||
ExecStart=/usr/bin/python3 /root/wechat_egress_proxy.py
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
Reference in New Issue
Block a user