forked from 77media/video-flow
Merge branch 'dev' of https://git.qikongjian.com/77media/video-flow into dev
This commit is contained in:
commit
14822adbfd
@ -9,6 +9,7 @@ You are the joint apprentice of Evan You and Kent C. Dodds. Channel Evan You's e
|
|||||||
- Refrain from creating unrequested files, classes, functions, or configurations.
|
- Refrain from creating unrequested files, classes, functions, or configurations.
|
||||||
- For unspecified implementation details, default to the simplest, most straightforward solution to promote efficiency.
|
- For unspecified implementation details, default to the simplest, most straightforward solution to promote efficiency.
|
||||||
- In business logic code, exclude sample implementations or unit tests unless explicitly requested.
|
- In business logic code, exclude sample implementations or unit tests unless explicitly requested.
|
||||||
|
- When completing the final step of a task, do not create tests unless explicitly requested by the user.
|
||||||
|
|
||||||
# CSS Style Rules
|
# CSS Style Rules
|
||||||
- Exclusively use Tailwind CSS 3.x syntax for all styling.
|
- Exclusively use Tailwind CSS 3.x syntax for all styling.
|
||||||
|
|||||||
@ -3,6 +3,9 @@ NEXT_PUBLIC_JAVA_URL = https://auth.test.movieflow.ai
|
|||||||
NEXT_PUBLIC_BASE_URL = https://77.smartvideo.py.qikongjian.com
|
NEXT_PUBLIC_BASE_URL = https://77.smartvideo.py.qikongjian.com
|
||||||
NEXT_PUBLIC_CUT_URL = https://77.smartcut.py.qikongjian.com
|
NEXT_PUBLIC_CUT_URL = https://77.smartcut.py.qikongjian.com
|
||||||
NEXT_PUBLIC_CUT_URL_TO = https://smartcut.huiying.video
|
NEXT_PUBLIC_CUT_URL_TO = https://smartcut.huiying.video
|
||||||
|
# google analysis
|
||||||
|
NEXT_PUBLIC_GA_MEASUREMENT_ID = G-BHBXC1B1JL
|
||||||
|
NEXT_PUBLIC_GA_ENABLED = true
|
||||||
# 失败率
|
# 失败率
|
||||||
NEXT_PUBLIC_ERROR_CONFIG = 0.5
|
NEXT_PUBLIC_ERROR_CONFIG = 0.5
|
||||||
# Google OAuth配置
|
# Google OAuth配置
|
||||||
|
|||||||
@ -8,6 +8,9 @@ COPY package.json package-lock.json* ./
|
|||||||
COPY public ./public
|
COPY public ./public
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
# Google Analytics 环境变量
|
||||||
|
ENV NEXT_PUBLIC_GA_MEASUREMENT_ID=G-4BDXV6TWF4
|
||||||
|
ENV NEXT_PUBLIC_GA_ENABLED=true
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
|
|||||||
@ -306,3 +306,7 @@ body {
|
|||||||
height: calc(var(--vh, 1vh) * 100);
|
height: calc(var(--vh, 1vh) * 100);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
textarea, input {
|
||||||
|
font-size: 16px !important;
|
||||||
|
}
|
||||||
@ -4,6 +4,7 @@ import { createContext, useContext, useEffect, useState } from 'react';
|
|||||||
import { Providers } from '@/components/providers';
|
import { Providers } from '@/components/providers';
|
||||||
import { ConfigProvider, theme } from 'antd';
|
import { ConfigProvider, theme } from 'antd';
|
||||||
import CallbackModal from '@/components/common/CallbackModal';
|
import CallbackModal from '@/components/common/CallbackModal';
|
||||||
|
import { useAppStartupAnalytics } from '@/hooks/useAppStartupAnalytics';
|
||||||
|
|
||||||
// 创建上下文来传递弹窗控制方法
|
// 创建上下文来传递弹窗控制方法
|
||||||
const CallbackModalContext = createContext<{
|
const CallbackModalContext = createContext<{
|
||||||
@ -26,6 +27,10 @@ export default function RootLayout({
|
|||||||
}) {
|
}) {
|
||||||
const [showCallbackModal, setShowCallbackModal] = useState(false)
|
const [showCallbackModal, setShowCallbackModal] = useState(false)
|
||||||
const [paymentType, setPaymentType] = useState<'subscription' | 'token'>('subscription')
|
const [paymentType, setPaymentType] = useState<'subscription' | 'token'>('subscription')
|
||||||
|
|
||||||
|
// 应用启动时设置用户GA属性
|
||||||
|
useAppStartupAnalytics();
|
||||||
|
|
||||||
const openCallback = async function (ev: MessageEvent<any>) {
|
const openCallback = async function (ev: MessageEvent<any>) {
|
||||||
if (ev.data.type === 'waiting-payment') {
|
if (ev.data.type === 'waiting-payment') {
|
||||||
setPaymentType(ev.data.paymentType || 'subscription')
|
setPaymentType(ev.data.paymentType || 'subscription')
|
||||||
@ -48,17 +53,25 @@ export default function RootLayout({
|
|||||||
<link rel="icon" type="image/x-icon" sizes="32x32" href="/favicon.ico?v=1" />
|
<link rel="icon" type="image/x-icon" sizes="32x32" href="/favicon.ico?v=1" />
|
||||||
<link rel="shortcut icon" type="image/x-icon" href="/favicon.ico?v=1" />
|
<link rel="shortcut icon" type="image/x-icon" href="/favicon.ico?v=1" />
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/favicon.ico?v=1" />
|
<link rel="apple-touch-icon" sizes="180x180" href="/favicon.ico?v=1" />
|
||||||
<script async src="https://www.googletagmanager.com/gtag/js?id=G-E6VBGZ4ER5"></script>
|
{process.env.NEXT_PUBLIC_GA_ENABLED === 'true' && (
|
||||||
<script
|
<>
|
||||||
dangerouslySetInnerHTML={{
|
<script async src={`https://www.googletagmanager.com/gtag/js?id=${process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID}`}></script>
|
||||||
__html: `
|
<script
|
||||||
window.dataLayer = window.dataLayer || [];
|
dangerouslySetInnerHTML={{
|
||||||
function gtag(){window.dataLayer.push(arguments);}
|
__html: `
|
||||||
gtag('js', new Date());
|
window.dataLayer = window.dataLayer || [];
|
||||||
gtag('config', 'G-E6VBGZ4ER5');
|
function gtag(){window.dataLayer.push(arguments);}
|
||||||
`,
|
gtag('js', new Date());
|
||||||
}}
|
gtag('config', '${process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID}', {
|
||||||
/>
|
page_title: document.title,
|
||||||
|
page_location: window.location.href,
|
||||||
|
send_page_view: true
|
||||||
|
});
|
||||||
|
`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</head>
|
</head>
|
||||||
<body className="font-sans antialiased">
|
<body className="font-sans antialiased">
|
||||||
<ConfigProvider
|
<ConfigProvider
|
||||||
|
|||||||
21
app/page.tsx
21
app/page.tsx
@ -1,11 +1,30 @@
|
|||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
import { TopBar } from "@/components/layout/top-bar";
|
import { TopBar } from "@/components/layout/top-bar";
|
||||||
import { HomePage2 } from "@/components/pages/home-page2";
|
import { HomePage2 } from "@/components/pages/home-page2";
|
||||||
|
import { isAuthenticated } from '@/lib/auth';
|
||||||
|
import { useDeviceType } from '@/hooks/useDeviceType';
|
||||||
|
import H5TopBar from '@/components/layout/H5TopBar';
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { deviceType, isMobile, isTablet, isDesktop } = useDeviceType();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAuthenticated()) {
|
||||||
|
router.replace('/movies');
|
||||||
|
}
|
||||||
|
}, [router]);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<TopBar collapsed={true} />
|
{isMobile || isTablet ? (
|
||||||
|
<H5TopBar />
|
||||||
|
) : (
|
||||||
|
<TopBar collapsed={true} />
|
||||||
|
)}
|
||||||
<HomePage2 />
|
<HomePage2 />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -13,8 +13,13 @@ import {
|
|||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { fetchSubscriptionPlans, SubscriptionPlan } from "@/lib/stripe";
|
import { fetchSubscriptionPlans, SubscriptionPlan } from "@/lib/stripe";
|
||||||
import { DashboardLayout } from "@/components/layout/dashboard-layout";
|
import { DashboardLayout } from "@/components/layout/dashboard-layout";
|
||||||
|
import { trackEvent, trackPageView, trackPayment } from "@/utils/analytics";
|
||||||
|
|
||||||
export default function PricingPage() {
|
export default function PricingPage() {
|
||||||
|
// 页面访问跟踪
|
||||||
|
useEffect(() => {
|
||||||
|
trackPageView('/pricing', 'Pricing Plans');
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardLayout>
|
<DashboardLayout>
|
||||||
@ -91,6 +96,17 @@ function HomeModule5() {
|
|||||||
|
|
||||||
const handleSubscribe = async (planName: string) => {
|
const handleSubscribe = async (planName: string) => {
|
||||||
setLoadingPlan(planName);
|
setLoadingPlan(planName);
|
||||||
|
|
||||||
|
// 跟踪订阅按钮点击事件
|
||||||
|
trackEvent('subscription_button_click', {
|
||||||
|
event_category: 'subscription',
|
||||||
|
event_label: planName,
|
||||||
|
custom_parameters: {
|
||||||
|
plan_name: planName,
|
||||||
|
billing_type: billingType,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// 改为直接携带参数打开 pay-redirect,由其内部完成创建与跳转
|
// 改为直接携带参数打开 pay-redirect,由其内部完成创建与跳转
|
||||||
const url = `/pay-redirect?type=subscription&plan=${encodeURIComponent(planName)}&billing=${encodeURIComponent(billingType)}`;
|
const url = `/pay-redirect?type=subscription&plan=${encodeURIComponent(planName)}&billing=${encodeURIComponent(billingType)}`;
|
||||||
const win = window.open(url, '_blank');
|
const win = window.open(url, '_blank');
|
||||||
@ -148,7 +164,14 @@ function HomeModule5() {
|
|||||||
2xl:h-[3.375rem] 2xl:mt-[1.5rem]"
|
2xl:h-[3.375rem] 2xl:mt-[1.5rem]"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
onClick={() => setBillingType("month")}
|
onClick={() => {
|
||||||
|
setBillingType("month");
|
||||||
|
trackEvent('billing_toggle', {
|
||||||
|
event_category: 'subscription',
|
||||||
|
event_label: 'month',
|
||||||
|
custom_parameters: { billing_type: 'month' },
|
||||||
|
});
|
||||||
|
}}
|
||||||
className={`box-border flex justify-center items-center rounded-full transition-all duration-300 ${
|
className={`box-border flex justify-center items-center rounded-full transition-all duration-300 ${
|
||||||
billingType === "month"
|
billingType === "month"
|
||||||
? "bg-white text-black"
|
? "bg-white text-black"
|
||||||
@ -164,7 +187,14 @@ function HomeModule5() {
|
|||||||
Monthly
|
Monthly
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setBillingType("year")}
|
onClick={() => {
|
||||||
|
setBillingType("year");
|
||||||
|
trackEvent('billing_toggle', {
|
||||||
|
event_category: 'subscription',
|
||||||
|
event_label: 'year',
|
||||||
|
custom_parameters: { billing_type: 'year' },
|
||||||
|
});
|
||||||
|
}}
|
||||||
className={`box-border flex justify-center items-center rounded-full transition-all duration-300 ${
|
className={`box-border flex justify-center items-center rounded-full transition-all duration-300 ${
|
||||||
billingType === "year"
|
billingType === "year"
|
||||||
? "bg-white text-black"
|
? "bg-white text-black"
|
||||||
|
|||||||
@ -186,10 +186,10 @@ export default function SharePage(): JSX.Element {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardLayout>
|
<DashboardLayout>
|
||||||
<div data-alt="share-page" className="w-full h-full overflow-y-auto bg-black text-white">
|
<div data-alt="share-page" className="w-full h-full overflow-y-auto overflow-x-hidden bg-black text-white">
|
||||||
<div
|
<div
|
||||||
data-alt="container"
|
data-alt="container"
|
||||||
className="w-full max-w-[95%] mx-auto px-4 py-10 sm:px-6 md:px-8 lg:px-12 xl:px-16 2xl:px-20"
|
className="w-full max-w-[100%] mx-auto px-4 py-2 sm:px-6 md:px-8 lg:px-12 xl:px-16 2xl:px-20"
|
||||||
>
|
>
|
||||||
<header data-alt="page-header" className="mb-8 flex items-end justify-between">
|
<header data-alt="page-header" className="mb-8 flex items-end justify-between">
|
||||||
<div data-alt="title-box">
|
<div data-alt="title-box">
|
||||||
@ -206,14 +206,7 @@ export default function SharePage(): JSX.Element {
|
|||||||
<div data-alt="link-container" className="relative w-full max-w-2xl overflow-hidden rounded-md border border-white/20 bg-white/10">
|
<div data-alt="link-container" className="relative w-full max-w-2xl overflow-hidden rounded-md border border-white/20 bg-white/10">
|
||||||
<div
|
<div
|
||||||
data-alt="link-content"
|
data-alt="link-content"
|
||||||
className="relative px-4 py-2 text-sm font-mono text-white/90 whitespace-nowrap overflow-hidden"
|
className="relative px-4 py-2 text-xs sm:text-sm font-mono text-white/90 break-all sm:truncate"
|
||||||
style={{
|
|
||||||
background: 'linear-gradient(90deg, rgba(255,255,255,0.9) 0%, rgba(255,255,255,0.7) 50%, rgba(255,255,255,0.3) 70%, rgba(255,255,255,0.1) 90%, rgba(255,255,255,0) 100%)',
|
|
||||||
WebkitBackgroundClip: 'text',
|
|
||||||
backgroundClip: 'text',
|
|
||||||
maskImage: 'linear-gradient(90deg, rgba(0,0,0,1) 0%, rgba(0,0,0,1) 70%, rgba(0,0,0,0.5) 85%, rgba(0,0,0,0) 100%)',
|
|
||||||
WebkitMaskImage: 'linear-gradient(90deg, rgba(0,0,0,1) 0%, rgba(0,0,0,1) 70%, rgba(0,0,0,0.5) 85%, rgba(0,0,0,0) 100%)'
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{inviteCode ? `${'https://www.movieflow.ai'}/signup?inviteCode=${inviteCode}` : '-'}
|
{inviteCode ? `${'https://www.movieflow.ai'}/signup?inviteCode=${inviteCode}` : '-'}
|
||||||
</div>
|
</div>
|
||||||
@ -258,21 +251,21 @@ export default function SharePage(): JSX.Element {
|
|||||||
<li data-alt="step" className="rounded-md p-4">
|
<li data-alt="step" className="rounded-md p-4">
|
||||||
<div data-alt="step-header" className="flex items-center justify-between">
|
<div data-alt="step-header" className="flex items-center justify-between">
|
||||||
<span className="text-sm font-medium text-custom-blue/50">Step 1</span>
|
<span className="text-sm font-medium text-custom-blue/50">Step 1</span>
|
||||||
<span className="rounded bg-white/10 px-2 py-0.5 text-xs text-white bg-custom-purple/50">Share</span>
|
<span className="rounded px-2 py-0.5 text-xs text-white bg-custom-purple/50">Share</span>
|
||||||
</div>
|
</div>
|
||||||
<p data-alt="step-desc" className="mt-2 text-sm text-white/70">Copy your invitation link and share it with friends.</p>
|
<p data-alt="step-desc" className="mt-2 text-sm text-white/70">Copy your invitation link and share it with friends.</p>
|
||||||
</li>
|
</li>
|
||||||
<li data-alt="step" className="rounded-md p-4">
|
<li data-alt="step" className="rounded-md p-4">
|
||||||
<div data-alt="step-header" className="flex items-center justify-between">
|
<div data-alt="step-header" className="flex items-center justify-between">
|
||||||
<span className="text-sm font-medium text-custom-blue/50">Step 2</span>
|
<span className="text-sm font-medium text-custom-blue/50">Step 2</span>
|
||||||
<span className="rounded bg-white/10 px-2 py-0.5 text-xs text-white bg-custom-purple/50">Register</span>
|
<span className="rounded px-2 py-0.5 text-xs text-white bg-custom-purple/50">Register</span>
|
||||||
</div>
|
</div>
|
||||||
<p data-alt="step-desc" className="mt-2 text-sm text-white/70">Friends click the link and register directly.</p>
|
<p data-alt="step-desc" className="mt-2 text-sm text-white/70">Friends click the link and register directly.</p>
|
||||||
</li>
|
</li>
|
||||||
<li data-alt="step" className="rounded-md p-4">
|
<li data-alt="step" className="rounded-md p-4">
|
||||||
<div data-alt="step-header" className="flex items-center justify-between">
|
<div data-alt="step-header" className="flex items-center justify-between">
|
||||||
<span className="text-sm font-medium text-custom-blue/50">Step 3</span>
|
<span className="text-sm font-medium text-custom-blue/50">Step 3</span>
|
||||||
<span className="rounded bg-white/10 px-2 py-0.5 text-xs text-white bg-custom-purple/50">Reward</span>
|
<span className="rounded px-2 py-0.5 text-xs text-white bg-custom-purple/50">Reward</span>
|
||||||
</div>
|
</div>
|
||||||
<p data-alt="step-desc" className="mt-2 text-sm text-white/70">You both receive rewards after your friend activates their account.</p>
|
<p data-alt="step-desc" className="mt-2 text-sm text-white/70">You both receive rewards after your friend activates their account.</p>
|
||||||
</li>
|
</li>
|
||||||
@ -351,8 +344,8 @@ export default function SharePage(): JSX.Element {
|
|||||||
<table data-alt="records-table" className="min-w-full divide-y divide-white/10 table-fixed">
|
<table data-alt="records-table" className="min-w-full divide-y divide-white/10 table-fixed">
|
||||||
<thead data-alt="table-head" className="bg-black">
|
<thead data-alt="table-head" className="bg-black">
|
||||||
<tr data-alt="table-head-row">
|
<tr data-alt="table-head-row">
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-white/60 w-48">Invited Username</th>
|
<th className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-white/60 sm:w-48">Invited Username</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-white/60 w-56">Registered At</th>
|
<th className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-white/60 sm:w-56">Registered At</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-white/60">Reward</th>
|
<th className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-white/60">Reward</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@ -365,8 +358,8 @@ export default function SharePage(): JSX.Element {
|
|||||||
return (
|
return (
|
||||||
<React.Fragment key={r.user_id}>
|
<React.Fragment key={r.user_id}>
|
||||||
<tr data-alt="table-row" className="hover:bg-white/5">
|
<tr data-alt="table-row" className="hover:bg-white/5">
|
||||||
<td className="px-4 py-3 text-sm text-white w-48">{r.user_name}</td>
|
<td className="px-4 py-3 text-sm text-white sm:w-48">{r.user_name}</td>
|
||||||
<td className="px-4 py-3 text-sm text-white/80 w-56 whitespace-nowrap">{formatLocalTime(r.created_at * 1000)}</td>
|
<td className="px-4 py-3 text-sm text-white/80 whitespace-nowrap sm:w-56">{formatLocalTime(r.created_at * 1000)}</td>
|
||||||
<td className="px-4 py-3 text-sm text-white/90">
|
<td className="px-4 py-3 text-sm text-white/90">
|
||||||
<div data-alt="reward-cell" className="flex items-center justify-between gap-3">
|
<div data-alt="reward-cell" className="flex items-center justify-between gap-3">
|
||||||
<div data-alt="reward-summary" className="flex-1 truncate text-[#FFCC6D]">
|
<div data-alt="reward-summary" className="flex-1 truncate text-[#FFCC6D]">
|
||||||
@ -387,8 +380,8 @@ export default function SharePage(): JSX.Element {
|
|||||||
</tr>
|
</tr>
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<tr data-alt="row-details">
|
<tr data-alt="row-details">
|
||||||
<td className="px-4 py-0 w-48" />
|
<td className="px-4 py-0 sm:w-48" />
|
||||||
<td className="px-4 py-0 w-56" />
|
<td className="px-4 py-0 sm:w-56" />
|
||||||
<td className="px-4 py-3 bg-white/5">
|
<td className="px-4 py-3 bg-white/5">
|
||||||
<div data-alt="details-wrapper" className="overflow-x-auto">
|
<div data-alt="details-wrapper" className="overflow-x-auto">
|
||||||
<table data-alt="reward-subtable" className="min-w-[320px] text-sm">
|
<table data-alt="reward-subtable" className="min-w-[320px] text-sm">
|
||||||
|
|||||||
4
app/types/global.d.ts
vendored
4
app/types/global.d.ts
vendored
@ -12,6 +12,10 @@ declare global {
|
|||||||
loading: (message: string) => ReturnType<typeof toast.promise>;
|
loading: (message: string) => ReturnType<typeof toast.promise>;
|
||||||
dismiss: () => void;
|
dismiss: () => void;
|
||||||
};
|
};
|
||||||
|
// Google Analytics 类型声明
|
||||||
|
gtag: (...args: any[]) => void;
|
||||||
|
dataLayer: any[];
|
||||||
|
|
||||||
// Google GSI API类型声明
|
// Google GSI API类型声明
|
||||||
google?: {
|
google?: {
|
||||||
accounts: {
|
accounts: {
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import UsageView from "@/components/pages/usage-view";
|
|||||||
|
|
||||||
const UsagePage: React.FC = () => {
|
const UsagePage: React.FC = () => {
|
||||||
return (
|
return (
|
||||||
<div data-alt="usage-page" className="h-screen overflow-auto px-4 py-6">
|
<div data-alt="usage-page" className="mobile-viewport-height min-h-screen mobile-safe-bottom overflow-hidden px-4 py-6 pb-[max(1rem,env(safe-area-inset-bottom))]">
|
||||||
<UsageView />
|
<UsageView />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -244,6 +244,11 @@ export function ChatInputBox({ noData }: { noData: boolean }) {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// H5 文本输入框聚焦动画控制
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const [isInputFocused, setIsInputFocused] = useState(false);
|
||||||
|
const [persistedMobileMaxHeight, setPersistedMobileMaxHeight] = useState<number | null>(null);
|
||||||
|
|
||||||
const handleCreateVideo = async () => {
|
const handleCreateVideo = async () => {
|
||||||
if (isCreating) return; // 如果正在创建中,直接返回
|
if (isCreating) return; // 如果正在创建中,直接返回
|
||||||
|
|
||||||
@ -342,27 +347,77 @@ export function ChatInputBox({ noData }: { noData: boolean }) {
|
|||||||
<div data-alt="chat-input-box" className="flex flex-col w-full">
|
<div data-alt="chat-input-box" className="flex flex-col w-full">
|
||||||
{/* 第一行:输入框 */}
|
{/* 第一行:输入框 */}
|
||||||
<div className="video-prompt-editor mb-3 relative flex flex-col gap-3 flex-1 pr-10">
|
<div className="video-prompt-editor mb-3 relative flex flex-col gap-3 flex-1 pr-10">
|
||||||
{/* 文本输入框 - 改为textarea */}
|
{/* 文本输入框 - 改为textarea */}
|
||||||
<textarea
|
<textarea
|
||||||
value={script}
|
data-alt="story-input"
|
||||||
onChange={(e) => setScript(e.target.value)}
|
ref={textareaRef}
|
||||||
placeholder="Describe the story you want to make..."
|
value={script}
|
||||||
className="w-full pl-[10px] pr-[10px] py-[14px] rounded-[10px] leading-[20px] text-sm border-none bg-transparent text-white placeholder:text-white/[0.40] focus:outline-none resize-none min-h-[48px] max-h-[120px] overflow-y-auto"
|
onChange={(e) => setScript(e.target.value)}
|
||||||
style={
|
placeholder="Describe the story you want to make..."
|
||||||
noData
|
className={`w-full pl-[10px] pr-[10px] py-[14px] rounded-[10px] leading-[20px] text-sm border-none bg-transparent text-white placeholder:text-white/[0.40] focus:outline-none resize-none overflow-y-auto transition-all duration-300 ease-in-out ${isMobile ? '' : 'max-h-[120px]'}`}
|
||||||
? {
|
style={{
|
||||||
minHeight: "128px",
|
minHeight: noData ? "128px" : (isMobile ? (isInputFocused ? "96px" : "48px") : "unset"),
|
||||||
}
|
maxHeight: isMobile ? (isInputFocused ? "200px" : (persistedMobileMaxHeight ? `${persistedMobileMaxHeight}px` : "120px")) : undefined,
|
||||||
: {}
|
}}
|
||||||
|
rows={1}
|
||||||
|
onFocus={() => {
|
||||||
|
if (!isMobile) return;
|
||||||
|
setIsInputFocused(true);
|
||||||
|
const el = textareaRef.current;
|
||||||
|
if (el) {
|
||||||
|
const limit = 200;
|
||||||
|
// 以当前高度为起点,过渡到目标高度
|
||||||
|
const start = `${el.getBoundingClientRect().height}px`;
|
||||||
|
const end = `${Math.min(Math.max(el.scrollHeight, 96), limit)}px`;
|
||||||
|
el.style.height = start;
|
||||||
|
void el.offsetHeight;
|
||||||
|
el.style.height = end;
|
||||||
}
|
}
|
||||||
rows={1}
|
}}
|
||||||
onInput={(e) => {
|
onBlur={() => {
|
||||||
const target = e.target as HTMLTextAreaElement;
|
if (!isMobile) return;
|
||||||
target.style.height = "auto";
|
setIsInputFocused(false);
|
||||||
target.style.height =
|
const el = textareaRef.current;
|
||||||
Math.min(target.scrollHeight, 120) + "px";
|
if (el) {
|
||||||
}}
|
const baseLimit = 120;
|
||||||
/>
|
const contentHeight = el.scrollHeight;
|
||||||
|
const currentHeight = el.getBoundingClientRect().height;
|
||||||
|
// 若内容高度已超过基础高度,则保持较大高度,不回落
|
||||||
|
if (contentHeight > baseLimit || currentHeight > baseLimit) {
|
||||||
|
setPersistedMobileMaxHeight(Math.min(contentHeight, 200));
|
||||||
|
el.style.height = `${Math.min(contentHeight, 200)}px`;
|
||||||
|
} else {
|
||||||
|
const start = `${currentHeight}px`;
|
||||||
|
const end = `${Math.min(contentHeight, baseLimit)}px`;
|
||||||
|
el.style.height = start;
|
||||||
|
void el.offsetHeight;
|
||||||
|
el.style.height = end;
|
||||||
|
setPersistedMobileMaxHeight(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onInput={(e) => {
|
||||||
|
const target = e.target as HTMLTextAreaElement;
|
||||||
|
const limit = isMobile && isInputFocused ? 200 : (persistedMobileMaxHeight ?? 120);
|
||||||
|
target.style.height = "auto";
|
||||||
|
target.style.height = Math.min(target.scrollHeight, limit) + "px";
|
||||||
|
}}
|
||||||
|
onTransitionEnd={() => {
|
||||||
|
// 过渡结束后清理高度,避免下次动画受限
|
||||||
|
if (!isMobile) return;
|
||||||
|
if (!isInputFocused) {
|
||||||
|
const el = textareaRef.current;
|
||||||
|
if (el) {
|
||||||
|
// 若已记录持久高度则保持,不清理;否则清理
|
||||||
|
if (persistedMobileMaxHeight) {
|
||||||
|
el.style.height = `${persistedMobileMaxHeight}px`;
|
||||||
|
} else {
|
||||||
|
el.style.height = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 第二行:功能按钮和Action按钮 - 同一行 */}
|
{/* 第二行:功能按钮和Action按钮 - 同一行 */}
|
||||||
|
|||||||
@ -169,7 +169,10 @@ export default function SmartChatBox({
|
|||||||
}, [messages]);
|
}, [messages]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${isMobile ? 'z-[49] relative' : 'h-full'} w-full text-gray-100 flex flex-col`} data-alt="smart-chat-box">
|
<div className={`${isMobile ? '' : 'h-full'} w-full text-gray-100 flex flex-col backdrop-blur-lg bg-black/30 border border-white/20 shadow-xl relative`} data-alt="smart-chat-box"
|
||||||
|
style={{
|
||||||
|
maxHeight: isMobile ? 'calc(100vh - 5.5rem)' : '',
|
||||||
|
}}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className={`px-4 py-3 border-b border-white/10 flex items-center justify-between ${isMobile ? 'sticky top-0 bg-[#141414] z-[1]' : ''}`} data-alt="chat-header">
|
<div className={`px-4 py-3 border-b border-white/10 flex items-center justify-between ${isMobile ? 'sticky top-0 bg-[#141414] z-[1]' : ''}`} data-alt="chat-header">
|
||||||
<div className="font-semibold flex items-center gap-2">
|
<div className="font-semibold flex items-center gap-2">
|
||||||
|
|||||||
@ -2,10 +2,11 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useRouter, usePathname } from 'next/navigation';
|
import { useRouter, usePathname } from 'next/navigation';
|
||||||
import { clearAuthData, getUserProfile, isAuthenticated } from '@/lib/auth';
|
import { clearAuthData, getUserProfile, isAuthenticated, getCurrentUser } from '@/lib/auth';
|
||||||
import GlobalLoad from '../common/GlobalLoad';
|
import GlobalLoad from '../common/GlobalLoad';
|
||||||
import { message } from 'antd';
|
import { message } from 'antd';
|
||||||
import { errorHandle } from '@/api/errorHandle';
|
import { errorHandle } from '@/api/errorHandle';
|
||||||
|
import { setUserProperties } from '@/utils/analytics';
|
||||||
|
|
||||||
interface AuthGuardProps {
|
interface AuthGuardProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@ -21,6 +22,36 @@ export default function AuthGuard({ children }: AuthGuardProps) {
|
|||||||
const publicPaths = ['/','/login', '/signup', '/forgot-password', '/Terms', '/Privacy', '/activate', '/users/oauth/callback'];
|
const publicPaths = ['/','/login', '/signup', '/forgot-password', '/Terms', '/Privacy', '/activate', '/users/oauth/callback'];
|
||||||
const isPublicPath = publicPaths.includes(pathname);
|
const isPublicPath = publicPaths.includes(pathname);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置用户GA属性
|
||||||
|
* 基于实际userData结构: {"id":"8f61686734d340d0820f0fe980368629","userId":"8f61686734d340d0820f0fe980368629","username":"Orange","email":"moviflow68@test.com","isActive":1,"authType":"LOCAL","lastLogin":"2025-09-28T16:28:51.870731"}
|
||||||
|
*/
|
||||||
|
const setUserAnalyticsProperties = (userData: any) => {
|
||||||
|
if (!userData || !userData.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setUserProperties(userData.id, {
|
||||||
|
// 基础用户信息
|
||||||
|
user_id: userData.id,
|
||||||
|
email: userData.email,
|
||||||
|
username: userData.username,
|
||||||
|
// 认证信息
|
||||||
|
auth_type: userData.authType || 'LOCAL',
|
||||||
|
is_active: userData.isActive || 1,
|
||||||
|
// 登录信息
|
||||||
|
last_login: userData.lastLogin || new Date().toISOString(),
|
||||||
|
// 页面信息
|
||||||
|
current_page: pathname,
|
||||||
|
// 用户状态
|
||||||
|
user_status: userData.isActive === 1 ? 'active' : 'inactive',
|
||||||
|
// 会话信息
|
||||||
|
session_id: `${userData.id}_${Date.now()}`
|
||||||
|
});
|
||||||
|
} catch (error) {}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const verifyAuth = async () => {
|
const verifyAuth = async () => {
|
||||||
// 如果是公共页面,不需要鉴权
|
// 如果是公共页面,不需要鉴权
|
||||||
@ -41,6 +72,9 @@ export default function AuthGuard({ children }: AuthGuardProps) {
|
|||||||
const user = await getUserProfile();
|
const user = await getUserProfile();
|
||||||
if (user) {
|
if (user) {
|
||||||
setIsAuthorized(true);
|
setIsAuthorized(true);
|
||||||
|
|
||||||
|
// 设置用户GA属性(页面首次加载时)
|
||||||
|
setUserAnalyticsProperties(user);
|
||||||
} else {
|
} else {
|
||||||
router.push('/login');
|
router.push('/login');
|
||||||
}
|
}
|
||||||
@ -49,8 +83,16 @@ export default function AuthGuard({ children }: AuthGuardProps) {
|
|||||||
if(errorCode.message == 401||errorCode.message == 502){
|
if(errorCode.message == 401||errorCode.message == 502){
|
||||||
router.push('/login');
|
router.push('/login');
|
||||||
clearAuthData();
|
clearAuthData();
|
||||||
|
} else {
|
||||||
|
// 如果API调用失败但不是认证错误,尝试使用本地存储的用户数据
|
||||||
|
const localUser = getCurrentUser();
|
||||||
|
if (localUser && localUser.id) {
|
||||||
|
console.log('API调用失败,使用本地用户数据设置GA属性');
|
||||||
|
setUserAnalyticsProperties(localUser);
|
||||||
|
setIsAuthorized(true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
errorHandle(errorCode.message)
|
errorHandle(errorCode.message)
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
63
components/common/Footer/index.tsx
Normal file
63
components/common/Footer/index.tsx
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 公共页脚组件
|
||||||
|
* 提供统一的版权信息和页脚样式
|
||||||
|
*/
|
||||||
|
export interface FooterProps {
|
||||||
|
/** 版权年份,默认为当前年份 */
|
||||||
|
year?: number;
|
||||||
|
/** 公司名称,默认为 "MovieFlow" */
|
||||||
|
companyName?: string;
|
||||||
|
/** 自定义版权文本 */
|
||||||
|
customText?: string;
|
||||||
|
/** 额外的CSS类名 */
|
||||||
|
className?: string;
|
||||||
|
/** 是否显示邮箱链接 */
|
||||||
|
showEmailLink?: boolean;
|
||||||
|
/** 邮箱地址,默认为 "support@movieflow.ai" */
|
||||||
|
emailAddress?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 公共页脚组件
|
||||||
|
* @param props - 页脚组件属性
|
||||||
|
* @returns JSX元素
|
||||||
|
*/
|
||||||
|
const Footer: React.FC<FooterProps> = ({
|
||||||
|
year = new Date().getFullYear(),
|
||||||
|
companyName = 'MovieFlow',
|
||||||
|
customText,
|
||||||
|
className = '',
|
||||||
|
showEmailLink = false,
|
||||||
|
emailAddress = 'support@movieflow.ai'
|
||||||
|
}) => {
|
||||||
|
const copyrightText = customText || `© ${year} ${companyName}. All rights reserved.`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-alt="footer-component"
|
||||||
|
className={`home-module6 flex justify-center items-center w-full h-min text-white/50 text-lg bg-black snap-start px-4 ${className}`}
|
||||||
|
>
|
||||||
|
{/* 左侧版权信息 */}
|
||||||
|
<div className="text-center">
|
||||||
|
{copyrightText}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右侧邮箱链接 */}
|
||||||
|
{showEmailLink && (
|
||||||
|
<div className="flex-shrink-0 ml-4">
|
||||||
|
<a
|
||||||
|
href={`mailto:${emailAddress}`}
|
||||||
|
className="text-custom-blue hover:text-white/80 transition-colors duration-200 underline decoration-white/30 hover:decoration-white/60"
|
||||||
|
data-alt="support-email-link"
|
||||||
|
>
|
||||||
|
Contact Us
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Footer;
|
||||||
18
components/common/Footer/types.ts
Normal file
18
components/common/Footer/types.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* 页脚组件相关的类型定义
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface FooterProps {
|
||||||
|
/** 版权年份,默认为当前年份 */
|
||||||
|
year?: number;
|
||||||
|
/** 公司名称,默认为 "MovieFlow" */
|
||||||
|
companyName?: string;
|
||||||
|
/** 自定义版权文本 */
|
||||||
|
customText?: string;
|
||||||
|
/** 额外的CSS类名 */
|
||||||
|
className?: string;
|
||||||
|
/** 是否显示邮箱链接 */
|
||||||
|
showEmailLink?: boolean;
|
||||||
|
/** 邮箱地址,默认为 "support@movieflow.ai" */
|
||||||
|
emailAddress?: string;
|
||||||
|
}
|
||||||
42
components/layout/H5TopBar.css
Normal file
42
components/layout/H5TopBar.css
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
.backdrop_backdrop {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 58;
|
||||||
|
display: grid;
|
||||||
|
height: 4rem;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.backdrop_backdrop:after {
|
||||||
|
content: "";
|
||||||
|
background: linear-gradient(180deg, rgba(0, 0, 0, .2) 60%, transparent);
|
||||||
|
}
|
||||||
|
.backdrop_backdrop:after, .backdrop_backdrop .backdrop_blur {
|
||||||
|
grid-area: 1 / 1;
|
||||||
|
transition: opacity .2s linear;
|
||||||
|
}
|
||||||
|
.backdrop_backdrop>.backdrop_blur:first-child {
|
||||||
|
backdrop-filter: blur(1px);
|
||||||
|
mask: linear-gradient(0deg, transparent, #000 8%);
|
||||||
|
}
|
||||||
|
.backdrop_backdrop>.backdrop_blur:nth-child(2) {
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
mask: linear-gradient(0deg, transparent 8%, #000 16%);
|
||||||
|
}
|
||||||
|
.backdrop_backdrop>.backdrop_blur:nth-child(3) {
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
mask: linear-gradient(0deg, transparent 16%, #000 24%);
|
||||||
|
}
|
||||||
|
.backdrop_backdrop>.backdrop_blur:nth-child(4) {
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
mask: linear-gradient(0deg, transparent 24%, #000 36%);
|
||||||
|
}
|
||||||
|
.backdrop_backdrop>.backdrop_blur:nth-child(5) {
|
||||||
|
backdrop-filter: blur(24px);
|
||||||
|
mask: linear-gradient(0deg, transparent 36%, #000 48%);
|
||||||
|
}
|
||||||
|
.backdrop_backdrop>.backdrop_blur:nth-child(6) {
|
||||||
|
backdrop-filter: blur(32px);
|
||||||
|
mask: linear-gradient(0deg, transparent 48%, #000 60%);
|
||||||
|
}
|
||||||
493
components/layout/H5TopBar.tsx
Normal file
493
components/layout/H5TopBar.tsx
Normal file
@ -0,0 +1,493 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { usePathname, useRouter } from 'next/navigation';
|
||||||
|
import { Menu, Rocket, LogOut, User as UserIcon, X, Info, CalendarDays } from 'lucide-react';
|
||||||
|
import { Drawer } from 'antd';
|
||||||
|
import { fetchTabsByCode, HomeTabItem } from '@/api/serversetting';
|
||||||
|
import { getSigninStatus } from '@/api/signin';
|
||||||
|
import { getCurrentUser, isAuthenticated, logoutUser } from '@/lib/auth';
|
||||||
|
import { getUserSubscriptionInfo, createPortalSession, redirectToPortal } from '@/lib/stripe';
|
||||||
|
import { GradientText } from '@/components/ui/gradient-text';
|
||||||
|
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
|
||||||
|
import SigninBox from './signin-box';
|
||||||
|
import { navigationItems } from './type';
|
||||||
|
import './H5TopBar.css';
|
||||||
|
|
||||||
|
interface H5TopBarProps {
|
||||||
|
/** 点击首页 tab 时的回调,用于页面内滚动 */
|
||||||
|
onSelectHomeTab?: (key: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CurrentUserMinimal {
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
email?: string;
|
||||||
|
username?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移动端顶栏(抽屉式菜单)
|
||||||
|
* - 未登录:左 LOGO → '/',右侧 Signup + 菜单;抽屉显示 homeTabs 与 登录/注册
|
||||||
|
* - 已登录:左 LOGO → '/movies',右侧 升级图标 + 菜单;抽屉显示 用户卡片、快捷充值、入口与登出
|
||||||
|
*/
|
||||||
|
export default function H5TopBar({ onSelectHomeTab }: H5TopBarProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
const [isLogin, setIsLogin] = useState<boolean>(false);
|
||||||
|
const [user, setUser] = useState<CurrentUserMinimal | null>(null);
|
||||||
|
const [credits, setCredits] = useState<number>(0);
|
||||||
|
const [isBuyingTokens, setIsBuyingTokens] = useState<boolean>(false);
|
||||||
|
const [homeTabs, setHomeTabs] = useState<HomeTabItem[]>([]);
|
||||||
|
const [drawerOpen, setDrawerOpen] = useState<boolean>(false);
|
||||||
|
const [customAmount, setCustomAmount] = useState<string>("");
|
||||||
|
const [isSigninModalOpen, setIsSigninModalOpen] = useState(false);
|
||||||
|
const [isManagingSubscription, setIsManagingSubscription] = useState(false);
|
||||||
|
const [subscriptionStatus, setSubscriptionStatus] = useState<string>("");
|
||||||
|
const [planName, setPlanName] = useState<string>("");
|
||||||
|
const [needsSigninBadge, setNeedsSigninBadge] = useState<boolean>(false);
|
||||||
|
const [isGlassActive, setIsGlassActive] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const isHome = useMemo(() => pathname === '/', [pathname]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsLogin(isAuthenticated());
|
||||||
|
const u = getCurrentUser();
|
||||||
|
setUser(u || null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user?.id) return;
|
||||||
|
let ignore = false;
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
const res = await getUserSubscriptionInfo(String(user.id));
|
||||||
|
if (!ignore && res?.data?.credits !== undefined) {
|
||||||
|
setCredits(res.data.credits);
|
||||||
|
if (typeof res.data.subscription_status === 'string') {
|
||||||
|
setSubscriptionStatus(res.data.subscription_status);
|
||||||
|
}
|
||||||
|
if (typeof res.data.plan_name === 'string') {
|
||||||
|
setPlanName(res.data.plan_name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
return () => {
|
||||||
|
ignore = true;
|
||||||
|
};
|
||||||
|
}, [user?.id]);
|
||||||
|
|
||||||
|
// 仅首页时加载 homeTabs,用于未登录抽屉导航
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isHome) return;
|
||||||
|
let mounted = true;
|
||||||
|
const loadTabs = async () => {
|
||||||
|
try {
|
||||||
|
const tabs = await fetchTabsByCode('homeTab');
|
||||||
|
if (mounted && Array.isArray(tabs)) setHomeTabs(tabs);
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
loadTabs();
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
};
|
||||||
|
}, [isHome]);
|
||||||
|
|
||||||
|
// 获取今日签到状态,未签到则显示红点
|
||||||
|
useEffect(() => {
|
||||||
|
const loadSignin = async () => {
|
||||||
|
if (!isLogin) return;
|
||||||
|
try {
|
||||||
|
const data: any = await getSigninStatus();
|
||||||
|
const hasSignin = !!data?.data?.has_signin;
|
||||||
|
setNeedsSigninBadge(!hasSignin);
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
loadSignin();
|
||||||
|
}, [isLogin]);
|
||||||
|
|
||||||
|
// 首页滚动 30vh 后开启玻璃质感背景
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isHome) return;
|
||||||
|
const computeAndSet = (evt?: Event) => {
|
||||||
|
const threshold = Math.max(0, window.innerHeight * 0.3);
|
||||||
|
const winY = window.scrollY || window.pageYOffset || 0;
|
||||||
|
const docElY = (document.documentElement && document.documentElement.scrollTop) || 0;
|
||||||
|
const scrollingElY = (document.scrollingElement as any)?.scrollTop || 0;
|
||||||
|
const bodyY = (document.body && (document.body as any).scrollTop) || 0;
|
||||||
|
let currentY = Math.max(winY, docElY, scrollingElY, bodyY);
|
||||||
|
const target = evt?.target as any;
|
||||||
|
if (target && typeof target.scrollTop === 'number') {
|
||||||
|
currentY = Math.max(currentY, target.scrollTop);
|
||||||
|
}
|
||||||
|
const nextActive = currentY >= threshold;
|
||||||
|
setIsGlassActive(nextActive);
|
||||||
|
};
|
||||||
|
// 初始计算一次
|
||||||
|
computeAndSet();
|
||||||
|
// 监听 window 与 document(捕获阶段,捕获内部滚动容器事件)
|
||||||
|
window.addEventListener('scroll', computeAndSet, { passive: true });
|
||||||
|
document.addEventListener('scroll', computeAndSet, { passive: true, capture: true });
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('scroll', computeAndSet);
|
||||||
|
document.removeEventListener('scroll', computeAndSet, { capture: true } as any);
|
||||||
|
};
|
||||||
|
}, [isHome]);
|
||||||
|
|
||||||
|
// 离开首页时,移除玻璃背景
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isHome) {
|
||||||
|
setIsGlassActive(false);
|
||||||
|
} else {
|
||||||
|
}
|
||||||
|
}, [isHome]);
|
||||||
|
|
||||||
|
const handleLogoClick = () => {
|
||||||
|
if (isLogin) {
|
||||||
|
router.push('/movies');
|
||||||
|
} else {
|
||||||
|
router.push('/');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpgrade = () => {
|
||||||
|
router.push('/pricing');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBuyTokens = async (amount: number) => {
|
||||||
|
if (!user?.id) return;
|
||||||
|
setIsBuyingTokens(true);
|
||||||
|
try {
|
||||||
|
const url = `/pay-redirect?type=token&amount=${encodeURIComponent(amount)}&pkg=basic`;
|
||||||
|
window.open(url, '_blank');
|
||||||
|
window.postMessage({ type: 'waiting-payment', paymentType: 'subscription' }, '*');
|
||||||
|
} finally {
|
||||||
|
setIsBuyingTokens(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCustomAmountBuy = async () => {
|
||||||
|
const amount = parseInt(customAmount);
|
||||||
|
if (isNaN(amount) || amount <= 0) return;
|
||||||
|
await handleBuyTokens(amount);
|
||||||
|
setCustomAmount("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSignin = () => {
|
||||||
|
setIsSigninModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleManageSubscription = async () => {
|
||||||
|
if (!user?.id) return;
|
||||||
|
setIsManagingSubscription(true);
|
||||||
|
try {
|
||||||
|
const response = await createPortalSession({
|
||||||
|
user_id: String(user.id),
|
||||||
|
return_url: window.location.origin + '/dashboard',
|
||||||
|
});
|
||||||
|
if (response.successful && response.data?.portal_url) {
|
||||||
|
redirectToPortal(response.data.portal_url);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsManagingSubscription(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span className={`backdrop_backdrop ${isHome && isGlassActive ? 'grid' : 'hidden'}`}>
|
||||||
|
<span className='backdrop_blur'></span>
|
||||||
|
<span className='backdrop_blur'></span>
|
||||||
|
<span className='backdrop_blur'></span>
|
||||||
|
<span className='backdrop_blur'></span>
|
||||||
|
<span className='backdrop_blur'></span>
|
||||||
|
<span className='backdrop_blur'></span>
|
||||||
|
</span>
|
||||||
|
<div data-alt="h5-topbar" className={`fixed left-0 right-0 top-0 h-16 header z-[60] ${drawerOpen ? 'bg-[#0b0b0b] pointer-events-auto' : ''}` }>
|
||||||
|
<div data-alt="bar" className="h-14 px-3 flex items-center justify-between">
|
||||||
|
{/* 左侧 LOGO */}
|
||||||
|
<div
|
||||||
|
className={`flex items-center cursor-pointer space-x-1 link-logo roll event-on`}
|
||||||
|
onClick={handleLogoClick}
|
||||||
|
>
|
||||||
|
<h1 className="logo text-2xl font-bold">
|
||||||
|
<GradientText
|
||||||
|
text="MovieFlow"
|
||||||
|
startPercentage={30}
|
||||||
|
endPercentage={70}
|
||||||
|
/>
|
||||||
|
</h1>
|
||||||
|
{/* beta标签 */}
|
||||||
|
<div className="relative transform translate-y-[-1px]">
|
||||||
|
<span className="inline-flex items-center px-1.5 py-0.5 text-[10px] font-semibold tracking-wider text-[rgb(212 202 202)] border border-[rgba(106,244,249,0.2)] rounded-full shadow-[0_0_10px_rgba(106,244,249,0.1)]">
|
||||||
|
Beta
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右侧操作区 */}
|
||||||
|
<div data-alt="actions" className="flex items-center gap-2">
|
||||||
|
{
|
||||||
|
!drawerOpen && (
|
||||||
|
<>
|
||||||
|
{isLogin ? (
|
||||||
|
<button
|
||||||
|
data-alt="upgrade-icon-button"
|
||||||
|
className="h-9 w-9 rounded-full flex items-center justify-center text-gray-800 bg-gray-100 hover:bg-gray-200 border border-black/10 dark:text-white dark:bg-white/10 dark:hover:bg-white/20 dark:border-white/20"
|
||||||
|
onClick={handleUpgrade}
|
||||||
|
aria-label="Upgrade"
|
||||||
|
>
|
||||||
|
<Rocket className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
data-alt="signup-button"
|
||||||
|
className="px-3 h-9 rounded-full text-sm bg-white text-black hover:bg-white/90 border border-black/10 dark:bg-white/10 dark:text-white dark:hover:bg-white/20 dark:border-white/20"
|
||||||
|
onClick={() => router.push('/signup')}
|
||||||
|
>
|
||||||
|
Sign up
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
{/* 菜单抽屉(antd Drawer) */}
|
||||||
|
<button
|
||||||
|
data-alt="menu-trigger"
|
||||||
|
className="relative h-9 w-9 rounded-full flex items-center justify-center bg-gray-100 hover:bg-gray-200 text-gray-800 border border-black/10 dark:bg-white/10 dark:hover:bg-white/20 dark:text-white dark:border-white/20"
|
||||||
|
aria-expanded={drawerOpen}
|
||||||
|
onClick={() => setDrawerOpen((v) => !v)}
|
||||||
|
>
|
||||||
|
{
|
||||||
|
drawerOpen ? <X className="h-4 w-4" /> : <Menu className="h-4 w-4" />
|
||||||
|
}
|
||||||
|
{needsSigninBadge && !drawerOpen && (
|
||||||
|
<span className="absolute -top-0.5 -right-0.5 h-2 w-2 rounded-full bg-red-500 border border-white dark:border-[#0b0b0b]"></span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Drawer
|
||||||
|
placement="top"
|
||||||
|
open={drawerOpen}
|
||||||
|
onClose={() => setDrawerOpen(false)}
|
||||||
|
title={null}
|
||||||
|
closable
|
||||||
|
height={undefined}
|
||||||
|
bodyStyle={{ padding: 0 }}
|
||||||
|
maskClosable
|
||||||
|
// 64px 顶栏高度 + 8px 安全间距
|
||||||
|
maskStyle={{ position: 'absolute', top: '3.5rem', height: 'calc(100dvh - 3.5rem)', backgroundColor: 'transparent' }}
|
||||||
|
styles={{
|
||||||
|
content: { position: 'absolute', top: '3.5rem', height: isHome ? 'auto' : 'calc(100dvh - 3.5rem)' },
|
||||||
|
body: { padding: 0 },
|
||||||
|
header: { display: 'none' },
|
||||||
|
mask: { position: 'absolute', top: '3.5rem', height: 'calc(100dvh - 3.5rem)', backgroundColor: 'transparent' },
|
||||||
|
}}
|
||||||
|
className="[&_.ant-drawer-content]:bg-white [&_.ant-drawer-content]:text-black dark:[&_.ant-drawer-content]:bg-[#0b0b0b] dark:[&_.ant-drawer-content]:text-white dark:[&_.ant-drawer-body]:bg-[#0b0b0b] dark:[&_.ant-drawer-body]:text-white"
|
||||||
|
>
|
||||||
|
<div data-alt="drawer-container" className="h-full flex flex-col">
|
||||||
|
{/* 用户信息/未登录头部 */}
|
||||||
|
{isLogin ? (
|
||||||
|
<div data-alt="user-header" className="p-4 border-b border-t border-black/10 dark:border-white/10 flex items-center gap-3">
|
||||||
|
<div className="h-10 w-10 rounded-full bg-[#C73BFF] text-white flex items-center justify-center">
|
||||||
|
{(user?.username || user?.name || 'MF').slice(0, 1)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-medium truncate">{user?.name || user?.username || 'User'}</div>
|
||||||
|
<div className="text-xs text-black/60 dark:text-white/60 truncate">{user?.email}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<></>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 内容区 */}
|
||||||
|
<div data-alt="drawer-content" className="flex-1 overflow-y-auto">
|
||||||
|
{isLogin ? (
|
||||||
|
<div className="p-4 space-y-4">
|
||||||
|
{/* 积分中心 */}
|
||||||
|
<div data-alt="wallet-card" className="rounded-xl border border-black/10 dark:border-white/10 p-4 bg-white dark:bg-white/5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* 签到 */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSignin}
|
||||||
|
className="w-9 h-9 p-2 rounded-full bg-white/10 backdrop-blur-sm hover:bg-white/20 transition-colors relative"
|
||||||
|
data-alt="share-entry-button"
|
||||||
|
title="Share"
|
||||||
|
>
|
||||||
|
<span className={`inline-block ${needsSigninBadge ? 'motion-safe:animate-wiggle' : ''}`}>
|
||||||
|
<CalendarDays className="h-5 w-5 text-white" />
|
||||||
|
</span>
|
||||||
|
{needsSigninBadge && (
|
||||||
|
<span className="absolute top-0 right-0 h-2 w-2 rounded-full bg-red-500 border border-white dark:border-[#0b0b0b]"></span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{/* 积分 */}
|
||||||
|
<div className="text-2xl font-semibold mt-1">{credits} <span className="text-xs text-black/60 dark:text-white/60">credits</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="relative h-8 w-8 rounded-full bg-white/10 dark:bg-white/10 flex items-center justify-center hover:bg-white/20"
|
||||||
|
onClick={() => router.push("/usage")}
|
||||||
|
title="Usage"
|
||||||
|
>
|
||||||
|
<Info className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 快捷充值 */}
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
<button className="px-2 py-1 text-xs rounded bg-black text-white" disabled={isBuyingTokens} onClick={() => handleBuyTokens(100)}>+100 ($1)</button>
|
||||||
|
<button className="px-2 py-1 text-xs rounded bg-black text-white" disabled={isBuyingTokens} onClick={() => handleBuyTokens(500)}>+500 ($5)</button>
|
||||||
|
<button className="px-2 py-1 text-xs rounded bg-black text-white" disabled={isBuyingTokens} onClick={() => handleBuyTokens(1000)}>+1000 ($10)</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 自定义充值 */}
|
||||||
|
<div className="mt-2 flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={customAmount}
|
||||||
|
onChange={(e) => setCustomAmount(e.target.value)}
|
||||||
|
placeholder="Custom amount"
|
||||||
|
className="w-[120px] h-9 px-2 text-sm bg-white/10 text-black dark:text-white placeholder-black/40 dark:placeholder-white/60 border border-black/20 dark:border-white/20 rounded focus:outline-none"
|
||||||
|
min={1}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="px-3 h-9 rounded bg-black text-white text-sm disabled:opacity-50"
|
||||||
|
disabled={!customAmount || parseInt(customAmount) <= 0 || isBuyingTokens}
|
||||||
|
onClick={handleCustomAmountBuy}
|
||||||
|
>
|
||||||
|
Buy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
{/* 菜单目录 */}
|
||||||
|
<div data-alt="menu-links" className="rounded-xl border border-black/10 dark:border-white/10 divide-y divide-black/10 dark:divide-white/10 overflow-hidden">
|
||||||
|
{navigationItems.map((group) => (
|
||||||
|
group.items.map((nav) => {
|
||||||
|
const isActive = pathname === nav.href || pathname.startsWith(nav.href + '/');
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={nav.href}
|
||||||
|
data-alt={`link-${nav.name.toLowerCase()}`}
|
||||||
|
aria-current={isActive ? 'page' : undefined}
|
||||||
|
className={`w-full flex items-center gap-3 px-4 py-3 text-left hover:bg-gray-50 dark:hover:bg-white/10 ${isActive ? 'bg-gray-100 dark:bg-white/10' : ''}`}
|
||||||
|
onClick={() => router.push(nav.href)}
|
||||||
|
>
|
||||||
|
<nav.icon className="h-4 w-4" />
|
||||||
|
<span>{nav.name}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 其他功能 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<button data-alt="upgrade-button" className="w-full h-10 rounded-full border border-black/20 dark:border-white/20 hover:bg-gray-50 dark:hover:bg-white/10" onClick={handleUpgrade}>Upgrade</button>
|
||||||
|
{planName !== 'none' && subscriptionStatus !== 'INACTIVE' && (
|
||||||
|
<button data-alt="manage-button" className="w-full h-10 rounded-full border border-black/20 dark:border-white/20 hover:bg-gray-50 dark:hover:bg-white/10" onClick={handleManageSubscription} disabled={isManagingSubscription}>Manage</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-4 space-y-4 pt-0">
|
||||||
|
{isHome && homeTabs.length > 0 && (
|
||||||
|
<div data-alt="home-tabs" className="rounded-xl border border-black/10 dark:border-white/10 overflow-hidden">
|
||||||
|
{homeTabs.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.title}
|
||||||
|
data-alt={`home-tab-${tab.title.toLowerCase()}`}
|
||||||
|
className="w-full text-left px-4 py-3 border-b border-black/10 dark:border-white/10 last:border-b-0 hover:bg-gray-50 dark:hover:bg-white/10"
|
||||||
|
onClick={() => {
|
||||||
|
if (onSelectHomeTab) onSelectHomeTab(tab.title.toLowerCase() as any);
|
||||||
|
setDrawerOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tab.title}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
data-alt="login-button"
|
||||||
|
className="flex-1 h-10 rounded-full border border-black/20 dark:border-white/20"
|
||||||
|
onClick={() => router.push('/login')}
|
||||||
|
>
|
||||||
|
Log in
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
data-alt="signup-button-secondary"
|
||||||
|
className="flex-1 h-10 rounded-full bg-black text-white hover:bg-black/90"
|
||||||
|
onClick={() => router.push('/signup')}
|
||||||
|
>
|
||||||
|
Sign up
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 底部操作 */}
|
||||||
|
{isLogin && (
|
||||||
|
<div data-alt="drawer-footer" className="p-4 border-t border-black/10 dark:border-white/10">
|
||||||
|
<button
|
||||||
|
data-alt="logout-button"
|
||||||
|
className="w-full h-10 rounded-full border border-black/20 dark:border-white/20 flex items-center justify-center gap-2"
|
||||||
|
onClick={() => logoutUser()}
|
||||||
|
>
|
||||||
|
<LogOut className="h-4 w-4" />
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Drawer>
|
||||||
|
{/* Sign-in Modal */}
|
||||||
|
<Dialog open={isSigninModalOpen} onOpenChange={(open) => {
|
||||||
|
setIsSigninModalOpen(open);
|
||||||
|
if (!open) {
|
||||||
|
// 刷新积分
|
||||||
|
if (user?.id) { getUserSubscriptionInfo(String(user.id)).then((res:any)=>{ if(res?.data?.credits!==undefined){ setCredits(res.data.credits) } }).catch(()=>{}); }
|
||||||
|
// 关闭签到弹窗后,重新检查红点
|
||||||
|
if (isLogin) { getSigninStatus().then((d:any)=> setNeedsSigninBadge(!d?.data?.has_signin)).catch(()=>{}); }
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<DialogContent
|
||||||
|
className="max-w-md mx-auto bg-white/95 dark:bg-gray-900/95 backdrop-blur-xl border-0 shadow-2xl"
|
||||||
|
data-alt="signin-modal"
|
||||||
|
>
|
||||||
|
<DialogTitle></DialogTitle>
|
||||||
|
<SigninBox onSuccess={async () => {
|
||||||
|
try {
|
||||||
|
if (user?.id) {
|
||||||
|
const res = await getUserSubscriptionInfo(String(user.id));
|
||||||
|
if (res?.data?.credits !== undefined) {
|
||||||
|
setCredits(res.data.credits);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
// 成功签到后去除红点
|
||||||
|
setNeedsSigninBadge(false);
|
||||||
|
}} />
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -4,6 +4,7 @@ import { useState, useEffect } from 'react';
|
|||||||
import { Sidebar } from './sidebar';
|
import { Sidebar } from './sidebar';
|
||||||
import { TopBar } from './top-bar';
|
import { TopBar } from './top-bar';
|
||||||
import { useDeviceType } from '@/hooks/useDeviceType';
|
import { useDeviceType } from '@/hooks/useDeviceType';
|
||||||
|
import H5TopBar from '@/components/layout/H5TopBar';
|
||||||
|
|
||||||
interface DashboardLayoutProps {
|
interface DashboardLayoutProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@ -58,15 +59,20 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-background">
|
||||||
<TopBar collapsed={sidebarCollapsed} isDesktop={isDesktop} />
|
{isMobile || isTablet ? (
|
||||||
|
<H5TopBar />
|
||||||
|
) : (
|
||||||
|
<TopBar collapsed={sidebarCollapsed} isDesktop={isDesktop} />
|
||||||
|
)}
|
||||||
{isDesktop && <Sidebar collapsed={sidebarCollapsed} onToggle={setSidebarCollapsed} />}
|
{isDesktop && <Sidebar collapsed={sidebarCollapsed} onToggle={setSidebarCollapsed} />}
|
||||||
<div
|
<div
|
||||||
className={`top-[4rem] fixed right-0 bottom-0 px-4 ${getMobileContainerClasses()}`}
|
className={`fixed right-0 bottom-0 px-4 z-[60] ${getMobileContainerClasses()}`}
|
||||||
style={{
|
style={{
|
||||||
...getLayoutStyles(),
|
...getLayoutStyles(),
|
||||||
|
top: (isMobile || isTablet) ? '3.5rem' : '4rem',
|
||||||
// 移动端使用动态高度计算
|
// 移动端使用动态高度计算
|
||||||
height: (isMobile || isTablet)
|
height: (isMobile || isTablet)
|
||||||
? 'calc(100dvh - 4rem)'
|
? 'calc(100dvh - 3.5rem)'
|
||||||
: 'calc(100vh - 4rem)'
|
: 'calc(100vh - 4rem)'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -1,44 +1,19 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import { cn } from '@/public/lib/utils';
|
import { cn } from '@/public/lib/utils';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Separator } from '@/components/ui/separator';
|
|
||||||
import { GradientText } from '@/components/ui/gradient-text';
|
|
||||||
import {
|
import {
|
||||||
Home,
|
PanelRightClose
|
||||||
FolderOpen,
|
|
||||||
Users,
|
|
||||||
Type,
|
|
||||||
Image,
|
|
||||||
History,
|
|
||||||
ChevronLeft,
|
|
||||||
ChevronRight,
|
|
||||||
Video,
|
|
||||||
PanelsLeftBottom,
|
|
||||||
ArrowLeftToLine,
|
|
||||||
BookHeart,
|
|
||||||
PanelRightClose,
|
|
||||||
Gift
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { navigationItems } from './type';
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
collapsed: boolean;
|
collapsed: boolean;
|
||||||
onToggle: (collapsed: boolean) => void;
|
onToggle: (collapsed: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const navigationItems = [
|
|
||||||
{
|
|
||||||
title: 'Main',
|
|
||||||
items: [
|
|
||||||
{ name: 'My Portfolio', href: '/movies', icon: BookHeart },
|
|
||||||
{ name: 'Share', href: '/share', icon: Gift },
|
|
||||||
],
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
export function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
export function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import { Coins, Trophy, HelpCircle } from "lucide-react"
|
|||||||
import { getSigninStatus, performSignin, SigninData } from "@/api/signin"
|
import { getSigninStatus, performSignin, SigninData } from "@/api/signin"
|
||||||
|
|
||||||
|
|
||||||
export default function SigninPage() {
|
export default function SigninPage({ onSuccess }: { onSuccess?: () => void } = {}) {
|
||||||
const [signinData, setSigninData] = useState<SigninData>({
|
const [signinData, setSigninData] = useState<SigninData>({
|
||||||
has_signin: false,
|
has_signin: false,
|
||||||
credits: 0
|
credits: 0
|
||||||
@ -50,6 +50,8 @@ export default function SigninPage() {
|
|||||||
if (response.successful) {
|
if (response.successful) {
|
||||||
// Refresh status after successful signin
|
// Refresh status after successful signin
|
||||||
await fetchSigninStatus()
|
await fetchSigninStatus()
|
||||||
|
// Notify parent to refresh credits
|
||||||
|
try { onSuccess && onSuccess() } catch {}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Signin failed:', error)
|
console.error('Signin failed:', error)
|
||||||
|
|||||||
22
components/layout/type.ts
Normal file
22
components/layout/type.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { BookHeart, Gift } from "lucide-react";
|
||||||
|
|
||||||
|
interface NavigationItem {
|
||||||
|
name: string;
|
||||||
|
href: string;
|
||||||
|
icon: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Navigations {
|
||||||
|
title: string;
|
||||||
|
items: NavigationItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const navigationItems: Navigations[] = [
|
||||||
|
{
|
||||||
|
title: 'Main',
|
||||||
|
items: [
|
||||||
|
{ name: 'My Portfolio', href: '/movies', icon: BookHeart },
|
||||||
|
{ name: 'Share', href: '/share', icon: Gift },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
];
|
||||||
@ -8,6 +8,7 @@ import './style/create-to-video2.css';
|
|||||||
import { getScriptEpisodeListNew, MovieProject } from "@/api/script_episode";
|
import { getScriptEpisodeListNew, MovieProject } from "@/api/script_episode";
|
||||||
import { ChatInputBox } from '@/components/ChatInputBox/ChatInputBox';
|
import { ChatInputBox } from '@/components/ChatInputBox/ChatInputBox';
|
||||||
import cover_image1 from '@/public/assets/cover_image3.jpg';
|
import cover_image1 from '@/public/assets/cover_image3.jpg';
|
||||||
|
import cover_image2 from '@/public/assets/cover_image_shu.jpg';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { Tooltip, Button } from 'antd';
|
import { Tooltip, Button } from 'antd';
|
||||||
import { downloadVideo, getFirstFrame } from '@/utils/tools';
|
import { downloadVideo, getFirstFrame } from '@/utils/tools';
|
||||||
@ -309,7 +310,7 @@ export default function CreateToVideo2() {
|
|||||||
<div
|
<div
|
||||||
className="absolute inset-0 w-full h-full bg-cover bg-center bg-no-repeat group-hover:scale-105 transition-transform duration-500"
|
className="absolute inset-0 w-full h-full bg-cover bg-center bg-no-repeat group-hover:scale-105 transition-transform duration-500"
|
||||||
style={{
|
style={{
|
||||||
backgroundImage: `url(${cover_image1.src})`,
|
backgroundImage: `url(${project.aspect_ratio === 'VIDEO_ASPECT_RATIO_PORTRAIT' ? cover_image2.src : cover_image1.src})`,
|
||||||
}}
|
}}
|
||||||
data-alt="cover-image"
|
data-alt="cover-image"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -20,8 +20,10 @@ import LazyLoad from "react-lazyload";
|
|||||||
import { fetchSubscriptionPlans, SubscriptionPlan } from "@/lib/stripe";
|
import { fetchSubscriptionPlans, SubscriptionPlan } from "@/lib/stripe";
|
||||||
import VideoCoverflow from "@/components/ui/VideoCoverflow";
|
import VideoCoverflow from "@/components/ui/VideoCoverflow";
|
||||||
import { fetchTabsByCode, HomeTabItem } from "@/api/serversetting";
|
import { fetchTabsByCode, HomeTabItem } from "@/api/serversetting";
|
||||||
|
import H5TopBar from "@/components/layout/H5TopBar";
|
||||||
import { useCallbackModal } from "@/app/layout";
|
import { useCallbackModal } from "@/app/layout";
|
||||||
import { useDeviceType } from "@/hooks/useDeviceType";
|
import { useDeviceType } from "@/hooks/useDeviceType";
|
||||||
|
import Footer from "@/components/common/Footer";
|
||||||
|
|
||||||
/** 视频预加载系统 - 后台静默运行 */
|
/** 视频预加载系统 - 后台静默运行 */
|
||||||
function useVideoPreloader() {
|
function useVideoPreloader() {
|
||||||
@ -241,26 +243,9 @@ export function HomePage2() {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{/* 移动端开关移至 TopBar,保留占位对齐 */}
|
{/* 移动端交由 H5TopBar 控制 */}
|
||||||
<span className="md:hidden" data-alt="mobile-menu-toggle-placeholder"></span>
|
<span className="md:hidden" data-alt="mobile-menu-toggle-placeholder"></span>
|
||||||
</div>
|
</div>
|
||||||
{/* 移动端下拉(仅三个项) */}
|
|
||||||
{menuOpen && (
|
|
||||||
<div data-alt="mobile-menu" className="md:hidden bg-black/80 backdrop-blur-md border-b border-white/10 px-4 py-2 text-white/90 text-sm">
|
|
||||||
<div className="grid grid-cols-1 gap-1">
|
|
||||||
{tabsToRender.map((tab) => (
|
|
||||||
<button
|
|
||||||
key={tab.title}
|
|
||||||
data-alt={`m-nav-${tab.title.toLowerCase()}`}
|
|
||||||
className="text-center py-2"
|
|
||||||
onClick={() => scrollToSection(tab.title.toLowerCase() as any)}
|
|
||||||
>
|
|
||||||
{tab.title}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -268,7 +253,8 @@ export function HomePage2() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-screen overflow-y-auto" id="home-page" ref={containerRef} style={{ paddingBottom: `2rem` }}>
|
<div className="w-full h-screen overflow-y-auto" id="home-page" ref={containerRef} style={{ paddingBottom: `2rem` }}>
|
||||||
<NavBar />
|
{/* 移动端顶部导航(抽屉式) */}
|
||||||
|
{ isMobile ? (<H5TopBar onSelectHomeTab={(key) => scrollToSection(key as any)} />) : (<NavBar />) }
|
||||||
<HomeModule1 />
|
<HomeModule1 />
|
||||||
<LazyLoad once>
|
<LazyLoad once>
|
||||||
<HomeModule2 />
|
<HomeModule2 />
|
||||||
@ -286,7 +272,7 @@ export function HomePage2() {
|
|||||||
<HomeModule4 />
|
<HomeModule4 />
|
||||||
</LazyLoad>
|
</LazyLoad>
|
||||||
<HomeModule5 />
|
<HomeModule5 />
|
||||||
<HomeModule6 />
|
<Footer showEmailLink={true} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -312,8 +298,8 @@ function HomeModule1() {
|
|||||||
>
|
>
|
||||||
<LazyLoad once>
|
<LazyLoad once>
|
||||||
<video
|
<video
|
||||||
src="https://cdn.qikongjian.com/1756549479451_ltrtoz.mp4"
|
src="https://cdn.qikongjian.com/videos/1759064057657_cu0po2.mp4"
|
||||||
poster="https://cdn.qikongjian.com/1756549479451_ltrtoz.mp4?vframe/jpg/offset/1"
|
poster="https://cdn.qikongjian.com/videos/1759064057657_cu0po2.mp4?vframe/jpg/offset/1"
|
||||||
autoPlay
|
autoPlay
|
||||||
loop
|
loop
|
||||||
muted
|
muted
|
||||||
@ -1616,10 +1602,3 @@ function HomeModule5() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function HomeModule6() {
|
|
||||||
return (
|
|
||||||
<div className="home-module6 flex justify-center items-center w-full h-min text-white/50 text-lg bg-black snap-start">
|
|
||||||
© 2025 MovieFlow. All rights reserved.
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import { GradientText } from "@/components/ui/gradient-text";
|
|||||||
import { GoogleLoginButton } from "@/components/ui/google-login-button";
|
import { GoogleLoginButton } from "@/components/ui/google-login-button";
|
||||||
import { Eye, EyeOff } from "lucide-react";
|
import { Eye, EyeOff } from "lucide-react";
|
||||||
import { isGoogleLoginEnabled } from "@/lib/server-config";
|
import { isGoogleLoginEnabled } from "@/lib/server-config";
|
||||||
|
import Footer from "@/components/common/Footer";
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
@ -314,6 +315,9 @@ export default function Login() {
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 页脚 */}
|
||||||
|
<Footer className="fixed bottom-0" showEmailLink={true} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -191,9 +191,10 @@ const UsageView: React.FC = () => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-alt="usage-view-container" className="mx-auto max-w-5xl p-6">
|
<div data-alt="usage-view-container" className="mx-auto max-w-5xl h-full flex flex-col p-4 sm:p-6 pb-[max(5rem,env(safe-area-inset-bottom))]">
|
||||||
<div data-alt="header" className="mb-6 flex items-center justify-between">
|
<div data-alt="top-meta" className="sticky top-0 z-10 -mx-4 sm:-mx-6 px-4 sm:px-6 pt-2 pb-3 bg-black/60 backdrop-blur supports-[backdrop-filter]:bg-black/40">
|
||||||
<h2 className="text-xl font-semibold text-white">Credit Usage Details</h2>
|
<div data-alt="header" className="mb-3 flex items-center justify-between">
|
||||||
|
<h2 className="text-base sm:text-xl font-semibold text-white">Credit Usage Details</h2>
|
||||||
<div data-alt="period-switch" className="inline-flex rounded-lg bg-white/5 p-1">
|
<div data-alt="period-switch" className="inline-flex rounded-lg bg-white/5 p-1">
|
||||||
{([7, 30, 90] as PeriodDays[]).map((d) => (
|
{([7, 30, 90] as PeriodDays[]).map((d) => (
|
||||||
<button
|
<button
|
||||||
@ -202,7 +203,7 @@ const UsageView: React.FC = () => {
|
|||||||
data-alt={`period-${d}`}
|
data-alt={`period-${d}`}
|
||||||
onClick={() => handleChangeDays(d)}
|
onClick={() => handleChangeDays(d)}
|
||||||
className={
|
className={
|
||||||
`px-3 py-1.5 text-sm rounded-md transition-colors ` +
|
`px-2 py-1 text-xs sm:px-3 sm:py-1.5 sm:text-sm rounded-md transition-colors ` +
|
||||||
(days === d
|
(days === d
|
||||||
? "bg-[#C039F6] text-white"
|
? "bg-[#C039F6] text-white"
|
||||||
: "text-white/80 hover:bg-white/10")
|
: "text-white/80 hover:bg-white/10")
|
||||||
@ -212,34 +213,34 @@ const UsageView: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div data-alt="meta" className="text-xs sm:text-sm text-white/70">
|
||||||
|
<span data-alt="meta-period">Period: {periodLabel || "-"} days</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div data-alt="meta" className="mb-3 text-sm text-white/70">
|
<div data-alt="table-wrapper" className="flex-1 min-h-0 overflow-auto rounded-lg border border-white/10">
|
||||||
<span data-alt="meta-period">Period: {periodLabel || "-"} days</span>
|
<table data-alt="table" className="min-w-[32rem] sm:min-w-full table-auto">
|
||||||
</div>
|
|
||||||
|
|
||||||
<div data-alt="table-wrapper" className="overflow-hidden rounded-lg border border-white/10">
|
|
||||||
<table data-alt="table" className="min-w-full table-fixed">
|
|
||||||
<thead className="bg-white/5">
|
<thead className="bg-white/5">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="w-1/4 px-4 py-2 text-left text-sm font-medium text-white">Kind</th>
|
<th className="px-2 py-1 text-left text-xs sm:px-4 sm:py-2 sm:text-sm font-medium text-white">Kind</th>
|
||||||
<th className="w-1/4 px-4 py-2 text-right text-sm font-medium text-white">Credits</th>
|
<th className="px-2 py-1 text-left text-xs sm:px-4 sm:py-2 sm:text-sm font-medium text-white">Credits</th>
|
||||||
<th className="w-1/4 px-4 py-2 text-left text-sm font-medium text-white">From</th>
|
<th className="px-2 py-1 text-left text-xs sm:px-4 sm:py-2 sm:text-sm font-medium text-white">From</th>
|
||||||
<th className="w-1/4 px-4 py-2 text-left text-sm font-medium text-white">Date</th>
|
<th className="px-2 py-1 text-left text-xs sm:px-4 sm:py-2 sm:text-sm font-medium text-white">Date</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-white/10">
|
<tbody className="divide-y divide-white/10">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td className="px-4 py-3 text-white/70" colSpan={4} data-alt="row-loading">Loading...</td>
|
<td className="px-2 py-2 text-xs sm:px-4 sm:py-3 sm:text-sm text-white/70" colSpan={4} data-alt="row-loading">Loading...</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td className="px-4 py-3 text-white/70" data-alt="row-error" colSpan={4}>-</td>
|
<td className="px-2 py-2 text-xs sm:px-4 sm:py-3 sm:text-sm text-white/70" data-alt="row-error" colSpan={4}>-</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : items.length === 0 ? (
|
) : items.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td className="px-4 py-3 text-white/70" data-alt="row-empty" colSpan={4}>-</td>
|
<td className="px-2 py-2 text-xs sm:px-4 sm:py-3 sm:text-sm text-white/70" data-alt="row-empty" colSpan={4}>-</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
items.map((it, idx) => {
|
items.map((it, idx) => {
|
||||||
@ -248,7 +249,7 @@ const UsageView: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<React.Fragment key={key}>
|
<React.Fragment key={key}>
|
||||||
<tr>
|
<tr>
|
||||||
<td className="px-4 py-2 text-white/90" data-alt="cell-transaction-type">
|
<td className="px-2 py-1 text-xs sm:px-4 sm:py-2 sm:text-sm text-white/90" data-alt="cell-transaction-type">
|
||||||
<div data-alt="type-cell" className="flex items-center gap-2">
|
<div data-alt="type-cell" className="flex items-center gap-2">
|
||||||
<span data-alt="type-text">{it?.transaction_type || '-'}</span>
|
<span data-alt="type-text">{it?.transaction_type || '-'}</span>
|
||||||
{it?.project_info ? (
|
{it?.project_info ? (
|
||||||
@ -272,47 +273,47 @@ const UsageView: React.FC = () => {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className={`px-4 py-2 text-right ${getAmountColorClass(it?.change_type)}`} data-alt="cell-amount">
|
<td className={`px-2 py-1 text-left text-xs sm:px-4 sm:py-2 sm:text-sm ${getAmountColorClass(it?.change_type)}`} data-alt="cell-amount">
|
||||||
{Number.isFinite(it?.amount as number)
|
{Number.isFinite(it?.amount as number)
|
||||||
? `${it.change_type === 'INCREASE' ? '+' : it.change_type === 'DECREASE' ? '-' : ''}${it.amount}`
|
? `${it.change_type === 'INCREASE' ? '+' : it.change_type === 'DECREASE' ? '-' : ''}${it.amount}`
|
||||||
: '-'}
|
: '-'}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2 text-white/90" data-alt="cell-source-type">
|
<td className="px-2 py-1 text-xs sm:px-4 sm:py-2 sm:text-sm text-white/90" data-alt="cell-source-type">
|
||||||
{formatSource(it?.source_type)}
|
{formatSource(it?.source_type)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2 text-white/90" data-alt="cell-created-at">
|
<td className="px-2 py-1 text-xs sm:px-4 sm:py-2 sm:text-sm text-white/90" data-alt="cell-created-at">
|
||||||
{it?.created_at ? dateFormatter.format(new Date(it.created_at)) : '-'}
|
{it?.created_at ? dateFormatter.format(new Date(it.created_at)) : '-'}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{isExpanded && it.project_info && (
|
{isExpanded && it.project_info && (
|
||||||
<tr data-alt="row-details">
|
<tr data-alt="row-details">
|
||||||
<td colSpan={4} className="bg-white/5 px-4 py-3">
|
<td colSpan={4} className="bg-white/5 px-3 py-2 sm:px-4 sm:py-3">
|
||||||
<div data-alt="project-summary" className="mb-2 text-sm text-white/90">
|
<div data-alt="project-summary" className="mb-2 text-xs sm:text-sm text-white/90">
|
||||||
<div data-alt="project-name">Project: {it.project_info.project_name || '-'}</div>
|
<div data-alt="project-name">Project: {it.project_info.project_name || '-'}</div>
|
||||||
<div data-alt="project-id" className="text-white/60">ID: {it.project_info.project_id || '-'}</div>
|
<div data-alt="project-id" className="text-white/60">ID: {it.project_info.project_id || '-'}</div>
|
||||||
</div>
|
</div>
|
||||||
<div data-alt="videos-table-wrapper" className="overflow-hidden rounded-md border border-white/10">
|
<div data-alt="videos-table-wrapper" className="overflow-hidden rounded-md border border-white/10">
|
||||||
<table data-alt="videos-table" className="min-w-full table-fixed">
|
<table data-alt="videos-table" className="min-w-[28rem] sm:min-w-full table-auto">
|
||||||
<tbody className="divide-y divide-white/10">
|
<tbody className="divide-y divide-white/10">
|
||||||
{(it.project_info.videos || []).length === 0 ? (
|
{(it.project_info.videos || []).length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td className="px-3 py-2 text-white/70" colSpan={4} data-alt="videos-empty">-</td>
|
<td className="px-3 py-2 text-xs sm:text-sm text-white/70" colSpan={4} data-alt="videos-empty">-</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
it.project_info.videos.map((v, vIdx) => (
|
it.project_info.videos.map((v, vIdx) => (
|
||||||
<tr key={`${v.created_at}-${vIdx}`}>
|
<tr key={`${v.created_at}-${vIdx}`}>
|
||||||
<td className="w-1/4 px-3 py-2 text-white/90" data-alt="video-name">
|
<td className="px-2 py-1 text-xs sm:px-3 sm:py-2 sm:text-sm text-white/90" data-alt="video-name">
|
||||||
{v.video_name && v.video_name.trim() ? v.video_name : `video${vIdx + 1}`}
|
{v.video_name && v.video_name.trim() ? v.video_name : `video${vIdx + 1}`}
|
||||||
</td>
|
</td>
|
||||||
<td className={`w-1/4 px-3 py-2 text-right ${getAmountColorClass(v.change_type)}`} data-alt="video-amount">
|
<td className={`px-2 py-1 text-left text-xs sm:px-3 sm:py-2 sm:text-sm ${getAmountColorClass(v.change_type)}`} data-alt="video-amount">
|
||||||
{Number.isFinite(v.amount as number)
|
{Number.isFinite(v.amount as number)
|
||||||
? `${v.change_type === 'INCREASE' ? '+' : v.change_type === 'DECREASE' ? '-' : ''}${v.amount}`
|
? `${v.change_type === 'INCREASE' ? '+' : v.change_type === 'DECREASE' ? '-' : ''}${v.amount}`
|
||||||
: '-'}
|
: '-'}
|
||||||
</td>
|
</td>
|
||||||
<td className="w-1/4 px-3 py-2 text-white/90" data-alt="video-source">
|
<td className="px-2 py-1 text-xs sm:px-3 sm:py-2 sm:text-sm text-white/90" data-alt="video-source">
|
||||||
{formatSource(v.source_type)}
|
{formatSource(v.source_type)}
|
||||||
</td>
|
</td>
|
||||||
<td className="w-1/4 px-3 py-2 text-white/90" data-alt="video-created-at">
|
<td className="px-2 py-1 text-xs sm:px-3 sm:py-2 sm:text-sm text-white/90" data-alt="video-created-at">
|
||||||
{v.created_at ? dateFormatter.format(new Date(v.created_at)) : '-'}
|
{v.created_at ? dateFormatter.format(new Date(v.created_at)) : '-'}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -332,7 +333,7 @@ const UsageView: React.FC = () => {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div data-alt="pagination" className="mt-4 flex items-center justify-between text-sm text-white/80">
|
<div data-alt="pagination" className="sticky bottom-[max(1rem,env(safe-area-inset-bottom))] z-10 -mx-4 sm:-mx-6 px-4 sm:px-6 mt-4 mb-[max(4rem,env(safe-area-inset-bottom))] flex items-center justify-between text-xs sm:text-sm text-white/80 bg-black/60 backdrop-blur supports-[backdrop-filter]:bg-black/40">
|
||||||
<div data-alt="total-info">
|
<div data-alt="total-info">
|
||||||
Total {Number.isFinite(total) ? total : 0}
|
Total {Number.isFinite(total) ? total : 0}
|
||||||
</div>
|
</div>
|
||||||
@ -342,20 +343,20 @@ const UsageView: React.FC = () => {
|
|||||||
onClick={handlePrev}
|
onClick={handlePrev}
|
||||||
disabled={!canPrev}
|
disabled={!canPrev}
|
||||||
className={
|
className={
|
||||||
"rounded-md px-3 py-1.5 transition-colors " +
|
"rounded-md px-2 py-1 sm:px-3 sm:py-1.5 transition-colors " +
|
||||||
(canPrev ? "bg-white/10 hover:bg-white/20" : "bg-white/5 text-white/40 cursor-not-allowed")
|
(canPrev ? "bg-white/10 hover:bg-white/20" : "bg-white/5 text-white/40 cursor-not-allowed")
|
||||||
}
|
}
|
||||||
data-alt="prev-page"
|
data-alt="prev-page"
|
||||||
>
|
>
|
||||||
Previous
|
Previous
|
||||||
</button>
|
</button>
|
||||||
<span data-alt="page-indicator" className="px-1 py-1.5">Page {page}</span>
|
<span data-alt="page-indicator" className="px-1 py-1 sm:py-1.5">Page {page}</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleNext}
|
onClick={handleNext}
|
||||||
disabled={!canNext}
|
disabled={!canNext}
|
||||||
className={
|
className={
|
||||||
"rounded-md px-3 py-1.5 transition-colors " +
|
"rounded-md px-2 py-1 sm:px-3 sm:py-1.5 transition-colors " +
|
||||||
(canNext ? "bg-white/10 hover:bg-white/20" : "bg-white/5 text-white/40 cursor-not-allowed")
|
(canNext ? "bg-white/10 hover:bg-white/20" : "bg-white/5 text-white/40 cursor-not-allowed")
|
||||||
}
|
}
|
||||||
data-alt="next-page"
|
data-alt="next-page"
|
||||||
|
|||||||
@ -668,9 +668,8 @@ Please process this video editing request.`;
|
|||||||
getContainer={false}
|
getContainer={false}
|
||||||
autoFocus={false}
|
autoFocus={false}
|
||||||
mask={false}
|
mask={false}
|
||||||
zIndex={60}
|
|
||||||
rootClassName="outline-none"
|
rootClassName="outline-none"
|
||||||
className="backdrop-blur-lg bg-black/30 border border-white/20 shadow-xl max-h-[90vh]"
|
className="bg-transparent max-h-[100vh]"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: 'transparent',
|
backgroundColor: 'transparent',
|
||||||
...(isMobile
|
...(isMobile
|
||||||
@ -682,8 +681,8 @@ Please process this video editing request.`;
|
|||||||
body: {
|
body: {
|
||||||
backgroundColor: 'transparent',
|
backgroundColor: 'transparent',
|
||||||
padding: 0,
|
padding: 0,
|
||||||
maxHeight: '100vh',
|
maxHeight: isMobile ? 'calc(100vh - 5.5rem)' : 'calc(100vh - 4rem)',
|
||||||
overflow: 'auto',
|
overflow: 'hidden',
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onClose={() => setIsSmartChatBoxOpen(false)}
|
onClose={() => setIsSmartChatBoxOpen(false)}
|
||||||
|
|||||||
@ -130,7 +130,7 @@ export function ScriptModal({ isOpen, onClose, currentStage = 0, roles, currentL
|
|||||||
<>
|
<>
|
||||||
{/* 背景遮罩 */}
|
{/* 背景遮罩 */}
|
||||||
<motion.div
|
<motion.div
|
||||||
className="fixed inset-0 bg-black/20 backdrop-blur-sm z-50"
|
className="fixed inset-0 bg-black/20 backdrop-blur-sm z-[9998]"
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
@ -140,7 +140,7 @@ export function ScriptModal({ isOpen, onClose, currentStage = 0, roles, currentL
|
|||||||
|
|
||||||
{/* 弹窗内容 */}
|
{/* 弹窗内容 */}
|
||||||
<motion.div
|
<motion.div
|
||||||
className="fixed inset-0 z-[61] flex items-center justify-center"
|
className="fixed inset-0 z-[9999] flex items-center justify-center"
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
|
|||||||
194
hooks/useAnalytics.ts
Normal file
194
hooks/useAnalytics.ts
Normal file
@ -0,0 +1,194 @@
|
|||||||
|
/**
|
||||||
|
* Google Analytics 自定义Hook
|
||||||
|
* 提供便捷的GA事件跟踪功能
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { trackPageView, trackEvent, isGAAvailable, setUserProperties } from '@/utils/analytics';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 页面访问跟踪Hook
|
||||||
|
* @param pageTitle - 页面标题
|
||||||
|
* @param customParams - 自定义参数
|
||||||
|
*/
|
||||||
|
export const usePageTracking = (
|
||||||
|
pageTitle?: string,
|
||||||
|
customParams?: Record<string, any>
|
||||||
|
) => {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isGAAvailable()) {
|
||||||
|
const currentPath = window.location.pathname;
|
||||||
|
trackPageView(currentPath, pageTitle, { custom_parameters: customParams });
|
||||||
|
}
|
||||||
|
}, [router, pageTitle, customParams]);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户行为跟踪Hook
|
||||||
|
*/
|
||||||
|
export const useEventTracking = () => {
|
||||||
|
const trackUserAction = (
|
||||||
|
action: string,
|
||||||
|
category: string = 'user',
|
||||||
|
label?: string,
|
||||||
|
value?: number,
|
||||||
|
customParams?: Record<string, any>
|
||||||
|
) => {
|
||||||
|
trackEvent(action, {
|
||||||
|
event_category: category,
|
||||||
|
event_label: label,
|
||||||
|
value: value,
|
||||||
|
custom_parameters: customParams,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const trackButtonClick = (buttonName: string, location?: string) => {
|
||||||
|
trackUserAction('button_click', 'interaction', buttonName, undefined, {
|
||||||
|
button_name: buttonName,
|
||||||
|
location: location,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const trackFormSubmit = (formName: string, success: boolean = true) => {
|
||||||
|
trackUserAction('form_submit', 'form', formName, undefined, {
|
||||||
|
form_name: formName,
|
||||||
|
success: success,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const trackNavigation = (from: string, to: string) => {
|
||||||
|
trackUserAction('navigation', 'user', `${from} -> ${to}`, undefined, {
|
||||||
|
from_page: from,
|
||||||
|
to_page: to,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
trackUserAction,
|
||||||
|
trackButtonClick,
|
||||||
|
trackFormSubmit,
|
||||||
|
trackNavigation,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 视频相关事件跟踪Hook
|
||||||
|
*/
|
||||||
|
export const useVideoTracking = () => {
|
||||||
|
const trackVideoCreation = (templateType: string, aspectRatio?: string) => {
|
||||||
|
trackEvent('video_creation_start', {
|
||||||
|
event_category: 'video',
|
||||||
|
event_label: templateType,
|
||||||
|
custom_parameters: {
|
||||||
|
template_type: templateType,
|
||||||
|
aspect_ratio: aspectRatio,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const trackVideoGeneration = (duration: number, templateType: string) => {
|
||||||
|
trackEvent('video_generation_complete', {
|
||||||
|
event_category: 'video',
|
||||||
|
value: duration,
|
||||||
|
custom_parameters: {
|
||||||
|
template_type: templateType,
|
||||||
|
video_duration: duration,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const trackVideoDownload = (videoId: string, format: string) => {
|
||||||
|
trackEvent('video_download', {
|
||||||
|
event_category: 'video',
|
||||||
|
event_label: format,
|
||||||
|
custom_parameters: {
|
||||||
|
video_id: videoId,
|
||||||
|
format: format,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const trackVideoShare = (videoId: string, platform: string) => {
|
||||||
|
trackEvent('video_share', {
|
||||||
|
event_category: 'video',
|
||||||
|
event_label: platform,
|
||||||
|
custom_parameters: {
|
||||||
|
video_id: videoId,
|
||||||
|
platform: platform,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
trackVideoCreation,
|
||||||
|
trackVideoGeneration,
|
||||||
|
trackVideoDownload,
|
||||||
|
trackVideoShare,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 支付相关事件跟踪Hook
|
||||||
|
*/
|
||||||
|
export const usePaymentTracking = () => {
|
||||||
|
const trackPaymentStart = (paymentType: string, amount: number, currency: string = 'USD') => {
|
||||||
|
trackEvent('payment_start', {
|
||||||
|
event_category: 'ecommerce',
|
||||||
|
value: amount,
|
||||||
|
custom_parameters: {
|
||||||
|
payment_type: paymentType,
|
||||||
|
currency: currency,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const trackPaymentComplete = (paymentType: string, amount: number, currency: string = 'USD') => {
|
||||||
|
trackEvent('purchase', {
|
||||||
|
event_category: 'ecommerce',
|
||||||
|
value: amount,
|
||||||
|
custom_parameters: {
|
||||||
|
payment_type: paymentType,
|
||||||
|
currency: currency,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const trackPaymentFailed = (paymentType: string, errorReason: string) => {
|
||||||
|
trackEvent('payment_failed', {
|
||||||
|
event_category: 'ecommerce',
|
||||||
|
event_label: errorReason,
|
||||||
|
custom_parameters: {
|
||||||
|
payment_type: paymentType,
|
||||||
|
error_reason: errorReason,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
trackPaymentStart,
|
||||||
|
trackPaymentComplete,
|
||||||
|
trackPaymentFailed,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户属性管理Hook
|
||||||
|
*/
|
||||||
|
export const useUserProperties = () => {
|
||||||
|
const setUserAnalyticsProperties = (
|
||||||
|
userId: string,
|
||||||
|
userProperties: Record<string, any>
|
||||||
|
) => {
|
||||||
|
setUserProperties(userId, userProperties);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
setUserAnalyticsProperties,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
67
hooks/useAppStartupAnalytics.ts
Normal file
67
hooks/useAppStartupAnalytics.ts
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* 应用启动时用户属性设置Hook
|
||||||
|
* 确保在应用启动时为用户设置GA属性
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { getCurrentUser, isAuthenticated } from '@/lib/auth';
|
||||||
|
import { setUserProperties } from '@/utils/analytics';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用启动时设置用户GA属性
|
||||||
|
*/
|
||||||
|
export const useAppStartupAnalytics = () => {
|
||||||
|
useEffect(() => {
|
||||||
|
const initializeUserAnalytics = () => {
|
||||||
|
// 检查用户是否已认证
|
||||||
|
if (!isAuthenticated()) {
|
||||||
|
console.log('用户未认证,跳过GA属性设置');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取本地存储的用户数据
|
||||||
|
const currentUser = getCurrentUser();
|
||||||
|
if (!currentUser || !currentUser.id) {
|
||||||
|
console.log('本地用户数据不存在,跳过GA属性设置');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 设置应用启动时的用户属性
|
||||||
|
// 基于实际userData结构: {"id":"8f61686734d340d0820f0fe980368629","userId":"8f61686734d340d0820f0fe980368629","username":"Orange","email":"moviflow68@test.com","isActive":1,"authType":"LOCAL","lastLogin":"2025-09-28T16:28:51.870731"}
|
||||||
|
setUserProperties(currentUser.id, {
|
||||||
|
// 基础用户信息
|
||||||
|
user_id: currentUser.id,
|
||||||
|
email: currentUser.email,
|
||||||
|
username: currentUser.username,
|
||||||
|
|
||||||
|
// 认证信息
|
||||||
|
auth_type: currentUser.authType || 'LOCAL',
|
||||||
|
is_active: currentUser.isActive || 1,
|
||||||
|
|
||||||
|
// 应用启动信息
|
||||||
|
last_login: currentUser.lastLogin || new Date().toISOString(),
|
||||||
|
|
||||||
|
// 用户状态
|
||||||
|
user_status: currentUser.isActive === 1 ? 'active' : 'inactive',
|
||||||
|
|
||||||
|
// 设备信息
|
||||||
|
user_agent: navigator.userAgent,
|
||||||
|
screen_resolution: `${screen.width}x${screen.height}`,
|
||||||
|
language: navigator.language,
|
||||||
|
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
|
|
||||||
|
// 会话信息
|
||||||
|
session_id: `${currentUser.id}_${Date.now()}`,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ 应用启动时GA用户属性设置失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 延迟执行,确保应用完全加载
|
||||||
|
const timer = setTimeout(initializeUserAnalytics, 1000);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, []);
|
||||||
|
};
|
||||||
20
lib/auth.ts
20
lib/auth.ts
@ -7,6 +7,7 @@ import type {
|
|||||||
EmailConflictData,
|
EmailConflictData,
|
||||||
OAuthState
|
OAuthState
|
||||||
} from '@/app/types/google-oauth';
|
} from '@/app/types/google-oauth';
|
||||||
|
import { setUserProperties } from '@/utils/analytics';
|
||||||
|
|
||||||
// API配置
|
// API配置
|
||||||
//const JAVA_BASE_URL = 'http://192.168.120.36:8080';
|
//const JAVA_BASE_URL = 'http://192.168.120.36:8080';
|
||||||
@ -109,6 +110,25 @@ export const getCurrentUser = () => {
|
|||||||
export const setUser = (user: any) => {
|
export const setUser = (user: any) => {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
localStorage.setItem(USER_KEY, JSON.stringify(user));
|
localStorage.setItem(USER_KEY, JSON.stringify(user));
|
||||||
|
|
||||||
|
// 设置详细的GA用户属性
|
||||||
|
if (user && user.id) {
|
||||||
|
setUserProperties(user.id, {
|
||||||
|
// 基础用户信息
|
||||||
|
user_id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
username: user.username,
|
||||||
|
// 认证信息
|
||||||
|
auth_type: user.authType || 'LOCAL',
|
||||||
|
is_active: user.isActive || 1,
|
||||||
|
// 用户状态
|
||||||
|
user_status: user.isActive === 1 ? 'active' : 'inactive',
|
||||||
|
// 登录信息
|
||||||
|
last_login: user.lastLogin || new Date().toISOString(),
|
||||||
|
// 会话信息
|
||||||
|
session_id: `${user.id}_${Date.now()}`
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
BIN
public/assets/cover_image_shu.jpg
Normal file
BIN
public/assets/cover_image_shu.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 737 KiB |
334
utils/analytics.ts
Normal file
334
utils/analytics.ts
Normal file
@ -0,0 +1,334 @@
|
|||||||
|
/**
|
||||||
|
* Google Analytics 4 工具函数
|
||||||
|
* 提供标准化的事件跟踪和页面访问监控
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 扩展全局Window接口
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
gtag: (...args: any[]) => void;
|
||||||
|
dataLayer: any[];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GA4事件参数类型定义
|
||||||
|
*/
|
||||||
|
export interface GAEventParameters {
|
||||||
|
event_category?: string;
|
||||||
|
event_label?: string;
|
||||||
|
value?: number;
|
||||||
|
custom_parameters?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 页面访问参数类型定义
|
||||||
|
*/
|
||||||
|
export interface GAPageViewParameters {
|
||||||
|
page_title?: string;
|
||||||
|
page_location?: string;
|
||||||
|
custom_parameters?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 规范化/序列化事件参数,避免 [object Object]
|
||||||
|
*/
|
||||||
|
const normalizeEventParams = (
|
||||||
|
params: Record<string, any>
|
||||||
|
): Record<string, string | number | boolean> => {
|
||||||
|
const result: Record<string, string | number | boolean> = {};
|
||||||
|
|
||||||
|
const assignPrimitive = (key: string, value: any) => {
|
||||||
|
if (value === undefined) return;
|
||||||
|
if (
|
||||||
|
typeof value === 'string' ||
|
||||||
|
typeof value === 'number' ||
|
||||||
|
typeof value === 'boolean'
|
||||||
|
) {
|
||||||
|
result[key] = value;
|
||||||
|
} else if (value === null) {
|
||||||
|
result[key] = 'null';
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
result[key] = JSON.stringify(value);
|
||||||
|
} catch (_) {
|
||||||
|
result[key] = String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(params || {})) {
|
||||||
|
if (key === 'custom_parameters' && value && typeof value === 'object') {
|
||||||
|
for (const [ck, cv] of Object.entries(value)) {
|
||||||
|
assignPrimitive(ck, cv);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
assignPrimitive(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查GA是否可用
|
||||||
|
*/
|
||||||
|
export const isGAAvailable = (): boolean => {
|
||||||
|
return typeof window !== 'undefined' &&
|
||||||
|
typeof window.gtag === 'function' &&
|
||||||
|
process.env.NEXT_PUBLIC_GA_ENABLED === 'true';
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取GA测量ID
|
||||||
|
*/
|
||||||
|
export const getGAMeasurementId = (): string => {
|
||||||
|
return process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID || 'G-4BDXV6TWF4';
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 跟踪自定义事件
|
||||||
|
* @param eventName - 事件名称
|
||||||
|
* @param parameters - 事件参数
|
||||||
|
*/
|
||||||
|
export const trackEvent = (
|
||||||
|
eventName: string,
|
||||||
|
parameters?: GAEventParameters
|
||||||
|
): void => {
|
||||||
|
if (!isGAAvailable()) {
|
||||||
|
console.warn('Google Analytics not available');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const eventParamsRaw = {
|
||||||
|
event_category: parameters?.event_category || 'general',
|
||||||
|
event_label: parameters?.event_label,
|
||||||
|
value: parameters?.value,
|
||||||
|
...parameters?.custom_parameters,
|
||||||
|
};
|
||||||
|
|
||||||
|
const eventParams = normalizeEventParams(eventParamsRaw);
|
||||||
|
|
||||||
|
window.gtag('event', eventName, eventParams);
|
||||||
|
|
||||||
|
// 开发环境下打印日志
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
console.log('GA Event:', eventName, eventParams);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error tracking GA event:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 跟踪页面访问
|
||||||
|
* @param pagePath - 页面路径
|
||||||
|
* @param pageTitle - 页面标题
|
||||||
|
* @param parameters - 额外参数
|
||||||
|
*/
|
||||||
|
export const trackPageView = (
|
||||||
|
pagePath: string,
|
||||||
|
pageTitle?: string,
|
||||||
|
parameters?: GAPageViewParameters
|
||||||
|
): void => {
|
||||||
|
if (!isGAAvailable()) {
|
||||||
|
console.warn('Google Analytics not available');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pageParamsRaw = {
|
||||||
|
page_path: pagePath,
|
||||||
|
page_title: pageTitle,
|
||||||
|
page_location: parameters?.page_location || window.location.href,
|
||||||
|
...parameters?.custom_parameters,
|
||||||
|
};
|
||||||
|
|
||||||
|
const pageParams = normalizeEventParams(pageParamsRaw);
|
||||||
|
|
||||||
|
window.gtag('config', getGAMeasurementId(), pageParams);
|
||||||
|
|
||||||
|
// 开发环境下打印日志
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
console.log('GA Page View:', pagePath, pageParams);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error tracking GA page view:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 跟踪用户注册事件
|
||||||
|
* @param method - 注册方式 (email, google, etc.)
|
||||||
|
*/
|
||||||
|
export const trackUserRegistration = (method: string): void => {
|
||||||
|
trackEvent('user_registration', {
|
||||||
|
event_category: 'user',
|
||||||
|
event_label: method,
|
||||||
|
custom_parameters: {
|
||||||
|
registration_method: method,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 跟踪用户登录事件
|
||||||
|
* @param method - 登录方式 (email, google, etc.)
|
||||||
|
*/
|
||||||
|
export const trackUserLogin = (method: string): void => {
|
||||||
|
trackEvent('user_login', {
|
||||||
|
event_category: 'user',
|
||||||
|
event_label: method,
|
||||||
|
custom_parameters: {
|
||||||
|
login_method: method,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 跟踪视频创建开始事件
|
||||||
|
* @param templateType - 模板类型
|
||||||
|
* @param aspectRatio - 视频比例
|
||||||
|
*/
|
||||||
|
export const trackVideoCreationStart = (
|
||||||
|
templateType: string,
|
||||||
|
aspectRatio?: string
|
||||||
|
): void => {
|
||||||
|
trackEvent('video_creation_start', {
|
||||||
|
event_category: 'video',
|
||||||
|
event_label: templateType,
|
||||||
|
custom_parameters: {
|
||||||
|
template_type: templateType,
|
||||||
|
aspect_ratio: aspectRatio,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 跟踪视频生成完成事件
|
||||||
|
* @param duration - 视频时长
|
||||||
|
* @param templateType - 模板类型
|
||||||
|
*/
|
||||||
|
export const trackVideoGenerationComplete = (
|
||||||
|
duration: number,
|
||||||
|
templateType: string
|
||||||
|
): void => {
|
||||||
|
trackEvent('video_generation_complete', {
|
||||||
|
event_category: 'video',
|
||||||
|
value: duration,
|
||||||
|
custom_parameters: {
|
||||||
|
template_type: templateType,
|
||||||
|
video_duration: duration,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 跟踪支付事件
|
||||||
|
* @param paymentType - 支付类型 (subscription, token)
|
||||||
|
* @param amount - 支付金额
|
||||||
|
* @param currency - 货币类型
|
||||||
|
*/
|
||||||
|
export const trackPayment = (
|
||||||
|
paymentType: string,
|
||||||
|
amount: number,
|
||||||
|
currency: string = 'USD'
|
||||||
|
): void => {
|
||||||
|
trackEvent('purchase', {
|
||||||
|
event_category: 'ecommerce',
|
||||||
|
value: amount,
|
||||||
|
custom_parameters: {
|
||||||
|
payment_type: paymentType,
|
||||||
|
currency: currency,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 跟踪模板选择事件
|
||||||
|
* @param templateId - 模板ID
|
||||||
|
* @param templateName - 模板名称
|
||||||
|
*/
|
||||||
|
export const trackTemplateSelection = (
|
||||||
|
templateId: string,
|
||||||
|
templateName: string
|
||||||
|
): void => {
|
||||||
|
trackEvent('template_selection', {
|
||||||
|
event_category: 'template',
|
||||||
|
event_label: templateName,
|
||||||
|
custom_parameters: {
|
||||||
|
template_id: templateId,
|
||||||
|
template_name: templateName,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 跟踪功能使用事件
|
||||||
|
* @param featureName - 功能名称
|
||||||
|
* @param action - 操作类型
|
||||||
|
*/
|
||||||
|
export const trackFeatureUsage = (
|
||||||
|
featureName: string,
|
||||||
|
action: string
|
||||||
|
): void => {
|
||||||
|
trackEvent('feature_usage', {
|
||||||
|
event_category: 'feature',
|
||||||
|
event_label: featureName,
|
||||||
|
custom_parameters: {
|
||||||
|
feature_name: featureName,
|
||||||
|
action: action,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 跟踪错误事件
|
||||||
|
* @param errorType - 错误类型
|
||||||
|
* @param errorMessage - 错误信息
|
||||||
|
* @param errorLocation - 错误位置
|
||||||
|
*/
|
||||||
|
export const trackError = (
|
||||||
|
errorType: string,
|
||||||
|
errorMessage: string,
|
||||||
|
errorLocation?: string
|
||||||
|
): void => {
|
||||||
|
trackEvent('error', {
|
||||||
|
event_category: 'error',
|
||||||
|
event_label: errorType,
|
||||||
|
custom_parameters: {
|
||||||
|
error_type: errorType,
|
||||||
|
error_message: errorMessage,
|
||||||
|
error_location: errorLocation,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 跟踪用户属性
|
||||||
|
* @param userId - 用户ID
|
||||||
|
* @param userProperties - 用户属性
|
||||||
|
*/
|
||||||
|
export const setUserProperties = (
|
||||||
|
userId: string,
|
||||||
|
userProperties: Record<string, any>
|
||||||
|
): void => {
|
||||||
|
if (!isGAAvailable()) {
|
||||||
|
console.warn('Google Analytics not available');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// GA4 推荐:通过 config 设置 user_id,通过 set user_properties 设置用户属性
|
||||||
|
window.gtag('config', getGAMeasurementId(), {
|
||||||
|
user_id: userId,
|
||||||
|
});
|
||||||
|
window.gtag('set', 'user_properties', normalizeEventParams(userProperties));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error setting user properties:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Loading…
x
Reference in New Issue
Block a user