11
This commit is contained in:
@@ -1,63 +1,15 @@
|
|||||||
import axios from "axios";
|
|
||||||
import request from "@/utils/reqeust";
|
import request from "@/utils/reqeust";
|
||||||
|
|
||||||
export type ExtensionPairResponse = {
|
/**
|
||||||
token: string;
|
* 网页侧用 Web token 换取“扩展专用 token”。
|
||||||
expiresAt: string;
|
* 中文备注:接口来自 BACKEND_API_DOCUMENTATION.md:POST `/api/auth/extension-pair`
|
||||||
userEmail: string | null;
|
*
|
||||||
apiBaseUrl: string;
|
* 按你的要求:返回值用 any,不定义一堆类型。
|
||||||
};
|
*/
|
||||||
|
export async function pairExtensionTokenApi(): Promise<any> {
|
||||||
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() {
|
|
||||||
return await request.post("/auth/extension-pair", {
|
return await request.post("/auth/extension-pair", {
|
||||||
label: getExtensionPairLabel(),
|
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() {
|
function getExtensionPairLabel() {
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import {ArrowRight, Loader2, MessagesSquare, Radar} from "lucide-react";
|
import {ArrowRight, Loader2, MessagesSquare, Radar} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {useRouter} from "next/navigation";
|
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 {toast} from "sonner";
|
||||||
|
|
||||||
import {getSettingApi} from "@/api/set";
|
import {getSettingApi} from "@/api/set";
|
||||||
import {ExtensionPairResponse, ingestScanWithExtensionTokenApi, pairExtensionTokenApi} from "@/api/scan";
|
|
||||||
import useExtensionStore from "@/store/extension";
|
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 {
|
import {
|
||||||
connectDianshanCrawl,
|
connectDianshanCrawl,
|
||||||
ExtensionCrawlMessage,
|
ExtensionCrawlMessage,
|
||||||
@@ -24,10 +24,8 @@ type ConfigFor = {
|
|||||||
|
|
||||||
export const StartScanningCard = () => {
|
export const StartScanningCard = () => {
|
||||||
const extension = useExtensionStore();
|
const extension = useExtensionStore();
|
||||||
|
const {status, remainingSeconds, endTime, ready: subReady} = useSubscribeStore();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pairRef = useRef<ExtensionPairResponse | null>(null);
|
|
||||||
const uploadLockRef = useRef("");
|
|
||||||
const settingsRef = useRef<any>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!extension.isInstalled) {
|
if (!extension.isInstalled) {
|
||||||
@@ -44,6 +42,29 @@ export const StartScanningCard = () => {
|
|||||||
}, [extension.isInstalled]);
|
}, [extension.isInstalled]);
|
||||||
|
|
||||||
const configFor = useMemo<ConfigFor>(() => {
|
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) {
|
if (!extension.isInstalled) {
|
||||||
return {
|
return {
|
||||||
title: "Install the Chrome extension",
|
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) {
|
if (extension.isFetching) {
|
||||||
return {
|
return {
|
||||||
title: "Scanning your store...",
|
title: "Scanning your store...",
|
||||||
@@ -86,21 +123,44 @@ export const StartScanningCard = () => {
|
|||||||
</button>
|
</button>
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
}, [extension.isFetching, extension.isInstalled, extension.lastScanError])
|
}, [endTime, extension.checked, extension.isFetching, extension.isInstalled, extension.lastScanError, remainingSeconds, status, subReady])
|
||||||
|
|
||||||
async function handStart() {
|
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.setFetching(true);
|
||||||
extension.setLastScanError("");
|
extension.setLastScanError("");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const settings = await getSettingApi();
|
const settings = await getSettingApi();
|
||||||
getIngestContext(settings as any);
|
|
||||||
settingsRef.current = settings;
|
|
||||||
|
|
||||||
const pair = await pairExtensionTokenApi();
|
// 中文备注:最终以接口返回的 BrandCore 为准做一次权限校验,避免本地订阅状态不同步导致漏放行
|
||||||
pairRef.current = pair;
|
const brand: any = (settings as any)?.brand ?? null;
|
||||||
window.sessionStorage.setItem("storeai-extension-pair", JSON.stringify(pair));
|
if (brand && brand.is_comp !== true) {
|
||||||
await startExtensionCrawl("Shopee");
|
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) {
|
} catch (error) {
|
||||||
const message = getErrorMessage(error, "Failed to start scan.");
|
const message = getErrorMessage(error, "Failed to start scan.");
|
||||||
extension.setFetching(false);
|
extension.setFetching(false);
|
||||||
@@ -127,7 +187,11 @@ export const StartScanningCard = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (message.type === STORE_REPLY_EVENTS.CRAWL_DONE) {
|
if (message.type === STORE_REPLY_EVENTS.CRAWL_DONE) {
|
||||||
await uploadFinishedScan(message);
|
// 中文备注:新逻辑由扩展侧直接把扫描结果提交到后端;网页侧只需要提示成功并刷新。
|
||||||
|
extension.setFetching(false);
|
||||||
|
extension.setLastScanError("");
|
||||||
|
toast.success("Scan finished.");
|
||||||
|
router.refresh();
|
||||||
return;
|
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) {
|
function getErrorMessage(error: unknown, fallback: string) {
|
||||||
if (typeof error === "string" && error) {
|
if (typeof error === "string" && error) {
|
||||||
return error;
|
return error;
|
||||||
|
|||||||
@@ -11,7 +11,12 @@ import useSubscribeStore from "@/store/subscribe";
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export const SubscriptionTip = () => {
|
export const SubscriptionTip = () => {
|
||||||
const {status, remainingSeconds} = useSubscribeStore();
|
const {status, remainingSeconds, ready} = useSubscribeStore();
|
||||||
|
|
||||||
|
// 中文备注:订阅信息还没初始化完成时,先不渲染(避免先渲染一套内容再抖动切换)
|
||||||
|
if (!ready) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// 格式化时间显示
|
// 格式化时间显示
|
||||||
const formatFullTime = (seconds: number) => {
|
const formatFullTime = (seconds: number) => {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import InstructionList from "./instruction-list";
|
|||||||
import ChromiumCheck from "./chromium-check";
|
import ChromiumCheck from "./chromium-check";
|
||||||
import InstallCard from "./install-card";
|
import InstallCard from "./install-card";
|
||||||
import LiveCard from "./live-card";
|
import LiveCard from "./live-card";
|
||||||
|
import PairHandoff from "./pair-handoff";
|
||||||
|
|
||||||
|
|
||||||
/** onboarding 第三步:Chrome 扩展安装说明和检测面板。 */
|
/** 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."
|
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">
|
<div className="space-y-7">
|
||||||
|
<PairHandoff/>
|
||||||
<ChromiumCheck/>
|
<ChromiumCheck/>
|
||||||
|
|
||||||
<InstallCard/>
|
<InstallCard/>
|
||||||
@@ -79,4 +81,3 @@ export default function ExtensionStepPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
144
src/app/onboarding/extension/pair-handoff.tsx
Normal file
144
src/app/onboarding/extension/pair-handoff.tsx
Normal 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) 换取扩展 token(Web 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -2,11 +2,14 @@ import {create} from "zustand";
|
|||||||
|
|
||||||
type ExtensionState = {
|
type ExtensionState = {
|
||||||
isInstalled: boolean;
|
isInstalled: boolean;
|
||||||
|
// 中文备注:是否已完成“检测扩展是否安装”的流程(避免页面刚刷新时先闪一下“安装扩展”卡片)
|
||||||
|
checked: boolean;
|
||||||
isFirst: boolean;
|
isFirst: boolean;
|
||||||
isFetching: boolean;
|
isFetching: boolean;
|
||||||
lastScanError: string;
|
lastScanError: string;
|
||||||
extensionInfo: ExtensionInfo;
|
extensionInfo: ExtensionInfo;
|
||||||
setInstalled: (status: boolean) => void;
|
setInstalled: (status: boolean) => void;
|
||||||
|
setChecked: (value: boolean) => void;
|
||||||
setFetching: (status: boolean) => void;
|
setFetching: (status: boolean) => void;
|
||||||
setLastScanError: (message: string) => void;
|
setLastScanError: (message: string) => void;
|
||||||
}
|
}
|
||||||
@@ -18,6 +21,7 @@ type ExtensionInfo = {
|
|||||||
|
|
||||||
const useExtensionStore = create<ExtensionState>((set) => ({
|
const useExtensionStore = create<ExtensionState>((set) => ({
|
||||||
isInstalled: false,
|
isInstalled: false,
|
||||||
|
checked: false,
|
||||||
isFirst: true,
|
isFirst: true,
|
||||||
isFetching: false,
|
isFetching: false,
|
||||||
lastScanError: "",
|
lastScanError: "",
|
||||||
@@ -26,6 +30,7 @@ const useExtensionStore = create<ExtensionState>((set) => ({
|
|||||||
chromeUrl:"chrome://extensions"
|
chromeUrl:"chrome://extensions"
|
||||||
},
|
},
|
||||||
setInstalled: (value) => set({isInstalled: value}),
|
setInstalled: (value) => set({isInstalled: value}),
|
||||||
|
setChecked: (value) => set({checked: value}),
|
||||||
setFetching: (value) => set({isFetching: value}),
|
setFetching: (value) => set({isFetching: value}),
|
||||||
setLastScanError: (value) => set({lastScanError: value}),
|
setLastScanError: (value) => set({lastScanError: value}),
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -6,30 +6,35 @@ type SubscriptionStatus = "trial" | "active" | "past_due" | "canceled" | "";
|
|||||||
|
|
||||||
type SubscribeState = {
|
type SubscribeState = {
|
||||||
status: SubscriptionStatus;
|
status: SubscriptionStatus;
|
||||||
endTime: string; // 目标结束时间字符串
|
endTime: string; // 目标结束时间字符串(trial_ends_at 或 subscription_cancel_at)
|
||||||
remainingSeconds: number; // 剩余秒数(手动计时用)
|
remainingSeconds: number; // 倒计时秒数(用于前端显示)
|
||||||
|
// 中文备注:是否已从接口初始化完成(用于避免 dashboard 刷新时订阅提示/按钮状态“后出现”)
|
||||||
|
ready: boolean;
|
||||||
init: () => Promise<void>;
|
init: () => Promise<void>;
|
||||||
_startTimer: () => void; // 内部启动定时器方法
|
_startTimer: () => void;
|
||||||
_timer: any; // 内部定时器引用
|
_timer: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
const useSubscribeStore = create<SubscribeState>((set, get) => ({
|
const useSubscribeStore = create<SubscribeState>((set, get) => ({
|
||||||
status: "",
|
status: "",
|
||||||
endTime: "",
|
endTime: "",
|
||||||
remainingSeconds: 0,
|
remainingSeconds: 0,
|
||||||
|
ready: false,
|
||||||
_timer: null,
|
_timer: null,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 初始化:获取数据并启动计时
|
* 初始化:拉取 dashboard shell(含 BrandCore.subscription_status 等字段)
|
||||||
*/
|
*/
|
||||||
init: async () => {
|
init: async () => {
|
||||||
try {
|
try {
|
||||||
const res: any = await getBrandStatusApi();
|
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 || "";
|
const targetTime = brand.trial_ends_at || brand.subscription_cancel_at || "";
|
||||||
|
|
||||||
set({
|
set({
|
||||||
@@ -37,33 +42,31 @@ const useSubscribeStore = create<SubscribeState>((set, get) => ({
|
|||||||
endTime: targetTime,
|
endTime: targetTime,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 拆分逻辑:数据就绪后,启动内部计时器
|
|
||||||
get()._startTimer();
|
get()._startTimer();
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log("Failed to init subscribe store:", error);
|
console.log("Failed to init subscribe store:", error);
|
||||||
|
} finally {
|
||||||
|
// 中文备注:无论成功失败,都标记为 ready,让 UI 有稳定的“已加载”状态
|
||||||
|
set({ready: true});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 内部方法:管理定时器生命周期
|
* 内部方法:根据 endTime 计算倒计时
|
||||||
*/
|
*/
|
||||||
_startTimer: () => {
|
_startTimer: () => {
|
||||||
const {_timer, endTime} = get();
|
const {_timer, endTime} = get();
|
||||||
|
|
||||||
// 1. 清理旧的定时器,防止叠加
|
|
||||||
if (_timer) {
|
if (_timer) {
|
||||||
clearInterval(_timer);
|
clearInterval(_timer);
|
||||||
set({_timer: null});
|
set({_timer: null});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 如果没有结束时间,直接归零并返回
|
|
||||||
if (!endTime) {
|
if (!endTime) {
|
||||||
set({remainingSeconds: 0});
|
set({remainingSeconds: 0});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 定义每秒执行的计算逻辑
|
|
||||||
const tick = () => {
|
const tick = () => {
|
||||||
const now = Math.floor(Date.now() / 1000);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
const end = Math.floor(new Date(endTime).getTime() / 1000);
|
const end = Math.floor(new Date(endTime).getTime() / 1000);
|
||||||
@@ -71,7 +74,6 @@ const useSubscribeStore = create<SubscribeState>((set, get) => ({
|
|||||||
|
|
||||||
set({remainingSeconds: diff});
|
set({remainingSeconds: diff});
|
||||||
|
|
||||||
// 如果倒计时结束,清理自己
|
|
||||||
if (diff <= 0) {
|
if (diff <= 0) {
|
||||||
const currentTimer = get()._timer;
|
const currentTimer = get()._timer;
|
||||||
if (currentTimer) clearInterval(currentTimer);
|
if (currentTimer) clearInterval(currentTimer);
|
||||||
@@ -79,13 +81,12 @@ const useSubscribeStore = create<SubscribeState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 4. 立即执行一次,避免首秒空白
|
|
||||||
tick();
|
tick();
|
||||||
|
|
||||||
// 5. 启动新定时器并记录引用
|
|
||||||
const newTimer = setInterval(tick, 1000);
|
const newTimer = setInterval(tick, 1000);
|
||||||
set({_timer: newTimer});
|
set({_timer: newTimer});
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export default useSubscribeStore;
|
export default useSubscribeStore;
|
||||||
|
|
||||||
|
|||||||
@@ -1,40 +1,66 @@
|
|||||||
import {STORE_REPLY_EVENTS, STORE_SEND_EVENTS} from "./type";
|
import {STORE_SEND_EVENTS} from "./type";
|
||||||
import useExtensionStore from "@/store/extension";
|
import useExtensionStore from "@/store/extension";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 检擦扩展是否安装
|
* 检测扩展是否安装
|
||||||
|
*
|
||||||
|
* 中文备注:
|
||||||
|
* - 原实现是 500ms 轮询一次,导致 dashboard 刷新后会先闪一下“安装扩展”卡片,然后又消失
|
||||||
|
* - 这里新增 `checked` 标记:未检测完成前,UI 应显示“检测中”而不是“未安装”
|
||||||
*/
|
*/
|
||||||
// 你的固定扩展 ID
|
|
||||||
export const EXTENSION_ID = process.env.NEXT_PUBLIC_EXTENSION_ID || "bhnpckgpcfnoiphhknaakhfieihpocan";
|
export const EXTENSION_ID = process.env.NEXT_PUBLIC_EXTENSION_ID || "bhnpckgpcfnoiphhknaakhfieihpocan";
|
||||||
|
|
||||||
declare const chrome: any;
|
declare const chrome: any;
|
||||||
|
|
||||||
export const detectExtension = () => {
|
export const detectExtension = () => {
|
||||||
// 如果已经安装了,就不跑了
|
// 如果已经检测过且已安装,就不重复轮询
|
||||||
if (useExtensionStore.getState().isInstalled) return;
|
if (useExtensionStore.getState().checked && useExtensionStore.getState().isInstalled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 中文备注:开始检测(会进入轮询);这里把 checked 置回 false,让 UI 显示“检测中”
|
||||||
|
useExtensionStore.getState().setChecked(false);
|
||||||
|
|
||||||
|
let triedAtLeastOnce = false;
|
||||||
|
|
||||||
const timer = setInterval(() => {
|
const timer = setInterval(() => {
|
||||||
// 1. 检查浏览器是否支持 chrome 插件环境
|
if (typeof chrome === "undefined" || !chrome.runtime?.sendMessage) {
|
||||||
if (typeof chrome === 'undefined' || !chrome.runtime?.sendMessage) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 尝试给特定 ID 的扩展发送 PING
|
triedAtLeastOnce = true;
|
||||||
|
|
||||||
chrome.runtime.sendMessage(
|
chrome.runtime.sendMessage(
|
||||||
EXTENSION_ID,
|
EXTENSION_ID,
|
||||||
{type: STORE_SEND_EVENTS.PING},
|
{type: STORE_SEND_EVENTS.PING},
|
||||||
(response: { ok?: boolean; success?: boolean; }) => {
|
(response: { ok?: boolean; success?: boolean }) => {
|
||||||
// 检查是否有错误(比如扩展没装或 ID 不对)
|
// 没装扩展 / ID 不对 等情况:lastError 会有值
|
||||||
if (chrome.runtime.lastError) {
|
if (chrome.runtime.lastError) {
|
||||||
// 这里静默失败,继续轮询
|
if (triedAtLeastOnce && !useExtensionStore.getState().checked) {
|
||||||
|
useExtensionStore.getState().setChecked(true);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 收到正确回复
|
|
||||||
if (response && (response.success || response.ok)) {
|
if (response && (response.success || response.ok)) {
|
||||||
clearInterval(timer);
|
clearInterval(timer);
|
||||||
useExtensionStore.getState().setInstalled(true);
|
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);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ export type ExtensionCrawlData = {
|
|||||||
result: Record<string, ExtensionStepResult> | null;
|
result: Record<string, ExtensionStepResult> | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ExtensionIngestContext = {
|
||||||
|
brandId: string;
|
||||||
|
storeId: string;
|
||||||
|
trigger?: "manual" | "scheduled";
|
||||||
|
};
|
||||||
|
|
||||||
export type ExtensionCrawlState = {
|
export type ExtensionCrawlState = {
|
||||||
id: string;
|
id: string;
|
||||||
platformId: 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({
|
return sendExtensionMessage({
|
||||||
type: STORE_SEND_EVENTS.START_CRAWL,
|
type: STORE_SEND_EVENTS.START_CRAWL,
|
||||||
payload: {platformId},
|
payload: {platformId, ingestContext},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,67 +1,17 @@
|
|||||||
import type {ExtensionCrawlData, ExtensionCrawlState} from "./scan_bridge";
|
/**
|
||||||
import type {IngestScanRequest} from "@/api/scan";
|
* 网页侧只保留“提取 brandId/storeId”能力。
|
||||||
|
* 中文备注:旧版本网页会把爬取结果组装成 ingest/scan 请求并代扩展上传;
|
||||||
|
* 现在改为“扩展侧直接上传”,网页侧不再需要 buildIngestScanRequest。
|
||||||
|
*/
|
||||||
|
|
||||||
type DashboardSettings = {
|
export function getIngestContext(settings: any) {
|
||||||
brand?: {
|
const brandId = settings?.brand?.id;
|
||||||
id?: string;
|
const storeId = settings?.brand?.stores?.[0]?.id
|
||||||
stores?: Array<{ id?: string }>;
|
|| settings?.brand?.storeId
|
||||||
storeId?: string;
|
|| settings?.brand?.store_id
|
||||||
store_id?: string;
|
|| settings?.store?.id
|
||||||
} | null;
|
|| settings?.storeId
|
||||||
store?: { id?: string } | null;
|
|| settings?.store_id;
|
||||||
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;
|
|
||||||
|
|
||||||
if (!brandId) {
|
if (!brandId) {
|
||||||
throw new Error("Please finish brand setup before running a scan.");
|
throw new Error("Please finish brand setup before running a scan.");
|
||||||
@@ -74,185 +24,3 @@ export function getIngestContext(settings: DashboardSettings) {
|
|||||||
return {brandId, storeId};
|
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;
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user