From bf6b44a9505318bf13371c35adff86f3b10d3cd7 Mon Sep 17 00:00:00 2001 From: zhu <1812073942@qq.com> Date: Sat, 9 May 2026 18:49:15 +0800 Subject: [PATCH] 1 --- middleware.ts | 33 +++ src/api/scan.ts | 70 +++++ src/app/(auth)/layout.tsx | 7 +- src/app/(auth)/login/page.tsx | 18 ++ src/app/(auth)/signup/verify-code.tsx | 7 +- .../_components/result/today-verdict.tsx | 3 +- .../(home)/_components/result/top-actions.tsx | 3 +- .../(home)/_components/start-scan.tsx | 176 ++++++++++-- src/app/dashboard/_components/header.tsx | 11 +- src/app/dashboard/billing/page.tsx | 10 +- src/app/dashboard/layout.tsx | 11 +- src/app/layout.tsx | 6 +- .../components/sign-out-link/index.tsx | 14 +- src/app/onboarding/layout.tsx | 7 +- src/components/auth/auth-redirect.tsx | 33 +++ src/components/auth/protected-route.tsx | 33 +++ src/store/extension.ts | 29 +- src/store/subscribe.ts | 2 +- src/store/user.ts | 17 +- src/utils/auth/session.ts | 33 +++ src/utils/extension/detect_extension.ts | 8 +- src/utils/extension/scan_bridge.ts | 113 ++++++++ src/utils/extension/scan_payload.ts | 258 ++++++++++++++++++ src/utils/extension/type.ts | 16 +- src/utils/reqeust.ts | 4 +- 25 files changed, 836 insertions(+), 86 deletions(-) create mode 100644 middleware.ts create mode 100644 src/api/scan.ts create mode 100644 src/components/auth/auth-redirect.tsx create mode 100644 src/components/auth/protected-route.tsx create mode 100644 src/utils/auth/session.ts create mode 100644 src/utils/extension/scan_bridge.ts create mode 100644 src/utils/extension/scan_payload.ts diff --git a/middleware.ts b/middleware.ts new file mode 100644 index 0000000..77bb980 --- /dev/null +++ b/middleware.ts @@ -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"], +}; diff --git a/src/api/scan.ts b/src/api/scan.ts new file mode 100644 index 0000000..28b05ee --- /dev/null +++ b/src/api/scan.ts @@ -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; + extractorStatus: "ok" | "partial" | "failed"; + extractorErrors: string[]; + trigger: "manual" | "scheduled"; + platformAccountId?: string | null; + fieldMeta: Record; +}; + +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}`; +} diff --git a/src/app/(auth)/layout.tsx b/src/app/(auth)/layout.tsx index d930c28..8c4a5c3 100644 --- a/src/app/(auth)/layout.tsx +++ b/src/app/(auth)/layout.tsx @@ -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,7 +17,8 @@ interface FeatureRowProps { */ export default function AuthLayout({ children }: { children: React.ReactNode }) { return ( -
+ +
-
+
+ ); } diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx index 8c01f7d..3820e7c 100644 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(auth)/login/page.tsx @@ -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(""); @@ -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 + } + return (
diff --git a/src/app/(auth)/signup/verify-code.tsx b/src/app/(auth)/signup/verify-code.tsx index eee48a1..aabd8fc 100644 --- a/src/app/(auth)/signup/verify-code.tsx +++ b/src/app/(auth)/signup/verify-code.tsx @@ -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); @@ -152,4 +153,4 @@ export default function VerifyCode({email}: Props) {
); -} \ No newline at end of file +} diff --git a/src/app/dashboard/(home)/_components/result/today-verdict.tsx b/src/app/dashboard/(home)/_components/result/today-verdict.tsx index 1bac615..fad8d85 100644 --- a/src/app/dashboard/(home)/_components/result/today-verdict.tsx +++ b/src/app/dashboard/(home)/_components/result/today-verdict.tsx @@ -1,6 +1,5 @@ import React from 'react'; import {Sparkles} from "lucide-react"; -import {humaniseFieldNames} from "../../../../../../frontend/src/utils/humanise-fields"; /** * 等级 @@ -91,4 +90,4 @@ const TodayVerdict = () => { ); }; -export default TodayVerdict; \ No newline at end of file +export default TodayVerdict; diff --git a/src/app/dashboard/(home)/_components/result/top-actions.tsx b/src/app/dashboard/(home)/_components/result/top-actions.tsx index 5783e35..6f39704 100644 --- a/src/app/dashboard/(home)/_components/result/top-actions.tsx +++ b/src/app/dashboard/(home)/_components/result/top-actions.tsx @@ -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) @@ -121,4 +120,4 @@ const TopActionsCard = () => { }; -export default TopActions; \ No newline at end of file +export default TopActions; diff --git a/src/app/dashboard/(home)/_components/start-scan.tsx b/src/app/dashboard/(home)/_components/start-scan.tsx index 596b4e1..b8e0bb1 100644 --- a/src/app/dashboard/(home)/_components/start-scan.tsx +++ b/src/app/dashboard/(home)/_components/start-scan.tsx @@ -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(null); + const uploadLockRef = useRef(""); + const settingsRef = useRef(null); + + useEffect(() => { + if (!extension.isInstalled) { + return; + } + + const connection = connectDianshanCrawl((message) => { + void handleExtensionMessage(message); + }); + + return () => { + connection?.disconnect(); + }; + }, [extension.isInstalled]); - /*** 配置*/ const configFor = useMemo(() => { - //如果插件未下载 if (!extension.isInstalled) { return { title: "Install the Chrome extension", @@ -27,17 +51,16 @@ export const StartScanningCard = () => { className: "border-amber-200 bg-amber-50 text-amber-600", action: + className="cursor-pointer inline-flex h-10 items-center gap-1.5 rounded-md bg-foreground px-5 text-sm font-medium text-background opacity-70"> Install extension } } - //如果正在爬取中 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: } } + return { - title: "Run your first scan", - body: 'Everything is wired up. One click to pull today’s 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: ( ), } - }, [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 (
{
+ className={`inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border ${configFor.className ? configFor.className : 'border-emerald-200 bg-emerald-50 text-emerald-600'} `}>

Start scanning

-

+

{configFor.title}

@@ -100,11 +227,6 @@ export const StartScanningCard = () => { ); }; - -/** - * 链接卡片 - * @constructor - */ export function ConnectTelegramCard() { return (

@@ -134,5 +256,3 @@ export function ConnectTelegramCard() {
); } - - diff --git a/src/app/dashboard/_components/header.tsx b/src/app/dashboard/_components/header.tsx index a7df7f9..c91d932 100644 --- a/src/app/dashboard/_components/header.tsx +++ b/src/app/dashboard/_components/header.tsx @@ -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 (
@@ -134,7 +141,7 @@ const UserMenu = () => {
userStore.logout()}> + onClick={handleSignOut}> Sign out
diff --git a/src/app/dashboard/billing/page.tsx b/src/app/dashboard/billing/page.tsx index 75a310c..3429c43 100644 --- a/src/app/dashboard/billing/page.tsx +++ b/src/app/dashboard/billing/page.tsx @@ -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 ( + + + + ); +} + +function BillingContent() { const searchParams = useSearchParams(); const {status, init} = useSubscribeStore(); const [agreed, setAgreed] = useState(false) diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx index 4a361b5..2191a4b 100644 --- a/src/app/dashboard/layout.tsx +++ b/src/app/dashboard/layout.tsx @@ -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 ( -
-
-
{children}
-
+ +
+
+
{children}
+
+
); } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 57f0d14..c14fb25 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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`} > - {children} + + {children} + + ); } diff --git a/src/app/onboarding/components/sign-out-link/index.tsx b/src/app/onboarding/components/sign-out-link/index.tsx index 13d143c..bdd94ca 100644 --- a/src/app/onboarding/components/sign-out-link/index.tsx +++ b/src/app/onboarding/components/sign-out-link/index.tsx @@ -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 (
{email} -
); } diff --git a/src/app/onboarding/layout.tsx b/src/app/onboarding/layout.tsx index 09a3048..2d70b40 100644 --- a/src/app/onboarding/layout.tsx +++ b/src/app/onboarding/layout.tsx @@ -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,7 +16,8 @@ interface OnboardingLayoutProps { /** onboarding 四步流程共享布局,顶部、进度条和底部提示在这里统一渲染。 */ export default function OnboardingLayout({children}: OnboardingLayoutProps) { return ( -
+ +
-
+
+ ); } diff --git a/src/components/auth/auth-redirect.tsx b/src/components/auth/auth-redirect.tsx new file mode 100644 index 0000000..191bc6b --- /dev/null +++ b/src/components/auth/auth-redirect.tsx @@ -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}; +} diff --git a/src/components/auth/protected-route.tsx b/src/components/auth/protected-route.tsx new file mode 100644 index 0000000..b39a598 --- /dev/null +++ b/src/components/auth/protected-route.tsx @@ -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}; +} diff --git a/src/store/extension.ts b/src/store/extension.ts index e3f776f..898f62f 100644 --- a/src/store/extension.ts +++ b/src/store/extension.ts @@ -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((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 \ No newline at end of file +export default useExtensionStore diff --git a/src/store/subscribe.ts b/src/store/subscribe.ts index c3c99cd..6f19dde 100644 --- a/src/store/subscribe.ts +++ b/src/store/subscribe.ts @@ -41,7 +41,7 @@ const useSubscribeStore = create((set, get) => ({ get()._startTimer(); } catch (error) { - console.error("Failed to init subscribe store:", error); + console.log("Failed to init subscribe store:", error); } }, diff --git a/src/store/user.ts b/src/store/user.ts index 4068bd7..92523c6 100644 --- a/src/store/user.ts +++ b/src/store/user.ts @@ -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()( (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", @@ -30,4 +41,4 @@ const useUserStore = create()( } ) ); -export default useUserStore; \ No newline at end of file +export default useUserStore; diff --git a/src/utils/auth/session.ts b/src/utils/auth/session.ts new file mode 100644 index 0000000..91a6115 --- /dev/null +++ b/src/utils/auth/session.ts @@ -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 ""; + } +} diff --git a/src/utils/extension/detect_extension.ts b/src/utils/extension/detect_extension.ts index e46299e..aa4bc21 100644 --- a/src/utils/extension/detect_extension.ts +++ b/src/utils/extension/detect_extension.ts @@ -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!"); @@ -37,4 +37,4 @@ export const detectExtension = () => { } ); }, 500); // 2秒轮询一次 -}; \ No newline at end of file +}; diff --git a/src/utils/extension/scan_bridge.ts b/src/utils/extension/scan_bridge.ts new file mode 100644 index 0000000..5e30137 --- /dev/null +++ b/src/utils/extension/scan_bridge.ts @@ -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 | 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 { + return sendExtensionMessage({ + type: STORE_SEND_EVENTS.START_CRAWL, + payload: {platformId}, + }); +} + +export function getExtensionCrawlState(): Promise { + return sendExtensionMessage({ + type: STORE_SEND_EVENTS.GET_CRAWL_STATE, + }); +} + +export function cancelExtensionCrawl(): Promise { + return sendExtensionMessage({ + type: STORE_SEND_EVENTS.CANCEL_CRAWL, + }); +} + +function sendExtensionMessage(message: unknown): Promise { + 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 || ""); +} diff --git a/src/utils/extension/scan_payload.ts b/src/utils/extension/scan_payload.ts new file mode 100644 index 0000000..6f65f66 --- /dev/null +++ b/src/utils/extension/scan_payload.ts @@ -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) { + 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) { + 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, 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) { + 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, 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 { + return value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; +} + +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; +} diff --git a/src/utils/extension/type.ts b/src/utils/extension/type.ts index 0be23e6..bf00f7e 100644 --- a/src/utils/extension/type.ts +++ b/src/utils/extension/type.ts @@ -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", }; diff --git a/src/utils/reqeust.ts b/src/utils/reqeust.ts index 34e0b90..dee1cce 100644 --- a/src/utils/reqeust.ts +++ b/src/utils/reqeust.ts @@ -42,7 +42,7 @@ service.interceptors.response.use((config: AxiosResponse) => { if (error.message == 'Network Error') { // Toast.error("网络异常") } - return Promise.reject() + return Promise.reject(error) }) @@ -68,4 +68,4 @@ let request = { delete: requestDelete, patch: requestPatch, } -export default request \ No newline at end of file +export default request