1
This commit is contained in:
33
middleware.ts
Normal file
33
middleware.ts
Normal 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
70
src/api/scan.ts
Normal 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}`;
|
||||
}
|
||||
@@ -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 (
|
||||
<main className="storeai-auth relative min-h-screen overflow-hidden bg-background">
|
||||
<AuthRedirect>
|
||||
<main className="storeai-auth relative min-h-screen overflow-hidden bg-background">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute -right-32 -top-32 h-[28rem] w-[28rem] rounded-full bg-emerald-500/10 blur-3xl"
|
||||
@@ -117,7 +119,8 @@ export default function AuthLayout({ children }: { children: React.ReactNode })
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</main>
|
||||
</AuthRedirect>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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) {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
export default TodayVerdict;
|
||||
|
||||
@@ -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;
|
||||
export default TopActions;
|
||||
|
||||
@@ -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",
|
||||
@@ -27,17 +51,16 @@ export const StartScanningCard = () => {
|
||||
className: "border-amber-200 bg-amber-50 text-amber-600",
|
||||
action: <Link
|
||||
href={"/onboarding/extension"}
|
||||
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">
|
||||
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
|
||||
<ArrowRight className="h-4 w-4"/>
|
||||
</Link>
|
||||
}
|
||||
}
|
||||
|
||||
//如果正在爬取中
|
||||
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 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: (
|
||||
<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
|
||||
@@ -77,14 +204,14 @@ export const StartScanningCard = () => {
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-start gap-4">
|
||||
<div
|
||||
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'} `}>
|
||||
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'} `}>
|
||||
<Radar/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 (
|
||||
<div className="storeai-dashboard relative min-h-screen bg-background">
|
||||
<Header/>
|
||||
<div className="relative">{children}</div>
|
||||
</div>
|
||||
<ProtectedRoute>
|
||||
<div className="storeai-dashboard relative min-h-screen bg-background">
|
||||
<Header/>
|
||||
<div className="relative">{children}</div>
|
||||
</div>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<main className="storeai-onboarding relative min-h-screen overflow-hidden bg-background">
|
||||
<ProtectedRoute>
|
||||
<main className="storeai-onboarding relative min-h-screen overflow-hidden bg-background">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute -right-32 -top-32 h-112 w-md rounded-full bg-emerald-500/10 blur-3xl"
|
||||
@@ -48,6 +50,7 @@ export default function OnboardingLayout({children}: OnboardingLayoutProps) {
|
||||
You can change anything later in Settings.
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</main>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
|
||||
33
src/components/auth/auth-redirect.tsx
Normal file
33
src/components/auth/auth-redirect.tsx
Normal 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}</>;
|
||||
}
|
||||
33
src/components/auth/protected-route.tsx
Normal file
33
src/components/auth/protected-route.tsx
Normal 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}</>;
|
||||
}
|
||||
@@ -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
|
||||
export default useExtensionStore
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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",
|
||||
@@ -30,4 +41,4 @@ const useUserStore = create<UserState>()(
|
||||
}
|
||||
)
|
||||
);
|
||||
export default useUserStore;
|
||||
export default useUserStore;
|
||||
|
||||
33
src/utils/auth/session.ts
Normal file
33
src/utils/auth/session.ts
Normal 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 "";
|
||||
}
|
||||
}
|
||||
@@ -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秒轮询一次
|
||||
};
|
||||
};
|
||||
|
||||
113
src/utils/extension/scan_bridge.ts
Normal file
113
src/utils/extension/scan_bridge.ts
Normal 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 || "");
|
||||
}
|
||||
258
src/utils/extension/scan_payload.ts
Normal file
258
src/utils/extension/scan_payload.ts
Normal 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;
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -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
|
||||
export default request
|
||||
|
||||
Reference in New Issue
Block a user