feat: 接入运维扫码登录与任务实时执行链路
This commit is contained in:
+91
-3
@@ -29,6 +29,7 @@ import {
|
||||
clearJdSessionManagerConfig,
|
||||
clearPlatformSession,
|
||||
confirmTask,
|
||||
createTaskEventsSource,
|
||||
createTask,
|
||||
deleteTask,
|
||||
getJdKeywordPreview,
|
||||
@@ -80,6 +81,50 @@ function isRetryablePlatformStatus(
|
||||
return status === "SearchBlocked" || status === "Blocked" || status === "Failed";
|
||||
}
|
||||
|
||||
function useTaskLiveSync(
|
||||
taskId: string,
|
||||
options: {
|
||||
includeCandidates?: boolean;
|
||||
} = {}
|
||||
) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
useEffect(() => {
|
||||
if (!taskId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const eventSource = createTaskEventsSource(taskId);
|
||||
const handleSnapshot = (event: MessageEvent<string>) => {
|
||||
const payload = JSON.parse(event.data) as { task: TaskRecord };
|
||||
queryClient.setQueryData(["task", taskId], payload);
|
||||
|
||||
if (options.includeCandidates) {
|
||||
void queryClient.invalidateQueries({ queryKey: ["task-candidates", taskId] });
|
||||
}
|
||||
|
||||
if (
|
||||
payload.task.taskStatus === "Completed" ||
|
||||
payload.task.taskStatus === "PartialCompleted" ||
|
||||
payload.task.taskStatus === "Blocked" ||
|
||||
payload.task.taskStatus === "Failed"
|
||||
) {
|
||||
void queryClient.invalidateQueries({ queryKey: ["history"] });
|
||||
if (payload.task.defaultReportVersion) {
|
||||
void queryClient.invalidateQueries({ queryKey: ["report", taskId] });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.addEventListener("task.snapshot", handleSnapshot as EventListener);
|
||||
|
||||
return () => {
|
||||
eventSource.removeEventListener("task.snapshot", handleSnapshot as EventListener);
|
||||
eventSource.close();
|
||||
};
|
||||
}, [options.includeCandidates, queryClient, taskId]);
|
||||
}
|
||||
|
||||
function getTaskDestination(
|
||||
taskId: string,
|
||||
taskStatus: TaskStatus,
|
||||
@@ -552,6 +597,8 @@ function CandidateCard(props: {
|
||||
function ConfirmPage() {
|
||||
const navigate = useNavigate();
|
||||
const { taskId = "" } = useParams();
|
||||
const queryClient = useQueryClient();
|
||||
useTaskLiveSync(taskId, { includeCandidates: true });
|
||||
const taskQuery = useQuery({
|
||||
queryKey: ["task", taskId],
|
||||
queryFn: () => getTask(taskId)
|
||||
@@ -590,6 +637,16 @@ function ConfirmPage() {
|
||||
navigate(`/tasks/${task.taskId}/run`);
|
||||
}
|
||||
});
|
||||
const retryMutation = useMutation({
|
||||
mutationFn: (platform: PlatformId) => retryTaskPlatform(taskId, platform),
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["task", taskId] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["task-candidates", taskId] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["history"] })
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
if (!taskQuery.data || !candidatesQuery.data) {
|
||||
return (
|
||||
@@ -625,9 +682,27 @@ function ConfirmPage() {
|
||||
<p>{platformRun?.reason ?? "当前没有候选结果。"}</p>
|
||||
{platformRun?.status === "SearchBlocked" ? (
|
||||
isOpsManagedPlatform(platform) ? (
|
||||
<span className="inline-note inline-note--subtle">
|
||||
京东阻塞恢复已切到运维后台,当前页面不提供直接恢复入口。
|
||||
</span>
|
||||
<>
|
||||
<span className="inline-note inline-note--subtle">
|
||||
京东阻塞恢复已切到运维后台,当前页面不提供直接恢复入口。
|
||||
</span>
|
||||
<div className="panel-actions">
|
||||
<a
|
||||
className="text-link"
|
||||
href={buildOpsSessionManagerHref(platform, `/tasks/${taskId}/confirm`)}
|
||||
>
|
||||
去运维恢复
|
||||
</a>
|
||||
<button
|
||||
className="ghost-button"
|
||||
disabled={retryMutation.isPending && retryMutation.variables === platform}
|
||||
onClick={() => retryMutation.mutate(platform)}
|
||||
type="button"
|
||||
>
|
||||
恢复后重试候选
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<a
|
||||
className="text-link"
|
||||
@@ -637,6 +712,18 @@ function ConfirmPage() {
|
||||
</a>
|
||||
)
|
||||
) : null}
|
||||
{platformRun?.status === "Failed" ? (
|
||||
<div className="panel-actions">
|
||||
<button
|
||||
className="ghost-button"
|
||||
disabled={retryMutation.isPending && retryMutation.variables === platform}
|
||||
onClick={() => retryMutation.mutate(platform)}
|
||||
type="button"
|
||||
>
|
||||
重试平台搜索
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="stack">
|
||||
@@ -683,6 +770,7 @@ function ConfirmPage() {
|
||||
function RunPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const { taskId = "" } = useParams();
|
||||
useTaskLiveSync(taskId);
|
||||
const taskQuery = useQuery({
|
||||
queryKey: ["task", taskId],
|
||||
queryFn: () => getTask(taskId)
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { ReactNode } from "react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("./lib/api", () => ({
|
||||
cancelJdQrLogin: vi.fn(),
|
||||
cancelTmallQrLogin: vi.fn(),
|
||||
clearJdManagedSession: vi.fn(),
|
||||
clearJdSessionManagerConfig: vi.fn(),
|
||||
clearPlatformSession: vi.fn(),
|
||||
@@ -13,9 +15,15 @@ vi.mock("./lib/api", () => ({
|
||||
clearTmallSessionManagerConfig: vi.fn(),
|
||||
confirmTask: vi.fn(),
|
||||
createTask: vi.fn(),
|
||||
createTaskEventsSource: vi.fn(() => ({
|
||||
addEventListener: vi.fn(),
|
||||
close: vi.fn(),
|
||||
removeEventListener: vi.fn()
|
||||
})),
|
||||
deleteTask: vi.fn(),
|
||||
getJdKeywordPreview: vi.fn(),
|
||||
getJdLiveSession: vi.fn(),
|
||||
getJdQrLoginState: vi.fn(),
|
||||
getJdSessionManager: vi.fn(),
|
||||
getHistoryTasks: vi.fn(),
|
||||
getPlatformReadiness: vi.fn(),
|
||||
@@ -24,37 +32,50 @@ vi.mock("./lib/api", () => ({
|
||||
getTaskCandidates: vi.fn(),
|
||||
getTaskReport: vi.fn(),
|
||||
getTmallLiveSession: vi.fn(),
|
||||
getTmallQrLoginState: vi.fn(),
|
||||
getTmallSessionManager: vi.fn(),
|
||||
importJdManagedSession: vi.fn(),
|
||||
importTmallManagedSession: vi.fn(),
|
||||
preparePlatform: vi.fn(),
|
||||
resumeJdQrLoginManualRecovery: vi.fn(),
|
||||
runJdSessionManagerHealthCheck: vi.fn(),
|
||||
runJdSessionManagerRecovery: vi.fn(),
|
||||
runTmallSessionManagerHealthCheck: vi.fn(),
|
||||
retryTaskPlatform: vi.fn(),
|
||||
startJdQrLogin: vi.fn(),
|
||||
startTmallQrLogin: vi.fn(),
|
||||
updateJdSessionManagerConfig: vi.fn(),
|
||||
updateTmallSessionManagerConfig: vi.fn()
|
||||
}));
|
||||
|
||||
import { App, NewTaskPage } from "./App";
|
||||
import {
|
||||
cancelJdQrLogin,
|
||||
cancelTmallQrLogin,
|
||||
clearJdManagedSession,
|
||||
clearPlatformSession,
|
||||
clearTmallManagedSession,
|
||||
getJdKeywordPreview,
|
||||
getJdLiveSession,
|
||||
getJdQrLoginState,
|
||||
getJdSessionManager,
|
||||
getHistoryTasks,
|
||||
getPlatformReadiness,
|
||||
getPlatformSession,
|
||||
getTask,
|
||||
getTaskCandidates,
|
||||
getTmallLiveSession,
|
||||
getTmallQrLoginState,
|
||||
getTmallSessionManager,
|
||||
importJdManagedSession,
|
||||
importTmallManagedSession,
|
||||
preparePlatform,
|
||||
createTaskEventsSource,
|
||||
runJdSessionManagerHealthCheck,
|
||||
runJdSessionManagerRecovery,
|
||||
runTmallSessionManagerHealthCheck,
|
||||
startJdQrLogin,
|
||||
startTmallQrLogin,
|
||||
updateJdSessionManagerConfig
|
||||
} from "./lib/api";
|
||||
|
||||
@@ -219,6 +240,14 @@ describe("task composer and session console", () => {
|
||||
}
|
||||
}
|
||||
} as any);
|
||||
vi.mocked(getJdQrLoginState).mockResolvedValue({
|
||||
qrLogin: {
|
||||
platform: "jd",
|
||||
status: "idle",
|
||||
note: "尚未启动扫码登录。",
|
||||
sessionImported: false
|
||||
}
|
||||
} as any);
|
||||
vi.mocked(getJdKeywordPreview).mockResolvedValue({
|
||||
preview: {
|
||||
query: "小米手环10",
|
||||
@@ -323,6 +352,14 @@ describe("task composer and session console", () => {
|
||||
vi.mocked(getTmallLiveSession).mockResolvedValue({
|
||||
session: tmallManagerState.session
|
||||
} as any);
|
||||
vi.mocked(getTmallQrLoginState).mockResolvedValue({
|
||||
qrLogin: {
|
||||
platform: "tmall",
|
||||
status: "idle",
|
||||
note: "尚未启动扫码登录。",
|
||||
sessionImported: false
|
||||
}
|
||||
} as any);
|
||||
vi.mocked(clearPlatformSession).mockResolvedValue(undefined);
|
||||
vi.mocked(importJdManagedSession).mockResolvedValue({
|
||||
manager: {
|
||||
@@ -408,10 +445,46 @@ describe("task composer and session console", () => {
|
||||
state: jdManagerState,
|
||||
recovered: true
|
||||
} as any);
|
||||
vi.mocked(startJdQrLogin).mockResolvedValue({
|
||||
qrLogin: {
|
||||
platform: "jd",
|
||||
status: "waiting_for_scan",
|
||||
note: "二维码已生成,请扫码。",
|
||||
targetId: "100068388533",
|
||||
qrImageDataUrl: "data:image/png;base64,stub",
|
||||
sessionImported: false
|
||||
}
|
||||
} as any);
|
||||
vi.mocked(cancelJdQrLogin).mockResolvedValue({
|
||||
qrLogin: {
|
||||
platform: "jd",
|
||||
status: "cancelled",
|
||||
note: "扫码已取消。",
|
||||
sessionImported: false
|
||||
}
|
||||
} as any);
|
||||
vi.mocked(runTmallSessionManagerHealthCheck).mockResolvedValue({
|
||||
state: tmallManagerState,
|
||||
recovered: false
|
||||
} as any);
|
||||
vi.mocked(startTmallQrLogin).mockResolvedValue({
|
||||
qrLogin: {
|
||||
platform: "tmall",
|
||||
status: "waiting_for_scan",
|
||||
note: "二维码已生成,请扫码。",
|
||||
targetId: "934454505228",
|
||||
qrImageDataUrl: "data:image/png;base64,stub",
|
||||
sessionImported: false
|
||||
}
|
||||
} as any);
|
||||
vi.mocked(cancelTmallQrLogin).mockResolvedValue({
|
||||
qrLogin: {
|
||||
platform: "tmall",
|
||||
status: "cancelled",
|
||||
note: "扫码已取消。",
|
||||
sessionImported: false
|
||||
}
|
||||
} as any);
|
||||
vi.mocked(preparePlatform).mockResolvedValue({
|
||||
platform: "jd",
|
||||
session_ready: true,
|
||||
@@ -480,6 +553,134 @@ describe("task composer and session console", () => {
|
||||
expect(screen.getByText(/返回业务页面:\/tasks\/new/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("updates the confirm page when a task snapshot restores JD candidates", async () => {
|
||||
let snapshotHandler: ((event: MessageEvent<string>) => void) | undefined;
|
||||
vi.mocked(createTaskEventsSource).mockReturnValue({
|
||||
addEventListener: vi.fn((_type: string, handler: EventListenerOrEventListenerObject) => {
|
||||
snapshotHandler = handler as (event: MessageEvent<string>) => void;
|
||||
}),
|
||||
close: vi.fn(),
|
||||
removeEventListener: vi.fn()
|
||||
} as unknown as EventSource);
|
||||
|
||||
vi.mocked(getTask).mockResolvedValue({
|
||||
task: {
|
||||
taskId: "task-confirm-live",
|
||||
query: "iPhone 15",
|
||||
createdAt: "2026-04-07T09:00:00.000Z",
|
||||
updatedAt: "2026-04-07T09:00:00.000Z",
|
||||
perLinkLimit: 100,
|
||||
taskTotalLimit: 500,
|
||||
taskStatus: "AwaitingConfirmation",
|
||||
taskStage: "confirmation",
|
||||
platformRuns: [
|
||||
{
|
||||
platform: "tmall",
|
||||
searchRequirement: "recommended",
|
||||
status: "AwaitingSelection",
|
||||
candidateCount: 1,
|
||||
selectedCandidateIds: [],
|
||||
lastUpdatedAt: "2026-04-07T09:00:00.000Z"
|
||||
},
|
||||
{
|
||||
platform: "jd",
|
||||
searchRequirement: "required",
|
||||
status: "SearchBlocked",
|
||||
candidateCount: 0,
|
||||
selectedCandidateIds: [],
|
||||
reason: "waiting for ops recovery",
|
||||
lastUpdatedAt: "2026-04-07T09:00:00.000Z"
|
||||
}
|
||||
],
|
||||
platformCandidates: {
|
||||
tmall: [],
|
||||
jd: []
|
||||
},
|
||||
events: [],
|
||||
reportVersions: []
|
||||
}
|
||||
} as any);
|
||||
vi.mocked(getTaskCandidates)
|
||||
.mockResolvedValueOnce({
|
||||
candidates: {
|
||||
tmall: [],
|
||||
jd: []
|
||||
}
|
||||
} as any)
|
||||
.mockResolvedValue({
|
||||
candidates: {
|
||||
tmall: [],
|
||||
jd: [
|
||||
{
|
||||
candidateId: "jd-100068388533",
|
||||
platform: "jd",
|
||||
title: "Apple iPhone 15",
|
||||
price: 3898,
|
||||
priceLabel: "CNY 3898",
|
||||
storeName: "JD Self Operated",
|
||||
productUrl: "https://item.jd.com/100068388533.html",
|
||||
imageUrl: "https://img14.360buyimg.com/example.jpg",
|
||||
salesHint: "sold 500+",
|
||||
specLabel: "128GB",
|
||||
highlights: ["A16"]
|
||||
}
|
||||
]
|
||||
}
|
||||
} as any);
|
||||
|
||||
renderWithProviders(<App />, ["/tasks/task-confirm-live/confirm"]);
|
||||
|
||||
expect(await screen.findByText("waiting for ops recovery")).toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
snapshotHandler?.(
|
||||
new MessageEvent("task.snapshot", {
|
||||
data: JSON.stringify({
|
||||
task: {
|
||||
taskId: "task-confirm-live",
|
||||
query: "iPhone 15",
|
||||
createdAt: "2026-04-07T09:00:00.000Z",
|
||||
updatedAt: "2026-04-07T09:01:00.000Z",
|
||||
perLinkLimit: 100,
|
||||
taskTotalLimit: 500,
|
||||
taskStatus: "AwaitingConfirmation",
|
||||
taskStage: "confirmation",
|
||||
platformRuns: [
|
||||
{
|
||||
platform: "tmall",
|
||||
searchRequirement: "recommended",
|
||||
status: "AwaitingSelection",
|
||||
candidateCount: 1,
|
||||
selectedCandidateIds: [],
|
||||
lastUpdatedAt: "2026-04-07T09:00:00.000Z"
|
||||
},
|
||||
{
|
||||
platform: "jd",
|
||||
searchRequirement: "required",
|
||||
status: "AwaitingSelection",
|
||||
candidateCount: 1,
|
||||
selectedCandidateIds: [],
|
||||
lastUpdatedAt: "2026-04-07T09:01:00.000Z"
|
||||
}
|
||||
],
|
||||
platformCandidates: {
|
||||
tmall: [],
|
||||
jd: []
|
||||
},
|
||||
events: [],
|
||||
reportVersions: []
|
||||
}
|
||||
})
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
expect(await screen.findByText("Apple iPhone 15")).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(getTaskCandidates).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
it("imports jd managed session payload from the ops page", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
|
||||
@@ -4,23 +4,31 @@ import { useEffect, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
|
||||
import {
|
||||
cancelJdQrLogin,
|
||||
cancelTmallQrLogin,
|
||||
clearJdManagedSession,
|
||||
clearJdSessionManagerConfig,
|
||||
clearTmallManagedSession,
|
||||
clearTmallSessionManagerConfig,
|
||||
getJdLiveSession,
|
||||
getJdQrLoginState,
|
||||
getJdSessionManager,
|
||||
getTmallLiveSession,
|
||||
getTmallQrLoginState,
|
||||
getTmallSessionManager,
|
||||
importJdManagedSession,
|
||||
importTmallManagedSession,
|
||||
runJdSessionManagerHealthCheck,
|
||||
runJdSessionManagerRecovery,
|
||||
runTmallSessionManagerHealthCheck,
|
||||
resumeJdQrLoginManualRecovery,
|
||||
type JdLiveSessionInput,
|
||||
type JdSessionManagerConfigInput,
|
||||
type OpsQrLoginState,
|
||||
type TmallLiveSessionInput,
|
||||
type TmallSessionManagerConfigInput,
|
||||
startJdQrLogin,
|
||||
startTmallQrLogin,
|
||||
updateJdSessionManagerConfig,
|
||||
updateTmallSessionManagerConfig
|
||||
} from "./lib/api";
|
||||
@@ -52,6 +60,112 @@ function formatTimestamp(timestamp?: string) {
|
||||
return new Date(timestamp).toLocaleString("zh-CN");
|
||||
}
|
||||
|
||||
const SCREENSHOT_POLL_INTERVAL_MS = 1500;
|
||||
|
||||
function isActiveQrLogin(state?: OpsQrLoginState) {
|
||||
return (
|
||||
state?.status === "launching" ||
|
||||
state?.status === "waiting_for_scan" ||
|
||||
state?.status === "capturing_session"
|
||||
);
|
||||
}
|
||||
|
||||
function QrLoginCard(props: {
|
||||
platformLabel: string;
|
||||
platformHint: string;
|
||||
qrLogin?: OpsQrLoginState | undefined;
|
||||
onStart: () => void;
|
||||
onCancel: () => void;
|
||||
onResumeManualRecovery?: (() => void) | undefined;
|
||||
startPending: boolean;
|
||||
cancelPending: boolean;
|
||||
resumePending?: boolean | undefined;
|
||||
}) {
|
||||
const active = isActiveQrLogin(props.qrLogin);
|
||||
const canResumeManualRecovery =
|
||||
props.qrLogin?.status === "failed" &&
|
||||
props.qrLogin.sessionImported &&
|
||||
Boolean(props.onResumeManualRecovery);
|
||||
const startLabel = active ? "重新生成二维码" : "开始扫码登录";
|
||||
const qrPreview = active ? props.qrLogin?.qrImageDataUrl : undefined;
|
||||
|
||||
let emptyState = "点击“开始扫码登录”后,这里会显示实时二维码截图。";
|
||||
if (props.qrLogin?.status === "launching") {
|
||||
emptyState = "二维码生成中...";
|
||||
} else if (props.qrLogin?.status === "failed") {
|
||||
emptyState = "上一次二维码已失效,请点击“重新生成二维码”。";
|
||||
} else if (props.qrLogin?.status === "cancelled") {
|
||||
emptyState = "当前扫码流程已取消,请重新生成二维码。";
|
||||
} else if (props.qrLogin?.status === "completed") {
|
||||
emptyState = "扫码流程已完成,可在右侧查看会话导入状态。";
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-panel ops-panel">
|
||||
<p className="eyebrow">QR Login</p>
|
||||
<strong>{props.platformLabel}扫码登录</strong>
|
||||
<p className="inline-note">{props.platformHint}</p>
|
||||
<div className="qr-login-card">
|
||||
<div className="qr-login-card__preview">
|
||||
{qrPreview ? (
|
||||
<img alt={`${props.platformLabel} 登录二维码`} src={qrPreview} />
|
||||
) : (
|
||||
<div className="qr-login-card__empty">{emptyState}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="stack stack--dense">
|
||||
<div className="session-details">
|
||||
<div className="session-details__row">
|
||||
<span>Status</span>
|
||||
<strong>{props.qrLogin?.status ?? "idle"}</strong>
|
||||
</div>
|
||||
<div className="session-details__row">
|
||||
<span>Target</span>
|
||||
<strong>{props.qrLogin?.targetId ?? "默认目标"}</strong>
|
||||
</div>
|
||||
<div className="session-details__row">
|
||||
<span>Updated</span>
|
||||
<strong>{formatTimestamp(props.qrLogin?.updatedAt)}</strong>
|
||||
</div>
|
||||
<p>{props.qrLogin?.note ?? "尚未启动扫码流程。"}</p>
|
||||
{props.qrLogin?.currentUrl ? (
|
||||
<p className="session-return-target">{props.qrLogin.currentUrl}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="panel-actions">
|
||||
<button
|
||||
className="primary-button"
|
||||
disabled={props.startPending}
|
||||
onClick={props.onStart}
|
||||
type="button"
|
||||
>
|
||||
{startLabel}
|
||||
</button>
|
||||
<button
|
||||
className="ghost-button"
|
||||
disabled={props.cancelPending || !active}
|
||||
onClick={props.onCancel}
|
||||
type="button"
|
||||
>
|
||||
取消当前扫码
|
||||
</button>
|
||||
{canResumeManualRecovery ? (
|
||||
<button
|
||||
className="ghost-button"
|
||||
disabled={props.resumePending}
|
||||
onClick={props.onResumeManualRecovery}
|
||||
type="button"
|
||||
>
|
||||
人工恢复后重新导入
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function OpsSessionManagerPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
@@ -102,6 +216,12 @@ export function OpsSessionManagerPage() {
|
||||
queryKey: ["jd-live-session"],
|
||||
queryFn: getJdLiveSession
|
||||
});
|
||||
const jdQrLoginQuery = useQuery({
|
||||
queryKey: ["jd-qr-login"],
|
||||
queryFn: getJdQrLoginState,
|
||||
refetchInterval: (query) =>
|
||||
isActiveQrLogin(query.state.data?.qrLogin) ? SCREENSHOT_POLL_INTERVAL_MS : false
|
||||
});
|
||||
const tmallManagerQuery = useQuery({
|
||||
queryKey: ["tmall-session-manager"],
|
||||
queryFn: getTmallSessionManager
|
||||
@@ -110,11 +230,18 @@ export function OpsSessionManagerPage() {
|
||||
queryKey: ["tmall-live-session"],
|
||||
queryFn: getTmallLiveSession
|
||||
});
|
||||
const tmallQrLoginQuery = useQuery({
|
||||
queryKey: ["tmall-qr-login"],
|
||||
queryFn: getTmallQrLoginState,
|
||||
refetchInterval: (query) =>
|
||||
isActiveQrLogin(query.state.data?.qrLogin) ? SCREENSHOT_POLL_INTERVAL_MS : false
|
||||
});
|
||||
|
||||
const invalidateJdOpsQueries = async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["jd-session-manager"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["jd-live-session"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["jd-qr-login"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["platform-readiness"] })
|
||||
]);
|
||||
};
|
||||
@@ -123,6 +250,7 @@ export function OpsSessionManagerPage() {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["tmall-session-manager"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["tmall-live-session"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["tmall-qr-login"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["platform-readiness"] })
|
||||
]);
|
||||
};
|
||||
@@ -157,6 +285,18 @@ export function OpsSessionManagerPage() {
|
||||
mutationFn: clearJdManagedSession,
|
||||
onSuccess: invalidateJdOpsQueries
|
||||
});
|
||||
const jdStartQrLoginMutation = useMutation({
|
||||
mutationFn: startJdQrLogin,
|
||||
onSuccess: invalidateJdOpsQueries
|
||||
});
|
||||
const jdCancelQrLoginMutation = useMutation({
|
||||
mutationFn: cancelJdQrLogin,
|
||||
onSuccess: invalidateJdOpsQueries
|
||||
});
|
||||
const jdResumeQrLoginMutation = useMutation({
|
||||
mutationFn: resumeJdQrLoginManualRecovery,
|
||||
onSuccess: invalidateJdOpsQueries
|
||||
});
|
||||
|
||||
const tmallSaveConfigMutation = useMutation({
|
||||
mutationFn: (payload: TmallSessionManagerConfigInput) =>
|
||||
@@ -185,11 +325,21 @@ export function OpsSessionManagerPage() {
|
||||
mutationFn: clearTmallManagedSession,
|
||||
onSuccess: invalidateTmallOpsQueries
|
||||
});
|
||||
const tmallStartQrLoginMutation = useMutation({
|
||||
mutationFn: startTmallQrLogin,
|
||||
onSuccess: invalidateTmallOpsQueries
|
||||
});
|
||||
const tmallCancelQrLoginMutation = useMutation({
|
||||
mutationFn: cancelTmallQrLogin,
|
||||
onSuccess: invalidateTmallOpsQueries
|
||||
});
|
||||
|
||||
const jdManager = jdManagerQuery.data?.manager;
|
||||
const jdLiveSession = jdLiveSessionQuery.data?.session;
|
||||
const jdQrLogin = jdQrLoginQuery.data?.qrLogin;
|
||||
const tmallManager = tmallManagerQuery.data?.manager;
|
||||
const tmallLiveSession = tmallLiveSessionQuery.data?.session;
|
||||
const tmallQrLogin = tmallQrLoginQuery.data?.qrLogin;
|
||||
|
||||
useEffect(() => {
|
||||
if (!jdManager || jdConfigDirty) {
|
||||
@@ -221,6 +371,22 @@ export function OpsSessionManagerPage() {
|
||||
});
|
||||
}, [tmallConfigDirty, tmallManager]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!jdQrLogin?.sessionImported || jdQrLogin.status !== "completed") {
|
||||
return;
|
||||
}
|
||||
|
||||
void invalidateJdOpsQueries();
|
||||
}, [jdQrLogin?.completedAt]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!tmallQrLogin?.sessionImported || tmallQrLogin.status !== "completed") {
|
||||
return;
|
||||
}
|
||||
|
||||
void invalidateTmallOpsQueries();
|
||||
}, [tmallQrLogin?.completedAt]);
|
||||
|
||||
const switchPlatform = (platform: PlatformId) => {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.set("platform", platform);
|
||||
@@ -323,6 +489,18 @@ export function OpsSessionManagerPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<QrLoginCard
|
||||
cancelPending={jdCancelQrLoginMutation.isPending}
|
||||
onCancel={() => jdCancelQrLoginMutation.mutate()}
|
||||
onResumeManualRecovery={() => jdResumeQrLoginMutation.mutate()}
|
||||
onStart={() => jdStartQrLoginMutation.mutate()}
|
||||
platformHint="后端会启动受控浏览器,实时截图京东登录二维码;扫码成功后会自动抓取 Cookie、详情模板和评论模板并导入当前运维会话。"
|
||||
platformLabel="京东"
|
||||
qrLogin={jdQrLogin}
|
||||
resumePending={jdResumeQrLoginMutation.isPending}
|
||||
startPending={jdStartQrLoginMutation.isPending}
|
||||
/>
|
||||
|
||||
<div className="page-panel ops-panel">
|
||||
<p className="eyebrow">Automation</p>
|
||||
<strong>自动恢复配置</strong>
|
||||
@@ -604,6 +782,10 @@ export function OpsSessionManagerPage() {
|
||||
<span>Live Session</span>
|
||||
<strong>{jdLiveSession?.configured ? "已导入" : "未导入"}</strong>
|
||||
</div>
|
||||
<div className="session-details__row">
|
||||
<span>QR Login</span>
|
||||
<strong>{jdQrLogin?.status ?? "idle"}</strong>
|
||||
</div>
|
||||
<div className="session-details__row">
|
||||
<span>Detail Template</span>
|
||||
<strong>
|
||||
@@ -656,6 +838,24 @@ export function OpsSessionManagerPage() {
|
||||
{jdClearSessionMutation.error.message}
|
||||
</p>
|
||||
) : null}
|
||||
{jdQrLoginQuery.error instanceof Error ? (
|
||||
<p className="inline-note inline-note--subtle">{jdQrLoginQuery.error.message}</p>
|
||||
) : null}
|
||||
{jdStartQrLoginMutation.error instanceof Error ? (
|
||||
<p className="inline-note inline-note--subtle">
|
||||
{jdStartQrLoginMutation.error.message}
|
||||
</p>
|
||||
) : null}
|
||||
{jdCancelQrLoginMutation.error instanceof Error ? (
|
||||
<p className="inline-note inline-note--subtle">
|
||||
{jdCancelQrLoginMutation.error.message}
|
||||
</p>
|
||||
) : null}
|
||||
{jdResumeQrLoginMutation.error instanceof Error ? (
|
||||
<p className="inline-note inline-note--subtle">
|
||||
{jdResumeQrLoginMutation.error.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -670,6 +870,16 @@ export function OpsSessionManagerPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<QrLoginCard
|
||||
cancelPending={tmallCancelQrLoginMutation.isPending}
|
||||
onCancel={() => tmallCancelQrLoginMutation.mutate()}
|
||||
onStart={() => tmallStartQrLoginMutation.mutate()}
|
||||
platformHint="后端会启动受控浏览器,实时截图淘宝/天猫登录二维码;扫码成功后会自动导入 Cookie,并优先抓取或回退生成评论模板。"
|
||||
platformLabel="天猫"
|
||||
qrLogin={tmallQrLogin}
|
||||
startPending={tmallStartQrLoginMutation.isPending}
|
||||
/>
|
||||
|
||||
<div className="page-panel ops-panel">
|
||||
<p className="eyebrow">Automation</p>
|
||||
<strong>自动巡检配置</strong>
|
||||
@@ -874,6 +1084,10 @@ export function OpsSessionManagerPage() {
|
||||
<span>Live Session</span>
|
||||
<strong>{tmallLiveSession?.configured ? "已导入" : "未导入"}</strong>
|
||||
</div>
|
||||
<div className="session-details__row">
|
||||
<span>QR Login</span>
|
||||
<strong>{tmallQrLogin?.status ?? "idle"}</strong>
|
||||
</div>
|
||||
<div className="session-details__row">
|
||||
<span>Detail Template</span>
|
||||
<strong>
|
||||
@@ -927,6 +1141,19 @@ export function OpsSessionManagerPage() {
|
||||
{tmallClearSessionMutation.error.message}
|
||||
</p>
|
||||
) : null}
|
||||
{tmallQrLoginQuery.error instanceof Error ? (
|
||||
<p className="inline-note inline-note--subtle">{tmallQrLoginQuery.error.message}</p>
|
||||
) : null}
|
||||
{tmallStartQrLoginMutation.error instanceof Error ? (
|
||||
<p className="inline-note inline-note--subtle">
|
||||
{tmallStartQrLoginMutation.error.message}
|
||||
</p>
|
||||
) : null}
|
||||
{tmallCancelQrLoginMutation.error instanceof Error ? (
|
||||
<p className="inline-note inline-note--subtle">
|
||||
{tmallCancelQrLoginMutation.error.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -37,6 +37,7 @@ a {
|
||||
.app-shell {
|
||||
display: grid;
|
||||
grid-template-columns: 280px minmax(0, 1fr);
|
||||
align-items: start;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
@@ -44,6 +45,12 @@ a {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
align-self: start;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
overflow: hidden;
|
||||
padding: 32px 24px;
|
||||
border-right: 1px solid rgba(31, 42, 48, 0.08);
|
||||
background: rgba(251, 248, 242, 0.72);
|
||||
@@ -75,6 +82,7 @@ a {
|
||||
.main-content {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
min-height: 100vh;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
@@ -669,6 +677,39 @@ a {
|
||||
padding: 0 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(20, 108, 110, 0.08);
|
||||
.qr-login-card {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 280px) minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.qr-login-card__preview {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 248px;
|
||||
padding: 16px;
|
||||
border-radius: 18px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(20, 108, 110, 0.08) 0%, rgba(255, 255, 255, 0.88) 100%);
|
||||
border: 1px solid rgba(20, 108, 110, 0.14);
|
||||
}
|
||||
|
||||
.qr-login-card__preview img {
|
||||
display: block;
|
||||
width: min(100%, 240px);
|
||||
max-height: 240px;
|
||||
object-fit: contain;
|
||||
border-radius: 12px;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.qr-login-card__empty {
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
color: var(--brand-primary);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
@@ -711,3 +752,7 @@ a {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.qr-login-card {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user