forked from 77media/video-flow
Merge branch 'dev' into prod
This commit is contained in:
commit
a7afcdcbff
@ -1,5 +1,4 @@
|
|||||||
|
|
||||||
# 临时使用旧域名配置,等待后端更新
|
|
||||||
# 测试
|
# 测试
|
||||||
# NEXT_PUBLIC_JAVA_URL = https://auth.test.movieflow.ai
|
# NEXT_PUBLIC_JAVA_URL = https://auth.test.movieflow.ai
|
||||||
# NEXT_PUBLIC_BASE_URL = https://77.smartvideo.py.qikongjian.com
|
# NEXT_PUBLIC_BASE_URL = https://77.smartvideo.py.qikongjian.com
|
||||||
|
|||||||
@ -94,7 +94,6 @@ export const useTemplateStoryServiceHook = (): UseTemplateStoryService => {
|
|||||||
|
|
||||||
setTemplateStoryList(templates);
|
setTemplateStoryList(templates);
|
||||||
setSelectedTemplate(templates[0]);
|
setSelectedTemplate(templates[0]);
|
||||||
console.log(selectedTemplate);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("获取模板列表失败:", err);
|
console.error("获取模板列表失败:", err);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@ -3,8 +3,26 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { CheckCircle, XCircle, Loader2, AlertTriangle } from "lucide-react";
|
import { CheckCircle, XCircle, Loader2, AlertTriangle } from "lucide-react";
|
||||||
import { loginWithGoogleToken } from "@/lib/auth";
|
import type { OAuthCallbackParams } from "@/app/types/google-oauth";
|
||||||
import type { OAuthCallbackParams, OAuthState } from "@/app/types/google-oauth";
|
|
||||||
|
// 根据接口文档定义响应类型
|
||||||
|
interface GoogleOAuthResponse {
|
||||||
|
success: boolean;
|
||||||
|
data: {
|
||||||
|
token: string;
|
||||||
|
user: {
|
||||||
|
userId: string;
|
||||||
|
userName: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
authType: "GOOGLE";
|
||||||
|
avatar: string;
|
||||||
|
isNewUser: boolean;
|
||||||
|
};
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export default function OAuthCallback() {
|
export default function OAuthCallback() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@ -78,98 +96,70 @@ export default function OAuthCallback() {
|
|||||||
console.log('State数据:', stateData);
|
console.log('State数据:', stateData);
|
||||||
console.log('最终使用的邀请码:', finalInviteCode);
|
console.log('最终使用的邀请码:', finalInviteCode);
|
||||||
|
|
||||||
// 直接处理 OAuth 回调(两步流程:Java验证 + Python注册)
|
// 根据 jiekou.md 文档调用统一的 Python OAuth 接口
|
||||||
console.log('开始直接处理 OAuth 回调,无需经过 API 路由');
|
// 使用 NEXT_PUBLIC_BASE_URL 配置,默认为 https://77.smartvideo.py.qikongjian.com
|
||||||
|
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'https://77.smartvideo.py.qikongjian.com';
|
||||||
// 第一步:调用Java验证接口(只验证不创建用户)
|
console.log('🔧 调用 Python OAuth 接口:', baseUrl);
|
||||||
const javaBaseUrl = 'https://auth.test.movieflow.ai';
|
|
||||||
console.log('🔧 调用 Java 验证接口:', javaBaseUrl);
|
|
||||||
|
|
||||||
const verifyResponse = await fetch(`${javaBaseUrl}/api/auth/google/callback`, {
|
const response = await fetch(`${baseUrl}/api/oauth/google`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Accept': 'application/json',
|
'Accept': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
code: params.code, // Google authorization code
|
code: params.code,
|
||||||
state: params.state, // state参数
|
state: params.state,
|
||||||
inviteCode: finalInviteCode, // 邀请码
|
invite_code: finalInviteCode || null
|
||||||
skipUserCreation: true // 🔑 关键:只验证不创建用户
|
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('Java验证接口响应状态:', verifyResponse.status);
|
console.log('Python OAuth接口响应状态:', response.status);
|
||||||
const verifyResult = await verifyResponse.json();
|
const result: GoogleOAuthResponse = await response.json();
|
||||||
|
|
||||||
if (!verifyResponse.ok || !verifyResult.success) {
|
if (!response.ok || !result.success) {
|
||||||
console.error('Java验证接口处理失败:', verifyResult);
|
console.error('Python OAuth接口处理失败:', result);
|
||||||
throw new Error(verifyResult.message || 'Google token verification failed');
|
|
||||||
|
// 处理常见错误码
|
||||||
|
if (result.message?.includes('GOOGLE_TOKEN_EXCHANGE_FAILED')) {
|
||||||
|
throw new Error('Google authorization failed. Please try again.');
|
||||||
|
} else if (result.message?.includes('INVALID_ID_TOKEN')) {
|
||||||
|
throw new Error('Invalid Google token. Please try again.');
|
||||||
|
} else if (result.message?.includes('UPSTREAM_AUTH_ERROR')) {
|
||||||
|
throw new Error('Authentication service error. Please try again later.');
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(result.message || 'Google OAuth failed');
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Google Token验证成功:', {
|
console.log('Google OAuth成功:', {
|
||||||
email: verifyResult.data?.email,
|
userId: result.data?.user?.userId,
|
||||||
name: verifyResult.data?.name
|
email: result.data?.user?.email,
|
||||||
});
|
isNewUser: result.data?.user?.isNewUser
|
||||||
|
|
||||||
// 第二步:调用Python注册接口进行用户创建和积分发放
|
|
||||||
const smartvideoBaseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'https://77.smartvideo.py.qikongjian.com';
|
|
||||||
console.log('🔧 调用 Python 注册接口:', smartvideoBaseUrl);
|
|
||||||
|
|
||||||
const registerResponse = await fetch(`${smartvideoBaseUrl}/api/user_fission/register_with_invite`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Accept': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
email: verifyResult.data.email,
|
|
||||||
name: verifyResult.data.name,
|
|
||||||
auth_type: 'GOOGLE',
|
|
||||||
google_user_info: {
|
|
||||||
email: verifyResult.data.email,
|
|
||||||
name: verifyResult.data.name,
|
|
||||||
picture: verifyResult.data.picture || '',
|
|
||||||
googleId: verifyResult.data.googleId || verifyResult.data.id || '',
|
|
||||||
verified: verifyResult.data.verified || true,
|
|
||||||
inviteCode: finalInviteCode
|
|
||||||
},
|
|
||||||
invite_code: finalInviteCode
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Python注册接口响应状态:', registerResponse.status);
|
|
||||||
const registerResult = await registerResponse.json();
|
|
||||||
|
|
||||||
if (!registerResponse.ok || !registerResult.successful) {
|
|
||||||
console.error('Python注册接口处理失败:', registerResult);
|
|
||||||
throw new Error(registerResult.message || 'User registration failed');
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Google OAuth注册成功:', {
|
|
||||||
userId: registerResult.data?.user_id,
|
|
||||||
email: registerResult.data?.email
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 处理成功结果
|
// 处理成功结果
|
||||||
console.log('Google登录成功:', registerResult);
|
console.log('Google登录成功:', result);
|
||||||
setStatus("success");
|
setStatus("success");
|
||||||
setMessage("Login successful! Redirecting to dashboard...");
|
setMessage(result.data.message || "Login successful! Redirecting to dashboard...");
|
||||||
|
|
||||||
|
// 根据接口文档的响应格式保存用户信息
|
||||||
|
const { token, user } = result.data;
|
||||||
|
|
||||||
// 保存用户信息到localStorage
|
// 保存用户信息到localStorage
|
||||||
const userData = {
|
const userData = {
|
||||||
userId: registerResult.data.user_id,
|
userId: user.userId,
|
||||||
userName: registerResult.data.name,
|
userName: user.userName,
|
||||||
name: registerResult.data.name,
|
name: user.name,
|
||||||
email: registerResult.data.email,
|
email: user.email,
|
||||||
authType: registerResult.data.auth_type || 'GOOGLE',
|
authType: user.authType,
|
||||||
isNewUser: true,
|
avatar: user.avatar,
|
||||||
inviteCode: registerResult.data.invite_code
|
isNewUser: user.isNewUser
|
||||||
};
|
};
|
||||||
|
|
||||||
localStorage.setItem('currentUser', JSON.stringify(userData));
|
localStorage.setItem('currentUser', JSON.stringify(userData));
|
||||||
if (registerResult.data.token) {
|
if (token) {
|
||||||
localStorage.setItem('token', registerResult.data.token);
|
localStorage.setItem('token', token);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2秒后跳转到主页
|
// 2秒后跳转到主页
|
||||||
|
|||||||
@ -34,7 +34,6 @@ import { StoryTemplateEntity } from "@/app/service/domain/Entities";
|
|||||||
import { useImageStoryServiceHook } from "@/app/service/Interaction/ImageStoryService";
|
import { useImageStoryServiceHook } from "@/app/service/Interaction/ImageStoryService";
|
||||||
import TemplateCard from "./templateCard";
|
import TemplateCard from "./templateCard";
|
||||||
import { AudioRecorder } from "./AudioRecorder";
|
import { AudioRecorder } from "./AudioRecorder";
|
||||||
import { useTemplateStoryServiceHook } from "@/app/service/Interaction/templateStoryService";
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { createMovieProjectV1 } from "@/api/video_flow";
|
import { createMovieProjectV1 } from "@/api/video_flow";
|
||||||
import {
|
import {
|
||||||
@ -51,6 +50,7 @@ import { H5TemplateDrawer } from "./H5TemplateDrawer";
|
|||||||
import { PcPhotoStoryModal } from "./PcPhotoStoryModal";
|
import { PcPhotoStoryModal } from "./PcPhotoStoryModal";
|
||||||
import { H5PhotoStoryDrawer } from "./H5PhotoStoryDrawer";
|
import { H5PhotoStoryDrawer } from "./H5PhotoStoryDrawer";
|
||||||
import { AspectRatioSelector, AspectRatioValue } from "./AspectRatioSelector";
|
import { AspectRatioSelector, AspectRatioValue } from "./AspectRatioSelector";
|
||||||
|
import { useTemplateStoryServiceHook } from "@/app/service/Interaction/templateStoryService";
|
||||||
|
|
||||||
const LauguageOptions = [
|
const LauguageOptions = [
|
||||||
{ value: "english", label: "English", isVip: false, code:'EN' },
|
{ value: "english", label: "English", isVip: false, code:'EN' },
|
||||||
@ -86,7 +86,7 @@ const VideoDurationOptions = [
|
|||||||
* @returns {Function} - 防抖后的函数
|
* @returns {Function} - 防抖后的函数
|
||||||
*/
|
*/
|
||||||
const debounce = (func: Function, wait: number) => {
|
const debounce = (func: Function, wait: number) => {
|
||||||
let timeout: NodeJS.Timeout;
|
let timeout: ReturnType<typeof setTimeout>;
|
||||||
return function executedFunction(...args: any[]) {
|
return function executedFunction(...args: any[]) {
|
||||||
const later = () => {
|
const later = () => {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
@ -108,6 +108,14 @@ export function ChatInputBox({ noData }: { noData: boolean }) {
|
|||||||
|
|
||||||
// 模板故事弹窗状态
|
// 模板故事弹窗状态
|
||||||
const [isTemplateModalOpen, setIsTemplateModalOpen] = useState(false);
|
const [isTemplateModalOpen, setIsTemplateModalOpen] = useState(false);
|
||||||
|
// 模板快捷入口:记录初始模板ID与是否自动聚焦
|
||||||
|
const [initialTemplateId, setInitialTemplateId] = useState<string | undefined>(undefined);
|
||||||
|
// 复用模板服务:获取模板列表
|
||||||
|
const {
|
||||||
|
templateStoryList,
|
||||||
|
isLoading: isTemplateLoading,
|
||||||
|
getTemplateStoryList,
|
||||||
|
} = useTemplateStoryServiceHook();
|
||||||
|
|
||||||
// 图片故事弹窗状态
|
// 图片故事弹窗状态
|
||||||
const [isPhotoStoryModalOpen, setIsPhotoStoryModalOpen] = useState(false);
|
const [isPhotoStoryModalOpen, setIsPhotoStoryModalOpen] = useState(false);
|
||||||
@ -164,19 +172,19 @@ export function ChatInputBox({ noData }: { noData: boolean }) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const onConfigChange = <K extends keyof ConfigOptions>(key: K, value: ConfigOptions[K]) => {
|
const onConfigChange = <K extends keyof ConfigOptions>(key: K, value: ConfigOptions[K]) => {
|
||||||
setConfigOptions((prev) => ({
|
setConfigOptions((prev: ConfigOptions) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[key]: value,
|
[key]: value,
|
||||||
}));
|
}));
|
||||||
if (key === 'videoDuration') {
|
if (key === 'videoDuration') {
|
||||||
// 当选择 8s 时,强制关闭剧本扩展并禁用开关
|
// 当选择 8s 时,强制关闭剧本扩展并禁用开关
|
||||||
if (value === '8s') {
|
if (value === '8s') {
|
||||||
setConfigOptions((prev) => ({
|
setConfigOptions((prev: ConfigOptions) => ({
|
||||||
...prev,
|
...prev,
|
||||||
expansion_mode: false,
|
expansion_mode: false,
|
||||||
}));
|
}));
|
||||||
} else {
|
} else {
|
||||||
setConfigOptions((prev) => ({
|
setConfigOptions((prev: ConfigOptions) => ({
|
||||||
...prev,
|
...prev,
|
||||||
expansion_mode: true,
|
expansion_mode: true,
|
||||||
}));
|
}));
|
||||||
@ -184,6 +192,12 @@ export function ChatInputBox({ noData }: { noData: boolean }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!templateStoryList || templateStoryList.length === 0) {
|
||||||
|
getTemplateStoryList();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleCreateVideo = async () => {
|
const handleCreateVideo = async () => {
|
||||||
if (isCreating) return; // 如果正在创建中,直接返回
|
if (isCreating) return; // 如果正在创建中,直接返回
|
||||||
|
|
||||||
@ -279,9 +293,9 @@ export function ChatInputBox({ noData }: { noData: boolean }) {
|
|||||||
|
|
||||||
{/* 输入框和Action按钮 - 只在展开状态显示 */}
|
{/* 输入框和Action按钮 - 只在展开状态显示 */}
|
||||||
{!isExpanded && (
|
{!isExpanded && (
|
||||||
<div className="flex flex-col gap-3 w-full">
|
<div data-alt="chat-input-box" className="flex flex-col w-full">
|
||||||
{/* 第一行:输入框 */}
|
{/* 第一行:输入框 */}
|
||||||
<div className="video-prompt-editor relative flex flex-col gap-3 flex-1 pr-10">
|
<div className="video-prompt-editor mb-3 relative flex flex-col gap-3 flex-1 pr-10">
|
||||||
{/* 文本输入框 - 改为textarea */}
|
{/* 文本输入框 - 改为textarea */}
|
||||||
<textarea
|
<textarea
|
||||||
value={script}
|
value={script}
|
||||||
@ -335,7 +349,10 @@ export function ChatInputBox({ noData }: { noData: boolean }) {
|
|||||||
<button
|
<button
|
||||||
data-alt="template-story-button"
|
data-alt="template-story-button"
|
||||||
className="flex items-center gap-1.5 px-2 py-2 text-white/[0.70] hover:text-white transition-colors"
|
className="flex items-center gap-1.5 px-2 py-2 text-white/[0.70] hover:text-white transition-colors"
|
||||||
onClick={() => setIsTemplateModalOpen(true)}
|
onClick={() => {
|
||||||
|
setInitialTemplateId(undefined);
|
||||||
|
setIsTemplateModalOpen(true);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<LayoutTemplate className="w-4 h-4" />
|
<LayoutTemplate className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
@ -400,7 +417,7 @@ export function ChatInputBox({ noData }: { noData: boolean }) {
|
|||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
})),
|
})),
|
||||||
onClick: ({ key }) => onConfigChange('language', key),
|
onClick: ({ key }: { key: string }) => onConfigChange('language', key),
|
||||||
}}
|
}}
|
||||||
trigger={["click"]}
|
trigger={["click"]}
|
||||||
placement="top"
|
placement="top"
|
||||||
@ -426,7 +443,7 @@ export function ChatInputBox({ noData }: { noData: boolean }) {
|
|||||||
size="small"
|
size="small"
|
||||||
checked={configOptions.expansion_mode}
|
checked={configOptions.expansion_mode}
|
||||||
disabled={configOptions.videoDuration === '8s'}
|
disabled={configOptions.videoDuration === '8s'}
|
||||||
onChange={(checked) => onConfigChange('expansion_mode', checked)}
|
onChange={(checked: boolean) => onConfigChange('expansion_mode', checked)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className={`text-xs text-white hidden sm:inline`}>
|
<span className={`text-xs text-white hidden sm:inline`}>
|
||||||
@ -454,7 +471,7 @@ export function ChatInputBox({ noData }: { noData: boolean }) {
|
|||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
})),
|
})),
|
||||||
onClick: ({ key }) => onConfigChange('videoDuration', key as string),
|
onClick: ({ key }: { key: string }) => onConfigChange('videoDuration', key as string),
|
||||||
}}
|
}}
|
||||||
trigger={["click"]}
|
trigger={["click"]}
|
||||||
placement="top"
|
placement="top"
|
||||||
@ -490,6 +507,41 @@ export function ChatInputBox({ noData }: { noData: boolean }) {
|
|||||||
height={isMobile ? "h-10" : "h-12"}
|
height={isMobile ? "h-10" : "h-12"}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 第三行:模板快捷入口水平滚动 */}
|
||||||
|
<div data-alt="template-quick-entries" className="relative pl-2">
|
||||||
|
<div className="flex items-center gap-2 overflow-x-auto scrollbar-hide pr-6 py-1">
|
||||||
|
{isTemplateLoading && (!templateStoryList || templateStoryList.length === 0) ? (
|
||||||
|
// 骨架屏:若正在加载且没有数据
|
||||||
|
Array.from({ length: 6 }).map((_, idx) => (
|
||||||
|
<div
|
||||||
|
key={`skeleton-${idx}`}
|
||||||
|
data-alt={`template-chip-skeleton-${idx}`}
|
||||||
|
className="flex-shrink-0 w-20 h-7 rounded-full bg-white/10 animate-pulse"
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
(templateStoryList || []).map((tpl) => (
|
||||||
|
<button
|
||||||
|
key={tpl.id}
|
||||||
|
data-alt={`template-chip-${tpl.id}`}
|
||||||
|
className="flex-shrink-0 px-3 py-1.5 rounded-full bg-white/10 hover:bg-white/20 text-white/80 hover:text-white text-xs transition-colors"
|
||||||
|
onClick={() => {
|
||||||
|
// id 映射:优先使用模板的 id;若需要兼容 template_id,则传两者之一
|
||||||
|
setInitialTemplateId(tpl.id || (tpl as any).template_id);
|
||||||
|
setIsTemplateModalOpen(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
const textarea = document.querySelector('textarea');
|
||||||
|
if (textarea) (textarea as HTMLTextAreaElement).focus();
|
||||||
|
}, 0);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tpl.name}
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -501,6 +553,7 @@ export function ChatInputBox({ noData }: { noData: boolean }) {
|
|||||||
configOptions={configOptions}
|
configOptions={configOptions}
|
||||||
isOpen={isTemplateModalOpen}
|
isOpen={isTemplateModalOpen}
|
||||||
onClose={() => setIsTemplateModalOpen(false)}
|
onClose={() => setIsTemplateModalOpen(false)}
|
||||||
|
initialTemplateId={initialTemplateId}
|
||||||
isTemplateCreating={isTemplateCreating}
|
isTemplateCreating={isTemplateCreating}
|
||||||
setIsTemplateCreating={setIsTemplateCreating}
|
setIsTemplateCreating={setIsTemplateCreating}
|
||||||
isRoleGenerating={isRoleGenerating}
|
isRoleGenerating={isRoleGenerating}
|
||||||
@ -514,6 +567,7 @@ export function ChatInputBox({ noData }: { noData: boolean }) {
|
|||||||
configOptions={configOptions}
|
configOptions={configOptions}
|
||||||
isOpen={isTemplateModalOpen}
|
isOpen={isTemplateModalOpen}
|
||||||
onClose={() => setIsTemplateModalOpen(false)}
|
onClose={() => setIsTemplateModalOpen(false)}
|
||||||
|
initialTemplateId={initialTemplateId}
|
||||||
isTemplateCreating={isTemplateCreating}
|
isTemplateCreating={isTemplateCreating}
|
||||||
setIsTemplateCreating={setIsTemplateCreating}
|
setIsTemplateCreating={setIsTemplateCreating}
|
||||||
isRoleGenerating={isRoleGenerating}
|
isRoleGenerating={isRoleGenerating}
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState, useRef } from "react";
|
||||||
import { Drawer, Tooltip, Upload, Image } from "antd";
|
import { Drawer, Tooltip, Upload, Image } from "antd";
|
||||||
|
import type { UploadRequestOption as RcCustomRequestOptions } from 'rc-upload/lib/interface';
|
||||||
import { UploadOutlined } from "@ant-design/icons";
|
import { UploadOutlined } from "@ant-design/icons";
|
||||||
import { Clapperboard, Sparkles, LayoutTemplate, ChevronDown, ChevronUp, CheckCircle2 } from "lucide-react";
|
import { Clapperboard, Sparkles, LayoutTemplate, ChevronDown, ChevronUp, CheckCircle2 } from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
@ -32,6 +33,8 @@ interface H5TemplateDrawerProps {
|
|||||||
) => void;
|
) => void;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
/** 指定初始选中的模板ID,用于从外部快速定位 */
|
||||||
|
initialTemplateId?: string;
|
||||||
configOptions: {
|
configOptions: {
|
||||||
mode: "auto" | "manual";
|
mode: "auto" | "manual";
|
||||||
resolution: "720p" | "1080p" | "4k";
|
resolution: "720p" | "1080p" | "4k";
|
||||||
@ -50,6 +53,7 @@ export const H5TemplateDrawer = ({
|
|||||||
setIsItemGenerating,
|
setIsItemGenerating,
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
|
initialTemplateId,
|
||||||
configOptions,
|
configOptions,
|
||||||
}: H5TemplateDrawerProps) => {
|
}: H5TemplateDrawerProps) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@ -76,6 +80,8 @@ export const H5TemplateDrawer = ({
|
|||||||
// 自由输入框布局
|
// 自由输入框布局
|
||||||
const [freeInputLayout, setFreeInputLayout] = useState('bottom');
|
const [freeInputLayout, setFreeInputLayout] = useState('bottom');
|
||||||
const [aspectUI, setAspectUI] = useState<AspectRatioValue>("VIDEO_ASPECT_RATIO_LANDSCAPE");
|
const [aspectUI, setAspectUI] = useState<AspectRatioValue>("VIDEO_ASPECT_RATIO_LANDSCAPE");
|
||||||
|
// 顶部列表所在的实际滚动容器(外层 top-section 才是滚动容器)
|
||||||
|
const topSectionRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
// 自由输入框布局
|
// 自由输入框布局
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -94,6 +100,56 @@ export const H5TemplateDrawer = ({
|
|||||||
}
|
}
|
||||||
}, [isOpen, getTemplateStoryList]);
|
}, [isOpen, getTemplateStoryList]);
|
||||||
|
|
||||||
|
// 当列表加载后,根据 initialTemplateId 自动选中
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
if (!initialTemplateId) return;
|
||||||
|
if (!templateStoryList || templateStoryList.length === 0) return;
|
||||||
|
const target = templateStoryList.find(t => t.id === initialTemplateId || t.template_id === initialTemplateId);
|
||||||
|
if (target) {
|
||||||
|
setSelectedTemplate(target);
|
||||||
|
}
|
||||||
|
}, [isOpen, initialTemplateId, templateStoryList, setSelectedTemplate]);
|
||||||
|
|
||||||
|
// 自动聚焦可编辑输入框
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
if (!selectedTemplate) return;
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
const topTextArea = document.querySelector('textarea[data-alt="h5-template-free-input-top"]') as HTMLTextAreaElement | null;
|
||||||
|
const bottomInput = document.querySelector('input[data-alt="h5-template-free-input-bottom"]') as HTMLInputElement | null;
|
||||||
|
if (freeInputLayout === 'top' && topTextArea) {
|
||||||
|
topTextArea.focus();
|
||||||
|
} else if (freeInputLayout === 'bottom' && bottomInput) {
|
||||||
|
bottomInput.focus();
|
||||||
|
}
|
||||||
|
}, 50);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [isOpen, selectedTemplate, freeInputLayout]);
|
||||||
|
|
||||||
|
// 当存在默认选中模板时,将其滚动到顶部(以外层 top-section 为滚动容器)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
if (!selectedTemplate) return;
|
||||||
|
const container = topSectionRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
// 延迟一帧确保子节点渲染
|
||||||
|
const tid = setTimeout(() => {
|
||||||
|
const targetId = (selectedTemplate as any).id || (selectedTemplate as any).template_id;
|
||||||
|
const el = container.querySelector(`[data-template-id="${targetId}"]`) as HTMLElement | null;
|
||||||
|
if (el) {
|
||||||
|
// 计算相对容器的 offsetTop
|
||||||
|
const containerTop = container.getBoundingClientRect().top;
|
||||||
|
const elTop = el.getBoundingClientRect().top;
|
||||||
|
const offset = elTop - containerTop + container.scrollTop;
|
||||||
|
const adjust = 16; // 向下偏移一些,让目标项不贴顶
|
||||||
|
const targetTop = Math.max(0, offset - adjust);
|
||||||
|
container.scrollTo({ top: targetTop, behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
return () => clearTimeout(tid);
|
||||||
|
}, [isOpen, selectedTemplate]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
const target = event.target as Element;
|
const target = event.target as Element;
|
||||||
@ -108,7 +164,7 @@ export const H5TemplateDrawer = ({
|
|||||||
const handleConfirm = async () => {
|
const handleConfirm = async () => {
|
||||||
if (!selectedTemplate || isTemplateCreating) return;
|
if (!selectedTemplate || isTemplateCreating) return;
|
||||||
setIsTemplateCreating(true);
|
setIsTemplateCreating(true);
|
||||||
let timer: NodeJS.Timeout | null = null;
|
let timer: ReturnType<typeof setInterval> | null = null;
|
||||||
try {
|
try {
|
||||||
const User = JSON.parse(localStorage.getItem("currentUser") || "{}");
|
const User = JSON.parse(localStorage.getItem("currentUser") || "{}");
|
||||||
if (!User.id) return;
|
if (!User.id) return;
|
||||||
@ -155,6 +211,7 @@ export const H5TemplateDrawer = ({
|
|||||||
<button
|
<button
|
||||||
key={template.id}
|
key={template.id}
|
||||||
data-alt={`template-row-${index}`}
|
data-alt={`template-row-${index}`}
|
||||||
|
data-template-id={(template as any).id || (template as any).template_id}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!isBottomExpanded) setIsBottomExpanded(true);
|
if (!isBottomExpanded) setIsBottomExpanded(true);
|
||||||
setSelectedTemplate(template);
|
setSelectedTemplate(template);
|
||||||
@ -287,7 +344,7 @@ export const H5TemplateDrawer = ({
|
|||||||
if (!isLt5M) return false;
|
if (!isLt5M) return false;
|
||||||
return true;
|
return true;
|
||||||
}}
|
}}
|
||||||
customRequest={async ({ file, onSuccess, onError }) => {
|
customRequest={async ({ file, onSuccess, onError }: RcCustomRequestOptions) => {
|
||||||
try {
|
try {
|
||||||
const fileObj = file as File;
|
const fileObj = file as File;
|
||||||
const uploadedUrl = await uploadFile(fileObj, () => {});
|
const uploadedUrl = await uploadFile(fileObj, () => {});
|
||||||
@ -414,7 +471,7 @@ export const H5TemplateDrawer = ({
|
|||||||
if (!isLt5M) return false;
|
if (!isLt5M) return false;
|
||||||
return true;
|
return true;
|
||||||
}}
|
}}
|
||||||
customRequest={async ({ file, onSuccess, onError }) => {
|
customRequest={async ({ file, onSuccess, onError }: RcCustomRequestOptions) => {
|
||||||
try {
|
try {
|
||||||
const fileObj = file as File;
|
const fileObj = file as File;
|
||||||
const uploadedUrl = await uploadFile(fileObj, () => {});
|
const uploadedUrl = await uploadFile(fileObj, () => {});
|
||||||
@ -516,6 +573,7 @@ export const H5TemplateDrawer = ({
|
|||||||
input Configuration
|
input Configuration
|
||||||
</h3>
|
</h3>
|
||||||
<textarea
|
<textarea
|
||||||
|
data-alt="h5-template-free-input-top"
|
||||||
value={selectedTemplate?.freeInput[0].free_input_text || ""}
|
value={selectedTemplate?.freeInput[0].free_input_text || ""}
|
||||||
placeholder={selectedTemplate?.freeInput[0].user_tips || ""}
|
placeholder={selectedTemplate?.freeInput[0].user_tips || ""}
|
||||||
className="w-full flex-1 px-3 py-2 pr-16 bg-white/0 border border-white/10 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/30 transition-all duration-200 text-sm"
|
className="w-full flex-1 px-3 py-2 pr-16 bg-white/0 border border-white/10 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/30 transition-all duration-200 text-sm"
|
||||||
@ -540,6 +598,7 @@ export const H5TemplateDrawer = ({
|
|||||||
<div data-alt="free-input" className="flex-1">
|
<div data-alt="free-input" className="flex-1">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
data-alt="h5-template-free-input-bottom"
|
||||||
value={selectedTemplate.freeInput[0].free_input_text || ""}
|
value={selectedTemplate.freeInput[0].free_input_text || ""}
|
||||||
placeholder={selectedTemplate.freeInput[0].user_tips}
|
placeholder={selectedTemplate.freeInput[0].user_tips}
|
||||||
className="w-full px-3 py-2 pr-12 bg-white/0 border border-white/10 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/30 transition-all duration-200 text-sm"
|
className="w-full px-3 py-2 pr-12 bg-white/0 border border-white/10 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/30 transition-all duration-200 text-sm"
|
||||||
@ -559,7 +618,7 @@ export const H5TemplateDrawer = ({
|
|||||||
{/* 横/竖屏选择 */}
|
{/* 横/竖屏选择 */}
|
||||||
<AspectRatioSelector
|
<AspectRatioSelector
|
||||||
value={aspectUI}
|
value={aspectUI}
|
||||||
onChange={setAspectUI}
|
onChange={(v: AspectRatioValue) => setAspectUI(v)}
|
||||||
placement="top"
|
placement="top"
|
||||||
/>
|
/>
|
||||||
<ActionButton
|
<ActionButton
|
||||||
@ -604,7 +663,7 @@ export const H5TemplateDrawer = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div data-alt="drawer-body" className="flex-1 overflow-y-auto">
|
<div data-alt="drawer-body" className="flex-1 overflow-y-auto">
|
||||||
<div data-alt="top-section" className="h-full overflow-y-auto">
|
<div data-alt="top-section" className="h-full overflow-y-auto" ref={topSectionRef}>
|
||||||
{renderTopTemplateList()}
|
{renderTopTemplateList()}
|
||||||
</div>
|
</div>
|
||||||
<motion.div
|
<motion.div
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import {
|
import {
|
||||||
Clapperboard,
|
Clapperboard,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
@ -13,6 +13,7 @@ import {
|
|||||||
Image,
|
Image,
|
||||||
Dropdown,
|
Dropdown,
|
||||||
} from "antd";
|
} from "antd";
|
||||||
|
import type { UploadRequestOption as RcCustomRequestOptions } from 'rc-upload/lib/interface';
|
||||||
import { UploadOutlined } from "@ant-design/icons";
|
import { UploadOutlined } from "@ant-design/icons";
|
||||||
import { StoryTemplateEntity } from "@/app/service/domain/Entities";
|
import { StoryTemplateEntity } from "@/app/service/domain/Entities";
|
||||||
import { useTemplateStoryServiceHook } from "@/app/service/Interaction/templateStoryService";
|
import { useTemplateStoryServiceHook } from "@/app/service/Interaction/templateStoryService";
|
||||||
@ -30,7 +31,7 @@ import { AspectRatioSelector, AspectRatioValue } from "./AspectRatioSelector";
|
|||||||
* @returns {Function} - 防抖后的函数
|
* @returns {Function} - 防抖后的函数
|
||||||
*/
|
*/
|
||||||
const debounce = (func: Function, wait: number) => {
|
const debounce = (func: Function, wait: number) => {
|
||||||
let timeout: NodeJS.Timeout;
|
let timeout: ReturnType<typeof setTimeout>;
|
||||||
return function executedFunction(...args: any[]) {
|
return function executedFunction(...args: any[]) {
|
||||||
const later = () => {
|
const later = () => {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
@ -50,6 +51,8 @@ interface PcTemplateModalProps {
|
|||||||
setIsItemGenerating: (value: { [key: string]: boolean } | ((prev: { [key: string]: boolean }) => { [key: string]: boolean })) => void;
|
setIsItemGenerating: (value: { [key: string]: boolean } | ((prev: { [key: string]: boolean }) => { [key: string]: boolean })) => void;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
/** 指定初始选中的模板ID,用于从外部快速定位 */
|
||||||
|
initialTemplateId?: string;
|
||||||
configOptions: {
|
configOptions: {
|
||||||
mode: "auto" | "manual";
|
mode: "auto" | "manual";
|
||||||
resolution: "720p" | "1080p" | "4k";
|
resolution: "720p" | "1080p" | "4k";
|
||||||
@ -70,6 +73,7 @@ export const PcTemplateModal = ({
|
|||||||
setIsItemGenerating,
|
setIsItemGenerating,
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
|
initialTemplateId,
|
||||||
configOptions = {
|
configOptions = {
|
||||||
mode: "auto" as "auto" | "manual",
|
mode: "auto" as "auto" | "manual",
|
||||||
resolution: "720p" as "720p" | "1080p" | "4k",
|
resolution: "720p" as "720p" | "1080p" | "4k",
|
||||||
@ -105,7 +109,7 @@ export const PcTemplateModal = ({
|
|||||||
const [freeInputLayout, setFreeInputLayout] = useState('bottom');
|
const [freeInputLayout, setFreeInputLayout] = useState('bottom');
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [aspectUI, setAspectUI] = useState<AspectRatioValue>("VIDEO_ASPECT_RATIO_LANDSCAPE");
|
const [aspectUI, setAspectUI] = useState<AspectRatioValue>("VIDEO_ASPECT_RATIO_LANDSCAPE");
|
||||||
|
const leftListRef = useRef<HTMLDivElement | null>(null);
|
||||||
// 组件挂载时获取模板列表
|
// 组件挂载时获取模板列表
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
@ -113,6 +117,17 @@ export const PcTemplateModal = ({
|
|||||||
}
|
}
|
||||||
}, [isOpen, getTemplateStoryList]);
|
}, [isOpen, getTemplateStoryList]);
|
||||||
|
|
||||||
|
// 当列表加载后,根据 initialTemplateId 自动选中
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
if (!initialTemplateId) return;
|
||||||
|
if (!templateStoryList || templateStoryList.length === 0) return;
|
||||||
|
const target = templateStoryList.find(t => t.id === initialTemplateId || t.template_id === initialTemplateId);
|
||||||
|
if (target) {
|
||||||
|
setSelectedTemplate(target);
|
||||||
|
}
|
||||||
|
}, [isOpen, initialTemplateId, templateStoryList, setSelectedTemplate]);
|
||||||
|
|
||||||
// 自由输入框布局
|
// 自由输入框布局
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedTemplate?.storyRole && selectedTemplate.storyRole.length > 0 ||
|
if (selectedTemplate?.storyRole && selectedTemplate.storyRole.length > 0 ||
|
||||||
@ -124,6 +139,36 @@ export const PcTemplateModal = ({
|
|||||||
}
|
}
|
||||||
}, [selectedTemplate])
|
}, [selectedTemplate])
|
||||||
|
|
||||||
|
// 自动聚焦可编辑输入框
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
if (!selectedTemplate) return;
|
||||||
|
// 略微延迟确保 DOM 更新
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
const topTextArea = document.querySelector('textarea[data-alt="pc-template-free-input-top"]') as HTMLTextAreaElement | null;
|
||||||
|
const bottomInput = document.querySelector('input[data-alt="pc-template-free-input-bottom"]') as HTMLInputElement | null;
|
||||||
|
if (freeInputLayout === 'top' && topTextArea) {
|
||||||
|
topTextArea.focus();
|
||||||
|
} else if (freeInputLayout === 'bottom' && bottomInput) {
|
||||||
|
bottomInput.focus();
|
||||||
|
}
|
||||||
|
}, 50);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [isOpen, selectedTemplate, freeInputLayout]);
|
||||||
|
|
||||||
|
// 当存在默认选中模板时,将其滚动到顶部
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
if (!selectedTemplate) return;
|
||||||
|
const container = leftListRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
const targetId = (selectedTemplate as any).id || (selectedTemplate as any).template_id;
|
||||||
|
const el = container.querySelector(`[data-template-id="${targetId}"]`) as HTMLElement | null;
|
||||||
|
if (el) {
|
||||||
|
container.scrollTo({ top: el.offsetTop - 90, behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
}, [isOpen, selectedTemplate]);
|
||||||
|
|
||||||
// 监听点击外部区域关闭输入框
|
// 监听点击外部区域关闭输入框
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
@ -155,7 +200,7 @@ export const PcTemplateModal = ({
|
|||||||
if (isTemplateCreating) return;
|
if (isTemplateCreating) return;
|
||||||
|
|
||||||
setIsTemplateCreating(true);
|
setIsTemplateCreating(true);
|
||||||
let timer: NodeJS.Timeout | null = null;
|
let timer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 获取当前用户信息
|
// 获取当前用户信息
|
||||||
@ -167,8 +212,8 @@ export const PcTemplateModal = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 启动进度条动画
|
// 启动进度条动画
|
||||||
timer = setInterval(() => {
|
timer = setInterval((): void => {
|
||||||
setLocalLoading((prev) => {
|
setLocalLoading((prev: number) => {
|
||||||
if (prev >= 95) {
|
if (prev >= 95) {
|
||||||
return 95;
|
return 95;
|
||||||
}
|
}
|
||||||
@ -210,12 +255,13 @@ export const PcTemplateModal = ({
|
|||||||
// 模板列表渲染
|
// 模板列表渲染
|
||||||
const templateListRender = () => {
|
const templateListRender = () => {
|
||||||
return (
|
return (
|
||||||
<div className="w-1/3 p-4 border-r border-white/[0.1] overflow-y-auto">
|
<div className="w-1/3 p-4 border-r border-white/[0.1] overflow-y-auto" ref={leftListRef}>
|
||||||
<div className="space-y-4 overflow-y-auto template-list-scroll">
|
<div className="space-y-4 overflow-y-auto template-list-scroll">
|
||||||
{templateStoryList.map((template, index) => (
|
{templateStoryList.map((template, index) => (
|
||||||
<div
|
<div
|
||||||
key={template.id}
|
key={template.id}
|
||||||
data-alt={`template-card-${index}`}
|
data-alt={`template-card-${index}`}
|
||||||
|
data-template-id={(template as any).id || (template as any).template_id}
|
||||||
className="flex justify-center"
|
className="flex justify-center"
|
||||||
onClick={() => handleTemplateSelect(template)}
|
onClick={() => handleTemplateSelect(template)}
|
||||||
>
|
>
|
||||||
@ -429,12 +475,12 @@ export const PcTemplateModal = ({
|
|||||||
file,
|
file,
|
||||||
onSuccess,
|
onSuccess,
|
||||||
onError,
|
onError,
|
||||||
}) => {
|
}: RcCustomRequestOptions) => {
|
||||||
try {
|
try {
|
||||||
const fileObj = file as File;
|
const fileObj = file as File;
|
||||||
const uploadedUrl = await uploadFile(
|
const uploadedUrl = await uploadFile(
|
||||||
fileObj,
|
fileObj,
|
||||||
(progress) => {
|
(progress: number) => {
|
||||||
console.log(`上传进度: ${progress}%`);
|
console.log(`上传进度: ${progress}%`);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@ -620,12 +666,12 @@ export const PcTemplateModal = ({
|
|||||||
file,
|
file,
|
||||||
onSuccess,
|
onSuccess,
|
||||||
onError,
|
onError,
|
||||||
}) => {
|
}: RcCustomRequestOptions) => {
|
||||||
try {
|
try {
|
||||||
const fileObj = file as File;
|
const fileObj = file as File;
|
||||||
const uploadedUrl = await uploadFile(
|
const uploadedUrl = await uploadFile(
|
||||||
fileObj,
|
fileObj,
|
||||||
(progress) => {
|
(progress: number) => {
|
||||||
console.log(`上传进度: ${progress}%`);
|
console.log(`上传进度: ${progress}%`);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@ -666,6 +712,7 @@ export const PcTemplateModal = ({
|
|||||||
input Configuration
|
input Configuration
|
||||||
</h3>
|
</h3>
|
||||||
<textarea
|
<textarea
|
||||||
|
data-alt="pc-template-free-input-top"
|
||||||
value={selectedTemplate?.freeInput[0].free_input_text || ""}
|
value={selectedTemplate?.freeInput[0].free_input_text || ""}
|
||||||
placeholder={selectedTemplate?.freeInput[0].user_tips || ""}
|
placeholder={selectedTemplate?.freeInput[0].user_tips || ""}
|
||||||
className="w-full flex-1 px-3 py-2 pr-16 bg-white/0 border border-white/10 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/30 transition-all duration-200 text-sm"
|
className="w-full flex-1 px-3 py-2 pr-16 bg-white/0 border border-white/10 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/30 transition-all duration-200 text-sm"
|
||||||
@ -690,6 +737,7 @@ export const PcTemplateModal = ({
|
|||||||
<div className="py-2 flex-1">
|
<div className="py-2 flex-1">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
data-alt="pc-template-free-input-bottom"
|
||||||
value={selectedTemplate?.freeInput[0].free_input_text || ""}
|
value={selectedTemplate?.freeInput[0].free_input_text || ""}
|
||||||
placeholder={selectedTemplate?.freeInput[0].user_tips}
|
placeholder={selectedTemplate?.freeInput[0].user_tips}
|
||||||
className="w-full px-3 py-2 pr-16 bg-white/0 border border-white/10 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/30 transition-all duration-200 text-sm"
|
className="w-full px-3 py-2 pr-16 bg-white/0 border border-white/10 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/30 transition-all duration-200 text-sm"
|
||||||
|
|||||||
@ -191,7 +191,7 @@ export default function CreateToVideo2() {
|
|||||||
const renderProjectCard = (project: any) => {
|
const renderProjectCard = (project: any) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LazyLoad once>
|
<LazyLoad key={project.project_id} once>
|
||||||
<div
|
<div
|
||||||
key={project.project_id}
|
key={project.project_id}
|
||||||
className="group flex flex-col bg-black/20 rounded-lg overflow-hidden cursor-pointer hover:bg-white/5 transition-all duration-300"
|
className="group flex flex-col bg-black/20 rounded-lg overflow-hidden cursor-pointer hover:bg-white/5 transition-all duration-300"
|
||||||
|
|||||||
@ -540,9 +540,11 @@ Please process this video editing request.`;
|
|||||||
setCurrentSketchIndex(index);
|
setCurrentSketchIndex(index);
|
||||||
}}
|
}}
|
||||||
onRetryVideo={handleRetryVideo}
|
onRetryVideo={handleRetryVideo}
|
||||||
className={isDesktop ? 'auto-cols-[20%]' : (isTablet ? 'auto-cols-[25%]' : 'auto-cols-[40%]')}
|
className={isDesktop ? 'auto-cols-[20%]' : (isTablet ? 'auto-cols-[25%]' : 'auto-cols-[33%]')}
|
||||||
|
cols={isDesktop ? '20%' : isTablet ? '25%' : '33%'}
|
||||||
selectedView={selectedView}
|
selectedView={selectedView}
|
||||||
aspectRatio={aspectRatio}
|
aspectRatio={aspectRatio}
|
||||||
|
isMobile={isMobile}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -182,12 +182,15 @@ export function H5MediaViewer({
|
|||||||
const status = raw?.video_status;
|
const status = raw?.video_status;
|
||||||
const videoId = raw?.video_id as string | undefined;
|
const videoId = raw?.video_id as string | undefined;
|
||||||
return (
|
return (
|
||||||
<div key={`h5-video-${idx}`} data-alt="video-slide" className="relative w-full h-full">
|
<div key={`h5-video-${idx}`} data-alt="video-slide" className="relative w-full h-full flex justify-center">
|
||||||
{hasUrl ? (
|
{hasUrl ? (
|
||||||
<>
|
<>
|
||||||
<video
|
<video
|
||||||
ref={(el) => (videoRefs.current[idx] = el)}
|
ref={(el) => (videoRefs.current[idx] = el)}
|
||||||
className="w-full h-full object-contain [transform:translateZ(0)] [backface-visibility:hidden] [will-change:transform] bg-black"
|
className="w-full h-full object-contain [transform:translateZ(0)] [backface-visibility:hidden] [will-change:transform] bg-black"
|
||||||
|
style={{
|
||||||
|
maxHeight: 'calc(100vh - 20rem)',
|
||||||
|
}}
|
||||||
src={url}
|
src={url}
|
||||||
preload="metadata"
|
preload="metadata"
|
||||||
playsInline
|
playsInline
|
||||||
@ -217,38 +220,11 @@ export function H5MediaViewer({
|
|||||||
<Play className="w-8 h-8" />
|
<Play className="w-8 h-8" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 顶部操作按钮 */}
|
|
||||||
<div data-alt="video-actions" className="absolute top-2 right-2 z-10 flex items-center gap-2">
|
|
||||||
{showGotoCutButton && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onGotoCut}
|
|
||||||
data-alt="goto-cut-button"
|
|
||||||
className="w-9 h-9 rounded-full bg-black/50 text-white flex items-center justify-center active:scale-95"
|
|
||||||
aria-label={'Go to editing platform'}
|
|
||||||
>
|
|
||||||
<Scissors className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{!!setVideoPreview && !!onOpenChat && hasUrl && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setVideoPreview(url, videoId || '');
|
|
||||||
onOpenChat();
|
|
||||||
}}
|
|
||||||
data-alt="open-chat-button"
|
|
||||||
className="w-9 h-9 rounded-full bg-black/50 text-white flex items-center justify-center active:scale-95"
|
|
||||||
aria-label={'Open chat for editing'}
|
|
||||||
>
|
|
||||||
<MessageCircleMore className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-full aspect-auto min-h-[200px] flex items-center justify-center bg-black/10 relative" data-alt="video-status">
|
<div className="w-full aspect-auto min-h-[200px] flex items-center justify-center bg-black/10 relative" data-alt="video-status" style={{
|
||||||
|
maxHeight: 'calc(100vh - 20rem)',
|
||||||
|
}}>
|
||||||
{status === 0 && (
|
{status === 0 && (
|
||||||
<span className="text-blue-500 text-base">Generating...</span>
|
<span className="text-blue-500 text-base">Generating...</span>
|
||||||
)}
|
)}
|
||||||
@ -256,24 +232,11 @@ export function H5MediaViewer({
|
|||||||
<div className="flex flex-col items-center justify-center gap-3">
|
<div className="flex flex-col items-center justify-center gap-3">
|
||||||
<div className="text-4xl">⚠️</div>
|
<div className="text-4xl">⚠️</div>
|
||||||
<span className="text-red-500 text-base">Generate failed</span>
|
<span className="text-red-500 text-base">Generate failed</span>
|
||||||
{onRetryVideo && videoId && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onRetryVideo(videoId)}
|
|
||||||
data-alt="retry-button"
|
|
||||||
className="px-3 py-1.5 rounded-full bg-black/60 text-white text-sm inline-flex items-center gap-1 active:scale-95"
|
|
||||||
aria-label={'Retry'}
|
|
||||||
>
|
|
||||||
<RotateCcw className="w-4 h-4" />
|
|
||||||
Retry
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{status !== 0 && status !== 2 && (
|
{status !== 0 && status !== 2 && (
|
||||||
<span className="text-white/70 text-base">Pending</span>
|
<span className="text-white/70 text-base">Pending</span>
|
||||||
)}
|
)}
|
||||||
{/* 失败重试按钮改为全局固定渲染,移出 slide */}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -298,8 +261,10 @@ export function H5MediaViewer({
|
|||||||
adaptiveHeight
|
adaptiveHeight
|
||||||
>
|
>
|
||||||
{imageUrls.map((url, idx) => (
|
{imageUrls.map((url, idx) => (
|
||||||
<div key={`h5-image-${idx}`} data-alt="image-slide" className="relative w-full h-full">
|
<div key={`h5-image-${idx}`} data-alt="image-slide" className="relative w-full h-full flex justify-center">
|
||||||
<img src={url} alt="scene" className="w-full h-full object-contain" />
|
<img src={url} alt="scene" className="w-full h-full object-contain" style={{
|
||||||
|
maxHeight: 'calc(100vh - 20rem)',
|
||||||
|
}} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</Carousel>
|
</Carousel>
|
||||||
@ -403,8 +368,8 @@ export function H5MediaViewer({
|
|||||||
// 其他阶段:使用 Carousel
|
// 其他阶段:使用 Carousel
|
||||||
return (
|
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 px-4">
|
||||||
{/* 左侧最终视频缩略图栏(H5) */}
|
{/* 左侧最终视频缩略图栏(H5) 视频暂停时展示 */}
|
||||||
{taskObject?.final?.url && (
|
{taskObject?.final?.url && !isPlaying && (
|
||||||
<div className="absolute left-4 top-0 z-[60]" data-alt="final-sidebar-h5">
|
<div className="absolute left-4 top-0 z-[60]" data-alt="final-sidebar-h5">
|
||||||
<div className="flex items-start">
|
<div className="flex items-start">
|
||||||
{isFinalBarOpen && (
|
{isFinalBarOpen && (
|
||||||
@ -426,8 +391,8 @@ export function H5MediaViewer({
|
|||||||
{stage === 'final_video' && videoUrls.length > 0 && renderVideoSlides()}
|
{stage === 'final_video' && videoUrls.length > 0 && renderVideoSlides()}
|
||||||
{stage === 'video' && videoUrls.length > 0 && renderVideoSlides()}
|
{stage === 'video' && videoUrls.length > 0 && renderVideoSlides()}
|
||||||
{(stage === 'scene' || stage === 'character') && imageUrls.length > 0 && renderImageSlides()}
|
{(stage === 'scene' || stage === 'character') && imageUrls.length > 0 && renderImageSlides()}
|
||||||
{/* 全局固定操作区(右上角) */}
|
{/* 全局固定操作区(右上角)视频暂停时展示 */}
|
||||||
{(stage === 'video' || stage === 'final_video') && (
|
{(stage === 'video' || stage === 'final_video') && !isPlaying && (
|
||||||
<div data-alt="global-video-actions" className="absolute top-2 right-6 z-[60] flex items-center gap-2">
|
<div data-alt="global-video-actions" className="absolute top-2 right-6 z-[60] flex items-center gap-2">
|
||||||
{stage === 'video' && (
|
{stage === 'video' && (
|
||||||
<>
|
<>
|
||||||
@ -456,20 +421,25 @@ export function H5MediaViewer({
|
|||||||
await downloadAllVideos(all);
|
await downloadAllVideos(all);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<GlassIconButton
|
{(() => {
|
||||||
data-alt="download-current-button"
|
const status = (taskObject.videos?.data ?? [])[activeIndex]?.video_status;
|
||||||
className="w-8 h-8 bg-gradient-to-br from-amber-500/80 to-yellow-600/80 backdrop-blur-xl border border-amber-400/30 rounded-full flex items-center justify-center hover:from-amber-400/80 hover:to-yellow-500/80 transition-all"
|
return status === 1 ? (
|
||||||
icon={Download}
|
<GlassIconButton
|
||||||
size="sm"
|
data-alt="download-current-button"
|
||||||
aria-label="download-current"
|
className="w-8 h-8 bg-gradient-to-br from-amber-500/80 to-yellow-600/80 backdrop-blur-xl border border-amber-400/30 rounded-full flex items-center justify-center hover:from-amber-400/80 hover:to-yellow-500/80 transition-all"
|
||||||
onClick={async () => {
|
icon={Download}
|
||||||
const current = (taskObject.videos?.data ?? [])[activeIndex] as any;
|
size="sm"
|
||||||
const hasUrl = current && Array.isArray(current.urls) && current.urls.length > 0;
|
aria-label="download-current"
|
||||||
if (hasUrl) {
|
onClick={async () => {
|
||||||
await downloadVideo(current.urls[0]);
|
const current = (taskObject.videos?.data ?? [])[activeIndex] as any;
|
||||||
}
|
const hasUrl = current && Array.isArray(current.urls) && current.urls.length > 0;
|
||||||
}}
|
if (hasUrl) {
|
||||||
/>
|
await downloadVideo(current.urls[0]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
{(() => {
|
{(() => {
|
||||||
const status = (taskObject.videos?.data ?? [])[activeIndex]?.video_status;
|
const status = (taskObject.videos?.data ?? [])[activeIndex]?.video_status;
|
||||||
return status === 2 ? (
|
return status === 2 ? (
|
||||||
@ -518,7 +488,7 @@ export function H5MediaViewer({
|
|||||||
)}
|
)}
|
||||||
<style jsx global>{`
|
<style jsx global>{`
|
||||||
[data-alt='carousel-wrapper'] .slick-slide { display: block; }
|
[data-alt='carousel-wrapper'] .slick-slide { display: block; }
|
||||||
.slick-list { width: 100%;height: 100%; }
|
.slick-list { width: 100%;height: 100% !important;max-height: calc(100vh - 20rem); }
|
||||||
`}</style>
|
`}</style>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -80,7 +80,7 @@ const H5TaskInfo: React.FC<H5TaskInfoProps> = ({
|
|||||||
if (selectedView === 'final' && taskObject.final?.url) {
|
if (selectedView === 'final' && taskObject.final?.url) {
|
||||||
return 'Final 1/1'
|
return 'Final 1/1'
|
||||||
}
|
}
|
||||||
if (selectedView === 'video') {
|
if (selectedView === 'video' && !['scene', 'character'].includes(taskObject.currentStage)) {
|
||||||
const videosTotal = taskObject.videos?.total_count || taskObject.videos?.data?.length || 0
|
const videosTotal = taskObject.videos?.total_count || taskObject.videos?.data?.length || 0
|
||||||
return `Shots ${Math.max(displayCurrent, 1)}/${Math.max(videosTotal, 1)}`
|
return `Shots ${Math.max(displayCurrent, 1)}/${Math.max(videosTotal, 1)}`
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,6 +18,8 @@ interface ThumbnailGridProps {
|
|||||||
className: string;
|
className: string;
|
||||||
selectedView?: 'final' | 'video' | null;
|
selectedView?: 'final' | 'video' | null;
|
||||||
aspectRatio: AspectRatioValue;
|
aspectRatio: AspectRatioValue;
|
||||||
|
cols: string;
|
||||||
|
isMobile: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -31,7 +33,9 @@ export function ThumbnailGrid({
|
|||||||
onRetryVideo,
|
onRetryVideo,
|
||||||
className,
|
className,
|
||||||
selectedView,
|
selectedView,
|
||||||
aspectRatio
|
aspectRatio,
|
||||||
|
cols,
|
||||||
|
isMobile
|
||||||
}: ThumbnailGridProps) {
|
}: ThumbnailGridProps) {
|
||||||
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
@ -191,7 +195,7 @@ export function ThumbnailGrid({
|
|||||||
key={`video-${urls}-${index}`}
|
key={`video-${urls}-${index}`}
|
||||||
className={`relative aspect-auto rounded-lg overflow-hidden
|
className={`relative aspect-auto rounded-lg overflow-hidden
|
||||||
${(currentSketchIndex === index && !disabled && selectedView !== 'final') ? 'ring-2 ring-blue-500 z-10' : 'hover:ring-2 hover:ring-blue-500/50'}
|
${(currentSketchIndex === index && !disabled && selectedView !== 'final') ? 'ring-2 ring-blue-500 z-10' : 'hover:ring-2 hover:ring-blue-500/50'}
|
||||||
${aspectRatio === 'VIDEO_ASPECT_RATIO_LANDSCAPE' ? 'min-w-[210px]' : 'min-w-[70px]'}
|
${aspectRatio === 'VIDEO_ASPECT_RATIO_LANDSCAPE' ? `min-w-[${cols}]` : 'min-w-[70px]'}
|
||||||
`}
|
`}
|
||||||
onClick={() => !isDragging && !disabled && onSketchSelect(index)}
|
onClick={() => !isDragging && !disabled && onSketchSelect(index)}
|
||||||
>
|
>
|
||||||
@ -251,12 +255,16 @@ export function ThumbnailGrid({
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='absolute bottom-0 left-0 right-0 p-2'>
|
{!isMobile && (
|
||||||
<div className="inline-flex items-center px-2 py-1 rounded-full bg-green-500/20 backdrop-blur-sm">
|
<div className='absolute bottom-0 left-0 right-0 p-2'>
|
||||||
<Video className="w-3 h-3 text-green-400 mr-1" />
|
<div className="inline-flex items-center px-2 py-1 rounded-full bg-green-500/20 backdrop-blur-sm">
|
||||||
<span className="text-xs text-green-400">{index + 1}</span>
|
<Video className="w-3 h-3 text-green-400 mr-1" />
|
||||||
|
<span className="text-xs text-green-400">{index + 1}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{/* <div className="absolute bottom-0 left-0 right-0 p-2 bg-gradient-to-t from-black/60 to-transparent z-10">
|
{/* <div className="absolute bottom-0 left-0 right-0 p-2 bg-gradient-to-t from-black/60 to-transparent z-10">
|
||||||
<span className="text-xs text-white/90">Scene {index + 1}</span>
|
<span className="text-xs text-white/90">Scene {index + 1}</span>
|
||||||
@ -276,7 +284,7 @@ export function ThumbnailGrid({
|
|||||||
key={sketch.uniqueId || `sketch-${sketch.url}-${index}`}
|
key={sketch.uniqueId || `sketch-${sketch.url}-${index}`}
|
||||||
className={`relative aspect-auto rounded-lg overflow-hidden
|
className={`relative aspect-auto rounded-lg overflow-hidden
|
||||||
${currentSketchIndex === index ? 'ring-2 ring-blue-500 z-10' : 'hover:ring-2 hover:ring-blue-500/50'}
|
${currentSketchIndex === index ? 'ring-2 ring-blue-500 z-10' : 'hover:ring-2 hover:ring-blue-500/50'}
|
||||||
${aspectRatio === 'VIDEO_ASPECT_RATIO_LANDSCAPE' ? 'min-w-[210px]' : 'min-w-[70px]'}
|
${aspectRatio === 'VIDEO_ASPECT_RATIO_LANDSCAPE' ? `min-w-[${cols}]` : 'min-w-[70px]'}
|
||||||
`}
|
`}
|
||||||
onClick={() => !isDragging && onSketchSelect(index)}
|
onClick={() => !isDragging && onSketchSelect(index)}
|
||||||
>
|
>
|
||||||
@ -307,29 +315,33 @@ export function ThumbnailGrid({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className='absolute bottom-0 left-0 right-0 p-2'>
|
|
||||||
{/* 角色类型 */}
|
{!isMobile && (
|
||||||
{sketch.type === 'role' && (
|
<div className='absolute bottom-0 left-0 right-0 p-2'>
|
||||||
<div className="inline-flex items-center px-2 py-1 rounded-full bg-purple-500/20 backdrop-blur-sm">
|
{/* 角色类型 */}
|
||||||
<SquareUserRound className="w-3 h-3 text-purple-400 mr-1" />
|
{sketch.type === 'role' && (
|
||||||
<span className="text-xs text-purple-400">Role</span>
|
<div className="inline-flex items-center px-2 py-1 rounded-full bg-purple-500/20 backdrop-blur-sm">
|
||||||
</div>
|
<SquareUserRound className="w-3 h-3 text-purple-400 mr-1" />
|
||||||
)}
|
<span className="text-xs text-purple-400">Role</span>
|
||||||
{/* 场景类型 */}
|
</div>
|
||||||
{sketch.type === 'scene' && (
|
)}
|
||||||
<div className="inline-flex items-center px-2 py-1 rounded-full bg-purple-500/20 backdrop-blur-sm">
|
{/* 场景类型 */}
|
||||||
<MapPinHouse className="w-3 h-3 text-purple-400 mr-1" />
|
{sketch.type === 'scene' && (
|
||||||
<span className="text-xs text-purple-400">Scene</span>
|
<div className="inline-flex items-center px-2 py-1 rounded-full bg-purple-500/20 backdrop-blur-sm">
|
||||||
</div>
|
<MapPinHouse className="w-3 h-3 text-purple-400 mr-1" />
|
||||||
)}
|
<span className="text-xs text-purple-400">Scene</span>
|
||||||
{/* 分镜类型 */}
|
</div>
|
||||||
{(!sketch.type || sketch.type === 'shot_sketch') && (
|
)}
|
||||||
<div className="inline-flex items-center px-2 py-1 rounded-full bg-cyan-500/20 backdrop-blur-sm">
|
{/* 分镜类型 */}
|
||||||
<Clapperboard className="w-3 h-3 text-cyan-400 mr-1" />
|
{(!sketch.type || sketch.type === 'shot_sketch') && (
|
||||||
<span className="text-xs text-cyan-400">{index + 1}</span>
|
<div className="inline-flex items-center px-2 py-1 rounded-full bg-cyan-500/20 backdrop-blur-sm">
|
||||||
</div>
|
<Clapperboard className="w-3 h-3 text-cyan-400 mr-1" />
|
||||||
)}
|
<span className="text-xs text-cyan-400">{index + 1}</span>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
{/* <div className="absolute bottom-0 left-0 right-0 p-2 bg-gradient-to-t from-black/60 to-transparent z-10">
|
{/* <div className="absolute bottom-0 left-0 right-0 p-2 bg-gradient-to-t from-black/60 to-transparent z-10">
|
||||||
<span className="text-xs text-white/90">{sketch.type === 'role' ? 'Role' : (sketch.type === 'scene' ? 'Scene' : 'Shot')} {index + 1}</span>
|
<span className="text-xs text-white/90">{sketch.type === 'role' ? 'Role' : (sketch.type === 'scene' ? 'Scene' : 'Shot')} {index + 1}</span>
|
||||||
@ -344,7 +356,7 @@ export function ThumbnailGrid({
|
|||||||
<div
|
<div
|
||||||
ref={thumbnailsRef}
|
ref={thumbnailsRef}
|
||||||
tabIndex={0}
|
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 ${className} 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-max'}`}
|
||||||
autoFocus
|
autoFocus
|
||||||
onMouseDown={handleMouseDown}
|
onMouseDown={handleMouseDown}
|
||||||
onMouseMove={handleMouseMove}
|
onMouseMove={handleMouseMove}
|
||||||
|
|||||||
557
docs/google-oauth-callback-flow-analysis.md
Normal file
557
docs/google-oauth-callback-flow-analysis.md
Normal file
@ -0,0 +1,557 @@
|
|||||||
|
# 🔍 Google OAuth 回调逻辑全面梳理
|
||||||
|
|
||||||
|
## 📋 概述
|
||||||
|
|
||||||
|
Video Flow 项目的 Google OAuth 登录采用了**授权码模式**,整个流程涉及前端、Java 认证服务和 Python 业务服务的协调配合。本文档详细梳理了从用户点击登录到最终完成认证的完整流程。
|
||||||
|
|
||||||
|
## 🏗️ 架构图
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant U as 用户
|
||||||
|
participant F as 前端(Next.js)
|
||||||
|
participant G as Google OAuth
|
||||||
|
participant J as Java认证服务
|
||||||
|
participant P as Python业务服务
|
||||||
|
|
||||||
|
U->>F: 点击Google登录
|
||||||
|
F->>F: 生成state参数(包含邀请码)
|
||||||
|
F->>G: 重定向到Google授权页面
|
||||||
|
G->>U: 显示授权页面
|
||||||
|
U->>G: 确认授权
|
||||||
|
G->>F: 回调到/api/auth/google/callback
|
||||||
|
F->>F: API路由重定向到页面路由
|
||||||
|
F->>J: 调用Java验证接口
|
||||||
|
J->>G: 验证授权码获取用户信息
|
||||||
|
J->>F: 返回验证结果
|
||||||
|
F->>P: 调用Python注册接口
|
||||||
|
P->>F: 返回用户信息和token
|
||||||
|
F->>F: 保存用户信息到localStorage
|
||||||
|
F->>U: 跳转到主页面
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 核心组件分析
|
||||||
|
|
||||||
|
### 1. 环境配置 (.env.production)
|
||||||
|
|
||||||
|
<augment_code_snippet path=".env.production" mode="EXCERPT">
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Java认证服务
|
||||||
|
NEXT_PUBLIC_JAVA_URL = https://auth.test.movieflow.ai
|
||||||
|
# Python业务服务
|
||||||
|
NEXT_PUBLIC_BASE_URL = https://77.smartvideo.py.qikongjian.com
|
||||||
|
# Google OAuth配置
|
||||||
|
NEXT_PUBLIC_GOOGLE_CLIENT_ID=847079918888-o1nne8d3ij80dn20qurivo987pv07225.apps.googleusercontent.com
|
||||||
|
NEXT_PUBLIC_GOOGLE_REDIRECT_URI=https://www.movieflow.net/api/auth/google/callback
|
||||||
|
```
|
||||||
|
|
||||||
|
</augment_code_snippet>
|
||||||
|
|
||||||
|
**关键问题**:
|
||||||
|
|
||||||
|
- ❌ 域名不一致:redirect_uri 使用 `movieflow.net`,但实际部署可能在 `movieflow.ai`
|
||||||
|
- ❌ 硬编码配置:多个环境的配置混杂在一起
|
||||||
|
|
||||||
|
### 2. Next.js 路由代理配置
|
||||||
|
|
||||||
|
<augment_code_snippet path="next.config.js" mode="EXCERPT">
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Google OAuth2 API代理 (排除callback,让Next.js本地处理)
|
||||||
|
{
|
||||||
|
source: '/api/auth/google/((?!callback).*)',
|
||||||
|
destination: `${AUTH_API_URL}/api/auth/google/$1`,
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
</augment_code_snippet>
|
||||||
|
|
||||||
|
**设计意图**:
|
||||||
|
|
||||||
|
- ✅ 除了 callback 路由外,其他 Google OAuth API 都代理到 Java 服务
|
||||||
|
- ✅ callback 路由由 Next.js 本地处理,便于前端控制流程
|
||||||
|
|
||||||
|
### 3. 登录发起流程
|
||||||
|
|
||||||
|
#### 3.1 登录页面组件
|
||||||
|
|
||||||
|
<augment_code_snippet path="components/pages/login.tsx" mode="EXCERPT">
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const handleGoogleSignIn = async () => {
|
||||||
|
try {
|
||||||
|
setGoogleLoading(true)
|
||||||
|
setFormError('')
|
||||||
|
|
||||||
|
// 获取邀请码(从URL参数或其他来源)
|
||||||
|
const inviteCode = searchParams?.get('invite') || undefined
|
||||||
|
|
||||||
|
// 使用Google GSI SDK进行登录
|
||||||
|
await signInWithGoogle(inviteCode)
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Google sign-in error:', error)
|
||||||
|
setFormError(error.message || 'Google sign-in failed, please try again')
|
||||||
|
setGoogleLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
</augment_code_snippet>
|
||||||
|
|
||||||
|
#### 3.2 Google 登录核心逻辑
|
||||||
|
|
||||||
|
<augment_code_snippet path="lib/auth.ts" mode="EXCERPT">
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const signInWithGoogle = async (inviteCode?: string): Promise<void> => {
|
||||||
|
try {
|
||||||
|
// 从环境变量获取配置
|
||||||
|
const clientId = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID
|
||||||
|
const javaBaseUrl = process.env.NEXT_PUBLIC_JAVA_URL
|
||||||
|
const redirectUri = process.env.NEXT_PUBLIC_GOOGLE_REDIRECT_URI
|
||||||
|
|
||||||
|
// 生成随机nonce用于安全验证
|
||||||
|
const nonce = Array.from(crypto.getRandomValues(new Uint8Array(32)))
|
||||||
|
.map((b) => b.toString(16).padStart(2, '0'))
|
||||||
|
.join('')
|
||||||
|
|
||||||
|
// 构建state参数(包含邀请码等信息)
|
||||||
|
const stateData = {
|
||||||
|
inviteCode: finalInviteCode || '',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
origin: window.location.pathname + window.location.search,
|
||||||
|
nonce: nonce,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建Google OAuth2授权URL
|
||||||
|
const authParams = new URLSearchParams({
|
||||||
|
access_type: 'online',
|
||||||
|
client_id: clientId,
|
||||||
|
nonce: nonce,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
response_type: 'code', // 使用授权码模式
|
||||||
|
scope: 'email openid profile',
|
||||||
|
state: JSON.stringify(stateData),
|
||||||
|
prompt: 'select_account', // 总是显示账号选择
|
||||||
|
})
|
||||||
|
|
||||||
|
const authUrl = `https://accounts.google.com/o/oauth2/v2/auth?${authParams.toString()}`
|
||||||
|
|
||||||
|
// 保存state到sessionStorage用于验证
|
||||||
|
sessionStorage.setItem(
|
||||||
|
'google_oauth_state',
|
||||||
|
JSON.stringify({
|
||||||
|
nonce: nonce,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
inviteCode: finalInviteCode || '',
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
// 直接在当前页面跳转到Google
|
||||||
|
window.location.href = authUrl
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Google登录跳转失败:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
</augment_code_snippet>
|
||||||
|
|
||||||
|
**关键特性**:
|
||||||
|
|
||||||
|
- ✅ 使用 `crypto.getRandomValues` 生成安全的 nonce
|
||||||
|
- ✅ state 参数包含邀请码、时间戳等信息
|
||||||
|
- ✅ 使用 sessionStorage 保存状态用于后续验证
|
||||||
|
- ❌ 硬编码的 redirect_uri 可能导致域名不匹配问题
|
||||||
|
|
||||||
|
### 4. 回调处理流程
|
||||||
|
|
||||||
|
#### 4.1 API 路由回调处理
|
||||||
|
|
||||||
|
<augment_code_snippet path="app/api/auth/google/callback/route.ts" mode="EXCERPT">
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const { searchParams } = new URL(request.url)
|
||||||
|
const code = searchParams.get('code')
|
||||||
|
const state = searchParams.get('state')
|
||||||
|
|
||||||
|
if (!code || !state) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
message: 'Missing required parameters: code and state',
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重定向到页面路由,让页面处理OAuth回调
|
||||||
|
const callbackUrl = `/users/oauth/callback?code=${encodeURIComponent(
|
||||||
|
code
|
||||||
|
)}&state=${encodeURIComponent(state)}`
|
||||||
|
|
||||||
|
// 修复:确保使用正确的域名进行重定向
|
||||||
|
const host = request.headers.get('host') || 'www.movieflow.net'
|
||||||
|
const protocol = request.headers.get('x-forwarded-proto') || 'https'
|
||||||
|
const fullCallbackUrl = `${protocol}://${host}${callbackUrl}`
|
||||||
|
|
||||||
|
return NextResponse.redirect(fullCallbackUrl)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
</augment_code_snippet>
|
||||||
|
|
||||||
|
**设计模式**:
|
||||||
|
|
||||||
|
- ✅ API 路由仅作为中转,将 GET 请求重定向到页面路由
|
||||||
|
- ✅ 动态构建重定向 URL,避免硬编码域名
|
||||||
|
- ❌ 默认域名仍然是硬编码的 `movieflow.net`
|
||||||
|
|
||||||
|
#### 4.2 页面路由回调处理
|
||||||
|
|
||||||
|
<augment_code_snippet path="app/users/oauth/callback/page.tsx" mode="EXCERPT">
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const handleOAuthCallback = async () => {
|
||||||
|
try {
|
||||||
|
// 获取URL参数
|
||||||
|
const params: OAuthCallbackParams = {
|
||||||
|
code: searchParams.get('code') || undefined,
|
||||||
|
state: searchParams.get('state') || undefined,
|
||||||
|
error: searchParams.get('error') || undefined,
|
||||||
|
error_description: searchParams.get('error_description') || undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析state参数
|
||||||
|
let stateData: any = {}
|
||||||
|
if (params.state) {
|
||||||
|
try {
|
||||||
|
stateData = JSON.parse(params.state)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to parse state parameter:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 第一步:调用Java验证接口(只验证不创建用户)
|
||||||
|
const javaBaseUrl = 'https://auth.test.movieflow.ai'
|
||||||
|
const verifyResponse = await fetch(
|
||||||
|
`${javaBaseUrl}/api/auth/google/callback`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
code: params.code, // Google authorization code
|
||||||
|
state: params.state, // state参数
|
||||||
|
inviteCode: finalInviteCode, // 邀请码
|
||||||
|
skipUserCreation: true, // 🔑 关键:只验证不创建用户
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// 第二步:调用Python注册接口进行用户创建和积分发放
|
||||||
|
const smartvideoBaseUrl = process.env.NEXT_PUBLIC_BASE_URL
|
||||||
|
const registerResponse = await fetch(
|
||||||
|
`${smartvideoBaseUrl}/api/user_fission/google_register`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: verifyResult.data.email,
|
||||||
|
name: verifyResult.data.name,
|
||||||
|
google_id: verifyResult.data.sub,
|
||||||
|
invite_code: finalInviteCode,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// 保存用户信息到localStorage
|
||||||
|
const userData = {
|
||||||
|
userId: registerResult.data.user_id,
|
||||||
|
userName: registerResult.data.name,
|
||||||
|
name: registerResult.data.name,
|
||||||
|
email: registerResult.data.email,
|
||||||
|
authType: registerResult.data.auth_type || 'GOOGLE',
|
||||||
|
isNewUser: true,
|
||||||
|
inviteCode: registerResult.data.invite_code,
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem('currentUser', JSON.stringify(userData))
|
||||||
|
if (registerResult.data.token) {
|
||||||
|
localStorage.setItem('token', registerResult.data.token)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2秒后跳转到主页
|
||||||
|
setTimeout(() => {
|
||||||
|
const returnUrl = '/movies'
|
||||||
|
window.location.href = returnUrl
|
||||||
|
}, 2000)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('OAuth callback处理失败:', error)
|
||||||
|
setStatus('error')
|
||||||
|
setMessage(error.message || 'Authentication failed')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
</augment_code_snippet>
|
||||||
|
|
||||||
|
**核心特性**:
|
||||||
|
|
||||||
|
- ✅ 两步验证:先 Java 验证 Google Token,再 Python 创建用户
|
||||||
|
- ✅ 支持邀请码逻辑
|
||||||
|
- ✅ 完整的错误处理和用户反馈
|
||||||
|
- ❌ 硬编码的 Java 服务地址
|
||||||
|
- ❌ 缺少 state 参数的安全验证
|
||||||
|
|
||||||
|
## 🚨 发现的问题
|
||||||
|
|
||||||
|
### 1. 域名配置不一致
|
||||||
|
|
||||||
|
- **问题**:redirect_uri 配置为 `movieflow.net`,但实际可能部署在 `movieflow.ai`
|
||||||
|
- **影响**:Google OAuth 回调失败
|
||||||
|
- **建议**:统一域名配置,使用环境变量管理
|
||||||
|
|
||||||
|
### 2. 硬编码配置过多
|
||||||
|
|
||||||
|
- **问题**:Java 服务地址、域名等多处硬编码
|
||||||
|
- **影响**:环境切换困难,维护成本高
|
||||||
|
- **建议**:全部使用环境变量配置
|
||||||
|
|
||||||
|
### 3. 安全验证不完整
|
||||||
|
|
||||||
|
- **问题**:缺少 state 参数的 nonce 验证
|
||||||
|
- **影响**:存在 CSRF 攻击风险
|
||||||
|
- **建议**:完善 state 参数验证逻辑
|
||||||
|
|
||||||
|
### 4. 错误处理不够细致
|
||||||
|
|
||||||
|
- **问题**:某些错误场景处理不够详细
|
||||||
|
- **影响**:用户体验不佳,问题排查困难
|
||||||
|
- **建议**:增加更详细的错误分类和处理
|
||||||
|
|
||||||
|
## 📈 优化建议
|
||||||
|
|
||||||
|
### 1. 配置管理优化
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 建议的配置管理方式
|
||||||
|
const config = {
|
||||||
|
googleClientId: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID!,
|
||||||
|
javaBaseUrl: process.env.NEXT_PUBLIC_JAVA_URL!,
|
||||||
|
pythonBaseUrl: process.env.NEXT_PUBLIC_BASE_URL!,
|
||||||
|
redirectUri: process.env.NEXT_PUBLIC_GOOGLE_REDIRECT_URI!,
|
||||||
|
frontendUrl: process.env.NEXT_PUBLIC_FRONTEND_URL!,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 安全性增强
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 建议的 state 验证逻辑
|
||||||
|
const validateState = (receivedState: string): boolean => {
|
||||||
|
try {
|
||||||
|
const stateData = JSON.parse(receivedState)
|
||||||
|
const storedState = sessionStorage.getItem('google_oauth_state')
|
||||||
|
|
||||||
|
if (!storedState) return false
|
||||||
|
|
||||||
|
const stored = JSON.parse(storedState)
|
||||||
|
|
||||||
|
// 验证 nonce 和时间戳
|
||||||
|
return (
|
||||||
|
stateData.nonce === stored.nonce &&
|
||||||
|
Math.abs(Date.now() - stored.timestamp) < 600000
|
||||||
|
) // 10分钟有效期
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 错误处理优化
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 建议的错误处理策略
|
||||||
|
enum OAuthError {
|
||||||
|
INVALID_STATE = 'INVALID_STATE',
|
||||||
|
EXPIRED_STATE = 'EXPIRED_STATE',
|
||||||
|
GOOGLE_AUTH_FAILED = 'GOOGLE_AUTH_FAILED',
|
||||||
|
USER_CREATION_FAILED = 'USER_CREATION_FAILED',
|
||||||
|
NETWORK_ERROR = 'NETWORK_ERROR',
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOAuthError = (error: OAuthError, details?: any) => {
|
||||||
|
const errorMessages = {
|
||||||
|
[OAuthError.INVALID_STATE]: '安全验证失败,请重新登录',
|
||||||
|
[OAuthError.EXPIRED_STATE]: '登录会话已过期,请重新登录',
|
||||||
|
[OAuthError.GOOGLE_AUTH_FAILED]: 'Google 认证失败,请重试',
|
||||||
|
[OAuthError.USER_CREATION_FAILED]: '用户创建失败,请联系客服',
|
||||||
|
[OAuthError.NETWORK_ERROR]: '网络连接失败,请检查网络后重试',
|
||||||
|
}
|
||||||
|
|
||||||
|
setMessage(errorMessages[error] || '未知错误')
|
||||||
|
console.error(`OAuth Error: ${error}`, details)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔄 完整流程时序图
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
A[用户点击Google登录] --> B[检查Google登录是否启用]
|
||||||
|
B --> C{启用状态}
|
||||||
|
C -->|启用| D[生成state参数和nonce]
|
||||||
|
C -->|未启用| E[隐藏Google登录按钮]
|
||||||
|
|
||||||
|
D --> F[保存state到sessionStorage]
|
||||||
|
F --> G[构建Google OAuth URL]
|
||||||
|
G --> H[跳转到Google授权页面]
|
||||||
|
|
||||||
|
H --> I[用户在Google页面授权]
|
||||||
|
I --> J[Google回调到/api/auth/google/callback]
|
||||||
|
|
||||||
|
J --> K[API路由获取code和state]
|
||||||
|
K --> L{参数验证}
|
||||||
|
L -->|失败| M[返回400错误]
|
||||||
|
L -->|成功| N[重定向到/users/oauth/callback页面]
|
||||||
|
|
||||||
|
N --> O[页面路由解析URL参数]
|
||||||
|
O --> P[解析state参数获取邀请码]
|
||||||
|
P --> Q[调用Java验证接口]
|
||||||
|
|
||||||
|
Q --> R{Java验证结果}
|
||||||
|
R -->|失败| S[显示错误信息]
|
||||||
|
R -->|成功| T[调用Python注册接口]
|
||||||
|
|
||||||
|
T --> U{Python注册结果}
|
||||||
|
U -->|失败| V[显示注册失败]
|
||||||
|
U -->|成功| W[保存用户信息到localStorage]
|
||||||
|
|
||||||
|
W --> X[显示成功信息]
|
||||||
|
X --> Y[2秒后跳转到/movies]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📊 数据流分析
|
||||||
|
|
||||||
|
### 1. State 参数结构
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface StateData {
|
||||||
|
inviteCode: string // 邀请码
|
||||||
|
timestamp: number // 时间戳
|
||||||
|
origin: string // 原始页面路径
|
||||||
|
nonce: string // 安全随机数
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Java 验证接口请求
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface JavaVerifyRequest {
|
||||||
|
code: string // Google授权码
|
||||||
|
state: string // state参数
|
||||||
|
inviteCode?: string // 邀请码
|
||||||
|
skipUserCreation: true // 只验证不创建用户
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Python 注册接口请求
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface PythonRegisterRequest {
|
||||||
|
email: string // 用户邮箱
|
||||||
|
name: string // 用户姓名
|
||||||
|
google_id: string // Google用户ID
|
||||||
|
invite_code?: string // 邀请码
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🛡️ 安全机制分析
|
||||||
|
|
||||||
|
### 1. CSRF 防护
|
||||||
|
|
||||||
|
- ✅ 使用 state 参数防止 CSRF 攻击
|
||||||
|
- ✅ 生成随机 nonce 增强安全性
|
||||||
|
- ❌ 缺少 state 参数的服务端验证
|
||||||
|
|
||||||
|
### 2. 会话管理
|
||||||
|
|
||||||
|
- ✅ 使用 sessionStorage 临时存储状态
|
||||||
|
- ✅ 设置时间戳用于过期检查
|
||||||
|
- ❌ 缺少自动清理过期状态的机制
|
||||||
|
|
||||||
|
### 3. Token 安全
|
||||||
|
|
||||||
|
- ✅ 使用授权码模式,不直接暴露 access_token
|
||||||
|
- ✅ Token 存储在 localStorage
|
||||||
|
- ❌ 缺少 Token 过期自动刷新机制
|
||||||
|
|
||||||
|
## 🔧 技术债务清单
|
||||||
|
|
||||||
|
### 高优先级
|
||||||
|
|
||||||
|
1. **域名配置统一**
|
||||||
|
|
||||||
|
- 问题:redirect_uri 域名不匹配
|
||||||
|
- 影响:OAuth 回调失败
|
||||||
|
- 工作量:1 天
|
||||||
|
|
||||||
|
2. **硬编码配置清理**
|
||||||
|
- 问题:多处硬编码服务地址
|
||||||
|
- 影响:环境切换困难
|
||||||
|
- 工作量:0.5 天
|
||||||
|
|
||||||
|
### 中优先级
|
||||||
|
|
||||||
|
3. **State 参数验证完善**
|
||||||
|
|
||||||
|
- 问题:缺少 nonce 验证
|
||||||
|
- 影响:安全风险
|
||||||
|
- 工作量:1 天
|
||||||
|
|
||||||
|
4. **错误处理优化**
|
||||||
|
- 问题:错误分类不够细致
|
||||||
|
- 影响:用户体验和调试困难
|
||||||
|
- 工作量:1 天
|
||||||
|
|
||||||
|
### 低优先级
|
||||||
|
|
||||||
|
5. **Token 刷新机制**
|
||||||
|
|
||||||
|
- 问题:缺少自动刷新
|
||||||
|
- 影响:用户需要重新登录
|
||||||
|
- 工作量:2 天
|
||||||
|
|
||||||
|
6. **日志和监控完善**
|
||||||
|
- 问题:缺少结构化日志
|
||||||
|
- 影响:问题排查困难
|
||||||
|
- 工作量:1 天
|
||||||
|
|
||||||
|
## 🎯 总结
|
||||||
|
|
||||||
|
Video Flow 的 Google OAuth 回调逻辑整体设计合理,采用了前后端分离的架构,支持邀请码功能,具备基本的安全防护。主要问题集中在配置管理和安全验证的细节上。
|
||||||
|
|
||||||
|
**优势**:
|
||||||
|
|
||||||
|
- ✅ 架构清晰,职责分离
|
||||||
|
- ✅ 支持邀请码业务逻辑
|
||||||
|
- ✅ 具备基本的错误处理
|
||||||
|
- ✅ 使用标准的 OAuth 2.0 授权码模式
|
||||||
|
|
||||||
|
**待改进**:
|
||||||
|
|
||||||
|
- ❌ 配置管理需要优化
|
||||||
|
- ❌ 安全验证需要加强
|
||||||
|
- ❌ 错误处理需要细化
|
||||||
|
- ❌ 监控和日志需要完善
|
||||||
|
|
||||||
|
建议按照技术债务清单的优先级逐步改进,优先解决影响功能正常运行的配置问题,然后逐步完善安全性和用户体验。
|
||||||
148
docs/oauth-callback-refactor-summary.md
Normal file
148
docs/oauth-callback-refactor-summary.md
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
# Google OAuth 回调页面重构总结
|
||||||
|
|
||||||
|
## 📋 重构概述
|
||||||
|
|
||||||
|
根据 `docs/jiekou.md` 接口文档的要求,对 `app/users/oauth/callback/page.tsx` 进行了重构,简化了 Google OAuth 回调处理流程。
|
||||||
|
|
||||||
|
## 🔄 主要改进
|
||||||
|
|
||||||
|
### 1. 简化架构
|
||||||
|
**之前**:复杂的两步验证流程
|
||||||
|
```typescript
|
||||||
|
// 第一步:调用 Java 验证接口
|
||||||
|
const javaResponse = await fetch(`${javaBaseUrl}/api/auth/google/callback`, {...});
|
||||||
|
|
||||||
|
// 第二步:调用 Python 注册接口
|
||||||
|
const pythonResponse = await fetch(`${pythonBaseUrl}/api/user_fission/register_with_invite`, {...});
|
||||||
|
```
|
||||||
|
|
||||||
|
**现在**:统一的单步流程
|
||||||
|
```typescript
|
||||||
|
// 直接调用 Python OAuth 接口
|
||||||
|
const response = await fetch(`${baseUrl}/api/oauth/google`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
||||||
|
body: JSON.stringify({ code, state, invite_code })
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 标准化环境变量
|
||||||
|
**之前**:混合使用多个环境变量
|
||||||
|
```typescript
|
||||||
|
const javaBaseUrl = 'https://auth.test.movieflow.ai'; // 硬编码
|
||||||
|
const smartvideoBaseUrl = process.env.NEXT_PUBLIC_BASE_URL;
|
||||||
|
```
|
||||||
|
|
||||||
|
**现在**:按文档要求使用标准环境变量
|
||||||
|
```typescript
|
||||||
|
const baseUrl = process.env.NEXT_PUBLIC_SMARTVIDEO_URL ||
|
||||||
|
process.env.NEXT_PUBLIC_BASE_URL ||
|
||||||
|
'https://77.smartvideo.py.qikongjian.com';
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 统一响应格式
|
||||||
|
**之前**:处理不同服务的不同响应格式
|
||||||
|
```typescript
|
||||||
|
// Java 响应格式
|
||||||
|
{ success: boolean, data: { email, name, ... } }
|
||||||
|
|
||||||
|
// Python 响应格式
|
||||||
|
{ successful: boolean, data: { user_id, name, ... } }
|
||||||
|
```
|
||||||
|
|
||||||
|
**现在**:按文档标准化响应格式
|
||||||
|
```typescript
|
||||||
|
interface GoogleOAuthResponse {
|
||||||
|
success: boolean;
|
||||||
|
data: {
|
||||||
|
token: string;
|
||||||
|
user: {
|
||||||
|
userId: string;
|
||||||
|
userName: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
authType: "GOOGLE";
|
||||||
|
avatar: string;
|
||||||
|
isNewUser: boolean;
|
||||||
|
};
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 改进错误处理
|
||||||
|
**之前**:通用错误处理
|
||||||
|
```typescript
|
||||||
|
throw new Error(result.message || 'OAuth failed');
|
||||||
|
```
|
||||||
|
|
||||||
|
**现在**:按文档分类处理错误
|
||||||
|
```typescript
|
||||||
|
if (result.message?.includes('GOOGLE_TOKEN_EXCHANGE_FAILED')) {
|
||||||
|
throw new Error('Google authorization failed. Please try again.');
|
||||||
|
} else if (result.message?.includes('INVALID_ID_TOKEN')) {
|
||||||
|
throw new Error('Invalid Google token. Please try again.');
|
||||||
|
} else if (result.message?.includes('UPSTREAM_AUTH_ERROR')) {
|
||||||
|
throw new Error('Authentication service error. Please try again later.');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📊 重构对比
|
||||||
|
|
||||||
|
| 方面 | 重构前 | 重构后 |
|
||||||
|
|------|--------|--------|
|
||||||
|
| **API 调用** | 2 次(Java + Python) | 1 次(Python) |
|
||||||
|
| **代码行数** | ~80 行核心逻辑 | ~40 行核心逻辑 |
|
||||||
|
| **硬编码配置** | 多处硬编码 | 使用环境变量 |
|
||||||
|
| **错误处理** | 通用处理 | 分类处理 |
|
||||||
|
| **类型安全** | 部分类型 | 完整类型定义 |
|
||||||
|
| **维护性** | 复杂,难维护 | 简单,易维护 |
|
||||||
|
|
||||||
|
## ✅ 重构优势
|
||||||
|
|
||||||
|
1. **简化流程**:从两步验证简化为单步调用
|
||||||
|
2. **减少依赖**:不再依赖 Java 认证服务
|
||||||
|
3. **提高可靠性**:减少网络调用,降低失败概率
|
||||||
|
4. **标准化**:完全按照接口文档实现
|
||||||
|
5. **类型安全**:添加完整的 TypeScript 类型定义
|
||||||
|
6. **错误处理**:更细致的错误分类和用户提示
|
||||||
|
|
||||||
|
## 🔧 技术细节
|
||||||
|
|
||||||
|
### 请求格式
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
code: string, // Google 授权码
|
||||||
|
state: string, // 状态参数
|
||||||
|
invite_code: string | null // 邀请码
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 响应处理
|
||||||
|
```typescript
|
||||||
|
const { token, user } = result.data;
|
||||||
|
|
||||||
|
// 保存到 localStorage
|
||||||
|
localStorage.setItem('currentUser', JSON.stringify({
|
||||||
|
userId: user.userId,
|
||||||
|
userName: user.userName,
|
||||||
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
authType: user.authType,
|
||||||
|
avatar: user.avatar,
|
||||||
|
isNewUser: user.isNewUser
|
||||||
|
}));
|
||||||
|
|
||||||
|
localStorage.setItem('token', token);
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎯 后续建议
|
||||||
|
|
||||||
|
1. **环境变量配置**:确保生产环境正确配置 `NEXT_PUBLIC_SMARTVIDEO_URL`
|
||||||
|
2. **错误监控**:添加错误上报,监控 OAuth 失败率
|
||||||
|
3. **用户体验**:考虑添加重试机制
|
||||||
|
4. **安全性**:验证 state 参数的 CSRF 防护
|
||||||
|
|
||||||
|
## 📝 总结
|
||||||
|
|
||||||
|
这次重构大大简化了 Google OAuth 回调处理逻辑,提高了代码的可维护性和可靠性。新的实现完全符合接口文档要求,为后续的功能扩展和维护奠定了良好基础。
|
||||||
340
docs/页面加载优化方案.md
Normal file
340
docs/页面加载优化方案.md
Normal file
@ -0,0 +1,340 @@
|
|||||||
|
# ⚡ Video-Flow 性能优化快速实施指南
|
||||||
|
|
||||||
|
> **紧急修复清单** - 可在1-2天内完成,立即提升50%+性能
|
||||||
|
|
||||||
|
## 🚨 立即修复(30分钟内)
|
||||||
|
|
||||||
|
### 1. 启用图片优化
|
||||||
|
**影响**: 减少50-70%图片加载时间
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// next.config.js - 找到这行并修改
|
||||||
|
const nextConfig = {
|
||||||
|
images: {
|
||||||
|
unoptimized: false, // ✅ 改为 false
|
||||||
|
formats: ['image/webp', 'image/avif'],
|
||||||
|
domains: ['cdn.qikongjian.com'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 修复JSON解析性能灾难
|
||||||
|
**影响**: 消除每秒数百次异常,提升渲染性能70%
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 找到 parseTaskResult 函数并替换为:
|
||||||
|
function parseTaskResult(taskResult: any) {
|
||||||
|
if (!taskResult) return null;
|
||||||
|
|
||||||
|
if (typeof taskResult === 'string') {
|
||||||
|
const trimmed = taskResult.trim();
|
||||||
|
// 快速检查,避免异常
|
||||||
|
if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) {
|
||||||
|
return { raw_text: trimmed };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(taskResult);
|
||||||
|
} catch {
|
||||||
|
// 静默处理,避免大量日志
|
||||||
|
return { raw_text: trimmed, parse_error: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return taskResult;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 减少视频预加载
|
||||||
|
**影响**: 减少200-400MB初始加载
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// components/pages/home-page2.tsx
|
||||||
|
// 找到 preloadAllVideos 函数,替换为:
|
||||||
|
const preloadCriticalVideos = async () => {
|
||||||
|
// 只预加载首屏关键视频
|
||||||
|
const criticalVideo = "https://cdn.qikongjian.com/videos/home.mp4";
|
||||||
|
|
||||||
|
// 检查网络条件
|
||||||
|
const connection = (navigator as any).connection;
|
||||||
|
if (connection?.saveData || connection?.effectiveType === '2g') {
|
||||||
|
return; // 慢速网络跳过预加载
|
||||||
|
}
|
||||||
|
|
||||||
|
await preloadVideo(criticalVideo);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔥 快速优化(2小时内)
|
||||||
|
|
||||||
|
### 4. TensorFlow.js 按需加载
|
||||||
|
**影响**: 减少271MB首屏加载
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 创建 hooks/useTensorFlow.ts
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
|
||||||
|
export const useTensorFlow = () => {
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [tfModule, setTfModule] = useState(null);
|
||||||
|
|
||||||
|
const loadTensorFlow = useCallback(async () => {
|
||||||
|
if (tfModule) return tfModule;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const [tf, cocoSsd] = await Promise.all([
|
||||||
|
import('@tensorflow/tfjs'),
|
||||||
|
import('@tensorflow-models/coco-ssd')
|
||||||
|
]);
|
||||||
|
|
||||||
|
const module = { tf, cocoSsd };
|
||||||
|
setTfModule(module);
|
||||||
|
return module;
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [tfModule]);
|
||||||
|
|
||||||
|
return { loadTensorFlow, isLoading, isLoaded: !!tfModule };
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 更新人物检测组件
|
||||||
|
export const PersonDetection = ({ videoSrc, onDetection }) => {
|
||||||
|
const { loadTensorFlow, isLoading } = useTensorFlow();
|
||||||
|
const [isReady, setIsReady] = useState(false);
|
||||||
|
|
||||||
|
if (!isReady) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
await loadTensorFlow();
|
||||||
|
setIsReady(true);
|
||||||
|
}}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? 'Loading AI...' : 'Enable AI Detection'}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <PersonDetectionCore videoSrc={videoSrc} onDetection={onDetection} />;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. API请求超时优化
|
||||||
|
**影响**: 改善用户体验,减少长时间等待
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// api/request.ts - 修改超时配置
|
||||||
|
const request = axios.create({
|
||||||
|
baseURL: BASE_URL,
|
||||||
|
timeout: 30000, // ✅ 改为30秒,原来是300秒
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. 轮询频率优化
|
||||||
|
**影响**: 减少服务器负载,提升响应速度
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 找到轮询逻辑,修改间隔时间
|
||||||
|
const getRefreshInterval = () => {
|
||||||
|
const hasRunningTasks = /* 检查逻辑 */;
|
||||||
|
|
||||||
|
if (hasRunningTasks) {
|
||||||
|
return 15000; // ✅ 改为15秒,原来是10秒
|
||||||
|
} else {
|
||||||
|
return 60000; // ✅ 改为60秒,原来是30秒
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## ⚡ 中期优化(1天内)
|
||||||
|
|
||||||
|
### 7. Ant Design 按需导入
|
||||||
|
|
||||||
|
**步骤1**: 安装插件
|
||||||
|
```bash
|
||||||
|
npm install babel-plugin-import --save-dev
|
||||||
|
```
|
||||||
|
|
||||||
|
**步骤2**: 配置babel
|
||||||
|
```javascript
|
||||||
|
// .babelrc
|
||||||
|
{
|
||||||
|
"presets": ["next/babel"],
|
||||||
|
"plugins": [
|
||||||
|
["import", {
|
||||||
|
"libraryName": "antd",
|
||||||
|
"libraryDirectory": "es",
|
||||||
|
"style": "css"
|
||||||
|
}]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**步骤3**: 更新导入方式
|
||||||
|
```typescript
|
||||||
|
// ❌ 当前方式
|
||||||
|
import { Button, Modal, Input } from 'antd';
|
||||||
|
|
||||||
|
// ✅ 优化方式
|
||||||
|
import Button from 'antd/es/button';
|
||||||
|
import Modal from 'antd/es/modal';
|
||||||
|
import Input from 'antd/es/input';
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8. Three.js 条件加载
|
||||||
|
**影响**: 减少31MB非必要加载
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// components/vanta-halo-background.tsx
|
||||||
|
import { lazy, Suspense } from 'react';
|
||||||
|
|
||||||
|
// 检查设备性能
|
||||||
|
const shouldLoadThreeJS = () => {
|
||||||
|
const hardwareConcurrency = navigator.hardwareConcurrency || 2;
|
||||||
|
const deviceMemory = (navigator as any).deviceMemory || 4;
|
||||||
|
|
||||||
|
return hardwareConcurrency >= 4 && deviceMemory >= 4;
|
||||||
|
};
|
||||||
|
|
||||||
|
const VantaBackground = lazy(() => {
|
||||||
|
if (!shouldLoadThreeJS()) {
|
||||||
|
return import('./StaticBackground'); // 静态背景降级
|
||||||
|
}
|
||||||
|
return import('./VantaHaloBackground');
|
||||||
|
});
|
||||||
|
|
||||||
|
export const BackgroundWrapper = () => (
|
||||||
|
<Suspense fallback={<div className="bg-gradient-to-br from-blue-900 to-purple-900" />}>
|
||||||
|
<VantaBackground />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9. 构建配置优化
|
||||||
|
**影响**: 改善代码分割,减少bundle大小
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// next.config.js - 添加生产环境优化
|
||||||
|
const nextConfig = {
|
||||||
|
webpack: (config, { dev, isServer }) => {
|
||||||
|
// 生产环境也需要优化
|
||||||
|
if (!dev && !isServer) {
|
||||||
|
config.optimization.splitChunks = {
|
||||||
|
chunks: 'all',
|
||||||
|
cacheGroups: {
|
||||||
|
// 分离大型第三方库
|
||||||
|
tensorflow: {
|
||||||
|
test: /[\\/]node_modules[\\/]@tensorflow/,
|
||||||
|
name: 'tensorflow',
|
||||||
|
chunks: 'async',
|
||||||
|
priority: 30,
|
||||||
|
},
|
||||||
|
antd: {
|
||||||
|
test: /[\\/]node_modules[\\/]antd/,
|
||||||
|
name: 'antd',
|
||||||
|
chunks: 'all',
|
||||||
|
priority: 25,
|
||||||
|
},
|
||||||
|
vendor: {
|
||||||
|
test: /[\\/]node_modules[\\/]/,
|
||||||
|
name: 'vendors',
|
||||||
|
chunks: 'all',
|
||||||
|
priority: 20,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 启用压缩和优化
|
||||||
|
compress: true,
|
||||||
|
optimizeFonts: true,
|
||||||
|
|
||||||
|
experimental: {
|
||||||
|
optimizeCss: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📊 验证优化效果
|
||||||
|
|
||||||
|
### 快速测试命令
|
||||||
|
```bash
|
||||||
|
# 1. 构建并分析bundle
|
||||||
|
ANALYZE=true npm run build
|
||||||
|
|
||||||
|
# 2. 启动应用
|
||||||
|
npm start
|
||||||
|
|
||||||
|
# 3. 在另一个终端运行Lighthouse
|
||||||
|
npx lighthouse http://localhost:3000 --output=html --output-path=lighthouse-report.html
|
||||||
|
|
||||||
|
# 4. 检查bundle大小
|
||||||
|
ls -lh .next/static/chunks/ | head -10
|
||||||
|
```
|
||||||
|
|
||||||
|
### 关键指标检查
|
||||||
|
- **首屏加载时间**: 应该从8-15秒降到4-6秒
|
||||||
|
- **Bundle大小**: 主要chunk应该从500KB+降到300KB以下
|
||||||
|
- **Lighthouse性能分数**: 应该从30-50分提升到60-70分
|
||||||
|
- **控制台错误**: JSON解析错误应该大幅减少
|
||||||
|
|
||||||
|
## 🚀 预期效果
|
||||||
|
|
||||||
|
实施这些快速修复后,你应该看到:
|
||||||
|
|
||||||
|
| 指标 | 优化前 | 优化后 | 提升 |
|
||||||
|
|------|--------|--------|------|
|
||||||
|
| **首屏加载** | 8-15秒 | 4-6秒 | **50%** ⬇️ |
|
||||||
|
| **Bundle大小** | 2-3MB | 1.5-2MB | **30%** ⬇️ |
|
||||||
|
| **资源加载** | 300-500MB | 100-200MB | **60%** ⬇️ |
|
||||||
|
| **Lighthouse分数** | 30-50 | 60-70 | **40%** ⬆️ |
|
||||||
|
|
||||||
|
## ⚠️ 注意事项
|
||||||
|
|
||||||
|
1. **备份代码**: 修改前请确保代码已提交到git
|
||||||
|
2. **测试功能**: 每个修改后都要测试相关功能是否正常
|
||||||
|
3. **分步实施**: 建议一个一个修改,避免同时修改太多
|
||||||
|
4. **监控错误**: 修改后注意观察控制台是否有新的错误
|
||||||
|
|
||||||
|
## 🆘 遇到问题?
|
||||||
|
|
||||||
|
### 常见问题解决
|
||||||
|
|
||||||
|
**Q: 图片优化后显示异常?**
|
||||||
|
A: 检查图片路径和域名配置,确保CDN域名已添加到配置中
|
||||||
|
|
||||||
|
**Q: TensorFlow动态加载失败?**
|
||||||
|
A: 检查网络连接,可能需要添加错误处理和重试机制
|
||||||
|
|
||||||
|
**Q: Ant Design样式丢失?**
|
||||||
|
A: 确保babel配置正确,可能需要重启开发服务器
|
||||||
|
|
||||||
|
**Q: 构建失败?**
|
||||||
|
A: 检查webpack配置语法,确保所有依赖都已安装
|
||||||
|
|
||||||
|
### 紧急回滚
|
||||||
|
如果优化后出现严重问题,可以快速回滚:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 回滚到上一个提交
|
||||||
|
git reset --hard HEAD~1
|
||||||
|
|
||||||
|
# 或者撤销特定文件的修改
|
||||||
|
git checkout HEAD -- next.config.js
|
||||||
|
git checkout HEAD -- components/pages/home-page2.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**开始优化吧!** 🚀 这些修改可以立即带来显著的性能提升。
|
||||||
Loading…
x
Reference in New Issue
Block a user