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 Link from "next/link";
|
||||||
import { BellRing, Clock3, MessagesSquare, ShieldCheck } from "lucide-react";
|
import { BellRing, Clock3, MessagesSquare, ShieldCheck } from "lucide-react";
|
||||||
|
|
||||||
|
import {AuthRedirect} from "@/components/auth/auth-redirect";
|
||||||
import "./index.scss";
|
import "./index.scss";
|
||||||
|
|
||||||
/** 认证壳层左侧卖点行的入参。 */
|
/** 认证壳层左侧卖点行的入参。 */
|
||||||
@@ -16,7 +17,8 @@ interface FeatureRowProps {
|
|||||||
*/
|
*/
|
||||||
export default function AuthLayout({ children }: { children: React.ReactNode }) {
|
export default function AuthLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
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
|
<div
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="pointer-events-none absolute -right-32 -top-32 h-[28rem] w-[28rem] rounded-full bg-emerald-500/10 blur-3xl"
|
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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
</AuthRedirect>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {getLoginNotice} from "./login-error";
|
|||||||
import {validateSignup} from "../validate";
|
import {validateSignup} from "../validate";
|
||||||
import {loginApi} from "@/api/user";
|
import {loginApi} from "@/api/user";
|
||||||
import useUserStore from "@/store/user";
|
import useUserStore from "@/store/user";
|
||||||
|
import VerifyCode from "../signup/verify-code";
|
||||||
|
|
||||||
|
|
||||||
/** 登录页入口,为依赖查询参数的表单提供 Suspense 边界。 */
|
/** 登录页入口,为依赖查询参数的表单提供 Suspense 边界。 */
|
||||||
@@ -37,6 +38,7 @@ function LoginForm() {
|
|||||||
|
|
||||||
const [email, setEmail] = useState(prefillEmail);
|
const [email, setEmail] = useState(prefillEmail);
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
|
const [verifyEmail, setVerifyEmail] = useState("");
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
/*** 错误*/
|
/*** 错误*/
|
||||||
const [error, setError] = useState<string | null>("");
|
const [error, setError] = useState<string | null>("");
|
||||||
@@ -56,6 +58,18 @@ function LoginForm() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
let res: any = await loginApi(email, password)
|
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.setToken(res.token)
|
||||||
userStore.setUser(res.user)
|
userStore.setUser(res.user)
|
||||||
router.replace("/dashboard")
|
router.replace("/dashboard")
|
||||||
@@ -66,6 +80,10 @@ function LoginForm() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (verifyEmail) {
|
||||||
|
return <VerifyCode email={verifyEmail} redirectTo="/dashboard"/>
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-7">
|
<div className="space-y-7">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|||||||
@@ -9,9 +9,10 @@ import {useRouter} from "next/navigation";
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
email: string
|
email: string
|
||||||
|
redirectTo?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function VerifyCode({email}: Props) {
|
export default function VerifyCode({email, redirectTo = "/onboarding"}: Props) {
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
// 1. 初始状态改为 6 位
|
// 1. 初始状态改为 6 位
|
||||||
@@ -75,7 +76,7 @@ export default function VerifyCode({email}: Props) {
|
|||||||
if(res.verified){
|
if(res.verified){
|
||||||
userStore.setToken(res.token)
|
userStore.setToken(res.token)
|
||||||
userStore.setUser(res.user)
|
userStore.setUser(res.user)
|
||||||
router.replace("/onboarding")
|
router.replace(redirectTo)
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -152,4 +153,4 @@ export default function VerifyCode({email}: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import {Sparkles} from "lucide-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 React, {useState} from 'react';
|
||||||
import {Check, ExternalLink, Info, ListChecks, Loader2} from "lucide-react";
|
import {Check, ExternalLink, Info, ListChecks, Loader2} from "lucide-react";
|
||||||
import {humaniseFieldNames} from "../../../../../../frontend/src/utils/humanise-fields";
|
|
||||||
|
|
||||||
const TopActions = () => {
|
const TopActions = () => {
|
||||||
const [loading, setLoading] = useState(false)
|
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 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 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 = {
|
type ConfigFor = {
|
||||||
title: string,
|
title: string,
|
||||||
body: string,
|
body: string,
|
||||||
className?: string,
|
className?: string,
|
||||||
action: ReactNode,
|
action: ReactNode,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const StartScanningCard = () => {
|
export const StartScanningCard = () => {
|
||||||
const extension = useExtensionStore();
|
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>(() => {
|
const configFor = useMemo<ConfigFor>(() => {
|
||||||
//如果插件未下载
|
|
||||||
if (!extension.isInstalled) {
|
if (!extension.isInstalled) {
|
||||||
return {
|
return {
|
||||||
title: "Install the Chrome extension",
|
title: "Install the Chrome extension",
|
||||||
@@ -27,17 +51,16 @@ export const StartScanningCard = () => {
|
|||||||
className: "border-amber-200 bg-amber-50 text-amber-600",
|
className: "border-amber-200 bg-amber-50 text-amber-600",
|
||||||
action: <Link
|
action: <Link
|
||||||
href={"/onboarding/extension"}
|
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
|
Install extension
|
||||||
<ArrowRight className="h-4 w-4"/>
|
<ArrowRight className="h-4 w-4"/>
|
||||||
</Link>
|
</Link>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//如果正在爬取中
|
|
||||||
if (extension.isFetching) {
|
if (extension.isFetching) {
|
||||||
return {
|
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.",
|
body: "The extension is stepping through your store dashboard. This page will refresh automatically when the scan lands.",
|
||||||
action: <button
|
action: <button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -48,9 +71,11 @@ export const StartScanningCard = () => {
|
|||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: "Run your first scan",
|
title: extension.lastScanError ? "Scan needs attention" : "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.',
|
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: (
|
action: (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -61,15 +86,117 @@ export const StartScanningCard = () => {
|
|||||||
</button>
|
</button>
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
}, [extension.isFetching, extension.isInstalled])
|
}, [extension.isFetching, extension.isInstalled, extension.lastScanError])
|
||||||
|
|
||||||
/**
|
async function handStart() {
|
||||||
* 开始爬取
|
extension.setFetching(true);
|
||||||
*/
|
extension.setLastScanError("");
|
||||||
function handStart() {
|
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<section
|
<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 flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
<div
|
<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/>
|
<Radar/>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||||
Start scanning
|
Start scanning
|
||||||
</p>
|
</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}
|
{configFor.title}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-1 max-w-xl text-sm leading-relaxed text-muted-foreground">
|
<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() {
|
export function ConnectTelegramCard() {
|
||||||
return (
|
return (
|
||||||
<section className="overflow-hidden rounded-xl border border-border/60 bg-card p-4 sm:p-5">
|
<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>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import React, {useEffect} from 'react';
|
import React, {useEffect} from 'react';
|
||||||
import {AlertCircle, CheckCircle2, Clock, CreditCard, LayoutGrid, LogOut, SettingsIcon, XCircle} from "lucide-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 useUserStore from "@/store/user";
|
||||||
import useSubscribeStore from "@/store/subscribe";
|
import useSubscribeStore from "@/store/subscribe";
|
||||||
import {formatSecond} from "@/utils/format";
|
import {formatSecond} from "@/utils/format";
|
||||||
@@ -126,6 +126,13 @@ const NavTabs = () => {
|
|||||||
*/
|
*/
|
||||||
const UserMenu = () => {
|
const UserMenu = () => {
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
function handleSignOut() {
|
||||||
|
userStore.logout();
|
||||||
|
router.replace("/login");
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="hidden max-w-50 truncate text-xs text-muted-foreground md:inline">
|
<div className="hidden max-w-50 truncate text-xs text-muted-foreground md:inline">
|
||||||
@@ -134,7 +141,7 @@ const UserMenu = () => {
|
|||||||
<div
|
<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"
|
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"
|
title="Sign out"
|
||||||
onClick={() => userStore.logout()}>
|
onClick={handleSignOut}>
|
||||||
<LogOut className="h-3 w-3"/>
|
<LogOut className="h-3 w-3"/>
|
||||||
<span className="hidden sm:inline">Sign out</span>
|
<span className="hidden sm:inline">Sign out</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import PlanCard from "./plan-card";
|
import PlanCard from "./plan-card";
|
||||||
import {Hourglass, Sparkles} from "lucide-react";
|
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 Link from "next/link";
|
||||||
import {useSearchParams} from "next/navigation";
|
import {useSearchParams} from "next/navigation";
|
||||||
import useSubscribeStore from "@/store/subscribe";
|
import useSubscribeStore from "@/store/subscribe";
|
||||||
@@ -10,6 +10,14 @@ import {toast} from "sonner";
|
|||||||
import {stripeCheckoutApi, stripePortalApi} from "@/api/stripe";
|
import {stripeCheckoutApi, stripePortalApi} from "@/api/stripe";
|
||||||
|
|
||||||
export default function BillingPage() {
|
export default function BillingPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<BillingContent/>
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BillingContent() {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const {status, init} = useSubscribeStore();
|
const {status, init} = useSubscribeStore();
|
||||||
const [agreed, setAgreed] = useState(false)
|
const [agreed, setAgreed] = useState(false)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import useSubscribeStore from "@/store/subscribe";
|
|||||||
import Header from "./_components/header";
|
import Header from "./_components/header";
|
||||||
import {useEffect} from "react";
|
import {useEffect} from "react";
|
||||||
import {detectExtension} from "@/utils/extension/detect_extension";
|
import {detectExtension} from "@/utils/extension/detect_extension";
|
||||||
|
import {ProtectedRoute} from "@/components/auth/protected-route";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -17,9 +18,11 @@ export default function DashboardLayout({children}: Props) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="storeai-dashboard relative min-h-screen bg-background">
|
<ProtectedRoute>
|
||||||
<Header/>
|
<div className="storeai-dashboard relative min-h-screen bg-background">
|
||||||
<div className="relative">{children}</div>
|
<Header/>
|
||||||
</div>
|
<div className="relative">{children}</div>
|
||||||
|
</div>
|
||||||
|
</ProtectedRoute>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
|
import {Toaster} from "sonner";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
@@ -27,7 +28,10 @@ export default function RootLayout({
|
|||||||
lang="en"
|
lang="en"
|
||||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
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>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import Link from "next/link";
|
import {useRouter} from "next/navigation";
|
||||||
import { LogOut } from "lucide-react";
|
import { LogOut } from "lucide-react";
|
||||||
|
import useUserStore from "@/store/user";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 退出入口的展示参数。
|
* 退出入口的展示参数。
|
||||||
@@ -15,24 +16,27 @@ interface SignOutLinkProps {
|
|||||||
/** onboarding 顶部的退出入口,当前只保留 UI 和点击状态,不接真实退出接口。 */
|
/** onboarding 顶部的退出入口,当前只保留 UI 和点击状态,不接真实退出接口。 */
|
||||||
export function SignOutLink({ email = "you@brand.com" }: SignOutLinkProps) {
|
export function SignOutLink({ email = "you@brand.com" }: SignOutLinkProps) {
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
const router = useRouter();
|
||||||
|
const userStore = useUserStore();
|
||||||
|
|
||||||
/** 退出点击占位,后续接入真实认证时在这里调用退出接口。 */
|
/** 退出点击占位,后续接入真实认证时在这里调用退出接口。 */
|
||||||
function handleSignOut() {
|
function handleSignOut() {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setTimeout(() => setBusy(false), 300);
|
userStore.logout();
|
||||||
|
router.replace("/login");
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
<span className="hidden sm:inline">{email}</span>
|
<span className="hidden sm:inline">{email}</span>
|
||||||
<Link
|
<button
|
||||||
href="/login"
|
type="button"
|
||||||
onClick={handleSignOut}
|
onClick={handleSignOut}
|
||||||
className="inline-flex items-center gap-1 rounded-md px-2 py-1 transition-colors hover:bg-muted hover:text-foreground"
|
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" />
|
<LogOut className="h-3 w-3" aria-hidden="true" />
|
||||||
{busy ? "Signing out..." : "Sign out"}
|
{busy ? "Signing out..." : "Sign out"}
|
||||||
</Link>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import Link from "next/link";
|
|||||||
|
|
||||||
import {SignOutLink} from "./components/sign-out-link";
|
import {SignOutLink} from "./components/sign-out-link";
|
||||||
import {Stepper} from "./components/stepper";
|
import {Stepper} from "./components/stepper";
|
||||||
|
import {ProtectedRoute} from "@/components/auth/protected-route";
|
||||||
import "./index.scss";
|
import "./index.scss";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -15,7 +16,8 @@ interface OnboardingLayoutProps {
|
|||||||
/** onboarding 四步流程共享布局,顶部、进度条和底部提示在这里统一渲染。 */
|
/** onboarding 四步流程共享布局,顶部、进度条和底部提示在这里统一渲染。 */
|
||||||
export default function OnboardingLayout({children}: OnboardingLayoutProps) {
|
export default function OnboardingLayout({children}: OnboardingLayoutProps) {
|
||||||
return (
|
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
|
<div
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="pointer-events-none absolute -right-32 -top-32 h-112 w-md rounded-full bg-emerald-500/10 blur-3xl"
|
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.
|
You can change anything later in Settings.
|
||||||
</div>
|
</div>
|
||||||
</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";
|
import {create} from "zustand";
|
||||||
|
|
||||||
type ExtensionState = {
|
type ExtensionState = {
|
||||||
/*** 是否安装了扩展*/
|
isInstalled: boolean;
|
||||||
isInstalled: boolean,
|
isFirst: boolean;
|
||||||
/*** 是否第一次*/
|
isFetching: boolean;
|
||||||
isFirst: boolean,
|
lastScanError: string;
|
||||||
/*** 是否抓取中*/
|
extensionInfo: ExtensionInfo;
|
||||||
isFetching: boolean,
|
|
||||||
extensionInfo: ExtensionInfo,
|
|
||||||
setInstalled: (status: boolean) => void;
|
setInstalled: (status: boolean) => void;
|
||||||
|
setFetching: (status: boolean) => void;
|
||||||
|
setLastScanError: (message: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 扩展信息
|
|
||||||
*/
|
|
||||||
type ExtensionInfo = {
|
type ExtensionInfo = {
|
||||||
//下载地址
|
downloadUrl: string;
|
||||||
downloadUrl: string,
|
chromeUrl: string;
|
||||||
//扩展商店
|
|
||||||
chromeUrl: string,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const useExtensionStore = create<ExtensionState>((set) => ({
|
const useExtensionStore = create<ExtensionState>((set) => ({
|
||||||
isInstalled: false,
|
isInstalled: false,
|
||||||
isFirst: true,
|
isFirst: true,
|
||||||
isFetching: false,
|
isFetching: false,
|
||||||
|
lastScanError: "",
|
||||||
extensionInfo: {
|
extensionInfo: {
|
||||||
downloadUrl: "/extensions/storeai-extension-v0.1.4.zip",
|
downloadUrl: "/extensions/storeai-extension-v0.1.4.zip",
|
||||||
chromeUrl:"chrome://extensions"
|
chromeUrl:"chrome://extensions"
|
||||||
},
|
},
|
||||||
setInstalled: (value) => set({isInstalled: value}),
|
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();
|
get()._startTimer();
|
||||||
|
|
||||||
} catch (error) {
|
} 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 {create} from "zustand";
|
||||||
import {persist, createJSONStorage} from "zustand/middleware";
|
import {persist, createJSONStorage} from "zustand/middleware";
|
||||||
|
import {clearAuthenticated, markAuthenticated} from "@/utils/auth/session";
|
||||||
|
|
||||||
type UserState = {
|
type UserState = {
|
||||||
token: string;
|
token: string;
|
||||||
@@ -20,9 +21,19 @@ const useUserStore = create<UserState>()(
|
|||||||
(set) => ({
|
(set) => ({
|
||||||
token: "",
|
token: "",
|
||||||
user: null,
|
user: null,
|
||||||
setToken: (token) => set({token}),
|
setToken: (token) => {
|
||||||
|
if (token) {
|
||||||
|
markAuthenticated(token);
|
||||||
|
} else {
|
||||||
|
clearAuthenticated();
|
||||||
|
}
|
||||||
|
set({token});
|
||||||
|
},
|
||||||
setUser: (user) => set({user}),
|
setUser: (user) => set({user}),
|
||||||
logout: () => set({token: "", user: null}),
|
logout: () => {
|
||||||
|
clearAuthenticated();
|
||||||
|
set({token: "", user: null});
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: "user-storage",
|
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
|
// 你的固定扩展 ID
|
||||||
const EXTENSION_ID = "bhnpckgpcfnoiphhknaakhfieihpocan";
|
export const EXTENSION_ID = process.env.NEXT_PUBLIC_EXTENSION_ID || "bhnpckgpcfnoiphhknaakhfieihpocan";
|
||||||
declare const chrome: any;
|
declare const chrome: any;
|
||||||
export const detectExtension = () => {
|
export const detectExtension = () => {
|
||||||
// 如果已经安装了,就不跑了
|
// 如果已经安装了,就不跑了
|
||||||
@@ -21,7 +21,7 @@ export const detectExtension = () => {
|
|||||||
chrome.runtime.sendMessage(
|
chrome.runtime.sendMessage(
|
||||||
EXTENSION_ID,
|
EXTENSION_ID,
|
||||||
{type: STORE_SEND_EVENTS.PING},
|
{type: STORE_SEND_EVENTS.PING},
|
||||||
(response: { success: any; }) => {
|
(response: { ok?: boolean; success?: boolean; }) => {
|
||||||
// 检查是否有错误(比如扩展没装或 ID 不对)
|
// 检查是否有错误(比如扩展没装或 ID 不对)
|
||||||
if (chrome.runtime.lastError) {
|
if (chrome.runtime.lastError) {
|
||||||
// 这里静默失败,继续轮询
|
// 这里静默失败,继续轮询
|
||||||
@@ -29,7 +29,7 @@ export const detectExtension = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3. 收到正确回复
|
// 3. 收到正确回复
|
||||||
if (response && response.success) {
|
if (response && (response.success || response.ok)) {
|
||||||
clearInterval(timer);
|
clearInterval(timer);
|
||||||
useExtensionStore.getState().setInstalled(true);
|
useExtensionStore.getState().setInstalled(true);
|
||||||
console.log("Extension detected via ID!");
|
console.log("Extension detected via ID!");
|
||||||
@@ -37,4 +37,4 @@ export const detectExtension = () => {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
}, 500); // 2秒轮询一次
|
}, 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 = {
|
export const STORE_SEND_EVENTS = {
|
||||||
// 查询扩展是否安装
|
|
||||||
PING: "STORE_AI_PING",
|
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 = {
|
export const STORE_REPLY_EVENTS = {
|
||||||
// 确认已安装
|
|
||||||
PONG: "STORE_AI_EVT_EXT_PONG",
|
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') {
|
if (error.message == 'Network Error') {
|
||||||
// Toast.error("网络异常")
|
// Toast.error("网络异常")
|
||||||
}
|
}
|
||||||
return Promise.reject()
|
return Promise.reject(error)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -68,4 +68,4 @@ let request = {
|
|||||||
delete: requestDelete,
|
delete: requestDelete,
|
||||||
patch: requestPatch,
|
patch: requestPatch,
|
||||||
}
|
}
|
||||||
export default request
|
export default request
|
||||||
|
|||||||
Reference in New Issue
Block a user