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.
|
||||
- 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.
|
||||
- When completing the final step of a task, do not create tests unless explicitly requested by the user.
|
||||
|
||||
# CSS Style Rules
|
||||
- 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_CUT_URL = https://77.smartcut.py.qikongjian.com
|
||||
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
|
||||
# Google OAuth配置
|
||||
|
||||
@ -8,6 +8,9 @@ COPY package.json package-lock.json* ./
|
||||
COPY public ./public
|
||||
|
||||
ENV NODE_ENV=production
|
||||
# Google Analytics 环境变量
|
||||
ENV NEXT_PUBLIC_GA_MEASUREMENT_ID=G-4BDXV6TWF4
|
||||
ENV NEXT_PUBLIC_GA_ENABLED=true
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
|
||||
@ -305,4 +305,8 @@ body {
|
||||
height: 100dvh;
|
||||
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 { ConfigProvider, theme } from 'antd';
|
||||
import CallbackModal from '@/components/common/CallbackModal';
|
||||
import { useAppStartupAnalytics } from '@/hooks/useAppStartupAnalytics';
|
||||
|
||||
// 创建上下文来传递弹窗控制方法
|
||||
const CallbackModalContext = createContext<{
|
||||
@ -26,6 +27,10 @@ export default function RootLayout({
|
||||
}) {
|
||||
const [showCallbackModal, setShowCallbackModal] = useState(false)
|
||||
const [paymentType, setPaymentType] = useState<'subscription' | 'token'>('subscription')
|
||||
|
||||
// 应用启动时设置用户GA属性
|
||||
useAppStartupAnalytics();
|
||||
|
||||
const openCallback = async function (ev: MessageEvent<any>) {
|
||||
if (ev.data.type === 'waiting-payment') {
|
||||
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="shortcut icon" type="image/x-icon" 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>
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){window.dataLayer.push(arguments);}
|
||||
gtag('js', new Date());
|
||||
gtag('config', 'G-E6VBGZ4ER5');
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
{process.env.NEXT_PUBLIC_GA_ENABLED === 'true' && (
|
||||
<>
|
||||
<script async src={`https://www.googletagmanager.com/gtag/js?id=${process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID}`}></script>
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
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>
|
||||
<body className="font-sans antialiased">
|
||||
<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 { 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() {
|
||||
const router = useRouter();
|
||||
const { deviceType, isMobile, isTablet, isDesktop } = useDeviceType();
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated()) {
|
||||
router.replace('/movies');
|
||||
}
|
||||
}, [router]);
|
||||
return (
|
||||
<>
|
||||
<TopBar collapsed={true} />
|
||||
{isMobile || isTablet ? (
|
||||
<H5TopBar />
|
||||
) : (
|
||||
<TopBar collapsed={true} />
|
||||
)}
|
||||
<HomePage2 />
|
||||
</>
|
||||
);
|
||||
|
||||
@ -13,8 +13,13 @@ import {
|
||||
} from "@/components/ui/card";
|
||||
import { fetchSubscriptionPlans, SubscriptionPlan } from "@/lib/stripe";
|
||||
import { DashboardLayout } from "@/components/layout/dashboard-layout";
|
||||
import { trackEvent, trackPageView, trackPayment } from "@/utils/analytics";
|
||||
|
||||
export default function PricingPage() {
|
||||
// 页面访问跟踪
|
||||
useEffect(() => {
|
||||
trackPageView('/pricing', 'Pricing Plans');
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
@ -91,6 +96,17 @@ function HomeModule5() {
|
||||
|
||||
const handleSubscribe = async (planName: string) => {
|
||||
setLoadingPlan(planName);
|
||||
|
||||
// 跟踪订阅按钮点击事件
|
||||
trackEvent('subscription_button_click', {
|
||||
event_category: 'subscription',
|
||||
event_label: planName,
|
||||
custom_parameters: {
|
||||
plan_name: planName,
|
||||
billing_type: billingType,
|
||||
},
|
||||
});
|
||||
|
||||
// 改为直接携带参数打开 pay-redirect,由其内部完成创建与跳转
|
||||
const url = `/pay-redirect?type=subscription&plan=${encodeURIComponent(planName)}&billing=${encodeURIComponent(billingType)}`;
|
||||
const win = window.open(url, '_blank');
|
||||
@ -148,7 +164,14 @@ function HomeModule5() {
|
||||
2xl:h-[3.375rem] 2xl:mt-[1.5rem]"
|
||||
>
|
||||
<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 ${
|
||||
billingType === "month"
|
||||
? "bg-white text-black"
|
||||
@ -164,7 +187,14 @@ function HomeModule5() {
|
||||
Monthly
|
||||
</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 ${
|
||||
billingType === "year"
|
||||
? "bg-white text-black"
|
||||
|
||||
@ -186,10 +186,10 @@ export default function SharePage(): JSX.Element {
|
||||
|
||||
return (
|
||||
<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
|
||||
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">
|
||||
<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-content"
|
||||
className="relative px-4 py-2 text-sm font-mono text-white/90 whitespace-nowrap overflow-hidden"
|
||||
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%)'
|
||||
}}
|
||||
className="relative px-4 py-2 text-xs sm:text-sm font-mono text-white/90 break-all sm:truncate"
|
||||
>
|
||||
{inviteCode ? `${'https://www.movieflow.ai'}/signup?inviteCode=${inviteCode}` : '-'}
|
||||
</div>
|
||||
@ -258,21 +251,21 @@ export default function SharePage(): JSX.Element {
|
||||
<li data-alt="step" className="rounded-md p-4">
|
||||
<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="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>
|
||||
<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 data-alt="step" className="rounded-md p-4">
|
||||
<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="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>
|
||||
<p data-alt="step-desc" className="mt-2 text-sm text-white/70">Friends click the link and register directly.</p>
|
||||
</li>
|
||||
<li data-alt="step" className="rounded-md p-4">
|
||||
<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="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>
|
||||
<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>
|
||||
@ -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">
|
||||
<thead data-alt="table-head" className="bg-black">
|
||||
<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 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-48">Invited Username</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>
|
||||
</tr>
|
||||
</thead>
|
||||
@ -365,8 +358,8 @@ export default function SharePage(): JSX.Element {
|
||||
return (
|
||||
<React.Fragment key={r.user_id}>
|
||||
<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/80 w-56 whitespace-nowrap">{formatLocalTime(r.created_at * 1000)}</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 whitespace-nowrap sm:w-56">{formatLocalTime(r.created_at * 1000)}</td>
|
||||
<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-summary" className="flex-1 truncate text-[#FFCC6D]">
|
||||
@ -387,8 +380,8 @@ export default function SharePage(): JSX.Element {
|
||||
</tr>
|
||||
{isExpanded && (
|
||||
<tr data-alt="row-details">
|
||||
<td className="px-4 py-0 w-48" />
|
||||
<td className="px-4 py-0 w-56" />
|
||||
<td className="px-4 py-0 sm:w-48" />
|
||||
<td className="px-4 py-0 sm:w-56" />
|
||||
<td className="px-4 py-3 bg-white/5">
|
||||
<div data-alt="details-wrapper" className="overflow-x-auto">
|
||||
<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>;
|
||||
dismiss: () => void;
|
||||
};
|
||||
// Google Analytics 类型声明
|
||||
gtag: (...args: any[]) => void;
|
||||
dataLayer: any[];
|
||||
|
||||
// Google GSI API类型声明
|
||||
google?: {
|
||||
accounts: {
|
||||
|
||||
@ -5,7 +5,7 @@ import UsageView from "@/components/pages/usage-view";
|
||||
|
||||
const UsagePage: React.FC = () => {
|
||||
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 />
|
||||
</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 () => {
|
||||
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 className="video-prompt-editor mb-3 relative flex flex-col gap-3 flex-1 pr-10">
|
||||
{/* 文本输入框 - 改为textarea */}
|
||||
<textarea
|
||||
value={script}
|
||||
onChange={(e) => setScript(e.target.value)}
|
||||
placeholder="Describe the story you want to make..."
|
||||
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"
|
||||
style={
|
||||
noData
|
||||
? {
|
||||
minHeight: "128px",
|
||||
}
|
||||
: {}
|
||||
{/* 文本输入框 - 改为textarea */}
|
||||
<textarea
|
||||
data-alt="story-input"
|
||||
ref={textareaRef}
|
||||
value={script}
|
||||
onChange={(e) => setScript(e.target.value)}
|
||||
placeholder="Describe the story you want to make..."
|
||||
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: 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) => {
|
||||
const target = e.target as HTMLTextAreaElement;
|
||||
target.style.height = "auto";
|
||||
target.style.height =
|
||||
Math.min(target.scrollHeight, 120) + "px";
|
||||
}}
|
||||
/>
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (!isMobile) return;
|
||||
setIsInputFocused(false);
|
||||
const el = textareaRef.current;
|
||||
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>
|
||||
|
||||
{/* 第二行:功能按钮和Action按钮 - 同一行 */}
|
||||
|
||||
@ -169,7 +169,10 @@ export default function SmartChatBox({
|
||||
}, [messages]);
|
||||
|
||||
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 */}
|
||||
<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">
|
||||
|
||||
@ -2,10 +2,11 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
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 { message } from 'antd';
|
||||
import { errorHandle } from '@/api/errorHandle';
|
||||
import { setUserProperties } from '@/utils/analytics';
|
||||
|
||||
interface AuthGuardProps {
|
||||
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 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(() => {
|
||||
const verifyAuth = async () => {
|
||||
// 如果是公共页面,不需要鉴权
|
||||
@ -41,6 +72,9 @@ export default function AuthGuard({ children }: AuthGuardProps) {
|
||||
const user = await getUserProfile();
|
||||
if (user) {
|
||||
setIsAuthorized(true);
|
||||
|
||||
// 设置用户GA属性(页面首次加载时)
|
||||
setUserAnalyticsProperties(user);
|
||||
} else {
|
||||
router.push('/login');
|
||||
}
|
||||
@ -49,8 +83,16 @@ export default function AuthGuard({ children }: AuthGuardProps) {
|
||||
if(errorCode.message == 401||errorCode.message == 502){
|
||||
router.push('/login');
|
||||
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 {
|
||||
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 { TopBar } from './top-bar';
|
||||
import { useDeviceType } from '@/hooks/useDeviceType';
|
||||
import H5TopBar from '@/components/layout/H5TopBar';
|
||||
|
||||
interface DashboardLayoutProps {
|
||||
children: React.ReactNode;
|
||||
@ -58,15 +59,20 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||
|
||||
return (
|
||||
<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} />}
|
||||
<div
|
||||
className={`top-[4rem] fixed right-0 bottom-0 px-4 ${getMobileContainerClasses()}`}
|
||||
className={`fixed right-0 bottom-0 px-4 z-[60] ${getMobileContainerClasses()}`}
|
||||
style={{
|
||||
...getLayoutStyles(),
|
||||
top: (isMobile || isTablet) ? '3.5rem' : '4rem',
|
||||
// 移动端使用动态高度计算
|
||||
height: (isMobile || isTablet)
|
||||
? 'calc(100dvh - 4rem)'
|
||||
? 'calc(100dvh - 3.5rem)'
|
||||
: 'calc(100vh - 4rem)'
|
||||
}}
|
||||
>
|
||||
|
||||
@ -1,44 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { cn } from '@/public/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { GradientText } from '@/components/ui/gradient-text';
|
||||
import {
|
||||
Home,
|
||||
FolderOpen,
|
||||
Users,
|
||||
Type,
|
||||
Image,
|
||||
History,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Video,
|
||||
PanelsLeftBottom,
|
||||
ArrowLeftToLine,
|
||||
BookHeart,
|
||||
PanelRightClose,
|
||||
Gift
|
||||
PanelRightClose
|
||||
} from 'lucide-react';
|
||||
import { navigationItems } from './type';
|
||||
|
||||
interface SidebarProps {
|
||||
collapsed: boolean;
|
||||
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) {
|
||||
const pathname = usePathname();
|
||||
|
||||
|
||||
@ -7,7 +7,7 @@ import { Coins, Trophy, HelpCircle } from "lucide-react"
|
||||
import { getSigninStatus, performSignin, SigninData } from "@/api/signin"
|
||||
|
||||
|
||||
export default function SigninPage() {
|
||||
export default function SigninPage({ onSuccess }: { onSuccess?: () => void } = {}) {
|
||||
const [signinData, setSigninData] = useState<SigninData>({
|
||||
has_signin: false,
|
||||
credits: 0
|
||||
@ -50,6 +50,8 @@ export default function SigninPage() {
|
||||
if (response.successful) {
|
||||
// Refresh status after successful signin
|
||||
await fetchSigninStatus()
|
||||
// Notify parent to refresh credits
|
||||
try { onSuccess && onSuccess() } catch {}
|
||||
}
|
||||
} catch (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 { ChatInputBox } from '@/components/ChatInputBox/ChatInputBox';
|
||||
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 { Tooltip, Button } from 'antd';
|
||||
import { downloadVideo, getFirstFrame } from '@/utils/tools';
|
||||
@ -309,7 +310,7 @@ export default function CreateToVideo2() {
|
||||
<div
|
||||
className="absolute inset-0 w-full h-full bg-cover bg-center bg-no-repeat group-hover:scale-105 transition-transform duration-500"
|
||||
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"
|
||||
/>
|
||||
|
||||
@ -20,8 +20,10 @@ import LazyLoad from "react-lazyload";
|
||||
import { fetchSubscriptionPlans, SubscriptionPlan } from "@/lib/stripe";
|
||||
import VideoCoverflow from "@/components/ui/VideoCoverflow";
|
||||
import { fetchTabsByCode, HomeTabItem } from "@/api/serversetting";
|
||||
import H5TopBar from "@/components/layout/H5TopBar";
|
||||
import { useCallbackModal } from "@/app/layout";
|
||||
import { useDeviceType } from "@/hooks/useDeviceType";
|
||||
import Footer from "@/components/common/Footer";
|
||||
|
||||
/** 视频预加载系统 - 后台静默运行 */
|
||||
function useVideoPreloader() {
|
||||
@ -241,26 +243,9 @@ export function HomePage2() {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* 移动端开关移至 TopBar,保留占位对齐 */}
|
||||
{/* 移动端交由 H5TopBar 控制 */}
|
||||
<span className="md:hidden" data-alt="mobile-menu-toggle-placeholder"></span>
|
||||
</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>
|
||||
);
|
||||
@ -268,7 +253,8 @@ export function HomePage2() {
|
||||
|
||||
return (
|
||||
<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 />
|
||||
<LazyLoad once>
|
||||
<HomeModule2 />
|
||||
@ -286,7 +272,7 @@ export function HomePage2() {
|
||||
<HomeModule4 />
|
||||
</LazyLoad>
|
||||
<HomeModule5 />
|
||||
<HomeModule6 />
|
||||
<Footer showEmailLink={true} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -312,8 +298,8 @@ function HomeModule1() {
|
||||
>
|
||||
<LazyLoad once>
|
||||
<video
|
||||
src="https://cdn.qikongjian.com/1756549479451_ltrtoz.mp4"
|
||||
poster="https://cdn.qikongjian.com/1756549479451_ltrtoz.mp4?vframe/jpg/offset/1"
|
||||
src="https://cdn.qikongjian.com/videos/1759064057657_cu0po2.mp4"
|
||||
poster="https://cdn.qikongjian.com/videos/1759064057657_cu0po2.mp4?vframe/jpg/offset/1"
|
||||
autoPlay
|
||||
loop
|
||||
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 { Eye, EyeOff } from "lucide-react";
|
||||
import { isGoogleLoginEnabled } from "@/lib/server-config";
|
||||
import Footer from "@/components/common/Footer";
|
||||
|
||||
export default function Login() {
|
||||
const [email, setEmail] = useState("");
|
||||
@ -314,6 +315,9 @@ export default function Login() {
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 页脚 */}
|
||||
<Footer className="fixed bottom-0" showEmailLink={true} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -191,9 +191,10 @@ const UsageView: React.FC = () => {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div data-alt="usage-view-container" className="mx-auto max-w-5xl p-6">
|
||||
<div data-alt="header" className="mb-6 flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold text-white">Credit Usage Details</h2>
|
||||
<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="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">
|
||||
<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">
|
||||
{([7, 30, 90] as PeriodDays[]).map((d) => (
|
||||
<button
|
||||
@ -202,7 +203,7 @@ const UsageView: React.FC = () => {
|
||||
data-alt={`period-${d}`}
|
||||
onClick={() => handleChangeDays(d)}
|
||||
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
|
||||
? "bg-[#C039F6] text-white"
|
||||
: "text-white/80 hover:bg-white/10")
|
||||
@ -212,34 +213,34 @@ const UsageView: React.FC = () => {
|
||||
</button>
|
||||
))}
|
||||
</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 data-alt="meta" className="mb-3 text-sm text-white/70">
|
||||
<span data-alt="meta-period">Period: {periodLabel || "-"} days</span>
|
||||
</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">
|
||||
<div data-alt="table-wrapper" className="flex-1 min-h-0 overflow-auto rounded-lg border border-white/10">
|
||||
<table data-alt="table" className="min-w-[32rem] sm:min-w-full table-auto">
|
||||
<thead className="bg-white/5">
|
||||
<tr>
|
||||
<th className="w-1/4 px-4 py-2 text-left 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="w-1/4 px-4 py-2 text-left 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">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">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">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">Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/10">
|
||||
{loading ? (
|
||||
<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>
|
||||
) : error ? (
|
||||
<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>
|
||||
) : items.length === 0 ? (
|
||||
<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>
|
||||
) : (
|
||||
items.map((it, idx) => {
|
||||
@ -248,7 +249,7 @@ const UsageView: React.FC = () => {
|
||||
return (
|
||||
<React.Fragment key={key}>
|
||||
<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">
|
||||
<span data-alt="type-text">{it?.transaction_type || '-'}</span>
|
||||
{it?.project_info ? (
|
||||
@ -272,47 +273,47 @@ const UsageView: React.FC = () => {
|
||||
) : null}
|
||||
</div>
|
||||
</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)
|
||||
? `${it.change_type === 'INCREASE' ? '+' : it.change_type === 'DECREASE' ? '-' : ''}${it.amount}`
|
||||
: '-'}
|
||||
</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)}
|
||||
</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)) : '-'}
|
||||
</td>
|
||||
</tr>
|
||||
{isExpanded && it.project_info && (
|
||||
<tr data-alt="row-details">
|
||||
<td colSpan={4} className="bg-white/5 px-4 py-3">
|
||||
<div data-alt="project-summary" className="mb-2 text-sm text-white/90">
|
||||
<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-xs sm:text-sm text-white/90">
|
||||
<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>
|
||||
<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">
|
||||
{(it.project_info.videos || []).length === 0 ? (
|
||||
<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>
|
||||
) : (
|
||||
it.project_info.videos.map((v, 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}`}
|
||||
</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)
|
||||
? `${v.change_type === 'INCREASE' ? '+' : v.change_type === 'DECREASE' ? '-' : ''}${v.amount}`
|
||||
: '-'}
|
||||
</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)}
|
||||
</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)) : '-'}
|
||||
</td>
|
||||
</tr>
|
||||
@ -332,7 +333,7 @@ const UsageView: React.FC = () => {
|
||||
</table>
|
||||
</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">
|
||||
Total {Number.isFinite(total) ? total : 0}
|
||||
</div>
|
||||
@ -342,20 +343,20 @@ const UsageView: React.FC = () => {
|
||||
onClick={handlePrev}
|
||||
disabled={!canPrev}
|
||||
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")
|
||||
}
|
||||
data-alt="prev-page"
|
||||
>
|
||||
Previous
|
||||
</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
|
||||
type="button"
|
||||
onClick={handleNext}
|
||||
disabled={!canNext}
|
||||
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")
|
||||
}
|
||||
data-alt="next-page"
|
||||
|
||||
@ -668,9 +668,8 @@ Please process this video editing request.`;
|
||||
getContainer={false}
|
||||
autoFocus={false}
|
||||
mask={false}
|
||||
zIndex={60}
|
||||
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={{
|
||||
backgroundColor: 'transparent',
|
||||
...(isMobile
|
||||
@ -682,8 +681,8 @@ Please process this video editing request.`;
|
||||
body: {
|
||||
backgroundColor: 'transparent',
|
||||
padding: 0,
|
||||
maxHeight: '100vh',
|
||||
overflow: 'auto',
|
||||
maxHeight: isMobile ? 'calc(100vh - 5.5rem)' : 'calc(100vh - 4rem)',
|
||||
overflow: 'hidden',
|
||||
}
|
||||
}}
|
||||
onClose={() => setIsSmartChatBoxOpen(false)}
|
||||
|
||||
@ -130,7 +130,7 @@ export function ScriptModal({ isOpen, onClose, currentStage = 0, roles, currentL
|
||||
<>
|
||||
{/* 背景遮罩 */}
|
||||
<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 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
@ -140,7 +140,7 @@ export function ScriptModal({ isOpen, onClose, currentStage = 0, roles, currentL
|
||||
|
||||
{/* 弹窗内容 */}
|
||||
<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 }}
|
||||
animate={{ opacity: 1 }}
|
||||
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,
|
||||
OAuthState
|
||||
} from '@/app/types/google-oauth';
|
||||
import { setUserProperties } from '@/utils/analytics';
|
||||
|
||||
// API配置
|
||||
//const JAVA_BASE_URL = 'http://192.168.120.36:8080';
|
||||
@ -109,6 +110,25 @@ export const getCurrentUser = () => {
|
||||
export const setUser = (user: any) => {
|
||||
if (typeof window === 'undefined') return;
|
||||
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