Harden wechat summary and login input
This commit is contained in:
+95
-19
@@ -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 <Navigate to={rest ? `/${rest}` : '/'} replace />;
|
||||
}
|
||||
|
||||
function RejectOpsAdminRedirect() {
|
||||
const { pathname } = useLocation();
|
||||
const rest = pathname.replace(/^\/ops\/admin\/?/, '');
|
||||
return <Navigate to={rest ? `/${rest}` : '/'} replace />;
|
||||
}
|
||||
|
||||
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<string | null>(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 (
|
||||
<div className="admin-login-page">
|
||||
<div className="admin-login-card">
|
||||
<h1 className="admin-login-title">管理后台</h1>
|
||||
<h1 className="admin-login-title">TKMind 管理后台</h1>
|
||||
<p className="muted" style={{ marginTop: -8, marginBottom: 20 }}>
|
||||
使用平台账号登录,无需跳转主站
|
||||
</p>
|
||||
{error && <p className="banner banner-error">{error}</p>}
|
||||
<form onSubmit={handleSubmit} className="admin-login-form">
|
||||
<input
|
||||
@@ -54,6 +85,7 @@ function AdminLoginPage({ onAuth }: { onAuth: (user: PortalUser) => void }) {
|
||||
<input
|
||||
className="admin-login-input"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
placeholder="密码"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
@@ -71,24 +103,67 @@ function AdminLoginPage({ onAuth }: { onAuth: (user: PortalUser) => void }) {
|
||||
function AdminApp({ user, onLogout }: { user: PortalUser; onLogout: () => void }) {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/admin" element={<AdminLayout user={user} onLogout={onLogout} />}>
|
||||
<Route index element={<DashboardPage />} />
|
||||
<Route path="users" element={<UsersPage />} />
|
||||
<Route path="users/:userId" element={<UserDetailPage />} />
|
||||
<Route path="billing/*" element={<BillingPage />} />
|
||||
<Route path="capabilities" element={<CapabilitiesPage />} />
|
||||
<Route path="skills" element={<SkillsPage />} />
|
||||
<Route path="policies" element={<PoliciesPage />} />
|
||||
<Route path="providers" element={<ProvidersPage />} />
|
||||
<Route path="*" element={<Navigate to="/admin" replace />} />
|
||||
{user.role === 'admin' ? (
|
||||
<Route path="/" element={<AdminLayout user={user} onLogout={onLogout} />}>
|
||||
<Route index element={<DashboardPage />} />
|
||||
<Route path="users" element={<UsersPage />} />
|
||||
<Route path="users/:userId" element={<UserDetailPage />} />
|
||||
<Route path="billing/*" element={<BillingPage />} />
|
||||
<Route path="capabilities" element={<CapabilitiesPage />} />
|
||||
<Route path="skills" element={<SkillsPage />} />
|
||||
<Route path="policies" element={<PoliciesPage />} />
|
||||
<Route path="providers" element={<ProvidersPage />} />
|
||||
<Route path="wechat" element={<WechatPage />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Route>
|
||||
) : (
|
||||
<Route path="/" element={<Navigate to="/ops" replace />} />
|
||||
)}
|
||||
<Route path="/ops" element={<OpsLayout user={user} onLogout={onLogout} />}>
|
||||
<Route index element={<ReviewPage />} />
|
||||
<Route path="reports" element={<ReportsPage />} />
|
||||
<Route path="featured" element={<FeaturedPage />} />
|
||||
<Route path="creators" element={<CreatorsPage />} />
|
||||
<Route path="analytics" element={<AnalyticsPage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/admin" replace />} />
|
||||
<Route path="/admin/*" element={<LegacyAdminRedirect />} />
|
||||
<Route path="/ops/admin" element={<Navigate to="/" replace />} />
|
||||
<Route path="/ops/admin/*" element={<RejectOpsAdminRedirect />} />
|
||||
<Route path="*" element={<Navigate to={defaultHomePath(user.role)} replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
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<boolean | null>(null);
|
||||
const [user, setUser] = useState<PortalUser | null>(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 (
|
||||
<AdminLoginPage
|
||||
redirectTo={loginRedirectPath(location.pathname, undefined)}
|
||||
onAuth={(nextUser) => {
|
||||
setAuthed(true);
|
||||
setUser(nextUser);
|
||||
@@ -124,6 +199,7 @@ export function App() {
|
||||
void logout().finally(() => {
|
||||
setAuthed(false);
|
||||
setUser(null);
|
||||
navigate('/', { replace: true });
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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<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>
|
||||
);
|
||||
}
|
||||
+101
-3
@@ -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<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
try {
|
||||
res = await fetch(path, {
|
||||
...init,
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json', ...init?.headers },
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -119,7 +125,7 @@ async function portalFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
|
||||
export async function checkAuth(): Promise<AuthStatus> {
|
||||
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<PortalU
|
||||
try {
|
||||
response = await fetch('/auth/login', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
@@ -152,8 +159,7 @@ export async function login(username: string, password: string): Promise<PortalU
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
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<WechatAdminSummary> {
|
||||
const summary = await portalFetch<Partial<WechatAdminSummary>>('/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<WechatBinding[]> {
|
||||
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<WechatMessage[]> {
|
||||
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<WechatDigestSubscription[]> {
|
||||
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<WechatDeliveryLog[]> {
|
||||
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?: {
|
||||
|
||||
Reference in New Issue
Block a user