feat(web): 新增统一运维会话管理页与入口
This commit is contained in:
parent
e506e7d9c2
commit
6dbf0b70e7
@ -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>
|
||||||
|
|||||||
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 { 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>搜索 -> 选品 -> 详情 -> 评论</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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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";
|
||||||
|
|||||||
@ -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"
|
||||||
|
})
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
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);
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user