From 6dbf0b70e7e26e3fe688a8a706f08e93b4608fa9 Mon Sep 17 00:00:00 2001 From: renzhiye Date: Fri, 3 Apr 2026 13:59:19 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20=E6=96=B0=E5=A2=9E=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=E8=BF=90=E7=BB=B4=E4=BC=9A=E8=AF=9D=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E9=A1=B5=E4=B8=8E=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/index.html | 1 + apps/web/public/favicon.svg | 7 + apps/web/src/App.tsx | 815 ++++++++++++++++++++- apps/web/src/HistoryPage.test.tsx | 10 + apps/web/src/NewTaskPage.test.tsx | 416 ++++++++++- apps/web/src/OpsSessionManagerPage.tsx | 936 +++++++++++++++++++++++++ apps/web/src/styles.css | 48 ++ 7 files changed, 2183 insertions(+), 50 deletions(-) create mode 100644 apps/web/public/favicon.svg create mode 100644 apps/web/src/OpsSessionManagerPage.tsx diff --git a/apps/web/index.html b/apps/web/index.html index 6393d2c..323c891 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -3,6 +3,7 @@ + 跨平台商品聚合与 AI 分析 diff --git a/apps/web/public/favicon.svg b/apps/web/public/favicon.svg new file mode 100644 index 0000000..ac3bba9 --- /dev/null +++ b/apps/web/public/favicon.svg @@ -0,0 +1,7 @@ + + + + diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 6a85c23..727ec3b 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -23,19 +23,32 @@ import { import { PlatformIdentity, PlatformStatusPill, TaskStatusPill } from "./components/StatusPill"; import { TaskContextHeader } from "./components/TaskContextHeader"; import { TaskSpine } from "./components/TaskSpine"; +import { OpsSessionManagerPage } from "./OpsSessionManagerPage"; import { + clearJdManagedSession, + clearJdSessionManagerConfig, clearPlatformSession, confirmTask, createTask, deleteTask, + getJdKeywordPreview, + getJdLiveSession, + getJdSessionManager, getHistoryTasks, getPlatformReadiness, getPlatformSession, getTask, getTaskCandidates, getTaskReport, + importJdManagedSession, preparePlatform, - retryTaskPlatform + retryTaskPlatform, + runJdSessionManagerHealthCheck, + runJdSessionManagerRecovery, + type JdKeywordPreviewResult, + type JdLiveSessionInput, + type JdSessionManagerConfigInput, + updateJdSessionManagerConfig } from "./lib/api"; function Layout(props: { children: React.ReactNode }) { @@ -171,12 +184,30 @@ function getSessionSnapshotSummary(session: SessionStateRecord) { return "当前还没有可复用会话快照。"; } +function isOpsManagedPlatform(platform: PlatformId) { + return platform === "jd" || platform === "tmall"; +} + +function getOpsMaintenanceMessage(platform: PlatformId) { + return platform === "jd" + ? "京东会话由运维后台维护,普通用户页面不提供登录和恢复入口。" + : "天猫会话也已切到运维后台维护,普通用户页面不再暴露登录和模板入口。"; +} + +function buildOpsSessionManagerHref(platform: PlatformId, from: string) { + return `/ops/session-manager?platform=${encodeURIComponent(platform)}&from=${encodeURIComponent(from)}`; +} + export function NewTaskPage() { const navigate = useNavigate(); const readinessQuery = useQuery({ queryKey: ["platform-readiness"], queryFn: getPlatformReadiness }); + const jdLiveSessionQuery = useQuery({ + queryKey: ["jd-live-session"], + queryFn: getJdLiveSession + }); const historyQuery = useQuery({ queryKey: ["history"], queryFn: getHistoryTasks @@ -191,7 +222,27 @@ export function NewTaskPage() { navigate(`/tasks/${task.taskId}/confirm`); } }); + const jdKeywordPreviewMutation = useMutation({ + mutationFn: () => + getJdKeywordPreview(query.trim(), { + commentCount: Math.max(8, Math.min(perLinkLimit, 12)), + maxPages: 2 + }) + }); const recentTasks = (historyQuery.data?.tasks ?? []).slice(0, 4); + const jdLiveSession = jdLiveSessionQuery.data?.session; + const jdKeywordPreview = jdKeywordPreviewMutation.data?.preview; + const resetJdKeywordPreview = jdKeywordPreviewMutation.reset; + const jdPreviewDisabled = + jdKeywordPreviewMutation.isPending || + query.trim().length === 0 || + !jdLiveSession?.configured || + !jdLiveSession.detailTemplate.available || + !jdLiveSession.reviewsTemplate.available; + + useEffect(() => { + resetJdKeywordPreview(); + }, [perLinkLimit, query, resetJdKeywordPreview]); return ( @@ -282,12 +333,18 @@ export function NewTaskPage() {
- - 进入会话准备 - + {isOpsManagedPlatform(platform.platform) ? ( + + {getOpsMaintenanceMessage(platform.platform)} + + ) : ( + + 进入会话准备 + + )}
))} @@ -329,16 +386,143 @@ export function NewTaskPage() {

P0 当前不做什么

+ +
+
+
+

JD Quick Preview

+

关键词直连抓取

+
+
+ + {!jdLiveSession?.configured ? ( + + 京东预览依赖运维后台会话,请先由运维完成会话和模板维护。 + + ) : null} +
+
+

+ 自动执行 搜索 -> 选品 -> 详情 -> 评论,优先返回一份可读的结构化结果。 +

+ {jdKeywordPreviewMutation.error instanceof Error ? ( +

+ {jdKeywordPreviewMutation.error.message} +

+ ) : null} + {jdKeywordPreview ? : null} +
); } +function JdKeywordPreviewPanel(props: { preview: JdKeywordPreviewResult }) { + const { preview } = props; + const { selected, alternatives, candidateCount } = preview.search; + const { detail, reviews, pagination } = preview.product; + + return ( +
+
+
+

Selected

+

{selected.candidate.title}

+

{selected.candidate.storeName}

+

+ SKU {selected.skuId} · 候选 {candidateCount} 个 · 评分 {selected.score} +

+

{selected.summary}

+
+
+

Product

+

{detail.price ? `¥${detail.price}` : selected.candidate.priceLabel}

+

+ {detail.originalPrice ? `原价 ¥${detail.originalPrice}` : "原价暂无"} ·{" "} + {detail.stockState ?? "库存状态暂无"} +

+

{detail.categoryPath.length > 0 ? detail.categoryPath.join(" / ") : "类目暂无"}

+

{detail.averageScore ? `评分 ${detail.averageScore}` : "评分暂无"}

+
+
+

Reviews

+

{reviews.goodRate ? `好评率 ${reviews.goodRate}` : "好评率暂无"}

+

+ 总评论 {reviews.total ?? "暂无"} · 图片评论 {reviews.pictureCount ?? "暂无"} +

+

+ 本次抓取 {reviews.comments.length} 条,页数 {pagination.pagesFetched}/ + {pagination.maxPages} +

+
+
+ + {reviews.tags.length > 0 ? ( +
+ 评论标签 +
+ {reviews.tags.slice(0, 8).map((tag) => ( + + {tag.name} + {tag.count ? ` ${tag.count}` : ""} + + ))} +
+
+ ) : null} + +
+ 评论样本 +
+ {reviews.comments.slice(0, 6).map((comment) => ( +
+
+ {comment.userLevelName ?? "匿名用户"} + + {comment.score ? `${comment.score} 分` : "未评分"} + +
+

{comment.content}

+

+ {comment.creationTime ?? "时间未知"} · #{comment.id} +

+
+ ))} +
+
+ + {alternatives.length > 0 ? ( +
+ 备选候选 +
+ {alternatives.map((alternative) => ( +
+
+ {alternative.candidate.title} + {alternative.score} +
+

{alternative.summary}

+
+ ))} +
+
+ ) : null} +
+ ); +} + function CandidateCard(props: { candidate: CandidateRecord; checked: boolean; @@ -440,12 +624,18 @@ function ConfirmPage() {

{platformRun?.reason ?? "当前没有候选结果。"}

{platformRun?.status === "SearchBlocked" ? ( - - 处理阻塞并重试 - + isOpsManagedPlatform(platform) ? ( + + 京东阻塞恢复已切到运维后台,当前页面不提供直接恢复入口。 + + ) : ( + + 处理阻塞并重试 + + ) ) : null}
) : ( @@ -536,12 +726,18 @@ function RunPage() {

{run.reason ?? "当前平台已进入主线处理。"}

{run.status === "SearchBlocked" || run.status === "Blocked" ? ( - - 进入恢复并重试 - + isOpsManagedPlatform(run.platform) ? ( + + 京东阻塞恢复由运维后台接管。 + + ) : ( + + 进入恢复并重试 + + ) ) : null} {run.status === "Failed" ? (
+ {prepareMutation.error instanceof Error ? ( +

{prepareMutation.error.message}

+ ) : null} + {clearMutation.error instanceof Error ? ( +

{clearMutation.error.message}

+ ) : null} @@ -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 ; + } + + return ; +} + +export function JdOpsSessionManagerPage() { + const queryClient = useQueryClient(); + const [searchParams] = useSearchParams(); + const from = searchParams.get("from") ?? "/history"; + const [configDirty, setConfigDirty] = useState(false); + const [configForm, setConfigForm] = useState({ + enabled: true, + autoLoginMode: "disabled", + loginCommand: "", + browserProfilePath: "", + heartbeatQuery: "iPhone 15", + account: "", + password: "", + checkIntervalMs: 10 * 60 * 1000, + runnerTimeoutMs: 5 * 60 * 1000 + }); + const [manualSessionForm, setManualSessionForm] = useState({ + 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 ( + +
+

Ops Console

+

京东运维会话管理

+

用户页面不暴露登录态维护;京东会话、模板和恢复动作统一在这个运维页处理。

+
+
+
+
+

Automation

+ 自动恢复配置 +

+ 推荐配置一个后端命令。命令成功后输出 JSON,字段与 + JdLiveSessionInput 一致,Session Manager 会自动导入。 +

+
+ + +
+