From dae3e2f350caf0556411be5030ad6a2dd437f7b8 Mon Sep 17 00:00:00 2001 From: john Date: Sat, 20 Jun 2026 17:02:16 +0800 Subject: [PATCH] Harden wechat summary and login input --- src/App.tsx | 114 +++++++-- src/admin/pages/WechatPage.tsx | 440 +++++++++++++++++++++++++++++++++ src/api/client.ts | 104 +++++++- 3 files changed, 636 insertions(+), 22 deletions(-) create mode 100644 src/admin/pages/WechatPage.tsx diff --git a/src/App.tsx b/src/App.tsx index 64d878c..ec410ca 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react'; -import { Navigate, Route, Routes, useNavigate } from 'react-router-dom'; +import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom'; import { checkAuth, login, logout, setUnauthorizedHandler } from './api/client'; import { AdminLayout } from './admin/AdminLayout'; import { BillingPage } from './admin/pages/BillingPage'; @@ -10,9 +10,36 @@ import { ProvidersPage } from './admin/pages/ProvidersPage'; import { SkillsPage } from './admin/pages/SkillsPage'; import { UserDetailPage } from './admin/pages/UserDetailPage'; import { UsersPage } from './admin/pages/UsersPage'; +import { WechatPage } from './admin/pages/WechatPage'; +import { defaultHomePath } from './lib/routes'; +import { OpsLayout } from './ops/components/OpsLayout'; +import { AnalyticsPage } from './ops/pages/AnalyticsPage'; +import { CreatorsPage } from './ops/pages/CreatorsPage'; +import { FeaturedPage } from './ops/pages/FeaturedPage'; +import { ReportsPage } from './ops/pages/ReportsPage'; +import { ReviewPage } from './ops/pages/ReviewPage'; import type { PortalUser } from './types'; -function AdminLoginPage({ onAuth }: { onAuth: (user: PortalUser) => void }) { +function LegacyAdminRedirect() { + const { pathname } = useLocation(); + const rest = pathname.replace(/^\/admin\/?/, ''); + return ; +} + +function RejectOpsAdminRedirect() { + const { pathname } = useLocation(); + const rest = pathname.replace(/^\/ops\/admin\/?/, ''); + return ; +} + +function AdminLoginPage({ + onAuth, + redirectTo, +}: { + onAuth: (user: PortalUser) => void; + redirectTo?: string; +}) { + const navigate = useNavigate(); const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [error, setError] = useState(null); @@ -24,11 +51,12 @@ function AdminLoginPage({ onAuth }: { onAuth: (user: PortalUser) => void }) { setLoading(true); try { const user = await login(username, password); - if (!user || user.role !== 'admin') { - setError('无管理员权限'); + if (!user) { + setError('登录失败'); return; } onAuth(user); + navigate(redirectTo ?? defaultHomePath(user.role), { replace: true }); } catch (err) { setError(err instanceof Error ? err.message : '登录失败'); } finally { @@ -39,7 +67,10 @@ function AdminLoginPage({ onAuth }: { onAuth: (user: PortalUser) => void }) { return (
-

管理后台

+

TKMind 管理后台

+

+ 使用平台账号登录,无需跳转主站 +

{error &&

{error}

}
void }) { setPassword(e.target.value)} @@ -71,24 +103,67 @@ function AdminLoginPage({ onAuth }: { onAuth: (user: PortalUser) => void }) { function AdminApp({ user, onLogout }: { user: PortalUser; onLogout: () => void }) { return ( - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + {user.role === 'admin' ? ( + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + ) : ( + } /> + )} + }> + } /> + } /> + } /> + } /> + } /> - } /> + } /> + } /> + } /> + } /> ); } +function loginRedirectPath(pathname: string, role: string | undefined) { + if (pathname.startsWith('/ops/admin')) { + const rest = pathname.replace(/^\/ops\/admin\/?/, ''); + return rest ? `/${rest}` : '/'; + } + if (pathname.startsWith('/admin')) { + const rest = pathname.replace(/^\/admin\/?/, ''); + if (role === undefined) return rest ? `/${rest}` : '/'; + return role === 'admin' ? (rest ? `/${rest}` : '/') : '/ops'; + } + if (pathname === '/' || pathname.startsWith('/ops')) { + return pathname; + } + if ( + pathname.startsWith('/users') + || pathname.startsWith('/billing') + || pathname.startsWith('/capabilities') + || pathname.startsWith('/skills') + || pathname.startsWith('/policies') + || pathname.startsWith('/providers') + || pathname.startsWith('/wechat') + ) { + return role === 'admin' || role === undefined ? pathname : '/ops'; + } + return defaultHomePath(role); +} + export function App() { const navigate = useNavigate(); + const location = useLocation(); const [authed, setAuthed] = useState(null); const [user, setUser] = useState(null); @@ -99,9 +174,8 @@ export function App() { navigate('/', { replace: true }); }); void checkAuth().then((status) => { - const isAdmin = status.authenticated && status.user?.role === 'admin'; - setAuthed(isAdmin); - setUser(isAdmin ? (status.user ?? null) : null); + setAuthed(status.authenticated); + setUser(status.authenticated ? (status.user ?? null) : null); }); }, [navigate]); @@ -112,6 +186,7 @@ export function App() { if (!authed || !user) { return ( { setAuthed(true); setUser(nextUser); @@ -124,6 +199,7 @@ export function App() { void logout().finally(() => { setAuthed(false); setUser(null); + navigate('/', { replace: true }); }); }; diff --git a/src/admin/pages/WechatPage.tsx b/src/admin/pages/WechatPage.tsx new file mode 100644 index 0000000..f849c76 --- /dev/null +++ b/src/admin/pages/WechatPage.tsx @@ -0,0 +1,440 @@ +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, 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(null); + const [bindings, setBindings] = useState([]); + const [messages, setMessages] = useState([]); + const [digests, setDigests] = useState([]); + const [deliveries, setDeliveries] = useState([]); + 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(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) => { + setBusy(true); + setError(null); + try { + await action(); + await load(); + } catch (err) { + setError(err instanceof Error ? err.message : '操作失败'); + } finally { + setBusy(false); + } + }; + + return ( +
+
+

服务号管理

+

绑定、路由、每日待办推送和投递记录

+
+ + {error &&

{error}

} + +
+ + + + + + +
+ + {summary && ( +
+

配置状态

+
+
+
AppID
+
{summary.config.appId ?? '未配置'}
+
+
+
公网地址
+
{summary.config.publicBaseUrl ?? '未配置'}
+
+
+
绑定路径
+
{summary.config.bindPath ?? '未配置'}
+
+
+
定时服务
+
{summary.config.scheduleEnabled ? '已启用' : '未启用'}
+
+
+
推送 worker
+
{summary.config.reminderWorkerEnabled ? '已启用' : '未启用'}
+
+
+
+ )} + +
+
+

绑定与会话路由

+
+ setSearch(event.target.value)} + placeholder="搜索用户、昵称、openid" + /> + +
+
+
+ + + + + + + + + + + + {bindings.length === 0 ? ( + + ) : ( + bindings.map((binding) => ( + + + + + + + + )) + )} + +
用户微信路由最近登录操作
暂无记录
+
{userLabel(binding)}
+
@{binding.username}
+
+
{binding.nickname || '—'}
+
{binding.openidMasked}
+
+
+ {statusLabel(binding.routeStatus)} +
+
{binding.agentSessionId || '无会话'}
+
{dateLabel(binding.lastLoginAt)} + +
+
+
+ +
+ void load()} + busy={busy} + /> +
+ + + + + + + + + + + + + {digests.length === 0 ? ( + + ) : ( + digests.map((digest) => ( + + + + + + + + + )) + )} + +
用户时间状态下次运行错误操作
暂无记录
+
{userLabel(digest)}
+
@{digest.username}
+
+ {String(digest.hour).padStart(2, '0')}:{String(digest.minute).padStart(2, '0')} +
{digest.timezone}
+
{digest.status}{dateLabel(digest.nextRunAt)}{digest.lastError || '—'} + {digest.status === 'active' || digest.status === 'locked' ? ( + + ) : ( + + )} +
+
+
+ +
+ void load()} + busy={busy} + /> +
+ + + + + + + + + + + + {messages.length === 0 ? ( + + ) : ( + messages.map((message) => ( + + + + + + + + )) + )} + +
用户OpenID状态会话更新时间
暂无记录
{userLabel(message)}{message.openidMasked}{message.status}{message.agentSessionId || '—'}{dateLabel(message.updatedAt)}
+
+
+ +
+ void load()} + busy={busy} + /> +
+ + + + + + + + + + + + {deliveries.length === 0 ? ( + + ) : ( + deliveries.map((delivery) => ( + + + + + + + + )) + )} + +
用户类型状态错误/回执时间
暂无记录
{userLabel(delivery)}{delivery.digestType ?? (delivery.reminderId ? 'reminder' : '—')}{delivery.status}{delivery.errorMessage || delivery.errorCode || delivery.providerMessageId || '—'}{dateLabel(delivery.createdAt)}
+
+
+
+ ); +} + +function Stat({ label, value }: { label: string; value: string | number }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +function TableHead({ + title, + value, + options, + onChange, + onRefresh, + busy, +}: { + title: string; + value: string; + options: string[]; + onChange: (value: string) => void; + onRefresh: () => void; + busy: boolean; +}) { + return ( +
+

{title}

+
+ + +
+
+ ); +} diff --git a/src/api/client.ts b/src/api/client.ts index 5c68ed5..d0c36c8 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -16,6 +16,11 @@ import type { SkillDefinition, SkillMap, UsageRecord, + WechatAdminSummary, + WechatBinding, + WechatDeliveryLog, + WechatDigestSubscription, + WechatMessage, } from '../types'; export class ApiError extends Error { @@ -94,6 +99,7 @@ async function portalFetch(path: string, init?: RequestInit): Promise { try { res = await fetch(path, { ...init, + credentials: 'include', headers: { 'Content-Type': 'application/json', ...init?.headers }, }); } catch (err) { @@ -119,7 +125,7 @@ async function portalFetch(path: string, init?: RequestInit): Promise { export async function checkAuth(): Promise { try { - const response = await fetch('/auth/status'); + const response = await fetch('/auth/status', { credentials: 'include' }); if (!response.ok) return { authenticated: false }; const status = (await response.json()) as AuthStatus; if (status.authenticated) resetUnauthorizedGuard(); @@ -134,6 +140,7 @@ export async function login(username: string, password: string): Promise { - if (import.meta.env.DEV) return; - await fetch('/auth/logout', { method: 'POST' }); + await fetch('/auth/logout', { method: 'POST', credentials: 'include' }); } // ── Pagination helpers ──────────────────────────────── @@ -207,6 +213,98 @@ export async function listAdminLedger(params?: { return { items: result.entries ?? [], total, page: result.page ?? 1, pageSize, totalPages: Math.ceil(total / pageSize) }; } +// ── WeChat MP ───────────────────────────────────────── + +export async function getWechatAdminSummary(): Promise { + const summary = await portalFetch>('/admin-api/wechat/summary'); + return { + config: { + mpEnabled: summary.config?.mpEnabled ?? false, + scheduleEnabled: summary.config?.scheduleEnabled ?? false, + reminderWorkerEnabled: summary.config?.reminderWorkerEnabled ?? false, + appId: summary.config?.appId ?? null, + publicBaseUrl: summary.config?.publicBaseUrl ?? null, + bindPath: summary.config?.bindPath ?? null, + tokenEndpointConfigured: summary.config?.tokenEndpointConfigured ?? false, + customerServiceEndpointConfigured: summary.config?.customerServiceEndpointConfigured ?? false, + }, + counts: { + boundUsers: summary.counts?.boundUsers ?? 0, + routes: { + total: summary.counts?.routes?.total ?? 0, + active: summary.counts?.routes?.active ?? 0, + }, + recentMessages: summary.counts?.recentMessages ?? {}, + digests: summary.counts?.digests ?? {}, + recentDeliveries: summary.counts?.recentDeliveries ?? {}, + }, + }; +} + +export async function listWechatBindings(params?: { + search?: string; + limit?: number; +}): Promise { + const q = new URLSearchParams(); + if (params?.search) q.set('search', params.search); + if (params?.limit) q.set('limit', String(params.limit)); + const result = await portalFetch<{ bindings: WechatBinding[] }>( + `/admin-api/wechat/bindings${q.toString() ? `?${q}` : ''}`, + ); + return result.bindings ?? []; +} + +export async function listWechatMessages(params?: { + status?: string; + limit?: number; +}): Promise { + const q = new URLSearchParams(); + if (params?.status) q.set('status', params.status); + if (params?.limit) q.set('limit', String(params.limit)); + const result = await portalFetch<{ messages: WechatMessage[] }>( + `/admin-api/wechat/messages${q.toString() ? `?${q}` : ''}`, + ); + return result.messages ?? []; +} + +export async function listWechatDigests(params?: { + status?: string; + limit?: number; +}): Promise { + const q = new URLSearchParams(); + if (params?.status) q.set('status', params.status); + if (params?.limit) q.set('limit', String(params.limit)); + const result = await portalFetch<{ digests: WechatDigestSubscription[] }>( + `/admin-api/wechat/digests${q.toString() ? `?${q}` : ''}`, + ); + return result.digests ?? []; +} + +export async function listWechatDeliveries(params?: { + status?: string; + limit?: number; +}): Promise { + const q = new URLSearchParams(); + if (params?.status) q.set('status', params.status); + if (params?.limit) q.set('limit', String(params.limit)); + const result = await portalFetch<{ deliveries: WechatDeliveryLog[] }>( + `/admin-api/wechat/deliveries${q.toString() ? `?${q}` : ''}`, + ); + return result.deliveries ?? []; +} + +export async function clearWechatRoute(userId: string): Promise<{ ok: boolean; deleted: number }> { + return portalFetch(`/admin-api/wechat/users/${userId}/route/clear`, { method: 'POST' }); +} + +export async function cancelWechatDigest(id: string): Promise<{ ok: boolean }> { + return portalFetch(`/admin-api/wechat/digests/${id}/cancel`, { method: 'POST' }); +} + +export async function resumeWechatDigest(id: string): Promise<{ ok: boolean; nextRunAt: number }> { + return portalFetch(`/admin-api/wechat/digests/${id}/resume`, { method: 'POST' }); +} + // ── Admin users ─────────────────────────────────────── export async function listAdminUsers(params?: {