feat(web): 新增统一运维会话管理页与入口

This commit is contained in:
renzhiye 2026-04-03 13:59:19 +08:00
parent e506e7d9c2
commit 6dbf0b70e7
7 changed files with 2183 additions and 50 deletions

View File

@ -3,6 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>跨平台商品聚合与 AI 分析</title> <title>跨平台商品聚合与 AI 分析</title>
</head> </head>
<body> <body>

View File

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="16" fill="#146c6e" />
<path
d="M18 20h28v6H18zm0 18h18v6H18zm0-9h28v6H18zm24 9h4v6h-4z"
fill="#fbf8f2"
/>
</svg>

After

Width:  |  Height:  |  Size: 221 B

View File

@ -23,19 +23,32 @@ import {
import { PlatformIdentity, PlatformStatusPill, TaskStatusPill } from "./components/StatusPill"; import { PlatformIdentity, PlatformStatusPill, TaskStatusPill } from "./components/StatusPill";
import { TaskContextHeader } from "./components/TaskContextHeader"; import { TaskContextHeader } from "./components/TaskContextHeader";
import { TaskSpine } from "./components/TaskSpine"; import { TaskSpine } from "./components/TaskSpine";
import { OpsSessionManagerPage } from "./OpsSessionManagerPage";
import { import {
clearJdManagedSession,
clearJdSessionManagerConfig,
clearPlatformSession, clearPlatformSession,
confirmTask, confirmTask,
createTask, createTask,
deleteTask, deleteTask,
getJdKeywordPreview,
getJdLiveSession,
getJdSessionManager,
getHistoryTasks, getHistoryTasks,
getPlatformReadiness, getPlatformReadiness,
getPlatformSession, getPlatformSession,
getTask, getTask,
getTaskCandidates, getTaskCandidates,
getTaskReport, getTaskReport,
importJdManagedSession,
preparePlatform, preparePlatform,
retryTaskPlatform retryTaskPlatform,
runJdSessionManagerHealthCheck,
runJdSessionManagerRecovery,
type JdKeywordPreviewResult,
type JdLiveSessionInput,
type JdSessionManagerConfigInput,
updateJdSessionManagerConfig
} from "./lib/api"; } from "./lib/api";
function Layout(props: { children: React.ReactNode }) { function Layout(props: { children: React.ReactNode }) {
@ -171,12 +184,30 @@ function getSessionSnapshotSummary(session: SessionStateRecord) {
return "当前还没有可复用会话快照。"; return "当前还没有可复用会话快照。";
} }
function isOpsManagedPlatform(platform: PlatformId) {
return platform === "jd" || platform === "tmall";
}
function getOpsMaintenanceMessage(platform: PlatformId) {
return platform === "jd"
? "京东会话由运维后台维护,普通用户页面不提供登录和恢复入口。"
: "天猫会话也已切到运维后台维护,普通用户页面不再暴露登录和模板入口。";
}
function buildOpsSessionManagerHref(platform: PlatformId, from: string) {
return `/ops/session-manager?platform=${encodeURIComponent(platform)}&from=${encodeURIComponent(from)}`;
}
export function NewTaskPage() { export function NewTaskPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const readinessQuery = useQuery({ const readinessQuery = useQuery({
queryKey: ["platform-readiness"], queryKey: ["platform-readiness"],
queryFn: getPlatformReadiness queryFn: getPlatformReadiness
}); });
const jdLiveSessionQuery = useQuery({
queryKey: ["jd-live-session"],
queryFn: getJdLiveSession
});
const historyQuery = useQuery({ const historyQuery = useQuery({
queryKey: ["history"], queryKey: ["history"],
queryFn: getHistoryTasks queryFn: getHistoryTasks
@ -191,7 +222,27 @@ export function NewTaskPage() {
navigate(`/tasks/${task.taskId}/confirm`); navigate(`/tasks/${task.taskId}/confirm`);
} }
}); });
const jdKeywordPreviewMutation = useMutation({
mutationFn: () =>
getJdKeywordPreview(query.trim(), {
commentCount: Math.max(8, Math.min(perLinkLimit, 12)),
maxPages: 2
})
});
const recentTasks = (historyQuery.data?.tasks ?? []).slice(0, 4); const recentTasks = (historyQuery.data?.tasks ?? []).slice(0, 4);
const jdLiveSession = jdLiveSessionQuery.data?.session;
const jdKeywordPreview = jdKeywordPreviewMutation.data?.preview;
const resetJdKeywordPreview = jdKeywordPreviewMutation.reset;
const jdPreviewDisabled =
jdKeywordPreviewMutation.isPending ||
query.trim().length === 0 ||
!jdLiveSession?.configured ||
!jdLiveSession.detailTemplate.available ||
!jdLiveSession.reviewsTemplate.available;
useEffect(() => {
resetJdKeywordPreview();
}, [perLinkLimit, query, resetJdKeywordPreview]);
return ( return (
<Layout> <Layout>
@ -282,12 +333,18 @@ export function NewTaskPage() {
</span> </span>
</div> </div>
<div className="panel-actions"> <div className="panel-actions">
{isOpsManagedPlatform(platform.platform) ? (
<span className="inline-note inline-note--subtle">
{getOpsMaintenanceMessage(platform.platform)}
</span>
) : (
<a <a
className="text-link" className="text-link"
href={`/sessions/${platform.platform}/prepare?from=/tasks/new`} href={`/ops/platforms/${platform.platform}/prepare?from=/tasks/new`}
> >
</a> </a>
)}
</div> </div>
</article> </article>
))} ))}
@ -329,16 +386,143 @@ export function NewTaskPage() {
<h3>P0 </h3> <h3>P0 </h3>
<ul className="list"> <ul className="list">
<li></li> <li></li>
<li></li> <li></li>
<li></li> <li></li>
</ul> </ul>
</div> </div>
</div> </div>
</section> </section>
<section className="page-panel quick-preview-panel">
<div className="quick-preview-panel__header">
<div>
<p className="eyebrow">JD Quick Preview</p>
<h3></h3>
</div>
<div className="panel-actions">
<button
className="primary-button"
disabled={jdPreviewDisabled}
onClick={() => jdKeywordPreviewMutation.mutate()}
type="button"
>
</button>
{!jdLiveSession?.configured ? (
<span className="inline-note inline-note--subtle">
</span>
) : null}
</div>
</div>
<p className="inline-note">
<code> -&gt; -&gt; -&gt; </code>
</p>
{jdKeywordPreviewMutation.error instanceof Error ? (
<p className="inline-note inline-note--subtle">
{jdKeywordPreviewMutation.error.message}
</p>
) : null}
{jdKeywordPreview ? <JdKeywordPreviewPanel preview={jdKeywordPreview} /> : null}
</section>
</Layout> </Layout>
); );
} }
function JdKeywordPreviewPanel(props: { preview: JdKeywordPreviewResult }) {
const { preview } = props;
const { selected, alternatives, candidateCount } = preview.search;
const { detail, reviews, pagination } = preview.product;
return (
<div className="stack">
<div className="quick-preview-summary">
<article className="metric-card">
<p className="eyebrow">Selected</p>
<h4>{selected.candidate.title}</h4>
<p>{selected.candidate.storeName}</p>
<p className="inline-note inline-note--subtle">
SKU {selected.skuId} · {candidateCount} · {selected.score}
</p>
<p>{selected.summary}</p>
</article>
<article className="metric-card">
<p className="eyebrow">Product</p>
<h4>{detail.price ? `¥${detail.price}` : selected.candidate.priceLabel}</h4>
<p>
{detail.originalPrice ? `原价 ¥${detail.originalPrice}` : "原价暂无"} ·{" "}
{detail.stockState ?? "库存状态暂无"}
</p>
<p>{detail.categoryPath.length > 0 ? detail.categoryPath.join(" / ") : "类目暂无"}</p>
<p>{detail.averageScore ? `评分 ${detail.averageScore}` : "评分暂无"}</p>
</article>
<article className="metric-card">
<p className="eyebrow">Reviews</p>
<h4>{reviews.goodRate ? `好评率 ${reviews.goodRate}` : "好评率暂无"}</h4>
<p>
{reviews.total ?? "暂无"} · {reviews.pictureCount ?? "暂无"}
</p>
<p>
{reviews.comments.length} {pagination.pagesFetched}/
{pagination.maxPages}
</p>
</article>
</div>
{reviews.tags.length > 0 ? (
<div className="stack stack--dense">
<strong></strong>
<div className="tag-list">
{reviews.tags.slice(0, 8).map((tag) => (
<span key={`${tag.tagId ?? tag.name}`} className="tag-chip">
{tag.name}
{tag.count ? ` ${tag.count}` : ""}
</span>
))}
</div>
</div>
) : null}
<div className="stack stack--dense">
<strong></strong>
<div className="comment-list">
{reviews.comments.slice(0, 6).map((comment) => (
<article key={comment.id} className="evidence-card">
<div className="readiness-card__header">
<strong>{comment.userLevelName ?? "匿名用户"}</strong>
<span className="inline-note inline-note--subtle">
{comment.score ? `${comment.score}` : "未评分"}
</span>
</div>
<p>{comment.content}</p>
<p className="inline-note inline-note--subtle">
{comment.creationTime ?? "时间未知"} · #{comment.id}
</p>
</article>
))}
</div>
</div>
{alternatives.length > 0 ? (
<div className="stack stack--dense">
<strong></strong>
<div className="comment-list">
{alternatives.map((alternative) => (
<article key={alternative.candidate.candidateId} className="mini-task-link">
<div className="mini-task-link__topline">
<strong>{alternative.candidate.title}</strong>
<span className="inline-note inline-note--subtle">{alternative.score}</span>
</div>
<p>{alternative.summary}</p>
</article>
))}
</div>
</div>
) : null}
</div>
);
}
function CandidateCard(props: { function CandidateCard(props: {
candidate: CandidateRecord; candidate: CandidateRecord;
checked: boolean; checked: boolean;
@ -440,12 +624,18 @@ function ConfirmPage() {
<div className="empty-state"> <div className="empty-state">
<p>{platformRun?.reason ?? "当前没有候选结果。"}</p> <p>{platformRun?.reason ?? "当前没有候选结果。"}</p>
{platformRun?.status === "SearchBlocked" ? ( {platformRun?.status === "SearchBlocked" ? (
isOpsManagedPlatform(platform) ? (
<span className="inline-note inline-note--subtle">
</span>
) : (
<a <a
className="text-link" className="text-link"
href={`/tasks/${taskId}/recovery/${platform}?from=/tasks/${taskId}/confirm`} href={`/ops/tasks/${taskId}/recovery/${platform}?from=/tasks/${taskId}/confirm`}
> >
</a> </a>
)
) : null} ) : null}
</div> </div>
) : ( ) : (
@ -536,12 +726,18 @@ function RunPage() {
<p>{run.reason ?? "当前平台已进入主线处理。"}</p> <p>{run.reason ?? "当前平台已进入主线处理。"}</p>
<div className="panel-actions"> <div className="panel-actions">
{run.status === "SearchBlocked" || run.status === "Blocked" ? ( {run.status === "SearchBlocked" || run.status === "Blocked" ? (
isOpsManagedPlatform(run.platform) ? (
<span className="inline-note inline-note--subtle">
</span>
) : (
<a <a
className="text-link" className="text-link"
href={`/tasks/${taskId}/recovery/${run.platform}?from=/tasks/${taskId}/run`} href={`/ops/tasks/${taskId}/recovery/${run.platform}?from=/tasks/${taskId}/run`}
> >
</a> </a>
)
) : null} ) : null}
{run.status === "Failed" ? ( {run.status === "Failed" ? (
<button <button
@ -1087,13 +1283,13 @@ export function HistoryPage() {
); );
} }
export function SessionPreparePage() { function PlatformSessionPreparePage(props: {
from: string;
platformId: PlatformId;
}) {
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { platform = "tmall" } = useParams(); const { from, platformId } = props;
const [searchParams] = useSearchParams();
const from = searchParams.get("from") ?? "/tasks/new";
const platformId = platform as PlatformId;
const sessionQuery = useQuery({ const sessionQuery = useQuery({
queryKey: ["session", platformId], queryKey: ["session", platformId],
queryFn: () => getPlatformSession(platformId) queryFn: () => getPlatformSession(platformId)
@ -1129,7 +1325,9 @@ export function SessionPreparePage() {
<div className="session-placeholder__viewport">Remote Browser Viewport</div> <div className="session-placeholder__viewport">Remote Browser Viewport</div>
<div className="session-placeholder__sidebar"> <div className="session-placeholder__sidebar">
<strong>prepare</strong> <strong>prepare</strong>
<p></p> <p>
</p>
<div className="session-details"> <div className="session-details">
<div className="session-details__row"> <div className="session-details__row">
<span></span> <span></span>
@ -1168,6 +1366,12 @@ export function SessionPreparePage() {
</button> </button>
</div> </div>
{prepareMutation.error instanceof Error ? (
<p className="inline-note inline-note--subtle">{prepareMutation.error.message}</p>
) : null}
{clearMutation.error instanceof Error ? (
<p className="inline-note inline-note--subtle">{clearMutation.error.message}</p>
) : null}
</div> </div>
</div> </div>
</section> </section>
@ -1175,16 +1379,541 @@ export function SessionPreparePage() {
); );
} }
function RecoveryPage() { export function SessionPreparePage() {
const { platform = "tmall" } = useParams();
const [searchParams] = useSearchParams();
const from = searchParams.get("from") ?? "/tasks/new";
const platformId = platform as PlatformId;
if (isOpsManagedPlatform(platformId)) {
return <Navigate replace to={buildOpsSessionManagerHref(platformId, from)} />;
}
return <PlatformSessionPreparePage from={from} platformId={platformId} />;
}
export function JdOpsSessionManagerPage() {
const queryClient = useQueryClient();
const [searchParams] = useSearchParams();
const from = searchParams.get("from") ?? "/history";
const [configDirty, setConfigDirty] = useState(false);
const [configForm, setConfigForm] = useState<JdSessionManagerConfigInput>({
enabled: true,
autoLoginMode: "disabled",
loginCommand: "",
browserProfilePath: "",
heartbeatQuery: "iPhone 15",
account: "",
password: "",
checkIntervalMs: 10 * 60 * 1000,
runnerTimeoutMs: 5 * 60 * 1000
});
const [manualSessionForm, setManualSessionForm] = useState<JdLiveSessionInput>({
cookieHeader: "",
userAgent: "",
searchApiTemplateUrl: "",
detailTemplateUrl: "",
reviewsTemplateUrl: "",
searchReferer: "",
detailReferer: ""
});
const managerQuery = useQuery({
queryKey: ["jd-session-manager"],
queryFn: getJdSessionManager
});
const liveSessionQuery = useQuery({
queryKey: ["jd-live-session"],
queryFn: getJdLiveSession
});
const saveConfigMutation = useMutation({
mutationFn: (payload: JdSessionManagerConfigInput) => updateJdSessionManagerConfig(payload),
onSuccess: async () => {
setConfigDirty(false);
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["jd-session-manager"] }),
queryClient.invalidateQueries({ queryKey: ["platform-readiness"] })
]);
}
});
const clearConfigMutation = useMutation({
mutationFn: clearJdSessionManagerConfig,
onSuccess: async () => {
setConfigDirty(false);
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["jd-session-manager"] }),
queryClient.invalidateQueries({ queryKey: ["platform-readiness"] })
]);
}
});
const healthCheckMutation = useMutation({
mutationFn: runJdSessionManagerHealthCheck,
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["jd-session-manager"] }),
queryClient.invalidateQueries({ queryKey: ["platform-readiness"] }),
queryClient.invalidateQueries({ queryKey: ["jd-live-session"] })
]);
}
});
const recoverMutation = useMutation({
mutationFn: runJdSessionManagerRecovery,
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["jd-session-manager"] }),
queryClient.invalidateQueries({ queryKey: ["platform-readiness"] }),
queryClient.invalidateQueries({ queryKey: ["jd-live-session"] })
]);
}
});
const importMutation = useMutation({
mutationFn: (payload: JdLiveSessionInput) => importJdManagedSession(payload),
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["jd-session-manager"] }),
queryClient.invalidateQueries({ queryKey: ["platform-readiness"] }),
queryClient.invalidateQueries({ queryKey: ["jd-live-session"] })
]);
}
});
const clearSessionMutation = useMutation({
mutationFn: clearJdManagedSession,
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["jd-session-manager"] }),
queryClient.invalidateQueries({ queryKey: ["platform-readiness"] }),
queryClient.invalidateQueries({ queryKey: ["jd-live-session"] })
]);
}
});
const manager = managerQuery.data?.manager;
const liveSession = liveSessionQuery.data?.session;
useEffect(() => {
if (!manager || configDirty) {
return;
}
setConfigForm({
enabled: manager.enabled,
autoLoginMode: manager.autoLoginMode,
loginCommand: "",
browserProfilePath: manager.browserProfilePath ?? "",
heartbeatQuery: manager.heartbeatQuery,
account: "",
password: "",
checkIntervalMs: manager.checkIntervalMs,
runnerTimeoutMs: manager.runnerTimeoutMs
});
}, [configDirty, manager]);
const submitConfig = () => {
const payload: JdSessionManagerConfigInput = {
loginCommand: configForm.loginCommand?.trim() || null,
browserProfilePath: configForm.browserProfilePath?.trim() || null,
heartbeatQuery: configForm.heartbeatQuery?.trim() || null,
account: configForm.account?.trim() || null,
password: configForm.password?.trim() || null,
checkIntervalMs: configForm.checkIntervalMs ?? null,
runnerTimeoutMs: configForm.runnerTimeoutMs ?? null
};
if (typeof configForm.enabled === "boolean") {
payload.enabled = configForm.enabled;
}
if (configForm.autoLoginMode) {
payload.autoLoginMode = configForm.autoLoginMode;
}
saveConfigMutation.mutate(payload);
};
const submitManualSession = () => {
const payload = Object.fromEntries(
Object.entries(manualSessionForm).flatMap(([key, value]) => {
const normalizedValue = value?.trim();
return normalizedValue ? [[key, normalizedValue]] : [];
})
) as JdLiveSessionInput;
importMutation.mutate(payload);
};
return (
<Layout>
<section className="page-panel session-panel">
<p className="eyebrow">Ops Console</p>
<h2></h2>
<p></p>
<div className="session-placeholder">
<div className="session-placeholder__viewport">
<div className="stack session-import-form">
<div className="page-panel ops-panel">
<p className="eyebrow">Automation</p>
<strong></strong>
<p className="inline-note">
JSON
<code>JdLiveSessionInput</code> Session Manager
</p>
<div className="field-grid">
<label className="field">
<span></span>
<select
onChange={(event) => {
setConfigDirty(true);
const value = event.target.value as "disabled" | "command";
setConfigForm((current) => ({
...current,
enabled: value !== "disabled",
autoLoginMode: value
}));
}}
value={configForm.autoLoginMode ?? "disabled"}
>
<option value="disabled"></option>
<option value="command"></option>
</select>
</label>
<label className="field">
<span></span>
<input
onChange={(event) => {
setConfigDirty(true);
setConfigForm((current) => ({
...current,
heartbeatQuery: event.target.value
}));
}}
placeholder="iPhone 15"
value={configForm.heartbeatQuery ?? ""}
/>
</label>
</div>
<label className="field">
<span></span>
<textarea
onChange={(event) => {
setConfigDirty(true);
setConfigForm((current) => ({
...current,
loginCommand: event.target.value
}));
}}
placeholder="例如node scripts/jd-login-ops.mjs"
rows={3}
value={configForm.loginCommand ?? ""}
/>
</label>
<div className="field-grid">
<label className="field">
<span> Profile </span>
<input
onChange={(event) => {
setConfigDirty(true);
setConfigForm((current) => ({
...current,
browserProfilePath: event.target.value
}));
}}
placeholder="D:\\ops\\jd-profile"
value={configForm.browserProfilePath ?? ""}
/>
</label>
<label className="field">
<span>(ms)</span>
<input
min={60000}
onChange={(event) => {
setConfigDirty(true);
setConfigForm((current) => ({
...current,
checkIntervalMs: Number(event.target.value)
}));
}}
type="number"
value={String(configForm.checkIntervalMs ?? 600000)}
/>
</label>
</div>
<div className="field-grid">
<label className="field">
<span></span>
<input
onChange={(event) => {
setConfigDirty(true);
setConfigForm((current) => ({
...current,
account: event.target.value
}));
}}
placeholder="仅保存到当前后端进程"
value={configForm.account ?? ""}
/>
</label>
<label className="field">
<span></span>
<input
onChange={(event) => {
setConfigDirty(true);
setConfigForm((current) => ({
...current,
password: event.target.value
}));
}}
placeholder="留空表示不更新"
type="password"
value={configForm.password ?? ""}
/>
</label>
</div>
<div className="panel-actions">
<button
className="primary-button"
disabled={saveConfigMutation.isPending}
onClick={submitConfig}
type="button"
>
</button>
<button
className="ghost-button"
disabled={clearConfigMutation.isPending}
onClick={() => clearConfigMutation.mutate()}
type="button"
>
</button>
<button
className="ghost-button"
disabled={healthCheckMutation.isPending}
onClick={() => healthCheckMutation.mutate()}
type="button"
>
</button>
<button
className="ghost-button"
disabled={recoverMutation.isPending}
onClick={() => recoverMutation.mutate()}
type="button"
>
</button>
</div>
</div>
<div className="page-panel ops-panel">
<p className="eyebrow">Manual</p>
<strong></strong>
<p className="inline-note">
Cookie
</p>
<label className="field">
<span>Cookie Header</span>
<textarea
onChange={(event) =>
setManualSessionForm((current) => ({
...current,
cookieHeader: event.target.value
}))
}
placeholder="thor=...; pin=...;"
rows={5}
value={manualSessionForm.cookieHeader}
/>
</label>
<div className="field-grid">
<label className="field">
<span>Search Template URL</span>
<input
onChange={(event) =>
setManualSessionForm((current) => ({
...current,
searchApiTemplateUrl: event.target.value
}))
}
placeholder="可选pc_search_searchWare 请求 URL"
value={manualSessionForm.searchApiTemplateUrl}
/>
</label>
<label className="field">
<span>User-Agent</span>
<input
onChange={(event) =>
setManualSessionForm((current) => ({
...current,
userAgent: event.target.value
}))
}
placeholder="可选"
value={manualSessionForm.userAgent}
/>
</label>
</div>
<label className="field">
<span>Detail Template URL</span>
<textarea
onChange={(event) =>
setManualSessionForm((current) => ({
...current,
detailTemplateUrl: event.target.value
}))
}
placeholder="必填pc_detailpage_wareBusiness 请求 URL"
rows={3}
value={manualSessionForm.detailTemplateUrl}
/>
</label>
<label className="field">
<span>Reviews Template URL</span>
<textarea
onChange={(event) =>
setManualSessionForm((current) => ({
...current,
reviewsTemplateUrl: event.target.value
}))
}
placeholder="必填getLegoWareDetailComment 请求 URL"
rows={3}
value={manualSessionForm.reviewsTemplateUrl}
/>
</label>
<div className="panel-actions">
<button
className="primary-button"
disabled={
importMutation.isPending ||
(manualSessionForm.cookieHeader?.trim().length ?? 0) === 0 ||
(manualSessionForm.detailTemplateUrl?.trim().length ?? 0) === 0 ||
(manualSessionForm.reviewsTemplateUrl?.trim().length ?? 0) === 0
}
onClick={submitManualSession}
type="button"
>
</button>
<button
className="ghost-button"
disabled={clearSessionMutation.isPending}
onClick={() => clearSessionMutation.mutate()}
type="button"
>
</button>
</div>
</div>
</div>
</div>
<div className="session-placeholder__sidebar">
<strong>ops-session-manager</strong>
<p></p>
<div className="session-details">
<div className="session-details__row">
<span>Manager Status</span>
<strong>{manager?.status ?? "加载中"}</strong>
</div>
<div className="session-details__row">
<span>Public Note</span>
<strong>{manager?.publicNote ?? "加载中"}</strong>
</div>
<div className="session-details__row">
<span>Auto Mode</span>
<strong>{manager?.autoLoginMode ?? "disabled"}</strong>
</div>
<div className="session-details__row">
<span>Login Command</span>
<strong>{manager?.commandConfigured ? "已配置" : "未配置"}</strong>
</div>
<div className="session-details__row">
<span>Account</span>
<strong>{manager?.accountLabel ?? "未配置"}</strong>
</div>
<div className="session-details__row">
<span>Live Session</span>
<strong>{liveSession?.configured ? "已导入" : "未导入"}</strong>
</div>
<div className="session-details__row">
<span>Detail Template</span>
<strong>
{liveSession?.detailTemplate.available
? `可用${liveSession.detailTemplate.skuId ? ` · SKU ${liveSession.detailTemplate.skuId}` : ""}`
: "缺失"}
</strong>
</div>
<div className="session-details__row">
<span>Reviews Template</span>
<strong>
{liveSession?.reviewsTemplate.available
? `可用${liveSession.reviewsTemplate.skuId ? ` · SKU ${liveSession.reviewsTemplate.skuId}` : ""}`
: "缺失"}
</strong>
</div>
<p>{manager?.note ?? "正在读取京东运维状态..."}</p>
<p>
{formatTimestamp(manager?.lastCheckAt)} ·
{formatTimestamp(manager?.lastRecoveredAt)}
</p>
{manager?.lastFailureMessage ? (
<p className="inline-note inline-note--subtle">
{manager.lastFailureCode ?? "UNKNOWN"} · {manager.lastFailureMessage}
</p>
) : null}
<p className="session-return-target">{from}</p>
</div>
<div className="panel-actions">
<a className="text-link" href={from}>
</a>
</div>
{saveConfigMutation.error instanceof Error ? (
<p className="inline-note inline-note--subtle">
{saveConfigMutation.error.message}
</p>
) : null}
{clearConfigMutation.error instanceof Error ? (
<p className="inline-note inline-note--subtle">
{clearConfigMutation.error.message}
</p>
) : null}
{healthCheckMutation.error instanceof Error ? (
<p className="inline-note inline-note--subtle">
{healthCheckMutation.error.message}
</p>
) : null}
{recoverMutation.error instanceof Error ? (
<p className="inline-note inline-note--subtle">
{recoverMutation.error.message}
</p>
) : null}
{importMutation.error instanceof Error ? (
<p className="inline-note inline-note--subtle">{importMutation.error.message}</p>
) : null}
{clearSessionMutation.error instanceof Error ? (
<p className="inline-note inline-note--subtle">
{clearSessionMutation.error.message}
</p>
) : null}
</div>
</div>
</section>
</Layout>
);
}
function PlatformRecoveryPage(props: {
from: string;
platform: PlatformId;
taskId: string;
}) {
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { taskId = "", platform = "tmall" } = useParams(); const { from, platform, taskId } = props;
const [searchParams] = useSearchParams();
const from = searchParams.get("from") ?? `/tasks/${taskId}/run`;
const recoveryMutation = useMutation({ const recoveryMutation = useMutation({
mutationFn: async () => { mutationFn: async () => {
await preparePlatform(platform as PlatformId); await preparePlatform(platform);
return retryTaskPlatform(taskId, platform as PlatformId); return retryTaskPlatform(taskId, platform);
}, },
onSuccess: async () => { onSuccess: async () => {
await Promise.all([ await Promise.all([
@ -1202,8 +1931,8 @@ function RecoveryPage() {
<Layout> <Layout>
<section className="page-panel session-panel"> <section className="page-panel session-panel">
<p className="eyebrow">Recovery Console</p> <p className="eyebrow">Recovery Console</p>
<h2>{platformCatalogMap[platform as PlatformId].label} </h2> <h2>{platformCatalogMap[platform].label} </h2>
<p>{platformCatalogMap[platform as PlatformId].recoveryHint}</p> <p>{platformCatalogMap[platform].recoveryHint}</p>
<div className="session-placeholder"> <div className="session-placeholder">
<div className="session-placeholder__viewport">Remote Browser Viewport</div> <div className="session-placeholder__viewport">Remote Browser Viewport</div>
<div className="session-placeholder__sidebar"> <div className="session-placeholder__sidebar">
@ -1224,6 +1953,19 @@ function RecoveryPage() {
); );
} }
function RecoveryPage() {
const { taskId = "", platform = "tmall" } = useParams();
const [searchParams] = useSearchParams();
const from = searchParams.get("from") ?? `/tasks/${taskId}/run`;
const platformId = platform as PlatformId;
if (isOpsManagedPlatform(platformId)) {
return <Navigate replace to={buildOpsSessionManagerHref(platformId, from)} />;
}
return <PlatformRecoveryPage from={from} platform={platformId} taskId={taskId} />;
}
export function App() { export function App() {
return ( return (
<Routes> <Routes>
@ -1231,9 +1973,14 @@ export function App() {
<Route element={<NewTaskPage />} path="/tasks/new" /> <Route element={<NewTaskPage />} path="/tasks/new" />
<Route element={<ConfirmPage />} path="/tasks/:taskId/confirm" /> <Route element={<ConfirmPage />} path="/tasks/:taskId/confirm" />
<Route element={<RunPage />} path="/tasks/:taskId/run" /> <Route element={<RunPage />} path="/tasks/:taskId/run" />
<Route element={<RecoveryPage />} path="/ops/tasks/:taskId/recovery/:platform" />
<Route element={<RecoveryPage />} path="/tasks/:taskId/recovery/:platform" /> <Route element={<RecoveryPage />} path="/tasks/:taskId/recovery/:platform" />
<Route element={<ReportPage />} path="/tasks/:taskId/report" /> <Route element={<ReportPage />} path="/tasks/:taskId/report" />
<Route element={<HistoryPage />} path="/history" /> <Route element={<HistoryPage />} path="/history" />
<Route element={<OpsSessionManagerPage />} path="/ops/session-manager" />
<Route element={<OpsSessionManagerPage />} path="/ops/jd/session-manager" />
<Route element={<OpsSessionManagerPage />} path="/ops/tmall/session-manager" />
<Route element={<SessionPreparePage />} path="/ops/platforms/:platform/prepare" />
<Route element={<SessionPreparePage />} path="/sessions/:platform/prepare" /> <Route element={<SessionPreparePage />} path="/sessions/:platform/prepare" />
</Routes> </Routes>
); );

View File

@ -6,18 +6,28 @@ import { MemoryRouter } from "react-router-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("./lib/api", () => ({ vi.mock("./lib/api", () => ({
clearJdManagedSession: vi.fn(),
clearJdSessionManagerConfig: vi.fn(),
clearPlatformSession: vi.fn(), clearPlatformSession: vi.fn(),
confirmTask: vi.fn(), confirmTask: vi.fn(),
createTask: vi.fn(), createTask: vi.fn(),
deleteTask: vi.fn(), deleteTask: vi.fn(),
getJdKeywordPreview: vi.fn(),
getJdLiveSession: vi.fn(),
getJdSessionManager: vi.fn(),
getHistoryTasks: vi.fn(), getHistoryTasks: vi.fn(),
getPlatformReadiness: vi.fn(), getPlatformReadiness: vi.fn(),
getPlatformSession: vi.fn(), getPlatformSession: vi.fn(),
getTask: vi.fn(), getTask: vi.fn(),
getTaskCandidates: vi.fn(), getTaskCandidates: vi.fn(),
getTaskReport: vi.fn(), getTaskReport: vi.fn(),
importJdManagedSession: vi.fn(),
preparePlatform: vi.fn(), preparePlatform: vi.fn(),
runJdSessionManagerHealthCheck: vi.fn(),
runJdSessionManagerRecovery: vi.fn(),
retryTaskPlatform: vi.fn() retryTaskPlatform: vi.fn()
,
updateJdSessionManagerConfig: vi.fn()
})); }));
import { HistoryPage } from "./App"; import { HistoryPage } from "./App";

View File

@ -2,31 +2,60 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { cleanup, render, screen, waitFor } from "@testing-library/react"; import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { MemoryRouter, Route, Routes } from "react-router-dom"; import { MemoryRouter } from "react-router-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("./lib/api", () => ({ vi.mock("./lib/api", () => ({
clearJdManagedSession: vi.fn(),
clearJdSessionManagerConfig: vi.fn(),
clearPlatformSession: vi.fn(), clearPlatformSession: vi.fn(),
clearTmallManagedSession: vi.fn(),
clearTmallSessionManagerConfig: vi.fn(),
confirmTask: vi.fn(), confirmTask: vi.fn(),
createTask: vi.fn(), createTask: vi.fn(),
deleteTask: vi.fn(), deleteTask: vi.fn(),
getJdKeywordPreview: vi.fn(),
getJdLiveSession: vi.fn(),
getJdSessionManager: vi.fn(),
getHistoryTasks: vi.fn(), getHistoryTasks: vi.fn(),
getPlatformReadiness: vi.fn(), getPlatformReadiness: vi.fn(),
getPlatformSession: vi.fn(), getPlatformSession: vi.fn(),
getTask: vi.fn(), getTask: vi.fn(),
getTaskCandidates: vi.fn(), getTaskCandidates: vi.fn(),
getTaskReport: vi.fn(), getTaskReport: vi.fn(),
getTmallLiveSession: vi.fn(),
getTmallSessionManager: vi.fn(),
importJdManagedSession: vi.fn(),
importTmallManagedSession: vi.fn(),
preparePlatform: vi.fn(), preparePlatform: vi.fn(),
retryTaskPlatform: vi.fn() runJdSessionManagerHealthCheck: vi.fn(),
runJdSessionManagerRecovery: vi.fn(),
runTmallSessionManagerHealthCheck: vi.fn(),
retryTaskPlatform: vi.fn(),
updateJdSessionManagerConfig: vi.fn(),
updateTmallSessionManagerConfig: vi.fn()
})); }));
import { NewTaskPage, SessionPreparePage } from "./App"; import { App, NewTaskPage } from "./App";
import { import {
clearJdManagedSession,
clearPlatformSession, clearPlatformSession,
clearTmallManagedSession,
getJdKeywordPreview,
getJdLiveSession,
getJdSessionManager,
getHistoryTasks, getHistoryTasks,
getPlatformReadiness, getPlatformReadiness,
getPlatformSession, getPlatformSession,
preparePlatform getTmallLiveSession,
getTmallSessionManager,
importJdManagedSession,
importTmallManagedSession,
preparePlatform,
runJdSessionManagerHealthCheck,
runJdSessionManagerRecovery,
runTmallSessionManagerHealthCheck,
updateJdSessionManagerConfig
} from "./lib/api"; } from "./lib/api";
function renderWithProviders(node: ReactNode, initialEntries?: string[]) { function renderWithProviders(node: ReactNode, initialEntries?: string[]) {
@ -51,6 +80,69 @@ function renderWithProviders(node: ReactNode, initialEntries?: string[]) {
describe("task composer and session console", () => { describe("task composer and session console", () => {
beforeEach(() => { beforeEach(() => {
const jdManagerState = {
status: "healthy",
enabled: true,
autoLoginMode: "command",
commandConfigured: true,
accountConfigured: true,
passwordConfigured: false,
accountLabel: "jd***86",
browserProfilePath: "D:\\ops\\jd-profile",
heartbeatQuery: "iPhone 15",
checkIntervalMs: 600000,
runnerTimeoutMs: 300000,
pendingManualAction: false,
note: "京东会话当前可用。",
publicNote: "京东会话由运维后台维护,当前可用。",
configuredAt: "2026-04-02T08:00:00.000Z",
lastCheckAt: "2026-04-02T10:00:00.000Z",
lastRecoveredAt: "2026-04-02T09:00:00.000Z",
session: {
configured: true,
importedAt: "2026-04-02T10:00:00.000Z",
hasCookie: true,
userAgent: "stub-user-agent",
searchApiTemplate: {
available: true
},
detailTemplate: {
available: true,
skuId: "100068388533"
},
reviewsTemplate: {
available: true,
skuId: "100068388533"
}
}
};
const tmallManagerState = {
status: "healthy",
enabled: true,
heartbeatItemId: "934454505228",
checkIntervalMs: 600000,
pendingManualAction: false,
note: "天猫会话当前可用。",
publicNote: "天猫会话由运维后台维护,当前可用。",
configuredAt: "2026-04-02T08:00:00.000Z",
lastCheckAt: "2026-04-02T10:00:00.000Z",
lastHealthyAt: "2026-04-02T10:00:00.000Z",
session: {
configured: true,
importedAt: "2026-04-02T10:00:00.000Z",
hasCookie: true,
userAgent: "stub-user-agent",
detailTemplate: {
available: true,
itemId: "934454505228"
},
reviewsTemplate: {
available: true,
itemId: "934454505228"
}
}
};
vi.mocked(getPlatformReadiness).mockResolvedValue({ vi.mocked(getPlatformReadiness).mockResolvedValue({
platforms: [ platforms: [
{ {
@ -96,10 +188,10 @@ describe("task composer and session console", () => {
} as any); } as any);
vi.mocked(getPlatformSession).mockResolvedValue({ vi.mocked(getPlatformSession).mockResolvedValue({
session: { session: {
platform: "jd", platform: "tmall",
ready: true, ready: true,
status: "ready", status: "ready",
searchRequirement: "required", searchRequirement: "recommended",
scope: "workspace", scope: "workspace",
ttlHours: 24, ttlHours: 24,
lastPreparedAt: "2026-04-02T10:00:00.000Z", lastPreparedAt: "2026-04-02T10:00:00.000Z",
@ -108,7 +200,218 @@ describe("task composer and session console", () => {
cipherLabel: "mock-aes-gcm-v1" cipherLabel: "mock-aes-gcm-v1"
} }
} as any); } as any);
vi.mocked(getJdLiveSession).mockResolvedValue({
session: {
configured: true,
importedAt: "2026-04-02T10:00:00.000Z",
hasCookie: true,
userAgent: "stub-user-agent",
searchApiTemplate: {
available: false
},
detailTemplate: {
available: true,
skuId: "100068388533"
},
reviewsTemplate: {
available: true,
skuId: "100068388533"
}
}
} as any);
vi.mocked(getJdKeywordPreview).mockResolvedValue({
preview: {
query: "小米手环10",
search: {
source: "html",
candidateCount: 2,
selected: {
skuId: "222222222222",
score: 168,
summary: "标题完整命中关键词;位于搜索结果前列;已解析出可回放 SKU",
matchedTokens: ["小米手环10"],
candidate: {
candidateId: "jd-222222222222",
platform: "jd",
title: "小米手环10 标准版 智能手环",
price: 269,
priceLabel: "¥269",
storeName: "小米京东自营旗舰店",
productUrl: "https://item.jd.com/222222222222.html",
imageUrl: "https://img14.360buyimg.com/n2/jfs/t1/example-10.jpg",
salesHint: "已售50万+",
specLabel: "标准版",
highlights: ["14天续航"]
}
},
alternatives: [
{
skuId: "111111111111",
score: 118,
summary: "标题/卖点命中 1 个关键词片段;已解析出可回放 SKU",
matchedTokens: ["小米"],
candidate: {
candidateId: "jd-111111111111",
platform: "jd",
title: "小米手环9 NFC版",
price: 249,
priceLabel: "¥249",
storeName: "小米京东自营旗舰店",
productUrl: "https://item.jd.com/111111111111.html",
imageUrl: "https://img14.360buyimg.com/n2/jfs/t1/example-9.jpg",
salesHint: "已售20万+",
specLabel: "NFC版",
highlights: ["健康监测"]
}
}
]
},
product: {
skuId: "222222222222",
source: "api",
detail: {
skuId: "222222222222",
title: "小米手环10 标准版 智能手环",
price: "269.00",
originalPrice: "299.00",
estimatedPrice: "269.00",
shopName: "小米京东自营旗舰店",
vendorId: null,
categoryPath: ["智能设备", "智能手环"],
stockState: "现货",
mainImage: "https://img14.360buyimg.com/n2/jfs/t1/example-10.jpg",
averageScore: "4.9"
},
pagination: {
requestedPage: 1,
requestedCommentCount: 12,
maxPages: 2,
pagesFetched: 2,
pageKey: "page"
},
reviews: {
skuId: "222222222222",
total: "10000+",
goodRate: "97%",
pictureCount: "800",
tags: [
{
tagId: "tag-1",
name: "续航很久",
count: "5300"
}
],
comments: [
{
id: "comment-1",
content: "表带舒适,睡眠监测比较准。",
score: "5",
creationTime: "2026-04-03 09:00:00",
userLevelName: "PLUS会员"
}
]
}
}
}
} as any);
vi.mocked(getJdSessionManager).mockResolvedValue({
manager: jdManagerState
} as any);
vi.mocked(getTmallSessionManager).mockResolvedValue({
manager: tmallManagerState
} as any);
vi.mocked(getTmallLiveSession).mockResolvedValue({
session: tmallManagerState.session
} as any);
vi.mocked(clearPlatformSession).mockResolvedValue(undefined); vi.mocked(clearPlatformSession).mockResolvedValue(undefined);
vi.mocked(importJdManagedSession).mockResolvedValue({
manager: {
...jdManagerState,
note: "京东会话已更新。",
lastRecoveredAt: "2026-04-02T10:05:00.000Z",
session: {
...jdManagerState.session,
importedAt: "2026-04-02T10:05:00.000Z",
}
}
} as any);
vi.mocked(importTmallManagedSession).mockResolvedValue({
manager: {
...tmallManagerState,
note: "天猫会话已更新。",
lastHealthyAt: "2026-04-02T10:05:00.000Z",
session: {
...tmallManagerState.session,
importedAt: "2026-04-02T10:05:00.000Z"
}
}
} as any);
vi.mocked(clearJdManagedSession).mockResolvedValue({
manager: {
status: "idle",
enabled: true,
autoLoginMode: "command",
commandConfigured: true,
accountConfigured: true,
passwordConfigured: false,
accountLabel: "jd***86",
heartbeatQuery: "iPhone 15",
checkIntervalMs: 600000,
runnerTimeoutMs: 300000,
pendingManualAction: false,
note: "京东会话已清理。",
publicNote: "京东会话由运维后台维护,当前尚未就绪。",
session: {
configured: false,
hasCookie: false,
searchApiTemplate: {
available: false
},
detailTemplate: {
available: false
},
reviewsTemplate: {
available: false
}
}
}
} as any);
vi.mocked(clearTmallManagedSession).mockResolvedValue({
manager: {
status: "idle",
enabled: true,
heartbeatItemId: "934454505228",
checkIntervalMs: 600000,
pendingManualAction: false,
note: "天猫会话已清理。",
publicNote: "天猫会话由运维后台维护,当前尚未就绪。",
session: {
configured: false,
hasCookie: false,
detailTemplate: {
available: false
},
reviewsTemplate: {
available: false
}
}
}
} as any);
vi.mocked(updateJdSessionManagerConfig).mockResolvedValue({
manager: jdManagerState
} as any);
vi.mocked(runJdSessionManagerHealthCheck).mockResolvedValue({
state: jdManagerState,
recovered: false
} as any);
vi.mocked(runJdSessionManagerRecovery).mockResolvedValue({
state: jdManagerState,
recovered: true
} as any);
vi.mocked(runTmallSessionManagerHealthCheck).mockResolvedValue({
state: tmallManagerState,
recovered: false
} as any);
vi.mocked(preparePlatform).mockResolvedValue({ vi.mocked(preparePlatform).mockResolvedValue({
platform: "jd", platform: "jd",
session_ready: true, session_ready: true,
@ -136,23 +439,104 @@ describe("task composer and session console", () => {
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
it("shows session details and allows clearing the current session", async () => { it("runs the jd keyword-only preview loop from the new task page", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
renderWithProviders( renderWithProviders(<NewTaskPage />);
<Routes>
<Route element={<SessionPreparePage />} path="/sessions/:platform/prepare" />
</Routes>,
["/sessions/jd/prepare?from=/tasks/new"]
);
expect(await screen.findByText("已加密保存")).toBeInTheDocument(); await user.clear(await screen.findByLabelText("商品关键词 / 描述"));
expect(screen.getByText(/完成后将返回:\/tasks\/new/)).toBeInTheDocument(); await user.type(screen.getByLabelText("商品关键词 / 描述"), "小米手环10");
await user.click(screen.getByRole("button", { name: "只用关键词抓京东" }));
await waitFor(() => {
expect(getJdKeywordPreview).toHaveBeenCalledWith("小米手环10", {
commentCount: 12,
maxPages: 2
});
});
expect(await screen.findByText("小米手环10 标准版 智能手环")).toBeInTheDocument();
expect(await screen.findByText(/SKU 222222222222/)).toBeInTheDocument();
expect(await screen.findByText("好评率 97%")).toBeInTheDocument();
expect(await screen.findByText("表带舒适,睡眠监测比较准。")).toBeInTheDocument();
});
it("redirects the tmall prepare route to the unified ops page and clears the managed session", async () => {
const user = userEvent.setup();
renderWithProviders(<App />, ["/ops/platforms/tmall/prepare?from=/tasks/new"]);
await user.click(screen.getByRole("button", { name: "清理当前会话" })); await user.click(screen.getByRole("button", { name: "清理当前会话" }));
await waitFor(() => { await waitFor(() => {
expect(clearPlatformSession).toHaveBeenCalledWith("jd"); expect(clearTmallManagedSession).toHaveBeenCalled();
});
});
it("redirects the legacy jd prepare route to the ops session manager page", async () => {
renderWithProviders(<App />, ["/sessions/jd/prepare?from=/tasks/new"]);
expect(await screen.findByText("京东运维会话管理")).toBeInTheDocument();
expect(screen.getByText(/返回业务页面:\/tasks\/new/)).toBeInTheDocument();
});
it("imports jd managed session payload from the ops page", async () => {
const user = userEvent.setup();
renderWithProviders(<App />, ["/ops/session-manager?platform=jd&from=/tasks/new"]);
await user.clear(await screen.findByLabelText("Cookie Header"));
await user.type(screen.getByLabelText("Cookie Header"), "thor=masked; pin=masked;");
await user.type(
screen.getByLabelText("Detail Template URL"),
"https://api.m.jd.com/?functionId=pc_detailpage_wareBusiness"
);
await user.type(
screen.getByLabelText("Reviews Template URL"),
"https://api.m.jd.com/?functionId=getLegoWareDetailComment"
);
await user.click(screen.getByRole("button", { name: "注入京东会话" }));
await waitFor(() => {
expect(importJdManagedSession).toHaveBeenCalledWith(
expect.objectContaining({
cookieHeader: "thor=masked; pin=masked;",
detailTemplateUrl: "https://api.m.jd.com/?functionId=pc_detailpage_wareBusiness",
reviewsTemplateUrl: "https://api.m.jd.com/?functionId=getLegoWareDetailComment"
})
);
});
});
it("imports tmall managed session payload from the unified ops page", async () => {
const user = userEvent.setup();
renderWithProviders(<App />, ["/ops/session-manager?platform=tmall&from=/tasks/new"]);
await user.clear(await screen.findByLabelText("Cookie Header"));
await user.type(
screen.getByLabelText("Cookie Header"),
"_m_h5_tk=masked_token_123; cookie2=masked;"
);
await user.type(
screen.getByLabelText("Detail Template URL"),
"https://detail.tmall.com/item.htm?id=934454505228"
);
await user.type(
screen.getByLabelText("Reviews Template URL"),
"https://h5api.m.tmall.com/h5/mtop.taobao.rate.detaillist.get/6.0/?data=%7B%22auctionNumId%22%3A%22934454505228%22%7D"
);
await user.click(screen.getByRole("button", { name: "注入天猫会话" }));
await waitFor(() => {
expect(importTmallManagedSession).toHaveBeenCalledWith(
expect.objectContaining({
cookieHeader: "_m_h5_tk=masked_token_123; cookie2=masked;",
detailTemplateUrl: "https://detail.tmall.com/item.htm?id=934454505228",
reviewsTemplateUrl:
"https://h5api.m.tmall.com/h5/mtop.taobao.rate.detaillist.get/6.0/?data=%7B%22auctionNumId%22%3A%22934454505228%22%7D"
})
);
}); });
}); });
}); });

View File

@ -0,0 +1,936 @@
import type { PlatformId } from "@cross-ai/domain";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { useSearchParams } from "react-router-dom";
import {
clearJdManagedSession,
clearJdSessionManagerConfig,
clearTmallManagedSession,
clearTmallSessionManagerConfig,
getJdLiveSession,
getJdSessionManager,
getTmallLiveSession,
getTmallSessionManager,
importJdManagedSession,
importTmallManagedSession,
runJdSessionManagerHealthCheck,
runJdSessionManagerRecovery,
runTmallSessionManagerHealthCheck,
type JdLiveSessionInput,
type JdSessionManagerConfigInput,
type TmallLiveSessionInput,
type TmallSessionManagerConfigInput,
updateJdSessionManagerConfig,
updateTmallSessionManagerConfig
} from "./lib/api";
function Layout(props: { children: React.ReactNode }) {
return (
<div className="app-shell">
<aside className="sidebar">
<div>
<p className="eyebrow">Workspace</p>
<h1 className="sidebar__title"></h1>
<p className="sidebar__copy">线</p>
</div>
<nav className="sidebar__nav">
<a href="/tasks/new"></a>
<a href="/history"></a>
</nav>
</aside>
<main className="main-content">{props.children}</main>
</div>
);
}
function formatTimestamp(timestamp?: string) {
if (!timestamp) {
return "暂无";
}
return new Date(timestamp).toLocaleString("zh-CN");
}
export function OpsSessionManagerPage() {
const queryClient = useQueryClient();
const [searchParams, setSearchParams] = useSearchParams();
const from = searchParams.get("from") ?? "/history";
const activePlatform: PlatformId = searchParams.get("platform") === "tmall" ? "tmall" : "jd";
const [jdConfigDirty, setJdConfigDirty] = useState(false);
const [jdConfigForm, setJdConfigForm] = useState<JdSessionManagerConfigInput>({
enabled: true,
autoLoginMode: "disabled",
loginCommand: "",
browserProfilePath: "",
heartbeatQuery: "iPhone 15",
account: "",
password: "",
checkIntervalMs: 10 * 60 * 1000,
runnerTimeoutMs: 5 * 60 * 1000
});
const [jdManualSessionForm, setJdManualSessionForm] = useState<JdLiveSessionInput>({
cookieHeader: "",
userAgent: "",
searchApiTemplateUrl: "",
detailTemplateUrl: "",
reviewsTemplateUrl: "",
searchReferer: "",
detailReferer: ""
});
const [tmallConfigDirty, setTmallConfigDirty] = useState(false);
const [tmallConfigForm, setTmallConfigForm] = useState<TmallSessionManagerConfigInput>({
enabled: true,
heartbeatItemId: "934454505228",
checkIntervalMs: 10 * 60 * 1000
});
const [tmallManualSessionForm, setTmallManualSessionForm] = useState<TmallLiveSessionInput>({
cookieHeader: "",
userAgent: "",
detailTemplateUrl: "",
reviewsTemplateUrl: "",
detailReferer: ""
});
const jdManagerQuery = useQuery({
queryKey: ["jd-session-manager"],
queryFn: getJdSessionManager
});
const jdLiveSessionQuery = useQuery({
queryKey: ["jd-live-session"],
queryFn: getJdLiveSession
});
const tmallManagerQuery = useQuery({
queryKey: ["tmall-session-manager"],
queryFn: getTmallSessionManager
});
const tmallLiveSessionQuery = useQuery({
queryKey: ["tmall-live-session"],
queryFn: getTmallLiveSession
});
const invalidateJdOpsQueries = async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["jd-session-manager"] }),
queryClient.invalidateQueries({ queryKey: ["jd-live-session"] }),
queryClient.invalidateQueries({ queryKey: ["platform-readiness"] })
]);
};
const invalidateTmallOpsQueries = async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["tmall-session-manager"] }),
queryClient.invalidateQueries({ queryKey: ["tmall-live-session"] }),
queryClient.invalidateQueries({ queryKey: ["platform-readiness"] })
]);
};
const jdSaveConfigMutation = useMutation({
mutationFn: (payload: JdSessionManagerConfigInput) => updateJdSessionManagerConfig(payload),
onSuccess: async () => {
setJdConfigDirty(false);
await invalidateJdOpsQueries();
}
});
const jdClearConfigMutation = useMutation({
mutationFn: clearJdSessionManagerConfig,
onSuccess: async () => {
setJdConfigDirty(false);
await invalidateJdOpsQueries();
}
});
const jdHealthCheckMutation = useMutation({
mutationFn: runJdSessionManagerHealthCheck,
onSuccess: invalidateJdOpsQueries
});
const jdRecoverMutation = useMutation({
mutationFn: runJdSessionManagerRecovery,
onSuccess: invalidateJdOpsQueries
});
const jdImportMutation = useMutation({
mutationFn: (payload: JdLiveSessionInput) => importJdManagedSession(payload),
onSuccess: invalidateJdOpsQueries
});
const jdClearSessionMutation = useMutation({
mutationFn: clearJdManagedSession,
onSuccess: invalidateJdOpsQueries
});
const tmallSaveConfigMutation = useMutation({
mutationFn: (payload: TmallSessionManagerConfigInput) =>
updateTmallSessionManagerConfig(payload),
onSuccess: async () => {
setTmallConfigDirty(false);
await invalidateTmallOpsQueries();
}
});
const tmallClearConfigMutation = useMutation({
mutationFn: clearTmallSessionManagerConfig,
onSuccess: async () => {
setTmallConfigDirty(false);
await invalidateTmallOpsQueries();
}
});
const tmallHealthCheckMutation = useMutation({
mutationFn: runTmallSessionManagerHealthCheck,
onSuccess: invalidateTmallOpsQueries
});
const tmallImportMutation = useMutation({
mutationFn: (payload: TmallLiveSessionInput) => importTmallManagedSession(payload),
onSuccess: invalidateTmallOpsQueries
});
const tmallClearSessionMutation = useMutation({
mutationFn: clearTmallManagedSession,
onSuccess: invalidateTmallOpsQueries
});
const jdManager = jdManagerQuery.data?.manager;
const jdLiveSession = jdLiveSessionQuery.data?.session;
const tmallManager = tmallManagerQuery.data?.manager;
const tmallLiveSession = tmallLiveSessionQuery.data?.session;
useEffect(() => {
if (!jdManager || jdConfigDirty) {
return;
}
setJdConfigForm({
enabled: jdManager.enabled,
autoLoginMode: jdManager.autoLoginMode,
loginCommand: "",
browserProfilePath: jdManager.browserProfilePath ?? "",
heartbeatQuery: jdManager.heartbeatQuery,
account: "",
password: "",
checkIntervalMs: jdManager.checkIntervalMs,
runnerTimeoutMs: jdManager.runnerTimeoutMs
});
}, [jdConfigDirty, jdManager]);
useEffect(() => {
if (!tmallManager || tmallConfigDirty) {
return;
}
setTmallConfigForm({
enabled: tmallManager.enabled,
heartbeatItemId: tmallManager.heartbeatItemId ?? "",
checkIntervalMs: tmallManager.checkIntervalMs
});
}, [tmallConfigDirty, tmallManager]);
const switchPlatform = (platform: PlatformId) => {
const next = new URLSearchParams(searchParams);
next.set("platform", platform);
next.set("from", from);
setSearchParams(next, { replace: true });
};
const submitJdConfig = () => {
const payload: JdSessionManagerConfigInput = {
loginCommand: jdConfigForm.loginCommand?.trim() || null,
browserProfilePath: jdConfigForm.browserProfilePath?.trim() || null,
heartbeatQuery: jdConfigForm.heartbeatQuery?.trim() || null,
account: jdConfigForm.account?.trim() || null,
password: jdConfigForm.password?.trim() || null,
checkIntervalMs: jdConfigForm.checkIntervalMs ?? null,
runnerTimeoutMs: jdConfigForm.runnerTimeoutMs ?? null
};
if (typeof jdConfigForm.enabled === "boolean") {
payload.enabled = jdConfigForm.enabled;
}
if (jdConfigForm.autoLoginMode) {
payload.autoLoginMode = jdConfigForm.autoLoginMode;
}
jdSaveConfigMutation.mutate(payload);
};
const submitJdManualSession = () => {
const payload = Object.fromEntries(
Object.entries(jdManualSessionForm).flatMap(([key, value]) => {
const normalizedValue = value?.trim();
return normalizedValue ? [[key, normalizedValue]] : [];
})
) as JdLiveSessionInput;
jdImportMutation.mutate(payload);
};
const submitTmallConfig = () => {
const payload: TmallSessionManagerConfigInput = {
heartbeatItemId: tmallConfigForm.heartbeatItemId?.trim() || null,
checkIntervalMs: tmallConfigForm.checkIntervalMs ?? null
};
if (typeof tmallConfigForm.enabled === "boolean") {
payload.enabled = tmallConfigForm.enabled;
}
tmallSaveConfigMutation.mutate(payload);
};
const submitTmallManualSession = () => {
const payload = Object.fromEntries(
Object.entries(tmallManualSessionForm).flatMap(([key, value]) => {
const normalizedValue = value?.trim();
return normalizedValue ? [[key, normalizedValue]] : [];
})
) as TmallLiveSessionInput;
tmallImportMutation.mutate(payload);
};
return (
<Layout>
<section className="page-panel session-panel">
<p className="eyebrow">Ops Console</p>
<h2></h2>
<p></p>
<div className="panel-actions">
<button
className={activePlatform === "jd" ? "primary-button" : "ghost-button"}
onClick={() => switchPlatform("jd")}
type="button"
>
</button>
<button
className={activePlatform === "tmall" ? "primary-button" : "ghost-button"}
onClick={() => switchPlatform("tmall")}
type="button"
>
</button>
<a className="text-link" href={from}>
</a>
</div>
{activePlatform === "jd" ? (
<div className="session-placeholder">
<div className="session-placeholder__viewport">
<div className="stack session-import-form">
<div className="page-panel ops-panel">
<p className="eyebrow">Platform</p>
<strong></strong>
<p className="inline-note">
JSON
<code>JdLiveSessionInput</code> Session Manager
</p>
</div>
<div className="page-panel ops-panel">
<p className="eyebrow">Automation</p>
<strong></strong>
<div className="field-grid">
<label className="field">
<span></span>
<select
onChange={(event) => {
setJdConfigDirty(true);
const value = event.target.value as "disabled" | "command";
setJdConfigForm((current) => ({
...current,
enabled: value !== "disabled",
autoLoginMode: value
}));
}}
value={jdConfigForm.autoLoginMode ?? "disabled"}
>
<option value="disabled"></option>
<option value="command"></option>
</select>
</label>
<label className="field">
<span></span>
<input
onChange={(event) => {
setJdConfigDirty(true);
setJdConfigForm((current) => ({
...current,
heartbeatQuery: event.target.value
}));
}}
placeholder="iPhone 15"
value={jdConfigForm.heartbeatQuery ?? ""}
/>
</label>
</div>
<label className="field">
<span></span>
<textarea
onChange={(event) => {
setJdConfigDirty(true);
setJdConfigForm((current) => ({
...current,
loginCommand: event.target.value
}));
}}
placeholder="例如node scripts/jd-login-ops.mjs"
rows={3}
value={jdConfigForm.loginCommand ?? ""}
/>
</label>
<div className="field-grid">
<label className="field">
<span> Profile </span>
<input
onChange={(event) => {
setJdConfigDirty(true);
setJdConfigForm((current) => ({
...current,
browserProfilePath: event.target.value
}));
}}
placeholder="D:\\ops\\jd-profile"
value={jdConfigForm.browserProfilePath ?? ""}
/>
</label>
<label className="field">
<span> (ms)</span>
<input
min={60000}
onChange={(event) => {
setJdConfigDirty(true);
setJdConfigForm((current) => ({
...current,
checkIntervalMs: Number(event.target.value)
}));
}}
type="number"
value={String(jdConfigForm.checkIntervalMs ?? 600000)}
/>
</label>
</div>
<div className="field-grid">
<label className="field">
<span></span>
<input
onChange={(event) => {
setJdConfigDirty(true);
setJdConfigForm((current) => ({
...current,
account: event.target.value
}));
}}
placeholder="仅保存到当前后端进程"
value={jdConfigForm.account ?? ""}
/>
</label>
<label className="field">
<span></span>
<input
onChange={(event) => {
setJdConfigDirty(true);
setJdConfigForm((current) => ({
...current,
password: event.target.value
}));
}}
placeholder="留空表示不更新"
type="password"
value={jdConfigForm.password ?? ""}
/>
</label>
</div>
<div className="panel-actions">
<button
className="primary-button"
disabled={jdSaveConfigMutation.isPending}
onClick={submitJdConfig}
type="button"
>
</button>
<button
className="ghost-button"
disabled={jdClearConfigMutation.isPending}
onClick={() => jdClearConfigMutation.mutate()}
type="button"
>
</button>
<button
className="ghost-button"
disabled={jdHealthCheckMutation.isPending}
onClick={() => jdHealthCheckMutation.mutate()}
type="button"
>
</button>
<button
className="ghost-button"
disabled={jdRecoverMutation.isPending}
onClick={() => jdRecoverMutation.mutate()}
type="button"
>
</button>
</div>
</div>
<div className="page-panel ops-panel">
<p className="eyebrow">Manual</p>
<strong></strong>
<p className="inline-note">
Cookie
</p>
<label className="field">
<span>Cookie Header</span>
<textarea
onChange={(event) =>
setJdManualSessionForm((current) => ({
...current,
cookieHeader: event.target.value
}))
}
placeholder="thor=...; pin=...;"
rows={5}
value={jdManualSessionForm.cookieHeader}
/>
</label>
<div className="field-grid">
<label className="field">
<span>Search Template URL</span>
<input
onChange={(event) =>
setJdManualSessionForm((current) => ({
...current,
searchApiTemplateUrl: event.target.value
}))
}
placeholder="可选pc_search_searchWare 请求 URL"
value={jdManualSessionForm.searchApiTemplateUrl}
/>
</label>
<label className="field">
<span>User-Agent</span>
<input
onChange={(event) =>
setJdManualSessionForm((current) => ({
...current,
userAgent: event.target.value
}))
}
placeholder="可选"
value={jdManualSessionForm.userAgent}
/>
</label>
</div>
<label className="field">
<span>Detail Template URL</span>
<textarea
onChange={(event) =>
setJdManualSessionForm((current) => ({
...current,
detailTemplateUrl: event.target.value
}))
}
placeholder="必填pc_detailpage_wareBusiness 请求 URL"
rows={3}
value={jdManualSessionForm.detailTemplateUrl}
/>
</label>
<label className="field">
<span>Reviews Template URL</span>
<textarea
onChange={(event) =>
setJdManualSessionForm((current) => ({
...current,
reviewsTemplateUrl: event.target.value
}))
}
placeholder="必填getLegoWareDetailComment 请求 URL"
rows={3}
value={jdManualSessionForm.reviewsTemplateUrl}
/>
</label>
<div className="panel-actions">
<button
className="primary-button"
disabled={
jdImportMutation.isPending ||
(jdManualSessionForm.cookieHeader?.trim().length ?? 0) === 0 ||
(jdManualSessionForm.detailTemplateUrl?.trim().length ?? 0) === 0 ||
(jdManualSessionForm.reviewsTemplateUrl?.trim().length ?? 0) === 0
}
onClick={submitJdManualSession}
type="button"
>
</button>
<button
className="ghost-button"
disabled={jdClearSessionMutation.isPending}
onClick={() => jdClearSessionMutation.mutate()}
type="button"
>
</button>
</div>
</div>
</div>
</div>
<div className="session-placeholder__sidebar">
<strong>ops-session-manager</strong>
<p></p>
<div className="session-details">
<div className="session-details__row">
<span>Manager Status</span>
<strong>{jdManager?.status ?? "加载中"}</strong>
</div>
<div className="session-details__row">
<span>Public Note</span>
<strong>{jdManager?.publicNote ?? "加载中"}</strong>
</div>
<div className="session-details__row">
<span>Auto Mode</span>
<strong>{jdManager?.autoLoginMode ?? "disabled"}</strong>
</div>
<div className="session-details__row">
<span>Login Command</span>
<strong>{jdManager?.commandConfigured ? "已配置" : "未配置"}</strong>
</div>
<div className="session-details__row">
<span>Account</span>
<strong>{jdManager?.accountLabel ?? "未配置"}</strong>
</div>
<div className="session-details__row">
<span>Live Session</span>
<strong>{jdLiveSession?.configured ? "已导入" : "未导入"}</strong>
</div>
<div className="session-details__row">
<span>Detail Template</span>
<strong>
{jdLiveSession?.detailTemplate.available
? `可用${jdLiveSession.detailTemplate.skuId ? ` · SKU ${jdLiveSession.detailTemplate.skuId}` : ""}`
: "缺失"}
</strong>
</div>
<div className="session-details__row">
<span>Reviews Template</span>
<strong>
{jdLiveSession?.reviewsTemplate.available
? `可用${jdLiveSession.reviewsTemplate.skuId ? ` · SKU ${jdLiveSession.reviewsTemplate.skuId}` : ""}`
: "缺失"}
</strong>
</div>
<p>{jdManager?.note ?? "正在读取京东运维状态..."}</p>
<p>
{formatTimestamp(jdManager?.lastCheckAt)} ·
{formatTimestamp(jdManager?.lastRecoveredAt)}
</p>
{jdManager?.lastFailureMessage ? (
<p className="inline-note inline-note--subtle">
{jdManager.lastFailureCode ?? "UNKNOWN"} · {jdManager.lastFailureMessage}
</p>
) : null}
<p className="session-return-target">{from}</p>
</div>
{jdSaveConfigMutation.error instanceof Error ? (
<p className="inline-note inline-note--subtle">{jdSaveConfigMutation.error.message}</p>
) : null}
{jdClearConfigMutation.error instanceof Error ? (
<p className="inline-note inline-note--subtle">
{jdClearConfigMutation.error.message}
</p>
) : null}
{jdHealthCheckMutation.error instanceof Error ? (
<p className="inline-note inline-note--subtle">
{jdHealthCheckMutation.error.message}
</p>
) : null}
{jdRecoverMutation.error instanceof Error ? (
<p className="inline-note inline-note--subtle">{jdRecoverMutation.error.message}</p>
) : null}
{jdImportMutation.error instanceof Error ? (
<p className="inline-note inline-note--subtle">{jdImportMutation.error.message}</p>
) : null}
{jdClearSessionMutation.error instanceof Error ? (
<p className="inline-note inline-note--subtle">
{jdClearSessionMutation.error.message}
</p>
) : null}
</div>
</div>
) : (
<div className="session-placeholder">
<div className="session-placeholder__viewport">
<div className="stack session-import-form">
<div className="page-panel ops-panel">
<p className="eyebrow">Platform</p>
<strong></strong>
<p className="inline-note">
</p>
</div>
<div className="page-panel ops-panel">
<p className="eyebrow">Automation</p>
<strong></strong>
<div className="field-grid">
<label className="field">
<span></span>
<select
onChange={(event) => {
setTmallConfigDirty(true);
setTmallConfigForm((current) => ({
...current,
enabled: event.target.value === "enabled"
}));
}}
value={tmallConfigForm.enabled ? "enabled" : "disabled"}
>
<option value="enabled"></option>
<option value="disabled"></option>
</select>
</label>
<label className="field">
<span> Item ID</span>
<input
onChange={(event) => {
setTmallConfigDirty(true);
setTmallConfigForm((current) => ({
...current,
heartbeatItemId: event.target.value
}));
}}
placeholder="934454505228"
value={tmallConfigForm.heartbeatItemId ?? ""}
/>
</label>
</div>
<label className="field">
<span> (ms)</span>
<input
min={60000}
onChange={(event) => {
setTmallConfigDirty(true);
setTmallConfigForm((current) => ({
...current,
checkIntervalMs: Number(event.target.value)
}));
}}
type="number"
value={String(tmallConfigForm.checkIntervalMs ?? 600000)}
/>
</label>
<div className="panel-actions">
<button
className="primary-button"
disabled={tmallSaveConfigMutation.isPending}
onClick={submitTmallConfig}
type="button"
>
</button>
<button
className="ghost-button"
disabled={tmallClearConfigMutation.isPending}
onClick={() => tmallClearConfigMutation.mutate()}
type="button"
>
</button>
<button
className="ghost-button"
disabled={tmallHealthCheckMutation.isPending}
onClick={() => tmallHealthCheckMutation.mutate()}
type="button"
>
</button>
</div>
</div>
<div className="page-panel ops-panel">
<p className="eyebrow">Manual</p>
<strong></strong>
<p className="inline-note">
Cookie
</p>
<label className="field">
<span>Cookie Header</span>
<textarea
onChange={(event) =>
setTmallManualSessionForm((current) => ({
...current,
cookieHeader: event.target.value
}))
}
placeholder="_m_h5_tk=...; cookie2=...;"
rows={5}
value={tmallManualSessionForm.cookieHeader}
/>
</label>
<div className="field-grid">
<label className="field">
<span>User-Agent</span>
<input
onChange={(event) =>
setTmallManualSessionForm((current) => ({
...current,
userAgent: event.target.value
}))
}
placeholder="可选"
value={tmallManualSessionForm.userAgent}
/>
</label>
<label className="field">
<span>Detail Referer</span>
<input
onChange={(event) =>
setTmallManualSessionForm((current) => ({
...current,
detailReferer: event.target.value
}))
}
placeholder="可选https://detail.tmall.com/"
value={tmallManualSessionForm.detailReferer}
/>
</label>
</div>
<label className="field">
<span>Detail Template URL</span>
<textarea
onChange={(event) =>
setTmallManualSessionForm((current) => ({
...current,
detailTemplateUrl: event.target.value
}))
}
placeholder="可选;当前详情优先走登录态 HTML保留模板用于回放对齐"
rows={3}
value={tmallManualSessionForm.detailTemplateUrl}
/>
</label>
<label className="field">
<span>Reviews Template URL</span>
<textarea
onChange={(event) =>
setTmallManualSessionForm((current) => ({
...current,
reviewsTemplateUrl: event.target.value
}))
}
placeholder="必填mtop.taobao.rate.detaillist.get 请求 URL"
rows={3}
value={tmallManualSessionForm.reviewsTemplateUrl}
/>
</label>
<div className="panel-actions">
<button
className="primary-button"
disabled={
tmallImportMutation.isPending ||
(tmallManualSessionForm.cookieHeader?.trim().length ?? 0) === 0 ||
(tmallManualSessionForm.reviewsTemplateUrl?.trim().length ?? 0) === 0
}
onClick={submitTmallManualSession}
type="button"
>
</button>
<button
className="ghost-button"
disabled={tmallClearSessionMutation.isPending}
onClick={() => tmallClearSessionMutation.mutate()}
type="button"
>
</button>
</div>
</div>
</div>
</div>
<div className="session-placeholder__sidebar">
<strong>ops-session-manager</strong>
<p></p>
<div className="session-details">
<div className="session-details__row">
<span>Manager Status</span>
<strong>{tmallManager?.status ?? "加载中"}</strong>
</div>
<div className="session-details__row">
<span>Public Note</span>
<strong>{tmallManager?.publicNote ?? "加载中"}</strong>
</div>
<div className="session-details__row">
<span>Heartbeat Item</span>
<strong>{tmallManager?.heartbeatItemId ?? "未配置"}</strong>
</div>
<div className="session-details__row">
<span>Check Interval</span>
<strong>{tmallManager?.checkIntervalMs ?? 0} ms</strong>
</div>
<div className="session-details__row">
<span>Live Session</span>
<strong>{tmallLiveSession?.configured ? "已导入" : "未导入"}</strong>
</div>
<div className="session-details__row">
<span>Detail Template</span>
<strong>
{tmallLiveSession?.detailTemplate.available
? `可用${tmallLiveSession.detailTemplate.itemId ? ` · item ${tmallLiveSession.detailTemplate.itemId}` : ""}`
: "缺失"}
</strong>
</div>
<div className="session-details__row">
<span>Reviews Template</span>
<strong>
{tmallLiveSession?.reviewsTemplate.available
? `可用${tmallLiveSession.reviewsTemplate.itemId ? ` · item ${tmallLiveSession.reviewsTemplate.itemId}` : ""}`
: "缺失"}
</strong>
</div>
<p>{tmallManager?.note ?? "正在读取天猫运维状态..."}</p>
<p>
{formatTimestamp(tmallManager?.lastCheckAt)} ·
{formatTimestamp(tmallManager?.lastHealthyAt)}
</p>
{tmallManager?.lastFailureMessage ? (
<p className="inline-note inline-note--subtle">
{tmallManager.lastFailureCode ?? "UNKNOWN"} · {tmallManager.lastFailureMessage}
</p>
) : null}
<p className="session-return-target">{from}</p>
</div>
{tmallSaveConfigMutation.error instanceof Error ? (
<p className="inline-note inline-note--subtle">
{tmallSaveConfigMutation.error.message}
</p>
) : null}
{tmallClearConfigMutation.error instanceof Error ? (
<p className="inline-note inline-note--subtle">
{tmallClearConfigMutation.error.message}
</p>
) : null}
{tmallHealthCheckMutation.error instanceof Error ? (
<p className="inline-note inline-note--subtle">
{tmallHealthCheckMutation.error.message}
</p>
) : null}
{tmallImportMutation.error instanceof Error ? (
<p className="inline-note inline-note--subtle">
{tmallImportMutation.error.message}
</p>
) : null}
{tmallClearSessionMutation.error instanceof Error ? (
<p className="inline-note inline-note--subtle">
{tmallClearSessionMutation.error.message}
</p>
) : null}
</div>
</div>
)}
</section>
</Layout>
);
}

View File

@ -607,6 +607,12 @@ a {
color: var(--text-secondary); color: var(--text-secondary);
} }
.session-import-form {
width: 100%;
align-self: stretch;
color: var(--text-primary);
}
.session-details { .session-details {
display: grid; display: grid;
gap: 12px; gap: 12px;
@ -632,6 +638,47 @@ a {
font-size: 13px; font-size: 13px;
} }
.quick-preview-panel {
display: grid;
gap: 18px;
}
.quick-preview-panel__header {
display: flex;
align-items: start;
justify-content: space-between;
gap: 16px;
}
.quick-preview-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
}
.tag-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.tag-chip {
display: inline-flex;
align-items: center;
min-height: 32px;
padding: 0 12px;
border-radius: 999px;
background: rgba(20, 108, 110, 0.08);
color: var(--brand-primary);
font-size: 13px;
font-weight: 700;
}
.comment-list {
display: grid;
gap: 12px;
}
.list { .list {
margin: 0; margin: 0;
padding-left: 18px; padding-left: 18px;
@ -655,6 +702,7 @@ a {
.history-toolbar, .history-toolbar,
.field-grid, .field-grid,
.metrics-grid, .metrics-grid,
.quick-preview-summary,
.session-placeholder { .session-placeholder {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }