forked from 77media/video-flow
Merge branch 'dev' into prod
This commit is contained in:
commit
2a11235314
@ -95,8 +95,10 @@
|
||||
--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);
|
||||
@ -262,3 +268,41 @@ body {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@ -43,7 +43,7 @@ export default function RootLayout({
|
||||
<head>
|
||||
<title>MovieFlow - AI Movie Studio</title>
|
||||
<meta name="description" content="Share your story, or just a few words, and our AI turns it into a great film. We remove the barriers to creation. At MovieFlow, everyone is a movie master." />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<link rel="icon" type="image/x-icon" sizes="16x16" href="/favicon.ico?v=1" />
|
||||
<link rel="icon" type="image/x-icon" sizes="32x32" href="/favicon.ico?v=1" />
|
||||
<link rel="shortcut icon" type="image/x-icon" href="/favicon.ico?v=1" />
|
||||
|
||||
@ -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<string>("Waiting for secure checkout redirect...");
|
||||
const [status, setStatus] = React.useState<string>("Preparing checkout...");
|
||||
const [error, setError] = React.useState<string>("");
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
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.");
|
||||
}
|
||||
} catch {
|
||||
setError("An error occurred while processing redirect info. Please close this page and try again.");
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("message", handleMessage);
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
const type = (searchParams.get("type") || "").toLowerCase();
|
||||
if (!type) {
|
||||
setStatus("");
|
||||
setError("No redirect instruction received. It may be a network issue or the page was blocked. Please go back and try again.");
|
||||
}, 15000);
|
||||
setError("Missing payment type.");
|
||||
return;
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("message", handleMessage);
|
||||
window.clearTimeout(timeoutId);
|
||||
const redirectTo = async () => {
|
||||
try {
|
||||
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");
|
||||
}
|
||||
|
||||
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.");
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
redirectTo();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [searchParams]);
|
||||
|
||||
return (
|
||||
<div data-alt="pay-redirect-page" className="min-h-screen w-full flex items-center justify-center bg-black text-white">
|
||||
|
||||
@ -85,53 +85,17 @@ function HomeModule5() {
|
||||
|
||||
const handleSubscribe = async (planName: string) => {
|
||||
setLoadingPlan(planName);
|
||||
|
||||
// 先同步打开同域重定向页,避免拦截
|
||||
const redirectWindow = window.open('/pay-redirect', '_blank');
|
||||
if (!redirectWindow) {
|
||||
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");
|
||||
}
|
||||
|
||||
// 改为直接携带参数打开 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',
|
||||
}, '*');
|
||||
|
||||
// 通过 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 {}
|
||||
if (!win) {
|
||||
setLoadingPlan(null);
|
||||
throw new Error("create checkout session failed, please try again later");
|
||||
throw new Error('Unable to open redirect window, please check popup settings');
|
||||
}
|
||||
};
|
||||
return (
|
||||
|
||||
@ -169,7 +169,7 @@ export default function SmartChatBox({
|
||||
}, [messages]);
|
||||
|
||||
return (
|
||||
<div className={`${isMobile ? 'z-[49]' : 'h-full'} w-full text-gray-100 flex flex-col`} data-alt="smart-chat-box">
|
||||
<div className={`${isMobile ? 'z-[49] relative' : 'h-full'} w-full text-gray-100 flex flex-col`} data-alt="smart-chat-box">
|
||||
{/* 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">
|
||||
|
||||
@ -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 (
|
||||
<div className="min-h-screen bg-background">
|
||||
<TopBar collapsed={sidebarCollapsed} isDesktop={isDesktop} />
|
||||
{isDesktop && <Sidebar collapsed={sidebarCollapsed} onToggle={setSidebarCollapsed} />}
|
||||
<div
|
||||
className="h-[calc(100vh-4rem)] top-[4rem] fixed right-0 bottom-0 px-4"
|
||||
style={getLayoutStyles()}>
|
||||
className={`top-[4rem] fixed right-0 bottom-0 px-4 ${getMobileContainerClasses()}`}
|
||||
style={{
|
||||
...getLayoutStyles(),
|
||||
// 移动端使用动态高度计算
|
||||
height: (isMobile || isTablet)
|
||||
? 'calc(100dvh - 4rem)'
|
||||
: 'calc(100vh - 4rem)'
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -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购买
|
||||
// 直接打开带参数的 pay-redirect,新窗口内自行创建会话并跳转
|
||||
const url = `/pay-redirect?type=token&amount=${encodeURIComponent(tokenAmount)}&pkg=basic`;
|
||||
window.open(url, "_blank");
|
||||
// 通知当前窗口等待支付(显示loading模态框)
|
||||
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);
|
||||
}
|
||||
type: 'waiting-payment',
|
||||
paymentType: 'subscription',
|
||||
}, '*');
|
||||
};
|
||||
|
||||
// 处理自定义金额购买
|
||||
@ -255,7 +217,7 @@ export function TopBar({ collapsed, isDesktop=true }: { collapsed: boolean, isDe
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed right-0 top-0 h-16 header z-[999]"
|
||||
className="fixed right-0 top-0 h-16 header z-[20]"
|
||||
style={{
|
||||
isolation: "isolate",
|
||||
left: (pathname === "/" || !isDesktop) ? "0" : (collapsed ? "2.5rem" : "16rem")
|
||||
|
||||
@ -223,7 +223,7 @@ export function HomePage2() {
|
||||
return () => window.removeEventListener('home-menu-toggle' as any, handler as any);
|
||||
}, []);
|
||||
return (
|
||||
<div data-alt="home-navbar" className="fixed h-16 top-0 left-0 right-0 z-50">
|
||||
<div data-alt="home-navbar" className="fixed h-16 top-0 left-0 right-0 z-[19]">
|
||||
<div className="mx-auto h-full">
|
||||
<div className="flex h-full items-center justify-center px-4 sm:px-6 py-3 bg-black/60 backdrop-blur-md border-b border-white/10">
|
||||
{/* 桌面端菜单(居中,仅三个项) */}
|
||||
@ -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 (
|
||||
|
||||
@ -301,7 +301,7 @@ export function H5MediaViewer({
|
||||
<button
|
||||
type="button"
|
||||
data-alt="open-catalog-button"
|
||||
className="fixed bottom-4 right-4 z-[60] w-12 h-12 rounded-full bg-gradient-to-br from-blue-500 to-blue-600 text-white shadow-lg flex items-center justify-center active:scale-95"
|
||||
className="fixed bottom-[6rem] right-4 z-[60] w-12 h-12 rounded-full bg-gradient-to-br from-blue-500 to-blue-600 text-white shadow-lg flex items-center justify-center active:scale-95"
|
||||
aria-label="open-catalog"
|
||||
onClick={() => setIsCatalogOpen(true)}
|
||||
>
|
||||
@ -371,13 +371,13 @@ export function H5MediaViewer({
|
||||
|
||||
// 其他阶段:使用 Carousel
|
||||
return (
|
||||
<div ref={rootRef} data-alt="h5-media-viewer" className="w-[100vw] relative px-4">
|
||||
<div ref={rootRef} data-alt="h5-media-viewer" className={`w-[100vw] relative ${stage === 'final_video' ? '' : 'px-4'}`}>
|
||||
{/* 左侧最终视频缩略图栏(H5) 视频暂停时展示 */}
|
||||
{taskObject?.final?.url && !isPlaying && (
|
||||
<div className="absolute left-4 top-0 z-[60]" data-alt="final-sidebar-h5">
|
||||
<div className={`absolute left-0 top-4 z-[10] ${stage === 'final_video' ? 'left-0' : 'left-4'}`} data-alt="final-sidebar-h5">
|
||||
<div className="flex items-start">
|
||||
{isFinalBarOpen && (
|
||||
<div className="w-20 max-h-[50vh] overflow-y-auto rounded-md backdrop-blur-lg bg-black/30 border border-white/20 shadow-xl p-1 mr-2" data-alt="final-thumbnails">
|
||||
<div className="w-[3rem] max-h-[50vh] overflow-y-auto rounded-md backdrop-blur-lg bg-black/30 border border-white/20 shadow-xl p-1" data-alt="final-thumbnails">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectView && onSelectView('final')}
|
||||
@ -491,8 +491,11 @@ export function H5MediaViewer({
|
||||
</div>
|
||||
)}
|
||||
<style jsx global>{`
|
||||
[data-alt='carousel-wrapper'] .slick-slide { display: block; }
|
||||
[data-alt='carousel-wrapper'] .slick-slide { display: flex !important;justify-content: center; }
|
||||
.slick-slider { height: 100% !important;display: flex !important; }
|
||||
.ant-carousel { height: 100% !important; }
|
||||
.slick-list { width: 100%;height: 100% !important;max-height: calc(100vh - 20rem); }
|
||||
.slick-track { display: flex !important; align-items: center;height: 100% !important; }
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -125,14 +125,14 @@ const H5TaskInfo: React.FC<H5TaskInfoProps> = ({
|
||||
return (
|
||||
<div
|
||||
data-alt="h5-header"
|
||||
className={`absolute top-0 left-0 right-0 z-[50] pr-1 ${className || ''}`}
|
||||
className={`${className || ''}`}
|
||||
>
|
||||
<div
|
||||
data-alt="h5-header-bar"
|
||||
className="flex items-start gap-3"
|
||||
>
|
||||
{/* 左侧标题区域 */}
|
||||
<div data-alt="title-area" className="flex-1 min-w-0 bg-gradient-to-b from-slate-900/80 via-slate-900/40 to-transparent backdrop-blur-sm rounded-lg py-4 px-4">
|
||||
<div data-alt="title-area" className="flex-1 min-w-0">
|
||||
<h1
|
||||
data-alt="title"
|
||||
className="text-white text-lg font-bold"
|
||||
|
||||
@ -356,7 +356,7 @@ export function ThumbnailGrid({
|
||||
<div
|
||||
ref={thumbnailsRef}
|
||||
tabIndex={0}
|
||||
className={`w-full h-full grid grid-flow-col gap-2 overflow-x-auto hide-scrollbar px-1 py-1 cursor-grab active:cursor-grabbing focus:outline-none select-none auto-cols-[${cols}] ${aspectRatio === 'VIDEO_ASPECT_RATIO_LANDSCAPE' ? '' : '!auto-cols-max'}`}
|
||||
className={`w-full h-full grid grid-flow-col gap-2 overflow-x-auto hide-scrollbar px-1 py-1 cursor-grab active:cursor-grabbing focus:outline-none select-none auto-cols-[${cols}] ${aspectRatio === 'VIDEO_ASPECT_RATIO_LANDSCAPE' ? '' : '!auto-cols-min'}`}
|
||||
autoFocus
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
|
||||
@ -102,7 +102,7 @@ const H5ProgressToastUI: React.FC<UIProps> = ({ open, title, progress }) => {
|
||||
</div>
|
||||
<div className="mt-1 flex items-center justify-between text-xs text-white/70">
|
||||
<span data-alt="percent">{pct}%</span>
|
||||
<span data-alt="hint">{pct >= 100 ? '即将完成' : ''}</span>
|
||||
<span data-alt="hint">{pct >= 100 ? 'completed' : ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -140,7 +140,7 @@ export function ScriptModal({ isOpen, onClose, currentStage = 0, roles, currentL
|
||||
|
||||
{/* 弹窗内容 */}
|
||||
<motion.div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||
className="fixed inset-0 z-[61] flex items-center justify-center"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
|
||||
@ -2,16 +2,16 @@ import { useState, useEffect } from 'react';
|
||||
|
||||
// 定义设备类型枚举
|
||||
export enum DeviceType {
|
||||
MOBILE = 'mobile', // 手机
|
||||
TABLET = 'tablet', // 平板
|
||||
DESKTOP = 'desktop' // 桌面端
|
||||
MOBILE = 'mobile',
|
||||
TABLET = 'tablet',
|
||||
DESKTOP = 'desktop'
|
||||
}
|
||||
|
||||
// 定义屏幕断点
|
||||
const BREAKPOINTS = {
|
||||
MOBILE: 480, // 0-480px 为手机
|
||||
TABLET: 1024, // 481-1024px 为平板
|
||||
DESKTOP: 1025 // 1025px 及以上为桌面端
|
||||
MOBILE: 480,
|
||||
TABLET: 1024,
|
||||
DESKTOP: 1025
|
||||
};
|
||||
|
||||
export function useDeviceType() {
|
||||
@ -22,35 +22,39 @@ export function useDeviceType() {
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
/**
|
||||
* 根据窗口宽度判断设备类型
|
||||
*/
|
||||
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
|
||||
};
|
||||
}
|
||||
64
hooks/useSafeArea.ts
Normal file
64
hooks/useSafeArea.ts
Normal file
@ -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<SafeAreaInsets>({
|
||||
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)`,
|
||||
})
|
||||
};
|
||||
}
|
||||
@ -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))',
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user