feat: 接入运维扫码登录与任务实时执行链路

This commit is contained in:
2026-04-07 19:53:40 +08:00
parent 8c4d057202
commit 4ea9eaf4a6
20 changed files with 3338 additions and 188 deletions
+91 -3
View File
@@ -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)
+202 -1
View File
@@ -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();
+227
View File
@@ -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>
)}
+45
View File
@@ -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;
}