This commit is contained in:
zhu
2026-05-09 18:49:15 +08:00
parent 37c22705cf
commit bf6b44a950
25 changed files with 836 additions and 86 deletions

33
middleware.ts Normal file
View File

@@ -0,0 +1,33 @@
import {NextRequest, NextResponse} from "next/server";
const AUTH_COOKIE_NAME = "storeai-auth";
const PROTECTED_PREFIXES = ["/dashboard", "/onboarding"];
const AUTH_PATHS = ["/login", "/signup"];
export function middleware(request: NextRequest) {
const {pathname, search} = request.nextUrl;
const isAuthenticated = request.cookies.get(AUTH_COOKIE_NAME)?.value === "1";
const isProtectedPath = PROTECTED_PREFIXES.some((prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`));
const isAuthPath = AUTH_PATHS.includes(pathname);
if (isProtectedPath && !isAuthenticated) {
const url = request.nextUrl.clone();
url.pathname = "/login";
url.search = "";
url.searchParams.set("next", `${pathname}${search}`);
return NextResponse.redirect(url);
}
if (isAuthPath && isAuthenticated) {
const url = request.nextUrl.clone();
url.pathname = "/dashboard";
url.search = "";
return NextResponse.redirect(url);
}
return NextResponse.next();
}
export const config = {
matcher: ["/dashboard/:path*", "/onboarding/:path*", "/login", "/signup"],
};

70
src/api/scan.ts Normal file
View File

@@ -0,0 +1,70 @@
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() {
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() {
if (typeof navigator === "undefined") {
return "StoreAI Website";
}
const platform = (navigator as any).userAgentData?.platform || navigator.platform || "Chrome";
return `StoreAI Website - ${platform}`;
}

View File

@@ -1,6 +1,7 @@
import Link from "next/link";
import { BellRing, Clock3, MessagesSquare, ShieldCheck } from "lucide-react";
import {AuthRedirect} from "@/components/auth/auth-redirect";
import "./index.scss";
/** 认证壳层左侧卖点行的入参。 */
@@ -16,6 +17,7 @@ interface FeatureRowProps {
*/
export default function AuthLayout({ children }: { children: React.ReactNode }) {
return (
<AuthRedirect>
<main className="storeai-auth relative min-h-screen overflow-hidden bg-background">
<div
aria-hidden="true"
@@ -118,6 +120,7 @@ export default function AuthLayout({ children }: { children: React.ReactNode })
</section>
</div>
</main>
</AuthRedirect>
);
}

View File

@@ -10,6 +10,7 @@ import {getLoginNotice} from "./login-error";
import {validateSignup} from "../validate";
import {loginApi} from "@/api/user";
import useUserStore from "@/store/user";
import VerifyCode from "../signup/verify-code";
/** 登录页入口,为依赖查询参数的表单提供 Suspense 边界。 */
@@ -37,6 +38,7 @@ function LoginForm() {
const [email, setEmail] = useState(prefillEmail);
const [password, setPassword] = useState("");
const [verifyEmail, setVerifyEmail] = useState("");
const [showPassword, setShowPassword] = useState(false);
/*** 错误*/
const [error, setError] = useState<string | null>("");
@@ -56,6 +58,18 @@ function LoginForm() {
setLoading(true);
try {
let res: any = await loginApi(email, password)
if (res?.requiresEmailVerification) {
setVerifyEmail(res.email || email);
setPassword("");
return;
}
if (!res?.token) {
setError("Email verification is required before signing in.");
return;
}
userStore.setToken(res.token)
userStore.setUser(res.user)
router.replace("/dashboard")
@@ -66,6 +80,10 @@ function LoginForm() {
}
}
if (verifyEmail) {
return <VerifyCode email={verifyEmail} redirectTo="/dashboard"/>
}
return (
<div className="space-y-7">
<div className="space-y-2">

View File

@@ -9,9 +9,10 @@ import {useRouter} from "next/navigation";
interface Props {
email: string
redirectTo?: string
}
export default function VerifyCode({email}: Props) {
export default function VerifyCode({email, redirectTo = "/onboarding"}: Props) {
const userStore = useUserStore()
const router = useRouter()
// 1. 初始状态改为 6 位
@@ -75,7 +76,7 @@ export default function VerifyCode({email}: Props) {
if(res.verified){
userStore.setToken(res.token)
userStore.setUser(res.user)
router.replace("/onboarding")
router.replace(redirectTo)
}
} finally {
setLoading(false);

View File

@@ -1,6 +1,5 @@
import React from 'react';
import {Sparkles} from "lucide-react";
import {humaniseFieldNames} from "../../../../../../frontend/src/utils/humanise-fields";
/**
* 等级

View File

@@ -1,6 +1,5 @@
import React, {useState} from 'react';
import {Check, ExternalLink, Info, ListChecks, Loader2} from "lucide-react";
import {humaniseFieldNames} from "../../../../../../frontend/src/utils/humanise-fields";
const TopActions = () => {
const [loading, setLoading] = useState(false)

View File

@@ -1,25 +1,49 @@
import { ArrowRight, Loader2, MessagesSquare, Radar} from "lucide-react";
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 {toast} from "sonner";
import {getSettingApi} from "@/api/set";
import {ExtensionPairResponse, ingestScanWithExtensionTokenApi, pairExtensionTokenApi} from "@/api/scan";
import useExtensionStore from "@/store/extension";
import React, {ReactNode, useMemo} from "react";
import {buildIngestScanRequest, getIngestContext} from "@/utils/extension/scan_payload";
import {
connectDianshanCrawl,
ExtensionCrawlMessage,
startExtensionCrawl,
} from "@/utils/extension/scan_bridge";
import {STORE_REPLY_EVENTS} from "@/utils/extension/type";
/**
* 开始链接卡片
* @constructor
*/
type ConfigFor = {
title: string,
body: string,
className?: string,
action: ReactNode,
}
export const StartScanningCard = () => {
const extension = useExtensionStore();
const router = useRouter();
const pairRef = useRef<ExtensionPairResponse | null>(null);
const uploadLockRef = useRef("");
const settingsRef = useRef<any>(null);
useEffect(() => {
if (!extension.isInstalled) {
return;
}
const connection = connectDianshanCrawl((message) => {
void handleExtensionMessage(message);
});
return () => {
connection?.disconnect();
};
}, [extension.isInstalled]);
/*** 配置*/
const configFor = useMemo<ConfigFor>(() => {
//如果插件未下载
if (!extension.isInstalled) {
return {
title: "Install the Chrome extension",
@@ -34,10 +58,9 @@ export const StartScanningCard = () => {
}
}
//如果正在爬取中
if (extension.isFetching) {
return {
title: "Scanning your store",
title: "Scanning your store...",
body: "The extension is stepping through your store dashboard. This page will refresh automatically when the scan lands.",
action: <button
type="button"
@@ -48,9 +71,11 @@ export const StartScanningCard = () => {
</button>
}
}
return {
title: "Run your first scan",
body: 'Everything is wired up. One click to pull todays GMV, orders, ads and reviews from your store dashboard about 30 seconds.',
title: extension.lastScanError ? "Scan needs attention" : "Run your first scan",
body: extension.lastScanError || "Everything is wired up. One click to pull today's GMV, orders, ads and reviews from your store dashboard - about 30 seconds.",
className: extension.lastScanError ? "border-rose-200 bg-rose-50 text-rose-600" : undefined,
action: (
<button
type="button"
@@ -61,15 +86,117 @@ export const StartScanningCard = () => {
</button>
),
}
}, [extension.isFetching, extension.isInstalled])
}, [extension.isFetching, extension.isInstalled, extension.lastScanError])
/**
* 开始爬取
*/
function handStart() {
async function handStart() {
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");
} catch (error) {
const message = getErrorMessage(error, "Failed to start scan.");
extension.setFetching(false);
extension.setLastScanError(message);
toast.error(message);
if (message.includes("brand setup")) {
router.push("/onboarding/brand");
}
}
}
async function handleExtensionMessage(message: ExtensionCrawlMessage) {
if (!message.ok) {
extension.setFetching(false);
extension.setLastScanError(message.error || "Extension scan failed.");
return;
}
if (message.type === STORE_REPLY_EVENTS.CRAWL_STATE) {
const status = message.data?.state?.status;
extension.setFetching(status === "running" || status === "paused");
return;
}
if (message.type === STORE_REPLY_EVENTS.CRAWL_DONE) {
await uploadFinishedScan(message);
return;
}
if (message.type === STORE_REPLY_EVENTS.CRAWL_FAILED) {
extension.setFetching(false);
extension.setLastScanError("The extension scan failed. Please try again.");
toast.error("The extension scan failed. Please try again.");
return;
}
if (message.type === STORE_REPLY_EVENTS.CRAWL_CANCELED || message.type === STORE_REPLY_EVENTS.CRAWL_CLEARED) {
extension.setFetching(false);
}
}
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;
}
const value = error as any;
return value?.response?.data?.message
|| value?.response?.data?.data?.error
|| value?.message
|| value?.data?.error
|| (error instanceof Error ? error.message : "")
|| fallback;
}
return (
<section
@@ -84,7 +211,7 @@ export const StartScanningCard = () => {
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Start scanning
</p>
<h2 className={`mt-1 text-balance text-xl font-semibold tracking-tight sm:text-[22px]`}>
<h2 className="mt-1 text-balance text-xl font-semibold tracking-tight sm:text-[22px]">
{configFor.title}
</h2>
<p className="mt-1 max-w-xl text-sm leading-relaxed text-muted-foreground">
@@ -100,11 +227,6 @@ export const StartScanningCard = () => {
);
};
/**
* 链接卡片
* @constructor
*/
export function ConnectTelegramCard() {
return (
<section className="overflow-hidden rounded-xl border border-border/60 bg-card p-4 sm:p-5">
@@ -134,5 +256,3 @@ export function ConnectTelegramCard() {
</section>
);
}

View File

@@ -3,7 +3,7 @@
import Link from "next/link";
import React, {useEffect} from 'react';
import {AlertCircle, CheckCircle2, Clock, CreditCard, LayoutGrid, LogOut, SettingsIcon, XCircle} from "lucide-react";
import {usePathname} from "next/navigation";
import {usePathname, useRouter} from "next/navigation";
import useUserStore from "@/store/user";
import useSubscribeStore from "@/store/subscribe";
import {formatSecond} from "@/utils/format";
@@ -126,6 +126,13 @@ const NavTabs = () => {
*/
const UserMenu = () => {
const userStore = useUserStore();
const router = useRouter();
function handleSignOut() {
userStore.logout();
router.replace("/login");
}
return (
<div className="flex items-center gap-2">
<div className="hidden max-w-50 truncate text-xs text-muted-foreground md:inline">
@@ -134,7 +141,7 @@ const UserMenu = () => {
<div
className="cursor-pointer inline-flex h-7 items-center gap-1 rounded-md px-2 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
title="Sign out"
onClick={() => userStore.logout()}>
onClick={handleSignOut}>
<LogOut className="h-3 w-3"/>
<span className="hidden sm:inline">Sign out</span>
</div>

View File

@@ -2,7 +2,7 @@
import PlanCard from "./plan-card";
import {Hourglass, Sparkles} from "lucide-react";
import React, {useEffect, useState} from "react";
import React, {Suspense, useEffect, useState} from "react";
import Link from "next/link";
import {useSearchParams} from "next/navigation";
import useSubscribeStore from "@/store/subscribe";
@@ -10,6 +10,14 @@ import {toast} from "sonner";
import {stripeCheckoutApi, stripePortalApi} from "@/api/stripe";
export default function BillingPage() {
return (
<Suspense fallback={null}>
<BillingContent/>
</Suspense>
);
}
function BillingContent() {
const searchParams = useSearchParams();
const {status, init} = useSubscribeStore();
const [agreed, setAgreed] = useState(false)

View File

@@ -4,6 +4,7 @@ import useSubscribeStore from "@/store/subscribe";
import Header from "./_components/header";
import {useEffect} from "react";
import {detectExtension} from "@/utils/extension/detect_extension";
import {ProtectedRoute} from "@/components/auth/protected-route";
interface Props {
children: React.ReactNode;
@@ -17,9 +18,11 @@ export default function DashboardLayout({children}: Props) {
}, []);
return (
<ProtectedRoute>
<div className="storeai-dashboard relative min-h-screen bg-background">
<Header/>
<div className="relative">{children}</div>
</div>
</ProtectedRoute>
);
}

View File

@@ -1,5 +1,6 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import {Toaster} from "sonner";
import "./globals.css";
const geistSans = Geist({
@@ -27,7 +28,10 @@ export default function RootLayout({
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">{children}</body>
<body className="min-h-full flex flex-col">
{children}
<Toaster richColors position="top-center"/>
</body>
</html>
);
}

View File

@@ -1,8 +1,9 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import {useRouter} from "next/navigation";
import { LogOut } from "lucide-react";
import useUserStore from "@/store/user";
/**
* 退出入口的展示参数。
@@ -15,24 +16,27 @@ interface SignOutLinkProps {
/** onboarding 顶部的退出入口,当前只保留 UI 和点击状态,不接真实退出接口。 */
export function SignOutLink({ email = "you@brand.com" }: SignOutLinkProps) {
const [busy, setBusy] = useState(false);
const router = useRouter();
const userStore = useUserStore();
/** 退出点击占位,后续接入真实认证时在这里调用退出接口。 */
function handleSignOut() {
setBusy(true);
setTimeout(() => setBusy(false), 300);
userStore.logout();
router.replace("/login");
}
return (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="hidden sm:inline">{email}</span>
<Link
href="/login"
<button
type="button"
onClick={handleSignOut}
className="inline-flex items-center gap-1 rounded-md px-2 py-1 transition-colors hover:bg-muted hover:text-foreground"
>
<LogOut className="h-3 w-3" aria-hidden="true" />
{busy ? "Signing out..." : "Sign out"}
</Link>
</button>
</div>
);
}

View File

@@ -2,6 +2,7 @@ import Link from "next/link";
import {SignOutLink} from "./components/sign-out-link";
import {Stepper} from "./components/stepper";
import {ProtectedRoute} from "@/components/auth/protected-route";
import "./index.scss";
/**
@@ -15,6 +16,7 @@ interface OnboardingLayoutProps {
/** onboarding 四步流程共享布局,顶部、进度条和底部提示在这里统一渲染。 */
export default function OnboardingLayout({children}: OnboardingLayoutProps) {
return (
<ProtectedRoute>
<main className="storeai-onboarding relative min-h-screen overflow-hidden bg-background">
<div
aria-hidden="true"
@@ -49,5 +51,6 @@ export default function OnboardingLayout({children}: OnboardingLayoutProps) {
</div>
</div>
</main>
</ProtectedRoute>
);
}

View File

@@ -0,0 +1,33 @@
"use client";
import {useEffect, useState} from "react";
import {useRouter} from "next/navigation";
import {markAuthenticated, readStoredToken} from "@/utils/auth/session";
export function AuthRedirect({children}: { children: React.ReactNode }) {
const router = useRouter();
const [allowed, setAllowed] = useState(false);
useEffect(() => {
const token = readStoredToken();
if (token) {
markAuthenticated(token);
const searchParams = new URLSearchParams(window.location.search);
const next = searchParams.get("next");
router.replace(next?.startsWith("/") && !next.startsWith("/login") && !next.startsWith("/signup")
? next
: "/dashboard");
return;
}
setAllowed(true);
}, [router]);
if (!allowed) {
return null;
}
return <>{children}</>;
}

View File

@@ -0,0 +1,33 @@
"use client";
import {useEffect, useState} from "react";
import {usePathname, useRouter} from "next/navigation";
import {clearAuthenticated, markAuthenticated, readStoredToken} from "@/utils/auth/session";
export function ProtectedRoute({children}: { children: React.ReactNode }) {
const router = useRouter();
const pathname = usePathname();
const [allowed, setAllowed] = useState(false);
useEffect(() => {
const token = readStoredToken();
if (!token) {
clearAuthenticated();
const query = window.location.search.replace(/^\?/, "");
const next = `${pathname}${query ? `?${query}` : ""}`;
router.replace(`/login?next=${encodeURIComponent(next)}`);
return;
}
markAuthenticated(token);
setAllowed(true);
}, [pathname, router]);
if (!allowed) {
return null;
}
return <>{children}</>;
}

View File

@@ -1,35 +1,32 @@
import {create} from "zustand";
type ExtensionState = {
/*** 是否安装了扩展*/
isInstalled: boolean,
/*** 是否第一次*/
isFirst: boolean,
/*** 是否抓取中*/
isFetching: boolean,
extensionInfo: ExtensionInfo,
isInstalled: boolean;
isFirst: boolean;
isFetching: boolean;
lastScanError: string;
extensionInfo: ExtensionInfo;
setInstalled: (status: boolean) => void;
setFetching: (status: boolean) => void;
setLastScanError: (message: string) => void;
}
/**
* 扩展信息
*/
type ExtensionInfo = {
//下载地址
downloadUrl: string,
//扩展商店
chromeUrl: string,
downloadUrl: string;
chromeUrl: string;
}
const useExtensionStore = create<ExtensionState>((set) => ({
isInstalled: false,
isFirst: true,
isFetching: false,
lastScanError: "",
extensionInfo: {
downloadUrl: "/extensions/storeai-extension-v0.1.4.zip",
chromeUrl:"chrome://extensions"
},
setInstalled: (value) => set({isInstalled: value}),
setFetching: (value) => set({isFetching: value}),
setLastScanError: (value) => set({lastScanError: value}),
}))
export default useExtensionStore

View File

@@ -41,7 +41,7 @@ const useSubscribeStore = create<SubscribeState>((set, get) => ({
get()._startTimer();
} catch (error) {
console.error("Failed to init subscribe store:", error);
console.log("Failed to init subscribe store:", error);
}
},

View File

@@ -1,5 +1,6 @@
import {create} from "zustand";
import {persist, createJSONStorage} from "zustand/middleware";
import {clearAuthenticated, markAuthenticated} from "@/utils/auth/session";
type UserState = {
token: string;
@@ -20,9 +21,19 @@ const useUserStore = create<UserState>()(
(set) => ({
token: "",
user: null,
setToken: (token) => set({token}),
setToken: (token) => {
if (token) {
markAuthenticated(token);
} else {
clearAuthenticated();
}
set({token});
},
setUser: (user) => set({user}),
logout: () => set({token: "", user: null}),
logout: () => {
clearAuthenticated();
set({token: "", user: null});
},
}),
{
name: "user-storage",

33
src/utils/auth/session.ts Normal file
View File

@@ -0,0 +1,33 @@
export const AUTH_COOKIE_NAME = "storeai-auth";
const USER_STORAGE_KEY = "user-storage";
export function markAuthenticated(token?: string) {
if (typeof document === "undefined" || !token) {
return;
}
document.cookie = `${AUTH_COOKIE_NAME}=1; Path=/; Max-Age=2592000; SameSite=Lax`;
}
export function clearAuthenticated() {
if (typeof document === "undefined") {
return;
}
document.cookie = `${AUTH_COOKIE_NAME}=; Path=/; Max-Age=0; SameSite=Lax`;
}
export function readStoredToken() {
if (typeof window === "undefined") {
return "";
}
try {
const raw = window.localStorage.getItem(USER_STORAGE_KEY);
const parsed = raw ? JSON.parse(raw) : null;
const token = parsed?.state?.token;
return typeof token === "string" ? token : "";
} catch {
return "";
}
}

View File

@@ -5,7 +5,7 @@ import useExtensionStore from "@/store/extension";
* 检擦扩展是否安装
*/
// 你的固定扩展 ID
const EXTENSION_ID = "bhnpckgpcfnoiphhknaakhfieihpocan";
export const EXTENSION_ID = process.env.NEXT_PUBLIC_EXTENSION_ID || "bhnpckgpcfnoiphhknaakhfieihpocan";
declare const chrome: any;
export const detectExtension = () => {
// 如果已经安装了,就不跑了
@@ -21,7 +21,7 @@ export const detectExtension = () => {
chrome.runtime.sendMessage(
EXTENSION_ID,
{type: STORE_SEND_EVENTS.PING},
(response: { success: any; }) => {
(response: { ok?: boolean; success?: boolean; }) => {
// 检查是否有错误(比如扩展没装或 ID 不对)
if (chrome.runtime.lastError) {
// 这里静默失败,继续轮询
@@ -29,7 +29,7 @@ export const detectExtension = () => {
}
// 3. 收到正确回复
if (response && response.success) {
if (response && (response.success || response.ok)) {
clearInterval(timer);
useExtensionStore.getState().setInstalled(true);
console.log("Extension detected via ID!");

View File

@@ -0,0 +1,113 @@
import {EXTENSION_ID} from "./detect_extension";
import {STORE_REPLY_EVENTS, STORE_SEND_EVENTS} from "./type";
declare const chrome: any;
export type ExtensionCrawlMessage = {
ok: boolean;
type?: string;
data?: ExtensionCrawlData;
error?: string;
};
export type ExtensionCrawlData = {
state: ExtensionCrawlState | null;
result: Record<string, ExtensionStepResult> | null;
};
export type ExtensionCrawlState = {
id: string;
platformId: string;
platformName: string;
startedAt: number;
status: "running" | "paused" | "completed" | "failed" | "canceled";
currentStepIndex: number;
steps: Array<{
name: string;
uniqueKey: string;
status: "pending" | "running" | "success" | "failed";
message?: string;
result?: unknown;
}>;
};
export type ExtensionStepResult = {
name: string;
status: string;
result: unknown;
message: string | null;
};
export function canUseChromeExtension() {
return typeof chrome !== "undefined" && Boolean(chrome.runtime?.sendMessage);
}
export function connectDianshanCrawl(onMessage: (message: ExtensionCrawlMessage) => void) {
if (!canUseChromeExtension() || !chrome.runtime?.connect) {
return null;
}
const port = chrome.runtime.connect(EXTENSION_ID, {name: "DIANSHAN_CRAWL"});
port.onMessage.addListener(onMessage);
return {
disconnect() {
try {
port.onMessage.removeListener(onMessage);
port.disconnect();
} catch {
// Ignore stale extension ports.
}
},
};
}
export function startExtensionCrawl(platformId = "Shopee"): Promise<ExtensionCrawlMessage> {
return sendExtensionMessage({
type: STORE_SEND_EVENTS.START_CRAWL,
payload: {platformId},
});
}
export function getExtensionCrawlState(): Promise<ExtensionCrawlMessage> {
return sendExtensionMessage({
type: STORE_SEND_EVENTS.GET_CRAWL_STATE,
});
}
export function cancelExtensionCrawl(): Promise<ExtensionCrawlMessage> {
return sendExtensionMessage({
type: STORE_SEND_EVENTS.CANCEL_CRAWL,
});
}
function sendExtensionMessage(message: unknown): Promise<ExtensionCrawlMessage> {
if (!canUseChromeExtension()) {
return Promise.reject(new Error("Chrome extension runtime is not available."));
}
return new Promise((resolve, reject) => {
chrome.runtime.sendMessage(EXTENSION_ID, message, (response: ExtensionCrawlMessage) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
return;
}
if (!response?.ok) {
reject(new Error(response?.error || "Extension command failed."));
return;
}
resolve(response);
});
});
}
export function isTerminalCrawlMessage(message: ExtensionCrawlMessage) {
return [
STORE_REPLY_EVENTS.CRAWL_DONE,
STORE_REPLY_EVENTS.CRAWL_FAILED,
STORE_REPLY_EVENTS.CRAWL_CANCELED,
STORE_REPLY_EVENTS.CRAWL_CLEARED,
].includes(message.type || "");
}

View File

@@ -0,0 +1,258 @@
import type {ExtensionCrawlData, ExtensionCrawlState} from "./scan_bridge";
import type {IngestScanRequest} from "@/api/scan";
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;
if (!brandId) {
throw new Error("Please finish brand setup before running a scan.");
}
if (!storeId) {
throw new Error("No store was found for this brand. Please ask the backend to repair this account's store record.");
}
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;
}

View File

@@ -1,15 +1,15 @@
/**
* 发送给扩展的信息 (Web -> Extension)
*/
export const STORE_SEND_EVENTS = {
// 查询扩展是否安装
PING: "STORE_AI_PING",
START_CRAWL: "DIANSHAN_START_CRAWL",
GET_CRAWL_STATE: "DIANSHAN_GET_CRAWL_STATE",
CANCEL_CRAWL: "DIANSHAN_CANCEL_CRAWL",
};
/**
* 扩展返回的信息 (Extension -> Web)
*/
export const STORE_REPLY_EVENTS = {
// 确认已安装
PONG: "STORE_AI_EVT_EXT_PONG",
CRAWL_STATE: "DIANSHAN_CRAWL_STATE",
CRAWL_DONE: "DIANSHAN_CRAWL_DONE",
CRAWL_FAILED: "DIANSHAN_CRAWL_FAILED",
CRAWL_CANCELED: "DIANSHAN_CRAWL_CANCELED",
CRAWL_CLEARED: "DIANSHAN_CRAWL_CLEARED",
};

View File

@@ -42,7 +42,7 @@ service.interceptors.response.use((config: AxiosResponse) => {
if (error.message == 'Network Error') {
// Toast.error("网络异常")
}
return Promise.reject()
return Promise.reject(error)
})