feat(web): 新增统一运维会话管理页与入口
This commit is contained in:
parent
e506e7d9c2
commit
6dbf0b70e7
@ -3,6 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<title>跨平台商品聚合与 AI 分析</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
7
apps/web/public/favicon.svg
Normal file
7
apps/web/public/favicon.svg
Normal 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 |
@ -23,19 +23,32 @@ import {
|
||||
import { PlatformIdentity, PlatformStatusPill, TaskStatusPill } from "./components/StatusPill";
|
||||
import { TaskContextHeader } from "./components/TaskContextHeader";
|
||||
import { TaskSpine } from "./components/TaskSpine";
|
||||
import { OpsSessionManagerPage } from "./OpsSessionManagerPage";
|
||||
import {
|
||||
clearJdManagedSession,
|
||||
clearJdSessionManagerConfig,
|
||||
clearPlatformSession,
|
||||
confirmTask,
|
||||
createTask,
|
||||
deleteTask,
|
||||
getJdKeywordPreview,
|
||||
getJdLiveSession,
|
||||
getJdSessionManager,
|
||||
getHistoryTasks,
|
||||
getPlatformReadiness,
|
||||
getPlatformSession,
|
||||
getTask,
|
||||
getTaskCandidates,
|
||||
getTaskReport,
|
||||
importJdManagedSession,
|
||||
preparePlatform,
|
||||
retryTaskPlatform
|
||||
retryTaskPlatform,
|
||||
runJdSessionManagerHealthCheck,
|
||||
runJdSessionManagerRecovery,
|
||||
type JdKeywordPreviewResult,
|
||||
type JdLiveSessionInput,
|
||||
type JdSessionManagerConfigInput,
|
||||
updateJdSessionManagerConfig
|
||||
} from "./lib/api";
|
||||
|
||||
function Layout(props: { children: React.ReactNode }) {
|
||||
@ -171,12 +184,30 @@ function getSessionSnapshotSummary(session: SessionStateRecord) {
|
||||
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() {
|
||||
const navigate = useNavigate();
|
||||
const readinessQuery = useQuery({
|
||||
queryKey: ["platform-readiness"],
|
||||
queryFn: getPlatformReadiness
|
||||
});
|
||||
const jdLiveSessionQuery = useQuery({
|
||||
queryKey: ["jd-live-session"],
|
||||
queryFn: getJdLiveSession
|
||||
});
|
||||
const historyQuery = useQuery({
|
||||
queryKey: ["history"],
|
||||
queryFn: getHistoryTasks
|
||||
@ -191,7 +222,27 @@ export function NewTaskPage() {
|
||||
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 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 (
|
||||
<Layout>
|
||||
@ -282,12 +333,18 @@ export function NewTaskPage() {
|
||||
</span>
|
||||
</div>
|
||||
<div className="panel-actions">
|
||||
<a
|
||||
className="text-link"
|
||||
href={`/sessions/${platform.platform}/prepare?from=/tasks/new`}
|
||||
>
|
||||
进入会话准备
|
||||
</a>
|
||||
{isOpsManagedPlatform(platform.platform) ? (
|
||||
<span className="inline-note inline-note--subtle">
|
||||
{getOpsMaintenanceMessage(platform.platform)}
|
||||
</span>
|
||||
) : (
|
||||
<a
|
||||
className="text-link"
|
||||
href={`/ops/platforms/${platform.platform}/prepare?from=/tasks/new`}
|
||||
>
|
||||
进入会话准备
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
@ -329,16 +386,143 @@ export function NewTaskPage() {
|
||||
<h3>P0 当前不做什么</h3>
|
||||
<ul className="list">
|
||||
<li>不做自动绕过风控。</li>
|
||||
<li>不做无人工确认的同款判断。</li>
|
||||
<li>任务主链仍保留人工确认;下方直连预览只用于验证京东抓取闭环。</li>
|
||||
<li>当前工作台只覆盖天猫、京东。</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</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>搜索 -> 选品 -> 详情 -> 评论</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>
|
||||
);
|
||||
}
|
||||
|
||||
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: {
|
||||
candidate: CandidateRecord;
|
||||
checked: boolean;
|
||||
@ -440,12 +624,18 @@ function ConfirmPage() {
|
||||
<div className="empty-state">
|
||||
<p>{platformRun?.reason ?? "当前没有候选结果。"}</p>
|
||||
{platformRun?.status === "SearchBlocked" ? (
|
||||
<a
|
||||
className="text-link"
|
||||
href={`/tasks/${taskId}/recovery/${platform}?from=/tasks/${taskId}/confirm`}
|
||||
>
|
||||
处理阻塞并重试
|
||||
</a>
|
||||
isOpsManagedPlatform(platform) ? (
|
||||
<span className="inline-note inline-note--subtle">
|
||||
京东阻塞恢复已切到运维后台,当前页面不提供直接恢复入口。
|
||||
</span>
|
||||
) : (
|
||||
<a
|
||||
className="text-link"
|
||||
href={`/ops/tasks/${taskId}/recovery/${platform}?from=/tasks/${taskId}/confirm`}
|
||||
>
|
||||
处理阻塞并重试
|
||||
</a>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
@ -536,12 +726,18 @@ function RunPage() {
|
||||
<p>{run.reason ?? "当前平台已进入主线处理。"}</p>
|
||||
<div className="panel-actions">
|
||||
{run.status === "SearchBlocked" || run.status === "Blocked" ? (
|
||||
<a
|
||||
className="text-link"
|
||||
href={`/tasks/${taskId}/recovery/${run.platform}?from=/tasks/${taskId}/run`}
|
||||
>
|
||||
进入恢复并重试
|
||||
</a>
|
||||
isOpsManagedPlatform(run.platform) ? (
|
||||
<span className="inline-note inline-note--subtle">
|
||||
京东阻塞恢复由运维后台接管。
|
||||
</span>
|
||||
) : (
|
||||
<a
|
||||
className="text-link"
|
||||
href={`/ops/tasks/${taskId}/recovery/${run.platform}?from=/tasks/${taskId}/run`}
|
||||
>
|
||||
进入恢复并重试
|
||||
</a>
|
||||
)
|
||||
) : null}
|
||||
{run.status === "Failed" ? (
|
||||
<button
|
||||
@ -1087,13 +1283,13 @@ export function HistoryPage() {
|
||||
);
|
||||
}
|
||||
|
||||
export function SessionPreparePage() {
|
||||
function PlatformSessionPreparePage(props: {
|
||||
from: string;
|
||||
platformId: PlatformId;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { platform = "tmall" } = useParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
const from = searchParams.get("from") ?? "/tasks/new";
|
||||
const platformId = platform as PlatformId;
|
||||
const { from, platformId } = props;
|
||||
const sessionQuery = useQuery({
|
||||
queryKey: ["session", platformId],
|
||||
queryFn: () => getPlatformSession(platformId)
|
||||
@ -1129,7 +1325,9 @@ export function SessionPreparePage() {
|
||||
<div className="session-placeholder__viewport">Remote Browser Viewport</div>
|
||||
<div className="session-placeholder__sidebar">
|
||||
<strong>当前模式:prepare</strong>
|
||||
<p>本轮实现先用按钮模拟会话预热,后续替换为真正的远程浏览器接管。</p>
|
||||
<p>
|
||||
本轮实现先用按钮模拟会话预热,后续替换为真正的远程浏览器接管。
|
||||
</p>
|
||||
<div className="session-details">
|
||||
<div className="session-details__row">
|
||||
<span>当前状态</span>
|
||||
@ -1168,6 +1366,12 @@ export function SessionPreparePage() {
|
||||
清理当前会话
|
||||
</button>
|
||||
</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>
|
||||
</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 queryClient = useQueryClient();
|
||||
const { taskId = "", platform = "tmall" } = useParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
const from = searchParams.get("from") ?? `/tasks/${taskId}/run`;
|
||||
const { from, platform, taskId } = props;
|
||||
const recoveryMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
await preparePlatform(platform as PlatformId);
|
||||
return retryTaskPlatform(taskId, platform as PlatformId);
|
||||
await preparePlatform(platform);
|
||||
return retryTaskPlatform(taskId, platform);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
@ -1202,8 +1931,8 @@ function RecoveryPage() {
|
||||
<Layout>
|
||||
<section className="page-panel session-panel">
|
||||
<p className="eyebrow">Recovery Console</p>
|
||||
<h2>{platformCatalogMap[platform as PlatformId].label} 阻塞恢复</h2>
|
||||
<p>{platformCatalogMap[platform as PlatformId].recoveryHint}</p>
|
||||
<h2>{platformCatalogMap[platform].label} 阻塞恢复</h2>
|
||||
<p>{platformCatalogMap[platform].recoveryHint}</p>
|
||||
<div className="session-placeholder">
|
||||
<div className="session-placeholder__viewport">Remote Browser Viewport</div>
|
||||
<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() {
|
||||
return (
|
||||
<Routes>
|
||||
@ -1231,9 +1973,14 @@ export function App() {
|
||||
<Route element={<NewTaskPage />} path="/tasks/new" />
|
||||
<Route element={<ConfirmPage />} path="/tasks/:taskId/confirm" />
|
||||
<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={<ReportPage />} path="/tasks/:taskId/report" />
|
||||
<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" />
|
||||
</Routes>
|
||||
);
|
||||
|
||||
@ -6,18 +6,28 @@ import { MemoryRouter } from "react-router-dom";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("./lib/api", () => ({
|
||||
clearJdManagedSession: vi.fn(),
|
||||
clearJdSessionManagerConfig: vi.fn(),
|
||||
clearPlatformSession: vi.fn(),
|
||||
confirmTask: vi.fn(),
|
||||
createTask: vi.fn(),
|
||||
deleteTask: vi.fn(),
|
||||
getJdKeywordPreview: vi.fn(),
|
||||
getJdLiveSession: vi.fn(),
|
||||
getJdSessionManager: vi.fn(),
|
||||
getHistoryTasks: vi.fn(),
|
||||
getPlatformReadiness: vi.fn(),
|
||||
getPlatformSession: vi.fn(),
|
||||
getTask: vi.fn(),
|
||||
getTaskCandidates: vi.fn(),
|
||||
getTaskReport: vi.fn(),
|
||||
importJdManagedSession: vi.fn(),
|
||||
preparePlatform: vi.fn(),
|
||||
runJdSessionManagerHealthCheck: vi.fn(),
|
||||
runJdSessionManagerRecovery: vi.fn(),
|
||||
retryTaskPlatform: vi.fn()
|
||||
,
|
||||
updateJdSessionManagerConfig: vi.fn()
|
||||
}));
|
||||
|
||||
import { HistoryPage } from "./App";
|
||||
|
||||
@ -2,31 +2,60 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
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";
|
||||
|
||||
vi.mock("./lib/api", () => ({
|
||||
clearJdManagedSession: vi.fn(),
|
||||
clearJdSessionManagerConfig: vi.fn(),
|
||||
clearPlatformSession: vi.fn(),
|
||||
clearTmallManagedSession: vi.fn(),
|
||||
clearTmallSessionManagerConfig: vi.fn(),
|
||||
confirmTask: vi.fn(),
|
||||
createTask: vi.fn(),
|
||||
deleteTask: vi.fn(),
|
||||
getJdKeywordPreview: vi.fn(),
|
||||
getJdLiveSession: vi.fn(),
|
||||
getJdSessionManager: vi.fn(),
|
||||
getHistoryTasks: vi.fn(),
|
||||
getPlatformReadiness: vi.fn(),
|
||||
getPlatformSession: vi.fn(),
|
||||
getTask: vi.fn(),
|
||||
getTaskCandidates: vi.fn(),
|
||||
getTaskReport: vi.fn(),
|
||||
getTmallLiveSession: vi.fn(),
|
||||
getTmallSessionManager: vi.fn(),
|
||||
importJdManagedSession: vi.fn(),
|
||||
importTmallManagedSession: 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 {
|
||||
clearJdManagedSession,
|
||||
clearPlatformSession,
|
||||
clearTmallManagedSession,
|
||||
getJdKeywordPreview,
|
||||
getJdLiveSession,
|
||||
getJdSessionManager,
|
||||
getHistoryTasks,
|
||||
getPlatformReadiness,
|
||||
getPlatformSession,
|
||||
preparePlatform
|
||||
getTmallLiveSession,
|
||||
getTmallSessionManager,
|
||||
importJdManagedSession,
|
||||
importTmallManagedSession,
|
||||
preparePlatform,
|
||||
runJdSessionManagerHealthCheck,
|
||||
runJdSessionManagerRecovery,
|
||||
runTmallSessionManagerHealthCheck,
|
||||
updateJdSessionManagerConfig
|
||||
} from "./lib/api";
|
||||
|
||||
function renderWithProviders(node: ReactNode, initialEntries?: string[]) {
|
||||
@ -51,6 +80,69 @@ function renderWithProviders(node: ReactNode, initialEntries?: string[]) {
|
||||
|
||||
describe("task composer and session console", () => {
|
||||
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({
|
||||
platforms: [
|
||||
{
|
||||
@ -96,10 +188,10 @@ describe("task composer and session console", () => {
|
||||
} as any);
|
||||
vi.mocked(getPlatformSession).mockResolvedValue({
|
||||
session: {
|
||||
platform: "jd",
|
||||
platform: "tmall",
|
||||
ready: true,
|
||||
status: "ready",
|
||||
searchRequirement: "required",
|
||||
searchRequirement: "recommended",
|
||||
scope: "workspace",
|
||||
ttlHours: 24,
|
||||
lastPreparedAt: "2026-04-02T10:00:00.000Z",
|
||||
@ -108,7 +200,218 @@ describe("task composer and session console", () => {
|
||||
cipherLabel: "mock-aes-gcm-v1"
|
||||
}
|
||||
} 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(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({
|
||||
platform: "jd",
|
||||
session_ready: true,
|
||||
@ -136,23 +439,104 @@ describe("task composer and session console", () => {
|
||||
).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();
|
||||
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route element={<SessionPreparePage />} path="/sessions/:platform/prepare" />
|
||||
</Routes>,
|
||||
["/sessions/jd/prepare?from=/tasks/new"]
|
||||
);
|
||||
renderWithProviders(<NewTaskPage />);
|
||||
|
||||
expect(await screen.findByText("已加密保存")).toBeInTheDocument();
|
||||
expect(screen.getByText(/完成后将返回:\/tasks\/new/)).toBeInTheDocument();
|
||||
await user.clear(await screen.findByLabelText("商品关键词 / 描述"));
|
||||
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 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"
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
936
apps/web/src/OpsSessionManagerPage.tsx
Normal file
936
apps/web/src/OpsSessionManagerPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -607,6 +607,12 @@ a {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.session-import-form {
|
||||
width: 100%;
|
||||
align-self: stretch;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.session-details {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
@ -632,6 +638,47 @@ a {
|
||||
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 {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
@ -655,6 +702,7 @@ a {
|
||||
.history-toolbar,
|
||||
.field-grid,
|
||||
.metrics-grid,
|
||||
.quick-preview-summary,
|
||||
.session-placeholder {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user