diff --git a/app/globals.css b/app/globals.css index f8f96bd..29fa569 100644 --- a/app/globals.css +++ b/app/globals.css @@ -50,7 +50,7 @@ *, *:after, *:before { - box-sizing: border-box; + box-sizing: border-box; } :root { @@ -93,10 +93,12 @@ --muted-foreground: 0 0% 63.9%; --accent: 0 0% 14.9%; --accent-foreground: 0 0% 98%; - + /* 自定义渐变色变量 */ - --custom-blue: 186 100% 70%; /* rgb(106, 244, 249) */ - --custom-purple: 280 100% 62%; /* rgb(199, 59, 255) */ + --custom-blue: 186 100% 70%; + /* rgb(106, 244, 249) */ + --custom-purple: 280 100% 62%; + /* rgb(199, 59, 255) */ --custom-blue-rgb: 106, 244, 249; --custom-purple-rgb: 199, 59, 255; --destructive: 0 62.8% 30.6%; @@ -162,6 +164,7 @@ body { .hide-scrollbar::-webkit-scrollbar { display: none !important; } + *::-webkit-scrollbar { display: none !important; } @@ -184,6 +187,7 @@ body { border-radius: 8px; padding: 8px; } + .button-NxtqWZ:hover { background-color: #2f3237 !important; } @@ -226,6 +230,7 @@ body { height: 0; pointer-events: none; } + .ant-spin-nested-loading .ant-spin { max-height: none !important; } @@ -236,6 +241,7 @@ body { opacity: 0; transform: translateY(-8px); } + to { opacity: 1; transform: translateY(0); @@ -256,9 +262,47 @@ body { .ant-switch.ant-switch-checked:hover { background: rgb(146 78 173) !important; -} +} .language-dropdown li { padding: unset !important; margin: 0.25rem !important; +} + +/* 安全区域变量定义 */ +:root { + --sat: env(safe-area-inset-top, 0px); + --sar: env(safe-area-inset-right, 0px); + --sab: env(safe-area-inset-bottom, 0px); + --sal: env(safe-area-inset-left, 0px); +} + +/* 移动端适配:使用 dvh 动态视口高度 */ +@supports (height: 100dvh) { + body { + height: 100dvh; + } +} + +/* 移动端安全区域处理 */ +@media (max-width: 768px) { + body { + /* 使用动态视口高度,考虑移动端浏览器地址栏 */ + height: 100dvh; + height: calc(var(--vh, 1vh) * 100); + padding-bottom: env(safe-area-inset-bottom); + } +} + +/* 针对移动端底部导航栏/状态栏的特殊处理 */ +@media (max-width: 768px) and (display-mode: browser) { + .mobile-safe-bottom { + padding-bottom: max(1rem, env(safe-area-inset-bottom)); + margin-bottom: max(1rem, env(safe-area-inset-bottom)); + } + + .mobile-viewport-height { + height: 100dvh; + height: calc(var(--vh, 1vh) * 100); + } } \ No newline at end of file diff --git a/app/layout.tsx b/app/layout.tsx index db5e91e..118ff61 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -43,7 +43,7 @@ export default function RootLayout({ MovieFlow - AI Movie Studio - + diff --git a/app/pay-redirect/page.tsx b/app/pay-redirect/page.tsx index 24fc220..f11b8b5 100644 --- a/app/pay-redirect/page.tsx +++ b/app/pay-redirect/page.tsx @@ -2,38 +2,75 @@ import React from "react"; import { TailwindSpinner } from "@/components/common/GlobalLoad"; +import { useSearchParams } from "next/navigation"; +import { createCheckoutSession, buyTokens } from "@/lib/stripe"; export default function PayRedirectPage() { - const [status, setStatus] = React.useState("Waiting for secure checkout redirect..."); + const [status, setStatus] = React.useState("Preparing checkout..."); const [error, setError] = React.useState(""); + const searchParams = useSearchParams(); React.useEffect(() => { - const handleMessage = (event: MessageEvent) => { + const type = (searchParams.get("type") || "").toLowerCase(); + if (!type) { + setStatus(""); + setError("Missing payment type."); + return; + } + + const redirectTo = async () => { try { - if (event.origin !== window.location.origin) return; - const data = event.data || {}; - if (data?.type === "redirect-to-payment" && typeof data?.url === "string") { - setStatus("Redirecting to Stripe Checkout..."); - window.location.href = data.url as string; - } else if (data?.type === "redirect-error") { - setError(typeof data?.message === "string" ? data.message : "Failed to create payment. Please close this page and try again."); + if (type === "token") { + const amountStr = searchParams.get("amount"); + const pkg = searchParams.get("pkg") || "basic"; + const amount = Number(amountStr); + if (!amount || amount <= 0) { + throw new Error("Invalid token amount"); + } + setStatus("Creating token purchase session..."); + const resp = await buyTokens({ token_amount: amount, package_type: pkg }); + if (resp?.successful && resp?.data?.checkout_url) { + setStatus("Redirecting to Stripe Checkout..."); + window.location.href = resp.data.checkout_url as string; + return; + } + throw new Error(resp?.message || "Failed to create token checkout session"); } - } catch { - setError("An error occurred while processing redirect info. Please close this page and try again."); + + if (type === "subscription") { + const plan = searchParams.get("plan"); + const billing = (searchParams.get("billing") || "month") as "month" | "year"; + if (!plan) { + throw new Error("Missing plan name"); + } + const currentUser = JSON.parse(localStorage.getItem("currentUser") || "{}"); + if (!currentUser?.id) { + throw new Error("Not logged in. Please sign in and try again."); + } + setStatus("Creating subscription session..."); + const result = await createCheckoutSession({ + user_id: String(currentUser.id), + plan_name: plan, + billing_cycle: billing, + }); + if (result?.successful && result?.data?.checkout_url) { + setStatus("Redirecting to Stripe Checkout..."); + window.location.href = result.data.checkout_url as string; + return; + } + throw new Error(result?.message || "Failed to create subscription session"); + } + + throw new Error("Unsupported payment type"); + } catch (e: unknown) { + setStatus(""); + setError(e instanceof Error ? e.message : "Failed to prepare checkout."); } }; - window.addEventListener("message", handleMessage); - const timeoutId = window.setTimeout(() => { - setStatus(""); - setError("No redirect instruction received. It may be a network issue or the page was blocked. Please go back and try again."); - }, 15000); - - return () => { - window.removeEventListener("message", handleMessage); - window.clearTimeout(timeoutId); - }; - }, []); + redirectTo(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchParams]); return (
diff --git a/app/pricing/page.tsx b/app/pricing/page.tsx index 9ea40bd..01291e4 100644 --- a/app/pricing/page.tsx +++ b/app/pricing/page.tsx @@ -85,54 +85,18 @@ function HomeModule5() { const handleSubscribe = async (planName: string) => { setLoadingPlan(planName); - - // 先同步打开同域重定向页,避免拦截 - const redirectWindow = window.open('/pay-redirect', '_blank'); - if (!redirectWindow) { + // 改为直接携带参数打开 pay-redirect,由其内部完成创建与跳转 + const url = `/pay-redirect?type=subscription&plan=${encodeURIComponent(planName)}&billing=${encodeURIComponent(billingType)}`; + const win = window.open(url, '_blank'); + // 通知当前窗口等待支付(显示loading模态框) + window.postMessage({ + type: 'waiting-payment', + paymentType: 'subscription', + }, '*'); + if (!win) { setLoadingPlan(null); throw new Error('Unable to open redirect window, please check popup settings'); } - - try { - const { createCheckoutSession } = await import("@/lib/stripe"); - - const User = JSON.parse(localStorage.getItem("currentUser") || "{}"); - if (!User.id) { - throw new Error("Unable to obtain user ID, please log in again"); - } - - const result = await createCheckoutSession({ - user_id: String(User.id), - plan_name: planName, - billing_cycle: billingType, - }); - - if (!result.successful || !result.data?.checkout_url) { - throw new Error("create checkout session failed"); - } - - // 通知当前窗口等待支付(显示loading模态框) - window.postMessage({ - type: 'waiting-payment', - paymentType: 'subscription', - }, '*'); - - // 通过 postMessage 通知新页面执行重定向 - redirectWindow.postMessage({ - type: 'redirect-to-payment', - url: result.data.checkout_url, - }, window.location.origin); - } catch (error) { - // 通知新页错误信息 - try { - redirectWindow.postMessage({ - type: 'redirect-error', - message: 'Failed to create checkout session', - }, window.location.origin); - } catch {} - setLoadingPlan(null); - throw new Error("create checkout session failed, please try again later"); - } }; return (
+
{/* Header */}
diff --git a/components/layout/dashboard-layout.tsx b/components/layout/dashboard-layout.tsx index 6008f28..0d77dd4 100644 --- a/components/layout/dashboard-layout.tsx +++ b/components/layout/dashboard-layout.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { Sidebar } from './sidebar'; import { TopBar } from './top-bar'; import { useDeviceType } from '@/hooks/useDeviceType'; @@ -10,9 +10,28 @@ interface DashboardLayoutProps { } export function DashboardLayout({ children }: DashboardLayoutProps) { - const [sidebarCollapsed, setSidebarCollapsed] = useState(true); // 默认收起状态 + const [sidebarCollapsed, setSidebarCollapsed] = useState(true); const { deviceType, isMobile, isTablet, isDesktop } = useDeviceType(); + // 处理移动端视口高度动态计算 + useEffect(() => { + if (isMobile || isTablet) { + const setVH = () => { + const vh = window.innerHeight * 0.01; + document.documentElement.style.setProperty('--vh', `${vh}px`); + }; + + setVH(); + window.addEventListener('resize', setVH); + window.addEventListener('orientationchange', setVH); + + return () => { + window.removeEventListener('resize', setVH); + window.removeEventListener('orientationchange', setVH); + }; + } + }, [isMobile, isTablet]); + // 根据设备类型设置布局样式 const getLayoutStyles = () => { if (isMobile || isTablet) { @@ -29,13 +48,28 @@ export function DashboardLayout({ children }: DashboardLayoutProps) { }; }; + // 获取移动端容器类名 + const getMobileContainerClasses = () => { + if (isMobile || isTablet) { + return "mobile-viewport-height mobile-safe-bottom"; + } + return ""; + }; + return ( -
+
{isDesktop && }
+ className={`top-[4rem] fixed right-0 bottom-0 px-4 ${getMobileContainerClasses()}`} + style={{ + ...getLayoutStyles(), + // 移动端使用动态高度计算 + height: (isMobile || isTablet) + ? 'calc(100dvh - 4rem)' + : 'calc(100vh - 4rem)' + }} + > {children}
diff --git a/components/layout/top-bar.tsx b/components/layout/top-bar.tsx index 365fab3..92d6839 100644 --- a/components/layout/top-bar.tsx +++ b/components/layout/top-bar.tsx @@ -88,7 +88,7 @@ export function TopBar({ collapsed, isDesktop=true }: { collapsed: boolean, isDe } }; - // 处理Token购买(同域新页 + postMessage 方式重定向) + // 处理Token购买(改为携带参数打开 pay-redirect) const handleBuyTokens = async (tokenAmount: number) => { if (!currentUser?.id) { console.error("用户未登录"); @@ -99,52 +99,14 @@ export function TopBar({ collapsed, isDesktop=true }: { collapsed: boolean, isDe console.error("Token数量必须大于0"); return; } - - // 先同步打开同域新页面,避免被拦截 - const redirectWindow = window.open("/pay-redirect", "_blank"); - if (!redirectWindow) { - console.error("无法打开支付重定向页面,可能被浏览器拦截"); - return; - } - - setIsBuyingTokens(true); - try { - const response = await buyTokens({ - token_amount: tokenAmount, - package_type: "basic" - }); - - if (response.successful && response.data?.checkout_url) { - // 通知当前窗口等待支付,标识为Token购买 - window.postMessage({ - type: "waiting-payment", - paymentType: "token" - }, "*"); - sessionStorage.setItem('session_id', response.data.session_id); - // 通过 postMessage 通知新页面进行重定向 - redirectWindow.postMessage({ - type: "redirect-to-payment", - url: response.data.checkout_url - }, window.location.origin); - } else { - console.error("创建Token购买失败:", response.message); - // 通知新页显示错误 - redirectWindow.postMessage({ - type: "redirect-error", - message: response.message || "创建支付失败" - }, window.location.origin); - } - } catch (error: unknown) { - console.error("Token购买失败:", error); - try { - redirectWindow.postMessage({ - type: "redirect-error", - message: "网络或服务异常,请关闭此页重试" - }, window.location.origin); - } catch {} - } finally { - setIsBuyingTokens(false); - } + // 直接打开带参数的 pay-redirect,新窗口内自行创建会话并跳转 + const url = `/pay-redirect?type=token&amount=${encodeURIComponent(tokenAmount)}&pkg=basic`; + window.open(url, "_blank"); + // 通知当前窗口等待支付(显示loading模态框) + window.postMessage({ + type: 'waiting-payment', + paymentType: 'subscription', + }, '*'); }; // 处理自定义金额购买 @@ -255,7 +217,7 @@ export function TopBar({ collapsed, isDesktop=true }: { collapsed: boolean, isDe return (
window.removeEventListener('home-menu-toggle' as any, handler as any); }, []); return ( -
+
{/* 桌面端菜单(居中,仅三个项) */} @@ -1249,34 +1249,16 @@ function HomeModule5() { const handleSubscribe = async (planName: string) => { localStorage.setItem("callBackUrl", pathname); - try { - // 使用新的Checkout Session方案(更简单!) - const { createCheckoutSession, redirectToCheckout } = await import( - "@/lib/stripe" - ); - - // 从localStorage获取当前用户信息 - const User = JSON.parse(localStorage.getItem("currentUser") || "{}"); - - if (!User.id) { - throw new Error("无法获取用户ID,请重新登录"); - } - - // 1. 创建Checkout Session - const result = await createCheckoutSession({ - user_id: String(User.id), - plan_name: planName, - billing_cycle: billingType, - }); - - if (!result.successful || !result.data) { - throw new Error("create checkout session failed"); - } - - setShowCallbackModal(true); - window.open(result.data.checkout_url, "_blank"); - } catch (error) { - throw new Error("create checkout session failed, please try again later"); + // 改为直接携带参数打开 pay-redirect,由其内部完成创建与跳转 + const url = `/pay-redirect?type=subscription&plan=${encodeURIComponent(planName)}&billing=${encodeURIComponent(billingType)}`; + const win = window.open(url, "_blank"); + // 通知当前窗口等待支付(显示loading模态框) + window.postMessage({ + type: 'waiting-payment', + paymentType: 'subscription', + }, '*'); + if (!win) { + throw new Error("Unable to open redirect window, please check popup settings"); } }; return ( diff --git a/components/pages/work-flow/H5MediaViewer.tsx b/components/pages/work-flow/H5MediaViewer.tsx index 6ddcf36..8a4940f 100644 --- a/components/pages/work-flow/H5MediaViewer.tsx +++ b/components/pages/work-flow/H5MediaViewer.tsx @@ -301,7 +301,7 @@ export function H5MediaViewer({
)}
); diff --git a/components/pages/work-flow/H5TaskInfo.tsx b/components/pages/work-flow/H5TaskInfo.tsx index 9c25535..9661827 100644 --- a/components/pages/work-flow/H5TaskInfo.tsx +++ b/components/pages/work-flow/H5TaskInfo.tsx @@ -125,14 +125,14 @@ const H5TaskInfo: React.FC = ({ return (
{/* 左侧标题区域 */} -
+

= ({ open, title, progress }) => {

{pct}% - {pct >= 100 ? '即将完成' : ''} + {pct >= 100 ? 'completed' : ''}
diff --git a/components/ui/script-modal.tsx b/components/ui/script-modal.tsx index 27e3216..ce7218a 100644 --- a/components/ui/script-modal.tsx +++ b/components/ui/script-modal.tsx @@ -140,7 +140,7 @@ export function ScriptModal({ isOpen, onClose, currentStage = 0, roles, currentL {/* 弹窗内容 */} { - /** - * 根据窗口宽度判断设备类型 - */ const getDeviceType = (width: number): DeviceType => { if (width <= BREAKPOINTS.MOBILE) return DeviceType.MOBILE; if (width <= BREAKPOINTS.TABLET) return DeviceType.TABLET; return DeviceType.DESKTOP; }; - /** - * 处理窗口大小变化 - */ const handleResize = () => { const width = window.innerWidth; const height = window.innerHeight; setWindowSize({ width, height }); setDeviceType(getDeviceType(width)); + + // 移动端动态视口高度处理 + if (width <= BREAKPOINTS.TABLET) { + const vh = height * 0.01; + document.documentElement.style.setProperty('--vh', `${vh}px`); + } }; - // 初始化设备类型 + // 初始化 handleResize(); - // 添加窗口大小变化监听 + // 添加事件监听 window.addEventListener('resize', handleResize); + window.addEventListener('orientationchange', () => { + // 延迟处理以确保获取正确的视口尺寸 + setTimeout(handleResize, 100); + }); - // 清理监听器 return () => { window.removeEventListener('resize', handleResize); + window.removeEventListener('orientationchange', handleResize); }; }, []); @@ -59,6 +63,8 @@ export function useDeviceType() { windowSize, isMobile: deviceType === DeviceType.MOBILE, isTablet: deviceType === DeviceType.TABLET, - isDesktop: deviceType === DeviceType.DESKTOP + isDesktop: deviceType === DeviceType.DESKTOP, + /** 是否为移动端设备(包括平板) */ + isMobileDevice: deviceType === DeviceType.MOBILE || deviceType === DeviceType.TABLET }; } \ No newline at end of file diff --git a/hooks/useSafeArea.ts b/hooks/useSafeArea.ts new file mode 100644 index 0000000..42cdb71 --- /dev/null +++ b/hooks/useSafeArea.ts @@ -0,0 +1,64 @@ +import { useState, useEffect } from 'react'; + +interface SafeAreaInsets { + top: number; + right: number; + bottom: number; + left: number; +} + +export function useSafeArea() { + const [safeAreaInsets, setSafeAreaInsets] = useState({ + top: 0, + right: 0, + bottom: 0, + left: 0 + }); + + const [viewportHeight, setViewportHeight] = useState(0); + + useEffect(() => { + const updateSafeArea = () => { + // 获取 CSS 环境变量 + const computedStyle = getComputedStyle(document.documentElement); + + const top = parseInt(computedStyle.getPropertyValue('--sat').replace('px', '')) || 0; + const right = parseInt(computedStyle.getPropertyValue('--sar').replace('px', '')) || 0; + const bottom = parseInt(computedStyle.getPropertyValue('--sab').replace('px', '')) || 0; + const left = parseInt(computedStyle.getPropertyValue('--sal').replace('px', '')) || 0; + + setSafeAreaInsets({ top, right, bottom, left }); + + // 设置动态视口高度 + const vh = window.innerHeight; + setViewportHeight(vh); + document.documentElement.style.setProperty('--vh', `${vh * 0.01}px`); + }; + + updateSafeArea(); + + window.addEventListener('resize', updateSafeArea); + window.addEventListener('orientationchange', updateSafeArea); + + // 延迟更新以处理移动端浏览器地址栏变化 + const timeoutId = setTimeout(updateSafeArea, 500); + + return () => { + window.removeEventListener('resize', updateSafeArea); + window.removeEventListener('orientationchange', updateSafeArea); + clearTimeout(timeoutId); + }; + }, []); + + return { + safeAreaInsets, + viewportHeight, + /** 获取考虑安全区域的样式 */ + getSafeAreaStyle: (includeBottom = true) => ({ + paddingTop: `max(1rem, ${safeAreaInsets.top}px)`, + paddingRight: `max(1rem, ${safeAreaInsets.right}px)`, + paddingBottom: includeBottom ? `max(1rem, ${safeAreaInsets.bottom}px)` : undefined, + paddingLeft: `max(1rem, ${safeAreaInsets.left}px)`, + }) + }; +} diff --git a/tailwind.config.js b/tailwind.config.js index d01ae8a..fae3ec4 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -90,6 +90,20 @@ module.exports = { '100': '100ms', '200': '200ms', '300': '300ms', + }, + spacing: { + 'safe-top': 'env(safe-area-inset-top)', + 'safe-right': 'env(safe-area-inset-right)', + 'safe-bottom': 'env(safe-area-inset-bottom)', + 'safe-left': 'env(safe-area-inset-left)', + }, + height: { + 'dvh': '100dvh', + 'safe-screen': 'calc(100dvh - env(safe-area-inset-top) - env(safe-area-inset-bottom))', + }, + minHeight: { + 'dvh': '100dvh', + 'safe-screen': 'calc(100dvh - env(safe-area-inset-top) - env(safe-area-inset-bottom))', } }, },