This commit is contained in:
zhu
2026-05-13 16:59:46 +08:00
parent 3d2dc708cc
commit 95ef40c03d
10 changed files with 326 additions and 392 deletions

View File

@@ -1,63 +1,15 @@
import axios from "axios";
import request from "@/utils/reqeust";
export type ExtensionPairResponse = {
token: string;
expiresAt: string;
userEmail: string | null;
apiBaseUrl: string;
};
export type IngestScanRequest = {
brandId: string;
storeId: string;
scannedAt: string;
payload: Record<string, unknown>;
extractorStatus: "ok" | "partial" | "failed";
extractorErrors: string[];
trigger: "manual" | "scheduled";
platformAccountId?: string | null;
fieldMeta: Record<string, unknown>;
};
export async function pairExtensionTokenApi() {
/**
* 网页侧用 Web token 换取“扩展专用 token”。
* 中文备注:接口来自 BACKEND_API_DOCUMENTATION.mdPOST `/api/auth/extension-pair`
*
* 按你的要求:返回值用 any不定义一堆类型。
*/
export async function pairExtensionTokenApi(): Promise<any> {
return await request.post("/auth/extension-pair", {
label: getExtensionPairLabel(),
}) as unknown as ExtensionPairResponse;
}
export async function ingestScanWithExtensionTokenApi(
body: IngestScanRequest,
extensionToken: string,
apiBaseUrl?: string,
extensionVersion?: string,
) {
const url = `${normaliseApiBaseUrl(apiBaseUrl)}/ingest/scan`;
const response = await axios.post(url, body, {
headers: {
Authorization: `Bearer ${extensionToken}`,
"Content-Type": "application/json",
"X-Ext-Version": extensionVersion || "0.1.4",
},
timeout: 90000,
});
if (response.data?.code === 1 || response.data?.code === "200") {
return response.data.data;
}
throw response.data;
}
function normaliseApiBaseUrl(apiBaseUrl?: string) {
const fallback = process.env.NEXT_PUBLIC_API_URL || "";
const raw = (apiBaseUrl || fallback).replace(/\/$/, "");
if (!raw) {
throw new Error("Missing API base URL.");
}
return raw.endsWith("/api") ? raw : `${raw}/api`;
}
function getExtensionPairLabel() {

View File

@@ -1,13 +1,13 @@
import {ArrowRight, Loader2, MessagesSquare, Radar} from "lucide-react";
import Link from "next/link";
import {useRouter} from "next/navigation";
import React, {ReactNode, useEffect, useMemo, useRef} from "react";
import React, {ReactNode, useEffect, useMemo} from "react";
import {toast} from "sonner";
import {getSettingApi} from "@/api/set";
import {ExtensionPairResponse, ingestScanWithExtensionTokenApi, pairExtensionTokenApi} from "@/api/scan";
import useExtensionStore from "@/store/extension";
import {buildIngestScanRequest, getIngestContext} from "@/utils/extension/scan_payload";
import useSubscribeStore from "@/store/subscribe";
import {getIngestContext} from "@/utils/extension/scan_payload";
import {
connectDianshanCrawl,
ExtensionCrawlMessage,
@@ -24,10 +24,8 @@ type ConfigFor = {
export const StartScanningCard = () => {
const extension = useExtensionStore();
const {status, remainingSeconds, endTime, ready: subReady} = useSubscribeStore();
const router = useRouter();
const pairRef = useRef<ExtensionPairResponse | null>(null);
const uploadLockRef = useRef("");
const settingsRef = useRef<any>(null);
useEffect(() => {
if (!extension.isInstalled) {
@@ -44,6 +42,29 @@ export const StartScanningCard = () => {
}, [extension.isInstalled]);
const configFor = useMemo<ConfigFor>(() => {
// 中文备注:在“扩展检测/订阅状态”都没准备好之前,先给一个稳定的 Loading UI避免卡片闪烁
if (!extension.checked || !subReady) {
return {
title: "Preparing scan...",
body: "Checking extension and subscription status.",
action: (
<button
type="button"
disabled
className="inline-flex h-10 cursor-progress items-center gap-1.5 rounded-md bg-foreground px-5 text-sm font-medium text-background opacity-70">
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden="true"/>
Checking
</button>
),
};
}
// 中文备注订阅到期时首页必须禁止扫描trial 到期 / subscription 到期)
const scanLocked = (status === "trial" && remainingSeconds <= 0)
|| (status === "canceled" && remainingSeconds <= 0)
// 中文备注active + 已设置结束时间 + 倒计时归零,表示已到期但后端状态可能尚未刷新
|| (status === "active" && !!endTime && remainingSeconds <= 0);
if (!extension.isInstalled) {
return {
title: "Install the Chrome extension",
@@ -58,6 +79,22 @@ export const StartScanningCard = () => {
}
}
if (scanLocked) {
return {
title: "Scanning locked",
body: "Your subscription has expired. Scans are blocked until you upgrade or renew your plan.",
className: "border-rose-200 bg-rose-50 text-rose-600",
action: (
<Link
href={"/dashboard/billing"}
className="inline-flex h-10 items-center gap-1.5 rounded-md bg-foreground px-5 text-sm font-medium text-background transition-opacity hover:opacity-90">
Upgrade / Renew
<ArrowRight className="h-4 w-4"/>
</Link>
),
};
}
if (extension.isFetching) {
return {
title: "Scanning your store...",
@@ -86,21 +123,44 @@ export const StartScanningCard = () => {
</button>
),
}
}, [extension.isFetching, extension.isInstalled, extension.lastScanError])
}, [endTime, extension.checked, extension.isFetching, extension.isInstalled, extension.lastScanError, remainingSeconds, status, subReady])
async function handStart() {
// 中文备注:二次兜底,避免状态切换时按钮仍可点击
if ((status === "trial" && remainingSeconds <= 0)
|| (status === "canceled" && remainingSeconds <= 0)
|| (status === "active" && !!endTime && remainingSeconds <= 0)) {
toast.error("Subscription expired. Scanning is locked.");
router.push("/dashboard/billing");
return;
}
extension.setFetching(true);
extension.setLastScanError("");
try {
const settings = await getSettingApi();
getIngestContext(settings as any);
settingsRef.current = settings;
const pair = await pairExtensionTokenApi();
pairRef.current = pair;
window.sessionStorage.setItem("storeai-extension-pair", JSON.stringify(pair));
await startExtensionCrawl("Shopee");
// 中文备注:最终以接口返回的 BrandCore 为准做一次权限校验,避免本地订阅状态不同步导致漏放行
const brand: any = (settings as any)?.brand ?? null;
if (brand && brand.is_comp !== true) {
const now = Date.now();
const subStatus = String(brand.subscription_status || "");
const trialEndsAtMs = brand.trial_ends_at ? Date.parse(String(brand.trial_ends_at)) : NaN;
const cancelAtMs = brand.subscription_cancel_at ? Date.parse(String(brand.subscription_cancel_at)) : NaN;
const trialActive = subStatus === "trial" && (!brand.trial_ends_at || (!Number.isNaN(trialEndsAtMs) && trialEndsAtMs > now));
const activeAllowed = subStatus === "active" && (!brand.subscription_cancel_at || (!Number.isNaN(cancelAtMs) && cancelAtMs > now));
const pastDueAllowed = subStatus === "past_due";
if (!(trialActive || activeAllowed || pastDueAllowed)) {
throw new Error("Subscription expired. Scanning is locked.");
}
}
const ingestContext = getIngestContext(settings as any);
await startExtensionCrawl("Shopee", ingestContext);
} catch (error) {
const message = getErrorMessage(error, "Failed to start scan.");
extension.setFetching(false);
@@ -127,7 +187,11 @@ export const StartScanningCard = () => {
}
if (message.type === STORE_REPLY_EVENTS.CRAWL_DONE) {
await uploadFinishedScan(message);
// 中文备注:新逻辑由扩展侧直接把扫描结果提交到后端;网页侧只需要提示成功并刷新。
extension.setFetching(false);
extension.setLastScanError("");
toast.success("Scan finished.");
router.refresh();
return;
}
@@ -143,47 +207,6 @@ export const StartScanningCard = () => {
}
}
async function uploadFinishedScan(message: ExtensionCrawlMessage) {
const crawlId = message.data?.state?.id;
if (!message.data || !crawlId || uploadLockRef.current === crawlId) {
return;
}
uploadLockRef.current = crawlId;
try {
const pair = pairRef.current ?? readSavedPair();
if (!pair?.token) {
throw new Error("Extension token is missing. Please run the scan again from this page.");
}
const settings = settingsRef.current ?? await getSettingApi();
const body = buildIngestScanRequest(message.data, settings as any);
await ingestScanWithExtensionTokenApi(body, pair.token, pair.apiBaseUrl);
extension.setFetching(false);
extension.setLastScanError("");
toast.success("Scan uploaded successfully.");
router.refresh();
} catch (error) {
const messageText = getErrorMessage(error, "Scan finished, but upload failed.");
extension.setFetching(false);
extension.setLastScanError(messageText);
toast.error(messageText);
}
}
function readSavedPair(): ExtensionPairResponse | null {
try {
const value = window.sessionStorage.getItem("storeai-extension-pair");
return value ? JSON.parse(value) as ExtensionPairResponse : null;
} catch {
return null;
}
}
function getErrorMessage(error: unknown, fallback: string) {
if (typeof error === "string" && error) {
return error;

View File

@@ -11,7 +11,12 @@ import useSubscribeStore from "@/store/subscribe";
*/
export const SubscriptionTip = () => {
const {status, remainingSeconds} = useSubscribeStore();
const {status, remainingSeconds, ready} = useSubscribeStore();
// 中文备注:订阅信息还没初始化完成时,先不渲染(避免先渲染一套内容再抖动切换)
if (!ready) {
return null;
}
// 格式化时间显示
const formatFullTime = (seconds: number) => {

View File

@@ -9,6 +9,7 @@ import InstructionList from "./instruction-list";
import ChromiumCheck from "./chromium-check";
import InstallCard from "./install-card";
import LiveCard from "./live-card";
import PairHandoff from "./pair-handoff";
/** onboarding 第三步Chrome 扩展安装说明和检测面板。 */
@@ -23,6 +24,7 @@ export default function ExtensionStepPage() {
subtitle="The extension scans your store dashboard inside your already-authenticated browser. Read-only - your store password never touches our servers."
>
<div className="space-y-7">
<PairHandoff/>
<ChromiumCheck/>
<InstallCard/>
@@ -79,4 +81,3 @@ export default function ExtensionStepPage() {
);
}

View File

@@ -0,0 +1,144 @@
"use client";
import {useEffect, useState} from "react";
import {useSearchParams} from "next/navigation";
import useUserStore from "@/store/user";
import {pairExtensionTokenApi} from "@/api/scan";
import {getSettingApi} from "@/api/set";
import {EXTENSION_ID} from "@/utils/extension/detect_extension";
declare const chrome: any;
/**
* 网页侧“配对回传”:
* - 由扩展 popup 的“登录”按钮打开本页(带 from=extension
* - 若网页已登录:调用 `/api/auth/extension-pair` 拿到 extension token然后回传给扩展保存。
* 中文备注:这里不搞复杂类型,按需求全用 any。
*/
export default function PairHandoff() {
const params = useSearchParams();
const token = useUserStore((s) => s.token);
const [status, setStatus] = useState<"idle" | "pairing" | "ok" | "need_login" | "error">("idle");
const [errorText, setErrorText] = useState("");
useEffect(() => {
const from = params.get("from");
if (from !== "extension") return;
// 中文备注:必须在浏览器环境 + 可调用 chrome.runtime 的情况下才能回传。
if (typeof chrome === "undefined" || !chrome.runtime?.sendMessage) {
return;
}
if (!token) {
setStatus("need_login");
return;
}
void (async () => {
setStatus("pairing");
setErrorText("");
try {
const extId = params.get("extId") || EXTENSION_ID;
// 1) 先问扩展是否已经配对(避免重复 pair
const authCheck: any = await new Promise((resolve, reject) => {
chrome.runtime.sendMessage(
extId,
{type: "DIANSHAN_AUTH_CHECK"},
(resp: any) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
return;
}
resolve(resp);
},
);
});
if (authCheck?.data?.authed) {
setStatus("ok");
return;
}
// 2) 换取扩展 tokenWeb token -> extension token
const pair = await pairExtensionTokenApi();
// 3) 读取品牌/店铺/定时配置(给扩展做 ingest 与 scheduled scan 使用)
const settings: any = await getSettingApi();
const brand = settings?.brand || null;
const storeId = brand?.stores?.[0]?.id || brand?.storeId || brand?.store_id || null;
const authState: any = {
token: pair?.token,
apiBaseUrl: pair?.apiBaseUrl,
userEmail: pair?.userEmail ?? null,
brandId: brand?.id ?? null,
storeId,
timezone: brand?.timezone ?? null,
morningBriefHour: brand?.morning_brief_hour ?? null,
eveningRecapHour: brand?.evening_recap_hour ?? null,
};
// 4) 回传给扩展保存
await new Promise<void>((resolve, reject) => {
chrome.runtime.sendMessage(
extId,
{type: "DIANSHAN_SSO_HANDOFF", payload: {authState}},
(resp: any) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
return;
}
if (!resp?.ok) {
reject(new Error(resp?.error || "handoff_failed"));
return;
}
resolve();
},
);
});
setStatus("ok");
} catch (e: any) {
setStatus("error");
setErrorText(e?.message || "Pairing failed.");
}
})();
}, [params, token]);
if (status === "idle") return null;
if (status === "need_login") {
return (
<div className="rounded-lg border border-border/60 bg-muted/20 p-4 text-xs text-muted-foreground">
</div>
);
}
if (status === "pairing") {
return (
<div className="rounded-lg border border-border/60 bg-muted/20 p-4 text-xs text-muted-foreground">
</div>
);
}
if (status === "ok") {
return (
<div className="rounded-lg border border-emerald-200 bg-emerald-50/60 p-4 text-xs text-emerald-700">
</div>
);
}
return (
<div className="rounded-lg border border-rose-200 bg-rose-50/60 p-4 text-xs text-rose-700">
{errorText || "unknown error"}
</div>
);
}

View File

@@ -2,11 +2,14 @@ import {create} from "zustand";
type ExtensionState = {
isInstalled: boolean;
// 中文备注:是否已完成“检测扩展是否安装”的流程(避免页面刚刷新时先闪一下“安装扩展”卡片)
checked: boolean;
isFirst: boolean;
isFetching: boolean;
lastScanError: string;
extensionInfo: ExtensionInfo;
setInstalled: (status: boolean) => void;
setChecked: (value: boolean) => void;
setFetching: (status: boolean) => void;
setLastScanError: (message: string) => void;
}
@@ -18,6 +21,7 @@ type ExtensionInfo = {
const useExtensionStore = create<ExtensionState>((set) => ({
isInstalled: false,
checked: false,
isFirst: true,
isFetching: false,
lastScanError: "",
@@ -26,6 +30,7 @@ const useExtensionStore = create<ExtensionState>((set) => ({
chromeUrl:"chrome://extensions"
},
setInstalled: (value) => set({isInstalled: value}),
setChecked: (value) => set({checked: value}),
setFetching: (value) => set({isFetching: value}),
setLastScanError: (value) => set({lastScanError: value}),
}))

View File

@@ -6,30 +6,35 @@ type SubscriptionStatus = "trial" | "active" | "past_due" | "canceled" | "";
type SubscribeState = {
status: SubscriptionStatus;
endTime: string; // 目标结束时间字符串
remainingSeconds: number; // 剩余秒数(手动计时用
endTime: string; // 目标结束时间字符串trial_ends_at 或 subscription_cancel_at
remainingSeconds: number; // 倒计时秒数(用于前端显示
// 中文备注:是否已从接口初始化完成(用于避免 dashboard 刷新时订阅提示/按钮状态“后出现”)
ready: boolean;
init: () => Promise<void>;
_startTimer: () => void; // 内部启动定时器方法
_timer: any; // 内部定时器引用
_startTimer: () => void;
_timer: any;
}
const useSubscribeStore = create<SubscribeState>((set, get) => ({
status: "",
endTime: "",
remainingSeconds: 0,
ready: false,
_timer: null,
/**
* 初始化:获取数据并启动计时
* 初始化:拉取 dashboard shell含 BrandCore.subscription_status 等字段)
*/
init: async () => {
try {
const res: any = await getBrandStatusApi();
const brand = res.brand;
const brand = res?.brand;
if (!brand) return;
if (!brand) {
return;
}
// 逻辑优先级:试用结束时间 > 订阅取消时间
// 逻辑优先级:trial_ends_at > subscription_cancel_at
const targetTime = brand.trial_ends_at || brand.subscription_cancel_at || "";
set({
@@ -37,33 +42,31 @@ const useSubscribeStore = create<SubscribeState>((set, get) => ({
endTime: targetTime,
});
// 拆分逻辑:数据就绪后,启动内部计时器
get()._startTimer();
} catch (error) {
console.log("Failed to init subscribe store:", error);
} finally {
// 中文备注:无论成功失败,都标记为 ready让 UI 有稳定的“已加载”状态
set({ready: true});
}
},
/**
* 内部方法:管理定时器生命周期
* 内部方法:根据 endTime 计算倒计时
*/
_startTimer: () => {
const {_timer, endTime} = get();
// 1. 清理旧的定时器,防止叠加
if (_timer) {
clearInterval(_timer);
set({_timer: null});
}
// 2. 如果没有结束时间,直接归零并返回
if (!endTime) {
set({remainingSeconds: 0});
return;
}
// 3. 定义每秒执行的计算逻辑
const tick = () => {
const now = Math.floor(Date.now() / 1000);
const end = Math.floor(new Date(endTime).getTime() / 1000);
@@ -71,7 +74,6 @@ const useSubscribeStore = create<SubscribeState>((set, get) => ({
set({remainingSeconds: diff});
// 如果倒计时结束,清理自己
if (diff <= 0) {
const currentTimer = get()._timer;
if (currentTimer) clearInterval(currentTimer);
@@ -79,13 +81,12 @@ const useSubscribeStore = create<SubscribeState>((set, get) => ({
}
};
// 4. 立即执行一次,避免首秒空白
tick();
// 5. 启动新定时器并记录引用
const newTimer = setInterval(tick, 1000);
set({_timer: newTimer});
},
}));
export default useSubscribeStore;

View File

@@ -1,40 +1,66 @@
import {STORE_REPLY_EVENTS, STORE_SEND_EVENTS} from "./type";
import {STORE_SEND_EVENTS} from "./type";
import useExtensionStore from "@/store/extension";
/**
* 检扩展是否安装
* 检扩展是否安装
*
* 中文备注:
* - 原实现是 500ms 轮询一次,导致 dashboard 刷新后会先闪一下“安装扩展”卡片,然后又消失
* - 这里新增 `checked` 标记未检测完成前UI 应显示“检测中”而不是“未安装”
*/
// 你的固定扩展 ID
export const EXTENSION_ID = process.env.NEXT_PUBLIC_EXTENSION_ID || "bhnpckgpcfnoiphhknaakhfieihpocan";
declare const chrome: any;
export const detectExtension = () => {
// 如果已经安装了,就不跑了
if (useExtensionStore.getState().isInstalled) return;
const timer = setInterval(() => {
// 1. 检查浏览器是否支持 chrome 插件环境
if (typeof chrome === 'undefined' || !chrome.runtime?.sendMessage) {
declare const chrome: any;
export const detectExtension = () => {
// 如果已经检测过且已安装,就不重复轮询
if (useExtensionStore.getState().checked && useExtensionStore.getState().isInstalled) {
return;
}
// 2. 尝试给特定 ID 的扩展发送 PING
// 中文备注:开始检测(会进入轮询);这里把 checked 置回 false让 UI 显示“检测中”
useExtensionStore.getState().setChecked(false);
let triedAtLeastOnce = false;
const timer = setInterval(() => {
if (typeof chrome === "undefined" || !chrome.runtime?.sendMessage) {
return;
}
triedAtLeastOnce = true;
chrome.runtime.sendMessage(
EXTENSION_ID,
{type: STORE_SEND_EVENTS.PING},
(response: { ok?: boolean; success?: boolean; }) => {
// 检查是否有错误(比如扩展没装或 ID 不对)
(response: { ok?: boolean; success?: boolean }) => {
// 没装扩展 / ID 不对 等情况lastError 会有值
if (chrome.runtime.lastError) {
// 这里静默失败,继续轮询
if (triedAtLeastOnce && !useExtensionStore.getState().checked) {
useExtensionStore.getState().setChecked(true);
}
return;
}
// 3. 收到正确回复
if (response && (response.success || response.ok)) {
clearInterval(timer);
useExtensionStore.getState().setInstalled(true);
console.log("Extension detected via ID!");
useExtensionStore.getState().setChecked(true);
return;
}
// 有返回但不是 ok也视为检测结束未安装
if (triedAtLeastOnce && !useExtensionStore.getState().checked) {
useExtensionStore.getState().setChecked(true);
}
},
);
}, 500); // 2秒轮询一次
}, 500);
// 中文备注:兜底,最多等待 1.2s 就认为检测结束(未安装),避免一直 loading
setTimeout(() => {
if (!useExtensionStore.getState().checked) {
useExtensionStore.getState().setChecked(true);
}
}, 1200);
};

View File

@@ -15,6 +15,12 @@ export type ExtensionCrawlData = {
result: Record<string, ExtensionStepResult> | null;
};
export type ExtensionIngestContext = {
brandId: string;
storeId: string;
trigger?: "manual" | "scheduled";
};
export type ExtensionCrawlState = {
id: string;
platformId: string;
@@ -62,10 +68,13 @@ export function connectDianshanCrawl(onMessage: (message: ExtensionCrawlMessage)
};
}
export function startExtensionCrawl(platformId = "Shopee"): Promise<ExtensionCrawlMessage> {
export function startExtensionCrawl(
platformId = "Shopee",
ingestContext?: ExtensionIngestContext,
): Promise<ExtensionCrawlMessage> {
return sendExtensionMessage({
type: STORE_SEND_EVENTS.START_CRAWL,
payload: {platformId},
payload: {platformId, ingestContext},
});
}

View File

@@ -1,67 +1,17 @@
import type {ExtensionCrawlData, ExtensionCrawlState} from "./scan_bridge";
import type {IngestScanRequest} from "@/api/scan";
/**
* 网页侧只保留“提取 brandId/storeId”能力。
* 中文备注:旧版本网页会把爬取结果组装成 ingest/scan 请求并代扩展上传;
* 现在改为“扩展侧直接上传”,网页侧不再需要 buildIngestScanRequest。
*/
type DashboardSettings = {
brand?: {
id?: string;
stores?: Array<{ id?: string }>;
storeId?: string;
store_id?: string;
} | null;
store?: { id?: string } | null;
storeId?: string;
store_id?: string;
};
export function buildIngestScanRequest(
crawlData: ExtensionCrawlData,
settings: DashboardSettings,
): IngestScanRequest {
const {brandId, storeId} = getIngestContext(settings);
const scannedAt = new Date().toISOString();
const state = crawlData.state;
const rawResult = crawlData.result || {};
const databoard = readStepResult(rawResult, "databoard");
const adscenter = readStepResult(rawResult, "adscenter");
const reviews = readStepResult(rawResult, "message");
const accountHealth = readStepResult(rawResult, "accounthealth");
const business = readObject(databoard["商业分析"]);
return {
brandId,
storeId,
scannedAt,
extractorStatus: getExtractorStatus(state),
extractorErrors: getExtractorErrors(state),
trigger: "manual",
platformAccountId: null,
fieldMeta: {},
payload: {
store_id: storeId,
scanned_at: scannedAt,
today: buildTodayPayload(business),
recent_3h: [],
skus: [],
ads: buildAdsPayload(adscenter),
reviews: buildReviewsPayload(reviews, scannedAt),
competitors: [],
review_summary: null,
product_aggregates: null,
traffic_sources: [],
shop_health: buildShopHealthPayload(accountHealth),
},
};
}
export function getIngestContext(settings: DashboardSettings) {
const brandId = settings.brand?.id;
const storeId = settings.brand?.stores?.[0]?.id
|| settings.brand?.storeId
|| settings.brand?.store_id
|| settings.store?.id
|| settings.storeId
|| settings.store_id;
export function getIngestContext(settings: any) {
const brandId = settings?.brand?.id;
const storeId = settings?.brand?.stores?.[0]?.id
|| settings?.brand?.storeId
|| settings?.brand?.store_id
|| settings?.store?.id
|| settings?.storeId
|| settings?.store_id;
if (!brandId) {
throw new Error("Please finish brand setup before running a scan.");
@@ -74,185 +24,3 @@ export function getIngestContext(settings: DashboardSettings) {
return {brandId, storeId};
}
function buildTodayPayload(business: Record<string, unknown>) {
const sales = readMetric(business["销售"]);
const visitors = readMetric(business["访客数"]);
const productClicks = readMetric(business["Product Clicks"]);
const orders = readMetric(business["订单"]);
const conversionRate = readMetric(business["Order Conversion Rate"]);
return {
orders: parseInteger(orders.value),
gmv_cents: parseMoneyCents(sales.value),
cancel_rate: 0,
return_rate: 0,
gmv_delta_yesterday_pct: parsePercent(sales.change),
gmv_net_cents: null,
conversion_rate: parsePercent(conversionRate.value),
visitors: parseNullableInteger(visitors.value),
product_clicks: parseNullableInteger(productClicks.value),
aov_cents: null,
orders_delta_yesterday_pct: parsePercent(orders.change),
conversion_rate_delta_yesterday_pct: parsePercent(conversionRate.change),
visitors_delta_yesterday_pct: parsePercent(visitors.change),
product_clicks_delta_yesterday_pct: parsePercent(productClicks.change),
aov_delta_yesterday_pct: null,
};
}
function buildAdsPayload(adscenter: Record<string, unknown>) {
const rows = readArray(adscenter["进行中广告列表"]);
return rows.map((item, index) => {
const row = readObject(item);
const info = readObject(row["广告信息"]);
const name = stringify(info["广告名称"]) || `campaign-${index + 1}`;
return {
campaign_id: name,
campaign_name: name,
type: stringify(info["广告类型"]) || "unknown",
state: "ongoing",
spend_cents: parseMoneyCents(row["花费"]),
clicks: 0,
orders: 0,
revenue_cents: parseMoneyCents(row["销售额"]),
impressions: 0,
roas: parseNumber(row["广告支出回报率"]) ?? 0,
target_roas: parseNumber(row["目标ROAS"]),
daily_budget_cents: parseMoneyCents(row["每日预算"]),
keywords: [],
};
});
}
function buildReviewsPayload(reviews: Record<string, unknown>, scannedAt: string) {
const rows = readArray(reviews["低星评论"]);
return rows.map((item, index) => {
const row = readObject(item);
const orderId = stringify(row["订单编号"]);
return {
review_id: orderId || `review-${index + 1}`,
sku_id: null,
rating: 1,
text: stringify(row["评价内容"]) || stringify(row["商品名称"]) || "",
replied: false,
created_at: scannedAt,
};
});
}
function buildShopHealthPayload(accountHealth: Record<string, unknown>) {
const healthRows = readArray(accountHealth["健康状态"]);
if (healthRows.length === 0) {
return null;
}
return {
rating_overall: null,
penalty_points: null,
metrics: {},
};
}
function getExtractorStatus(state: ExtensionCrawlState | null): "ok" | "partial" | "failed" {
if (!state || state.status === "failed" || state.status === "canceled") {
return "failed";
}
const hasFailedStep = state.steps.some((step) => step.status === "failed");
const hasSuccessStep = state.steps.some((step) => step.status === "success");
if (hasFailedStep && hasSuccessStep) {
return "partial";
}
if (hasFailedStep) {
return "failed";
}
return "ok";
}
function getExtractorErrors(state: ExtensionCrawlState | null) {
if (!state) {
return ["missing extension crawl state"];
}
return state.steps
.filter((step) => step.status === "failed" || step.message)
.map((step) => `${step.name}: ${step.message || step.status}`)
.slice(0, 50);
}
function readStepResult(result: Record<string, unknown>, key: string) {
return readObject(readObject(result[key])["result"]);
}
function readMetric(value: unknown) {
const item = readObject(value);
return {
value: item.value,
change: item.change,
};
}
function readObject(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function readArray(value: unknown): unknown[] {
return Array.isArray(value) ? value : [];
}
function stringify(value: unknown) {
return typeof value === "string" ? value.trim() : value == null ? "" : String(value).trim();
}
function parseInteger(value: unknown) {
return Math.max(0, Math.round(parseNumber(value) ?? 0));
}
function parseNullableInteger(value: unknown) {
const parsed = parseNumber(value);
return parsed == null ? null : Math.max(0, Math.round(parsed));
}
function parseMoneyCents(value: unknown) {
const parsed = parseNumber(value);
return Math.max(0, Math.round((parsed ?? 0) * 100));
}
function parsePercent(value: unknown) {
const text = stringify(value);
if (!text) {
return null;
}
const parsed = parseNumber(text);
if (parsed == null) {
return null;
}
return text.includes("%") ? parsed / 100 : parsed;
}
function parseNumber(value: unknown) {
const text = stringify(value)
.replace(/RM/gi, "")
.replace(/,/g, "")
.replace(/%/g, "")
.replace(/[^\d.-]/g, "");
if (!text || text === "-" || text === "." || text === "-.") {
return null;
}
const parsed = Number(text);
return Number.isFinite(parsed) ? parsed : null;
}