forked from 77media/video-flow
merge dev & fix conflict
This commit is contained in:
commit
8daf561674
@ -4,7 +4,7 @@ NEXT_PUBLIC_BASE_URL = https://77.smartvideo.py.qikongjian.com
|
||||
NEXT_PUBLIC_CUT_URL = https://77.smartcut.py.qikongjian.com
|
||||
|
||||
# 失败率
|
||||
NEXT_PUBLIC_ERROR_CONFIG = 0.1
|
||||
NEXT_PUBLIC_ERROR_CONFIG = 0.5
|
||||
# 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
|
||||
|
||||
@ -367,6 +367,8 @@ export interface VideoFlowProjectResponse {
|
||||
final_simple_video: string;
|
||||
/** 最终视频 */
|
||||
final_video: string;
|
||||
/** 画面比例 */
|
||||
aspect_ratio: string;
|
||||
}
|
||||
/**
|
||||
* 新角色列表项接口
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { post } from './request';
|
||||
import { ApiResponse } from './common';
|
||||
import { Character } from './video_flow';
|
||||
import { AspectRatioValue } from '@/components/ChatInputBox/AspectRatioSelector';
|
||||
|
||||
// 创建剧集的数据类型
|
||||
export interface CreateScriptEpisodeRequest {
|
||||
@ -78,6 +79,7 @@ interface MovieProject {
|
||||
last_message: string;
|
||||
updated_at: string;
|
||||
created_at: string;
|
||||
aspect_ratio: AspectRatioValue;
|
||||
}
|
||||
|
||||
interface ListMovieProjectsResponse {
|
||||
|
||||
@ -1,244 +1,10 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import type { OAuthCallbackParams } from '@/app/types/google-oauth';
|
||||
|
||||
/**
|
||||
* Google OAuth回调处理API
|
||||
* 处理从Google OAuth返回的授权码,完成用户认证
|
||||
* 注意:POST 方法已废弃,业务逻辑已移至 /users/oauth/callback 页面
|
||||
* 此文件现在仅用于处理 Google 的 GET 回调重定向
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { code, state, inviteCode } = body;
|
||||
|
||||
// 验证必需参数
|
||||
if (!code || !state) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'Missing required parameters: code and state'
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 开发模式:使用测试环境的OAuth处理
|
||||
const isDevelopment = process.env.NODE_ENV === 'development';
|
||||
const useTestEnv = isDevelopment; // 开发环境默认使用测试环境
|
||||
|
||||
if (useTestEnv) {
|
||||
console.log('🧪 开发模式:使用模拟OAuth响应');
|
||||
|
||||
// 解析state参数获取origin信息
|
||||
let stateData: any = {};
|
||||
try {
|
||||
stateData = JSON.parse(state);
|
||||
} catch (e) {
|
||||
console.warn('无法解析state参数:', state);
|
||||
}
|
||||
|
||||
// 模拟成功的OAuth响应
|
||||
const mockResponse = {
|
||||
success: true,
|
||||
data: {
|
||||
token: 'dev-mock-token-' + Date.now(),
|
||||
user: {
|
||||
userId: 'dev-user-' + Math.random().toString(36).substr(2, 9),
|
||||
userName: 'Development User',
|
||||
name: 'Dev User',
|
||||
email: 'dev@movieflow.com',
|
||||
authType: 'GOOGLE',
|
||||
isNewUser: false
|
||||
},
|
||||
userInfo: {
|
||||
userId: 'dev-user-' + Math.random().toString(36).substr(2, 9),
|
||||
userName: 'Development User',
|
||||
name: 'Dev User',
|
||||
email: 'dev@movieflow.com',
|
||||
authType: 'GOOGLE',
|
||||
isNewUser: false
|
||||
},
|
||||
message: 'Development mode - Google authentication simulated'
|
||||
}
|
||||
};
|
||||
|
||||
console.log('返回模拟OAuth响应:', mockResponse);
|
||||
return NextResponse.json(mockResponse);
|
||||
}
|
||||
|
||||
// 解析state参数
|
||||
let stateData: any = {};
|
||||
try {
|
||||
stateData = JSON.parse(state);
|
||||
} catch (e) {
|
||||
console.warn('无法解析state参数:', state);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'Invalid state parameter'
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
console.log('Google OAuth回调处理开始:', {
|
||||
codeLength: code.length,
|
||||
stateData,
|
||||
inviteCode
|
||||
});
|
||||
|
||||
// 第一步:使用authorization code向Google换取access token和id_token
|
||||
const googleClientId = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || '847079918888-o1nne8d3ij80dn20qurivo987pv07225.apps.googleusercontent.com';
|
||||
const googleClientSecret = process.env.GOOGLE_CLIENT_SECRET || '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';
|
||||
|
||||
if (!googleClientSecret) {
|
||||
console.error('Google Client Secret未配置');
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'Google Client Secret not configured'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
console.log('开始向Google交换token...');
|
||||
|
||||
// 创建fetch配置,包含超时和重试机制
|
||||
const fetchOptions = {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
code,
|
||||
client_id: googleClientId,
|
||||
client_secret: googleClientSecret,
|
||||
redirect_uri: getRedirectUri(request),
|
||||
grant_type: 'authorization_code',
|
||||
}),
|
||||
// 增加超时时间到30秒
|
||||
signal: AbortSignal.timeout(30000)
|
||||
};
|
||||
|
||||
let tokenResponse;
|
||||
try {
|
||||
tokenResponse = await fetch('https://oauth2.googleapis.com/token', fetchOptions);
|
||||
} catch (fetchError: any) {
|
||||
console.error('Google API连接失败:', fetchError.message);
|
||||
|
||||
// 如果是超时错误,提供更友好的错误信息
|
||||
if (fetchError.name === 'TimeoutError' || fetchError.code === 'UND_ERR_CONNECT_TIMEOUT') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'Google authentication service is temporarily unavailable. Please check your network connection and try again.'
|
||||
},
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
|
||||
throw fetchError;
|
||||
}
|
||||
|
||||
console.log('Google token exchange响应状态:', tokenResponse.status);
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
const errorText = await tokenResponse.text();
|
||||
console.error('Google token exchange failed:', errorText);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'Failed to exchange authorization code for tokens'
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const tokenData = await tokenResponse.json();
|
||||
console.log('Google token exchange成功,获得token');
|
||||
const { id_token } = tokenData;
|
||||
|
||||
if (!id_token) {
|
||||
console.error('No id_token received from Google');
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'No id_token received from Google'
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 第二步:使用id_token调用Java后端
|
||||
const javaBaseUrl = process.env.NEXT_PUBLIC_JAVA_URL || 'https://auth.test.movieflow.ai';
|
||||
console.log('开始调用Java后端:', javaBaseUrl);
|
||||
|
||||
const backendResponse = await fetch(`${javaBaseUrl}/api/auth/google/login`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
idToken: id_token, // 使用从Google获取的id_token
|
||||
action: 'auto', // 自动判断登录或注册
|
||||
inviteCode: inviteCode || stateData.inviteCode || undefined
|
||||
})
|
||||
});
|
||||
|
||||
console.log('Java后端响应状态:', backendResponse.status);
|
||||
const backendResult = await backendResponse.json();
|
||||
|
||||
if (!backendResponse.ok || !backendResult.success) {
|
||||
console.error('Java后端处理Google OAuth失败:', backendResult);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: backendResult.message || 'Google authentication failed'
|
||||
},
|
||||
{ status: backendResponse.status || 500 }
|
||||
);
|
||||
}
|
||||
|
||||
console.log('Google OAuth认证成功:', {
|
||||
userId: backendResult.data?.user?.userId,
|
||||
email: backendResult.data?.user?.email
|
||||
});
|
||||
|
||||
// 返回成功结果
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
token: backendResult.data.token,
|
||||
user: backendResult.data.userInfo || backendResult.data.user,
|
||||
message: 'Google authentication successful'
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('Google OAuth回调处理错误:', error);
|
||||
|
||||
// 检查是否是网络连接错误
|
||||
if (error.code === 'ECONNREFUSED' || error.message?.includes('fetch failed')) {
|
||||
console.error('网络连接失败,可能是Java后端服务不可用');
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'Backend service unavailable. Please try again later.'
|
||||
},
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: error.message || 'Internal server error during Google OAuth callback'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据请求获取正确的redirect_uri
|
||||
@ -271,11 +37,11 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
if (!code || !state) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'Missing required parameters: code and state'
|
||||
},
|
||||
{ status: 400 }
|
||||
{
|
||||
success: false,
|
||||
message: 'Missing required parameters: code and state'
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -151,7 +151,7 @@ export default function SharePage(): JSX.Element {
|
||||
|
||||
|
||||
const toggleRow = React.useCallback((id: string) => {
|
||||
setExpandedRowIds((prev) => {
|
||||
setExpandedRowIds((prev: Set<string>) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
@ -185,45 +185,62 @@ export default function SharePage(): JSX.Element {
|
||||
<span className="text-sm font-medium text-custom-blue/50">Step 1</span>
|
||||
<span className="rounded bg-white/10 px-2 py-0.5 text-xs text-white bg-custom-purple/50">Share</span>
|
||||
</div>
|
||||
<p data-alt="step-desc" className="mt-2 text-sm text-white/70">Copy your invitation code and share it with friends.</p>
|
||||
<p data-alt="step-desc" className="mt-2 text-sm text-white/70">Copy your invitation link and share it with friends.</p>
|
||||
</li>
|
||||
<li data-alt="step" className="rounded-md border border-white/20 p-4">
|
||||
<div data-alt="step-header" className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-custom-blue/50">Step 2</span>
|
||||
<span className="rounded bg-white/10 px-2 py-0.5 text-xs text-white bg-custom-purple/50">Register</span>
|
||||
</div>
|
||||
<p data-alt="step-desc" className="mt-2 text-sm text-white/70">Friends register and enter your invitation code.</p>
|
||||
<p data-alt="step-desc" className="mt-2 text-sm text-white/70">Friends click the link and register directly.</p>
|
||||
</li>
|
||||
<li data-alt="step" className="rounded-md border border-white/20 p-4">
|
||||
<div data-alt="step-header" className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-custom-blue/50">Step 3</span>
|
||||
<span className="rounded bg-white/10 px-2 py-0.5 text-xs text-white bg-custom-purple/50">Reward</span>
|
||||
</div>
|
||||
<p data-alt="step-desc" className="mt-2 text-sm text-white/70">You both receive rewards after successful registration.</p>
|
||||
<p data-alt="step-desc" className="mt-2 text-sm text-white/70">You both receive rewards after your friend activates their account.</p>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
{/* Section 2: My Invitation Code */}
|
||||
<section data-alt="my-invite-code" className="mb-8 rounded-lg border border-white/20 bg-black p-6 shadow-sm">
|
||||
<div data-alt="code-panel" className="mt-4 grid gap-6 sm:grid-cols-4">
|
||||
<div data-alt="code-box" className="sm:col-span-2">
|
||||
<h2 data-alt="section-title" className="text-lg font-medium text-white">My Invitation Code</h2>
|
||||
{/* Section 2: My Invitation Link */}
|
||||
<section data-alt="my-invite-link" className="mb-8 rounded-lg border border-white/20 bg-black p-6 shadow-sm">
|
||||
<div data-alt="link-panel" className="mt-4 grid gap-6 sm:grid-cols-4">
|
||||
<div data-alt="link-box" className="sm:col-span-2">
|
||||
<h2 data-alt="section-title" className="text-lg font-medium text-white">My Invitation Link</h2>
|
||||
<div className="flex items-center gap-3">
|
||||
<div data-alt="code" className="rounded-md border border-white/20 bg-white/10 px-4 py-2 text-lg font-semibold tracking-wider text-white">
|
||||
{inviteCode}
|
||||
<div data-alt="link-container" className="relative w-full max-w-2xl overflow-hidden rounded-md border border-white/20 bg-white/10">
|
||||
<div
|
||||
data-alt="link-content"
|
||||
className="relative px-4 py-2 text-sm font-mono text-white/90 whitespace-nowrap overflow-hidden"
|
||||
style={{
|
||||
background: 'linear-gradient(90deg, rgba(255,255,255,0.9) 0%, rgba(255,255,255,0.7) 50%, rgba(255,255,255,0.3) 70%, rgba(255,255,255,0.1) 90%, rgba(255,255,255,0) 100%)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
backgroundClip: 'text',
|
||||
maskImage: 'linear-gradient(90deg, rgba(0,0,0,1) 0%, rgba(0,0,0,1) 70%, rgba(0,0,0,0.5) 85%, rgba(0,0,0,0) 100%)',
|
||||
WebkitMaskImage: 'linear-gradient(90deg, rgba(0,0,0,1) 0%, rgba(0,0,0,1) 70%, rgba(0,0,0,0.5) 85%, rgba(0,0,0,0) 100%)'
|
||||
}}
|
||||
>
|
||||
{inviteCode ? `${'https://www.movieflow.ai'}/signup?inviteCode=${inviteCode}` : '-'}
|
||||
</div>
|
||||
{/* 右侧渐变遮挡 */}
|
||||
<div
|
||||
data-alt="right-mask"
|
||||
className="absolute right-0 top-0 h-full w-16 bg-gradient-to-l from-black/80 to-transparent pointer-events-none"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
data-alt="copy-button"
|
||||
className="inline-flex h-9 items-center justify-center rounded-full bg-gradient-to-r from-custom-blue to-custom-purple px-3 text-sm font-medium text-black/90 hover:opacity-90 active:translate-y-px"
|
||||
className="inline-flex h-9 items-center justify-center rounded-full bg-gradient-to-r from-custom-blue to-custom-purple px-3 text-sm font-medium text-black/90 hover:opacity-90 active:translate-y-px flex-shrink-0"
|
||||
onClick={handleCopy}
|
||||
type="button"
|
||||
aria-label="Copy invitation code"
|
||||
aria-label="Copy invitation link"
|
||||
>
|
||||
{copyState === 'copied' ? 'Copied' : copyState === 'error' ? 'Failed' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
<p data-alt="hint" className="mt-2 text-xs text-white/60">Share this code. Your friends can enter it during registration.</p>
|
||||
<p data-alt="hint" className="mt-2 text-xs text-white/60">Share this link. Your friends can register directly through it.</p>
|
||||
</div>
|
||||
<div data-alt="total-credits" className="flex flex-col items-start justify-center rounded-md border border-white/20 bg-white/5 p-4">
|
||||
<span className="text-sm text-white/70">Total Credits</span>
|
||||
@ -233,7 +250,7 @@ export default function SharePage(): JSX.Element {
|
||||
<div data-alt="invited-count" className="flex flex-col items-start justify-center rounded-md border border-white/20 bg-white/5 p-4">
|
||||
<span className="text-sm text-white/70">Invited Friends</span>
|
||||
<span className="mt-1 text-2xl font-semibold text-transparent bg-clip-text bg-gradient-to-r from-custom-blue to-custom-purple">{invitedCount}</span>
|
||||
<span className="mt-2 text-xs text-white/60">Points detail will be available soon.</span>
|
||||
<span className="mt-2 text-xs text-white/60">Point details will be available soon.</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@ -245,9 +262,9 @@ export default function SharePage(): JSX.Element {
|
||||
<div data-alt="pagination" className="flex items-center gap-2">
|
||||
<button
|
||||
data-alt="prev-page"
|
||||
className="inline-flex h-8 items-center justify-center rounded border border-gray-300 bg-white px-2 text-sm text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
className="inline-flex h-6 items-center justify-center rounded border border-gray-300 bg-white px-2 text-sm text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
type="button"
|
||||
onClick={() => setPageIndex((p) => Math.max(0, p - 1))}
|
||||
onClick={() => setPageIndex((p: number) => Math.max(0, p - 1))}
|
||||
disabled={!pagination.has_prev}
|
||||
aria-label="Previous page"
|
||||
>
|
||||
@ -258,9 +275,9 @@ export default function SharePage(): JSX.Element {
|
||||
</span>
|
||||
<button
|
||||
data-alt="next-page"
|
||||
className="inline-flex h-8 items-center justify-center rounded border border-gray-300 bg-white px-2 text-sm text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
className="inline-flex h-6 items-center justify-center rounded border border-gray-300 bg-white px-2 text-sm text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
type="button"
|
||||
onClick={() => setPageIndex((p) => Math.min(totalPages - 1, p + 1))}
|
||||
onClick={() => setPageIndex((p: number) => Math.min(totalPages - 1, p + 1))}
|
||||
disabled={!pagination.has_next}
|
||||
aria-label="Next page"
|
||||
>
|
||||
@ -279,7 +296,7 @@ export default function SharePage(): JSX.Element {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody data-alt="table-body" className="divide-y divide-white/10 bg-black">
|
||||
{pagedRecords.map((r) => {
|
||||
{pagedRecords.map((r: any) => {
|
||||
const inviteRewardDisplay = r.reward_status === 1 ? r.activation_credits : 0;
|
||||
const payRewardDisplay = r.first_payment_credits;
|
||||
const totalReward = inviteRewardDisplay + payRewardDisplay;
|
||||
@ -321,7 +338,7 @@ export default function SharePage(): JSX.Element {
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-2 py-2">{payRewardDisplay ? payRewardDisplay : 'Unpaid'}</td>
|
||||
<td className="px-2 py-2">First Pay Reward</td>
|
||||
<td className="px-2 py-2">First Payment Reward</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@ -78,60 +78,126 @@ export default function OAuthCallback() {
|
||||
console.log('State数据:', stateData);
|
||||
console.log('最终使用的邀请码:', finalInviteCode);
|
||||
|
||||
// 调用后端处理授权码 - 直接调用Java后端
|
||||
const JAVA_BASE_URL = process.env.NEXT_PUBLIC_JAVA_URL || 'https://auth.test.movieflow.ai';
|
||||
// 直接处理 OAuth 回调(两步流程:Java验证 + Python注册)
|
||||
console.log('开始直接处理 OAuth 回调,无需经过 API 路由');
|
||||
|
||||
console.log('调用Java后端OAuth接口:', `${JAVA_BASE_URL}/api/auth/google/callback`);
|
||||
// 第一步:调用Java验证接口(只验证不创建用户)
|
||||
const javaBaseUrl = 'https://auth.test.movieflow.ai';
|
||||
console.log('🔧 调用 Java 验证接口:', javaBaseUrl);
|
||||
|
||||
const response = await fetch(`${JAVA_BASE_URL}/api/auth/google/callback`, {
|
||||
const verifyResponse = await fetch(`${javaBaseUrl}/api/auth/google/callback`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
code: params.code,
|
||||
state: params.state,
|
||||
...(finalInviteCode && { inviteCode: finalInviteCode })
|
||||
code: params.code, // Google authorization code
|
||||
state: params.state, // state参数
|
||||
inviteCode: finalInviteCode, // 邀请码
|
||||
skipUserCreation: true // 🔑 关键:只验证不创建用户
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// 处理HTTP错误状态
|
||||
const errorText = await response.text();
|
||||
console.error('OAuth API调用失败:', response.status, errorText);
|
||||
throw new Error(`OAuth API调用失败 (${response.status}): ${errorText}`);
|
||||
console.log('Java验证接口响应状态:', verifyResponse.status);
|
||||
const verifyResult = await verifyResponse.json();
|
||||
|
||||
if (!verifyResponse.ok || !verifyResult.success) {
|
||||
console.error('Java验证接口处理失败:', verifyResult);
|
||||
throw new Error(verifyResult.message || 'Google token verification failed');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log('OAuth API响应:', result);
|
||||
console.log('Google Token验证成功:', {
|
||||
email: verifyResult.data?.email,
|
||||
name: verifyResult.data?.name
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
console.log('Google登录成功:', result);
|
||||
setStatus("success");
|
||||
setMessage("Login successful! Redirecting to dashboard...");
|
||||
// 第二步:调用Python注册接口进行用户创建和积分发放
|
||||
const smartvideoBaseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'https://77.smartvideo.py.qikongjian.com';
|
||||
console.log('🔧 调用 Python 注册接口:', smartvideoBaseUrl);
|
||||
|
||||
// 保存用户信息到localStorage (使用认证库的统一键名)
|
||||
if (result.data?.user) {
|
||||
localStorage.setItem('currentUser', JSON.stringify(result.data.user));
|
||||
}
|
||||
if (result.data?.token) {
|
||||
localStorage.setItem('token', result.data.token);
|
||||
}
|
||||
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
|
||||
})
|
||||
});
|
||||
|
||||
// 2秒后跳转到主页
|
||||
setTimeout(() => {
|
||||
// 修复: Google登录成功后应该跳转到主页面,而不是来源页面
|
||||
const returnUrl = '/movies';
|
||||
window.location.href = returnUrl;
|
||||
}, 2000);
|
||||
} else {
|
||||
throw new Error(result.message || 'Google登录失败');
|
||||
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);
|
||||
setStatus("success");
|
||||
setMessage("Login successful! Redirecting to dashboard...");
|
||||
|
||||
// 保存用户信息到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: any) {
|
||||
console.error("OAuth callback error:", error);
|
||||
|
||||
// 检查是否是网络连接错误
|
||||
if (error.code === 'ECONNREFUSED' || error.message?.includes('fetch failed')) {
|
||||
console.error('网络连接失败,可能是后端服务不可用');
|
||||
setStatus("error");
|
||||
setMessage('Backend service unavailable. Please try again later.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否是 JSON 解析错误
|
||||
if (error.message?.includes('JSON') || error.name === 'SyntaxError') {
|
||||
console.error('响应数据解析失败:', error);
|
||||
setStatus("error");
|
||||
setMessage('Invalid response format from backend services');
|
||||
return;
|
||||
}
|
||||
|
||||
// 处理邮箱冲突错误
|
||||
if (error.type === 'EMAIL_CONFLICT') {
|
||||
setStatus("conflict");
|
||||
setMessage(error.message);
|
||||
|
||||
@ -50,7 +50,7 @@ import { PcTemplateModal } from "./PcTemplateModal";
|
||||
import { H5TemplateDrawer } from "./H5TemplateDrawer";
|
||||
import { PcPhotoStoryModal } from "./PcPhotoStoryModal";
|
||||
import { H5PhotoStoryDrawer } from "./H5PhotoStoryDrawer";
|
||||
import { AspectRatioSelector } from "./AspectRatioSelector";
|
||||
import { AspectRatioSelector, AspectRatioValue } from "./AspectRatioSelector";
|
||||
|
||||
const LauguageOptions = [
|
||||
{ value: "english", label: "English", isVip: false, code:'EN' },
|
||||
@ -131,7 +131,7 @@ export function ChatInputBox({ noData }: { noData: boolean }) {
|
||||
language: string;
|
||||
videoDuration: string;
|
||||
expansion_mode: boolean;
|
||||
aspect_ratio: "VIDEO_ASPECT_RATIO_LANDSCAPE" | "VIDEO_ASPECT_RATIO_PORTRAIT";
|
||||
aspect_ratio: AspectRatioValue;
|
||||
};
|
||||
|
||||
const [configOptions, setConfigOptions] = useState<ConfigOptions>({
|
||||
@ -469,15 +469,15 @@ export function ChatInputBox({ noData }: { noData: boolean }) {
|
||||
</Dropdown>
|
||||
|
||||
{/* 分隔线(移动端隐藏,避免拥挤) */}
|
||||
{/* <div className="hidden sm:block w-px h-4 bg-white/[0.20]"></div> */}
|
||||
<div className="hidden sm:block w-px h-4 bg-white/[0.20]"></div>
|
||||
|
||||
{/* 横/竖屏选择 上线暂时不开放 */}
|
||||
{/* <AspectRatioSelector
|
||||
{/* 横/竖屏选择 */}
|
||||
<AspectRatioSelector
|
||||
value={configOptions.aspect_ratio}
|
||||
onChange={(v) => onConfigChange('aspect_ratio', v)}
|
||||
placement="top"
|
||||
className={`${isMobile ? '!px-1' : ''}`}
|
||||
/> */}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 右侧Action按钮 */}
|
||||
|
||||
@ -359,12 +359,12 @@ export const H5PhotoStoryDrawer = ({
|
||||
|
||||
<div data-alt="bottom-action-bar" className="sticky bottom-0 left-0 right-0 backdrop-blur border-t border-white/10 px-3 py-2">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{/* 横/竖屏选择 上线暂时不开放 */}
|
||||
{/* <AspectRatioSelector
|
||||
{/* 横/竖屏选择 */}
|
||||
<AspectRatioSelector
|
||||
value={aspectUI}
|
||||
onChange={setAspectUI}
|
||||
placement="top"
|
||||
/> */}
|
||||
/>
|
||||
{!hasAnalyzed ? (
|
||||
<Tooltip title={activeImageUrl ? "Analyze image content" : "Please upload an image first"} placement="top">
|
||||
<div>
|
||||
|
||||
@ -556,12 +556,12 @@ export const H5TemplateDrawer = ({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* 横/竖屏选择 上线暂时不开放 */}
|
||||
{/* <AspectRatioSelector
|
||||
{/* 横/竖屏选择 */}
|
||||
<AspectRatioSelector
|
||||
value={aspectUI}
|
||||
onChange={setAspectUI}
|
||||
placement="top"
|
||||
/> */}
|
||||
/>
|
||||
<ActionButton
|
||||
isCreating={isTemplateCreating || localLoading > 0}
|
||||
handleCreateVideo={handleConfirm}
|
||||
|
||||
@ -321,12 +321,12 @@ export const PcPhotoStoryModal = ({
|
||||
placeholder="Share your creative ideas about the image and let AI create a movie story for you..."
|
||||
/>
|
||||
<div className="absolute bottom-1 right-0 flex gap-2 items-center">
|
||||
{/* 横/竖屏选择 上线暂时不开放 */}
|
||||
{/* <AspectRatioSelector
|
||||
{/* 横/竖屏选择 */}
|
||||
<AspectRatioSelector
|
||||
value={aspectUI}
|
||||
onChange={setAspectUI}
|
||||
placement="top"
|
||||
/> */}
|
||||
/>
|
||||
{!hasAnalyzed ? (
|
||||
<Tooltip title={activeImageUrl ? "Analyze image content" : "Please upload an image first"} placement="top">
|
||||
<ActionButton
|
||||
|
||||
@ -707,12 +707,12 @@ export const PcTemplateModal = ({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* 横/竖屏选择 上线暂时不开放 */}
|
||||
{/* <AspectRatioSelector
|
||||
{/* 横/竖屏选择 */}
|
||||
<AspectRatioSelector
|
||||
value={aspectUI}
|
||||
onChange={setAspectUI}
|
||||
placement="top"
|
||||
/> */}
|
||||
/>
|
||||
<ActionButton
|
||||
isCreating={isTemplateCreating || localLoading > 0}
|
||||
handleCreateVideo={handleConfirm}
|
||||
|
||||
@ -4,6 +4,8 @@ import { MessageBlock } from "./types";
|
||||
import { useUploadFile } from "@/app/service/domain/service";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { QuickActionTags, QuickAction } from "./QuickActionTags";
|
||||
import { useDeviceType } from '@/hooks/useDeviceType';
|
||||
|
||||
|
||||
// 防抖函数
|
||||
function debounce<T extends (...args: any[]) => void>(func: T, wait: number) {
|
||||
@ -35,6 +37,7 @@ export function InputBar({ onSend, setVideoPreview, initialVideoUrl, initialVide
|
||||
const [videoUrl, setVideoUrl] = useState<string | null>(initialVideoUrl || null);
|
||||
const [videoId, setVideoId] = useState<string | null>(initialVideoId || null);
|
||||
const [isMultiline, setIsMultiline] = useState(false);
|
||||
const { isMobile, isTablet, isDesktop } = useDeviceType();
|
||||
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const { uploadFile } = useUploadFile();
|
||||
@ -174,7 +177,7 @@ export function InputBar({ onSend, setVideoPreview, initialVideoUrl, initialVide
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-alt="input-bar">
|
||||
<div data-alt="input-bar" className={`${isMobile ? 'absolute bottom-0 left-0 right-0 bg-[#141414]' : ''}`}>
|
||||
{/* 媒体预览 */}
|
||||
<div className="px-3 pt-3 flex gap-2" data-alt="media-preview">
|
||||
{/* 图片预览 */}
|
||||
|
||||
@ -5,6 +5,7 @@ import { bubbleVariants, hhmm } from "./utils";
|
||||
import { ProgressBar } from "./ProgressBar";
|
||||
import { Loader2, AlertCircle, CheckCircle2 } from "lucide-react";
|
||||
import { Image } from 'antd';
|
||||
import { useDeviceType } from '@/hooks/useDeviceType';
|
||||
|
||||
interface MessageRendererProps {
|
||||
msg: ChatMessage;
|
||||
@ -15,6 +16,7 @@ export function MessageRenderer({ msg, sendMessage }: MessageRendererProps) {
|
||||
// Decide bubble style
|
||||
const isUser = msg.role === "user";
|
||||
const isSystem = msg.role === "system";
|
||||
const { isMobile, isTablet, isDesktop } = useDeviceType();
|
||||
|
||||
const bubbleClass = useMemo(() => {
|
||||
if (isSystem) return "bg-[#281c1459] text-white";
|
||||
@ -78,7 +80,7 @@ export function MessageRenderer({ msg, sendMessage }: MessageRendererProps) {
|
||||
data-alt="message-bubble"
|
||||
key={msg.id}
|
||||
>
|
||||
<div className={`max-w-[75%] rounded-2xl shadow-md p-3 ${bubbleClass}`}>
|
||||
<div className={`${isMobile ? 'max-w-full' : 'max-w-[75%]'} rounded-2xl shadow-md p-3 ${bubbleClass}`}>
|
||||
{/* Header */}
|
||||
{/* <div className="flex items-center gap-2 text-[11px] opacity-80 mb-1">
|
||||
{badge}
|
||||
|
||||
@ -7,6 +7,7 @@ import { useMessages } from "./useMessages";
|
||||
import { DateDivider } from "./DateDivider";
|
||||
import { LoadMoreButton } from "./LoadMoreButton";
|
||||
import { ChatMessage } from "./types";
|
||||
import { useDeviceType } from '@/hooks/useDeviceType';
|
||||
|
||||
interface SmartChatBoxProps {
|
||||
isSmartChatBoxOpen: boolean;
|
||||
@ -51,7 +52,7 @@ export default function SmartChatBox({
|
||||
// 消息列表引用
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const [isAtBottom, setIsAtBottom] = useState(true);
|
||||
|
||||
const { isMobile, isTablet, isDesktop } = useDeviceType();
|
||||
// 检查是否滚动到底部
|
||||
const checkIfAtBottom = useCallback(() => {
|
||||
if (listRef.current) {
|
||||
@ -148,9 +149,9 @@ export default function SmartChatBox({
|
||||
}, [messages]);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full text-gray-100 flex flex-col" data-alt="smart-chat-box">
|
||||
<div className={`${isMobile ? 'z-[49]' : '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" data-alt="chat-header">
|
||||
<div className={`px-4 py-3 border-b border-white/10 flex items-center justify-between ${isMobile ? 'sticky top-0 bg-[#141414] z-[1]' : ''}`} data-alt="chat-header">
|
||||
<div className="font-semibold flex items-center gap-2">
|
||||
<span>Chat</span>
|
||||
{/* System push toggle */}
|
||||
@ -159,7 +160,7 @@ export default function SmartChatBox({
|
||||
unCheckedChildren="Off"
|
||||
checked={systemPush}
|
||||
onChange={toggleSystemPush}
|
||||
className="ml-2 "
|
||||
className="ml-2"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs opacity-70">
|
||||
|
||||
@ -199,13 +199,13 @@ export default function CreateToVideo2() {
|
||||
onMouseLeave={() => handleMouseLeave(project.project_id)}
|
||||
data-alt="project-card"
|
||||
>
|
||||
{/* 视频/图片区域 */}
|
||||
<div className="relative aspect-video" onClick={() => router.push(`/movies/work-flow?episodeId=${project.project_id}`)}>
|
||||
{/* 视频/图片区域 */}
|
||||
<div className="relative w-full pb-[56.25%]" onClick={() => router.push(`/movies/work-flow?episodeId=${project.project_id}`)}>
|
||||
{(project.final_video_url || project.final_simple_video_url || project.video_urls) ? (
|
||||
<video
|
||||
ref={(el) => setVideoRef(project.project_id, el)}
|
||||
src={project.final_video_url || project.final_simple_video_url || project.video_urls}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
|
||||
className="absolute inset-0 w-full h-full object-contain group-hover:scale-105 transition-transform duration-500"
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
@ -216,7 +216,7 @@ export default function CreateToVideo2() {
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="w-full h-full bg-cover bg-center group-hover:scale-105 transition-transform duration-500"
|
||||
className="absolute inset-0 w-full h-full bg-contain bg-center bg-no-repeat group-hover:scale-105 transition-transform duration-500"
|
||||
style={{
|
||||
backgroundImage: `url(${cover_image1.src})`,
|
||||
}}
|
||||
|
||||
@ -20,8 +20,7 @@ import { showEditingNotification } from "@/components/pages/work-flow/editing-no
|
||||
// import { AIEditingIframeButton } from './work-flow/ai-editing-iframe';
|
||||
import { exportVideoWithRetry } from '@/utils/export-service';
|
||||
import { getFirstFrame } from '@/utils/tools';
|
||||
// 临时禁用视频编辑功能
|
||||
// import { EditPoint as EditPointType } from './work-flow/video-edit/types';
|
||||
import { EditPoint as EditPointType } from './work-flow/video-edit/types';
|
||||
import { AIEditingIframeButton } from './work-flow/ai-editing-iframe';
|
||||
import { useDeviceType } from '@/hooks/useDeviceType';
|
||||
import { H5ProgressToastProvider, useH5ProgressToast } from '@/components/ui/h5-progress-toast';
|
||||
@ -223,7 +222,8 @@ const WorkFlow = React.memo(function WorkFlow() {
|
||||
showGotoCutButton,
|
||||
generateEditPlan,
|
||||
handleRetryVideo,
|
||||
isShowAutoEditing
|
||||
isShowAutoEditing,
|
||||
aspectRatio
|
||||
} = useWorkflowData({
|
||||
onEditPlanGenerated: handleEditPlanGenerated,
|
||||
editingStatus: editingStatus,
|
||||
@ -235,6 +235,11 @@ const WorkFlow = React.memo(function WorkFlow() {
|
||||
toggleVideoPlay,
|
||||
} = usePlaybackControls(taskObject.videos.data, taskObject.currentStage);
|
||||
|
||||
useEffect(() => {
|
||||
if (isMobile) {
|
||||
setIsSmartChatBoxOpen(false);
|
||||
}
|
||||
}, [isMobile]);
|
||||
useEffect(() => {
|
||||
console.log('changedIndex_work-flow', currentSketchIndex, taskObject);
|
||||
}, [currentSketchIndex, taskObject]);
|
||||
@ -303,9 +308,8 @@ const WorkFlow = React.memo(function WorkFlow() {
|
||||
// setAiEditingInProgress(false); // 已移除该状态变量
|
||||
}, []);
|
||||
|
||||
// 临时禁用视频编辑功能
|
||||
// 视频编辑描述提交处理函数
|
||||
/*const handleVideoEditDescriptionSubmit = useCallback((editPoint: EditPointType, description: string) => {
|
||||
const handleVideoEditDescriptionSubmit = useCallback((editPoint: EditPointType, description: string) => {
|
||||
console.log('🎬 视频编辑描述提交:', { editPoint, description });
|
||||
|
||||
// 构造编辑消息发送到SmartChatBox
|
||||
@ -333,7 +337,7 @@ Please process this video editing request.`;
|
||||
description: `Your edit request for timestamp ${Math.floor(editPoint.timestamp)}s has been submitted successfully.`,
|
||||
duration: 3
|
||||
});
|
||||
}, [currentSketchIndex, isSmartChatBoxOpen]);*/
|
||||
}, [currentSketchIndex, isSmartChatBoxOpen]);
|
||||
|
||||
// 测试导出接口的处理函数(使用封装的导出服务)
|
||||
const handleTestExport = useCallback(async () => {
|
||||
@ -520,7 +524,9 @@ Please process this video editing request.`;
|
||||
isSmartChatBoxOpen={isSmartChatBoxOpen}
|
||||
onRetryVideo={(video_id) => handleRetryVideo(video_id)}
|
||||
onSelectView={(view) => setSelectedView(view)}
|
||||
// 临时禁用视频编辑功能: enableVideoEdit={true} onVideoEditDescriptionSubmit={handleVideoEditDescriptionSubmit}
|
||||
enableVideoEdit={true}
|
||||
onVideoEditDescriptionSubmit={handleVideoEditDescriptionSubmit}
|
||||
projectId={episodeId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@ -537,6 +543,7 @@ Please process this video editing request.`;
|
||||
onRetryVideo={handleRetryVideo}
|
||||
className={isDesktop ? 'auto-cols-[20%]' : (isTablet ? 'auto-cols-[25%]' : 'auto-cols-[40%]')}
|
||||
selectedView={selectedView}
|
||||
aspectRatio={aspectRatio}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@ -611,6 +618,14 @@ Please process this video editing request.`;
|
||||
<div
|
||||
className="fixed right-[1rem] bottom-[10rem] z-[49]"
|
||||
>
|
||||
{isMobile ? (
|
||||
<GlassIconButton
|
||||
icon={Bot}
|
||||
size='md'
|
||||
onClick={() => setIsSmartChatBoxOpen(true)}
|
||||
className="backdrop-blur-lg"
|
||||
/>
|
||||
) : (
|
||||
<Tooltip title="Open chat" placement="left">
|
||||
<GlassIconButton
|
||||
icon={Bot}
|
||||
@ -619,12 +634,13 @@ Please process this video editing request.`;
|
||||
className="backdrop-blur-lg"
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 智能对话弹窗 */}
|
||||
<Drawer
|
||||
width="25%"
|
||||
placement="right"
|
||||
width={isMobile ? '100vw' : '25%'}
|
||||
placement={isMobile ? 'bottom' : 'right'}
|
||||
closable={false}
|
||||
maskClosable={false}
|
||||
open={isSmartChatBoxOpen}
|
||||
@ -636,15 +652,18 @@ Please process this video editing request.`;
|
||||
className="backdrop-blur-lg bg-black/30 border border-white/20 shadow-xl"
|
||||
style={{
|
||||
backgroundColor: 'transparent',
|
||||
borderBottomLeftRadius: 10,
|
||||
borderTopLeftRadius: 10,
|
||||
...(isMobile
|
||||
? { borderTopLeftRadius: 10, borderTopRightRadius: 10 }
|
||||
: { borderBottomLeftRadius: 10, borderTopLeftRadius: 10 }),
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
styles={{
|
||||
body: {
|
||||
backgroundColor: 'transparent',
|
||||
padding: 0,
|
||||
},
|
||||
maxHeight: '100vh',
|
||||
overflow: 'auto',
|
||||
}
|
||||
}}
|
||||
onClose={() => setIsSmartChatBoxOpen(false)}
|
||||
>
|
||||
|
||||
@ -43,6 +43,12 @@ interface H5MediaViewerProps {
|
||||
onRetryVideo?: (video_id: string) => void;
|
||||
/** 切换选择视图(final 或 video) */
|
||||
onSelectView?: (view: 'final' | 'video') => void;
|
||||
/** 启用视频编辑功能 */
|
||||
enableVideoEdit?: boolean;
|
||||
/** 视频编辑描述提交回调 */
|
||||
onVideoEditDescriptionSubmit?: (editPoint: any, description: string) => void;
|
||||
/** 项目ID */
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -67,7 +73,10 @@ export function H5MediaViewer({
|
||||
onGotoCut,
|
||||
isSmartChatBoxOpen,
|
||||
onRetryVideo,
|
||||
onSelectView
|
||||
onSelectView,
|
||||
enableVideoEdit,
|
||||
onVideoEditDescriptionSubmit,
|
||||
projectId
|
||||
}: H5MediaViewerProps) {
|
||||
const carouselRef = useRef<CarouselRef>(null);
|
||||
const videoRefs = useRef<Array<HTMLVideoElement | null>>([]);
|
||||
@ -155,7 +164,7 @@ export function H5MediaViewer({
|
||||
|
||||
// 渲染视频 slide
|
||||
const renderVideoSlides = () => (
|
||||
<div data-alt="carousel-wrapper" className="relative w-full aspect-video min-h-[200px] overflow-hidden rounded-lg">
|
||||
<div data-alt="carousel-wrapper" className="relative w-full aspect-auto min-h-[200px] overflow-hidden rounded-lg">
|
||||
<Carousel
|
||||
ref={carouselRef}
|
||||
key={`h5-carousel-video-${stage}-${videoUrls.length}`}
|
||||
@ -178,7 +187,7 @@ export function H5MediaViewer({
|
||||
<>
|
||||
<video
|
||||
ref={(el) => (videoRefs.current[idx] = el)}
|
||||
className="w-full h-full object-cover [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"
|
||||
src={url}
|
||||
preload="metadata"
|
||||
playsInline
|
||||
@ -239,7 +248,7 @@ export function H5MediaViewer({
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="w-full aspect-video 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">
|
||||
{status === 0 && (
|
||||
<span className="text-blue-500 text-base">Generating...</span>
|
||||
)}
|
||||
@ -276,7 +285,7 @@ export function H5MediaViewer({
|
||||
|
||||
// 渲染图片 slide
|
||||
const renderImageSlides = () => (
|
||||
<div data-alt="carousel-wrapper" className="relative w-full aspect-video min-h-[200px] overflow-hidden rounded-lg">
|
||||
<div data-alt="carousel-wrapper" className="relative w-full aspect-auto min-h-[200px] overflow-hidden rounded-lg">
|
||||
<Carousel
|
||||
ref={carouselRef}
|
||||
key={`h5-carousel-image-${stage}-${imageUrls.length}`}
|
||||
@ -290,7 +299,7 @@ export function H5MediaViewer({
|
||||
>
|
||||
{imageUrls.map((url, idx) => (
|
||||
<div key={`h5-image-${idx}`} data-alt="image-slide" className="relative w-full h-full">
|
||||
<img src={url} alt="scene" className="w-full h-full object-cover" />
|
||||
<img src={url} alt="scene" className="w-full h-full object-contain" />
|
||||
</div>
|
||||
))}
|
||||
</Carousel>
|
||||
@ -407,7 +416,7 @@ export function H5MediaViewer({
|
||||
data-alt="final-thumb-item"
|
||||
aria-label="Select final video"
|
||||
>
|
||||
<img src={getFirstFrame(taskObject.final.url)} alt="final" className="w-full h-auto object-cover" />
|
||||
<img src={getFirstFrame(taskObject.final.url)} alt="final" className="w-full h-auto object-contain" />
|
||||
<div className="text-[10px] text-white/80 text-center py-0.5">Final</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import React, { useRef, useEffect, useState, SetStateAction, useMemo } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Edit3, Play, Pause, Volume2, VolumeX, Maximize, Minimize, Loader2, X, Scissors, RotateCcw, MessageCircleMore, Download, ArrowDownWideNarrow, CircleAlert /*, PenTool*/ } from 'lucide-react';
|
||||
import { Edit3, Play, Pause, Volume2, VolumeX, Maximize, Minimize, Loader2, X, Scissors, RotateCcw, MessageCircleMore, Download, ArrowDownWideNarrow, CircleAlert, PenTool } from 'lucide-react';
|
||||
import { ProgressiveReveal, presets } from '@/components/ui/progressive-reveal';
|
||||
import { GlassIconButton } from '@/components/ui/glass-icon-button';
|
||||
import { ScriptRenderer } from '@/components/script-renderer/ScriptRenderer';
|
||||
@ -12,9 +12,8 @@ import ScriptLoading from './script-loading';
|
||||
import { TaskObject } from '@/api/DTO/movieEdit';
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import { downloadVideo, downloadAllVideos, getFirstFrame } from '@/utils/tools';
|
||||
// 临时禁用视频编辑功能
|
||||
// import { VideoEditOverlay } from './video-edit/VideoEditOverlay';
|
||||
// import { EditPoint as EditPointType } from './video-edit/types';
|
||||
import { VideoEditOverlay } from './video-edit/VideoEditOverlay';
|
||||
import { EditPoint as EditPointType } from './video-edit/types';
|
||||
|
||||
interface MediaViewerProps {
|
||||
taskObject: TaskObject;
|
||||
@ -35,9 +34,9 @@ interface MediaViewerProps {
|
||||
onGotoCut: () => void;
|
||||
isSmartChatBoxOpen: boolean;
|
||||
onRetryVideo?: (video_id: string) => void;
|
||||
// 临时禁用视频编辑功能
|
||||
// enableVideoEdit?: boolean;
|
||||
// onVideoEditDescriptionSubmit?: (editPoint: EditPointType, description: string) => void;
|
||||
enableVideoEdit?: boolean;
|
||||
onVideoEditDescriptionSubmit?: (editPoint: EditPointType, description: string) => void;
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
export const MediaViewer = React.memo(function MediaViewer({
|
||||
@ -58,10 +57,10 @@ export const MediaViewer = React.memo(function MediaViewer({
|
||||
showGotoCutButton,
|
||||
onGotoCut,
|
||||
isSmartChatBoxOpen,
|
||||
onRetryVideo
|
||||
// 临时禁用视频编辑功能
|
||||
// enableVideoEdit = true,
|
||||
// onVideoEditDescriptionSubmit
|
||||
onRetryVideo,
|
||||
enableVideoEdit = true,
|
||||
onVideoEditDescriptionSubmit,
|
||||
projectId
|
||||
}: MediaViewerProps) {
|
||||
const mainVideoRef = useRef<HTMLVideoElement>(null);
|
||||
const finalVideoRef = useRef<HTMLVideoElement>(null);
|
||||
@ -78,8 +77,7 @@ export const MediaViewer = React.memo(function MediaViewer({
|
||||
const [toosBtnRight, setToodsBtnRight] = useState('1rem');
|
||||
const [isLoadingDownloadBtn, setIsLoadingDownloadBtn] = useState(false);
|
||||
const [isLoadingDownloadAllVideosBtn, setIsLoadingDownloadAllVideosBtn] = useState(false);
|
||||
// 临时禁用视频编辑功能
|
||||
// const [isVideoEditMode, setIsVideoEditMode] = useState(false);
|
||||
const [isVideoEditMode, setIsVideoEditMode] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSmartChatBoxOpen) {
|
||||
@ -178,7 +176,7 @@ export const MediaViewer = React.memo(function MediaViewer({
|
||||
return (
|
||||
<video
|
||||
ref={finalVideoRef}
|
||||
className="w-full h-full object-cover rounded-lg"
|
||||
className="w-full h-full object-contain rounded-lg"
|
||||
src={taskObject.final.url}
|
||||
autoPlay={isFinalVideoPlaying}
|
||||
loop
|
||||
@ -347,7 +345,7 @@ export const MediaViewer = React.memo(function MediaViewer({
|
||||
transition={{ duration: 0.8, ease: "easeInOut" }}
|
||||
>
|
||||
<video
|
||||
className="w-full h-full rounded-lg object-cover object-center"
|
||||
className="w-full h-full rounded-lg object-contain object-center"
|
||||
src={taskObject.final.url}
|
||||
loop
|
||||
playsInline
|
||||
@ -492,7 +490,7 @@ export const MediaViewer = React.memo(function MediaViewer({
|
||||
<video
|
||||
ref={mainVideoRef}
|
||||
key={taskObject.videos.data[currentSketchIndex].urls[0]}
|
||||
className="w-full h-full rounded-lg object-cover object-center relative z-10"
|
||||
className="w-full h-full rounded-lg object-contain object-center relative z-10"
|
||||
src={taskObject.videos.data[currentSketchIndex].urls[0]}
|
||||
poster={getFirstFrame(taskObject.videos.data[currentSketchIndex].urls[0])}
|
||||
preload="none"
|
||||
@ -506,11 +504,10 @@ export const MediaViewer = React.memo(function MediaViewer({
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 临时禁用视频编辑功能 */}
|
||||
{/* 视频编辑覆盖层 */}
|
||||
{/*enableVideoEdit && isVideoEditMode && (
|
||||
{enableVideoEdit && isVideoEditMode && (
|
||||
<VideoEditOverlay
|
||||
projectId={taskObject.project_id || ''}
|
||||
projectId={projectId || ''}
|
||||
userId={JSON.parse(localStorage.getItem("currentUser") || '{}').id || 0}
|
||||
currentVideo={{
|
||||
id: taskObject.videos.data[currentSketchIndex].video_id,
|
||||
@ -522,16 +519,15 @@ export const MediaViewer = React.memo(function MediaViewer({
|
||||
onDescriptionSubmit={onVideoEditDescriptionSubmit}
|
||||
className="rounded-lg"
|
||||
/>
|
||||
)*/}
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* 跳转剪辑按钮 */}
|
||||
<div className="absolute top-4 right-4 z-[21] flex items-center gap-2 transition-right duration-100" style={{
|
||||
right: toosBtnRight
|
||||
}}>
|
||||
{/* 临时禁用视频编辑功能 */}
|
||||
{/* 视频编辑模式切换按钮 */}
|
||||
{/*enableVideoEdit && (
|
||||
{/* 视频编辑模式切换按钮 - 临时注释 */}
|
||||
{/* {enableVideoEdit && (
|
||||
<Tooltip placement="top" title={isVideoEditMode ? "Exit edit mode" : "Enter edit mode"}>
|
||||
<GlassIconButton
|
||||
icon={PenTool}
|
||||
@ -540,7 +536,7 @@ export const MediaViewer = React.memo(function MediaViewer({
|
||||
className={isVideoEditMode ? 'bg-blue-500/20 border-blue-500/50' : ''}
|
||||
/>
|
||||
</Tooltip>
|
||||
)*/}
|
||||
)} */}
|
||||
{/* 添加到chat去编辑 按钮 */}
|
||||
<Tooltip placement="top" title="Edit video with chat">
|
||||
<GlassIconButton icon={MessageCircleMore} size='sm' onClick={() => {
|
||||
@ -659,7 +655,7 @@ export const MediaViewer = React.memo(function MediaViewer({
|
||||
<motion.img
|
||||
key={currentSketch.url}
|
||||
src={currentSketch.url}
|
||||
className="w-full h-full rounded-lg object-cover"
|
||||
className="w-full h-full rounded-lg object-contain"
|
||||
// 用 circle clip-path 实现“扩散”
|
||||
initial={{ clipPath: "circle(0% at 50% 50%)" }}
|
||||
animate={{ clipPath: "circle(150% at 50% 50%)" }}
|
||||
|
||||
@ -7,6 +7,7 @@ import { ProgressiveReveal, presets } from '@/components/ui/progressive-reveal';
|
||||
import { Loader2, X, SquareUserRound, MapPinHouse, Clapperboard, Video, RotateCcw, CircleAlert } from 'lucide-react';
|
||||
import { TaskObject } from '@/api/DTO/movieEdit';
|
||||
import { getFirstFrame } from '@/utils/tools';
|
||||
import { AspectRatioValue } from '@/components/ChatInputBox/AspectRatioSelector';
|
||||
|
||||
interface ThumbnailGridProps {
|
||||
isDisabledFocus: boolean;
|
||||
@ -16,6 +17,7 @@ interface ThumbnailGridProps {
|
||||
onRetryVideo: (video_id: string) => void;
|
||||
className: string;
|
||||
selectedView?: 'final' | 'video' | null;
|
||||
aspectRatio: AspectRatioValue;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -28,7 +30,8 @@ export function ThumbnailGrid({
|
||||
onSketchSelect,
|
||||
onRetryVideo,
|
||||
className,
|
||||
selectedView
|
||||
selectedView,
|
||||
aspectRatio
|
||||
}: ThumbnailGridProps) {
|
||||
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
||||
|
||||
@ -186,8 +189,10 @@ export function ThumbnailGrid({
|
||||
return (
|
||||
<div
|
||||
key={`video-${urls}-${index}`}
|
||||
className={`relative aspect-video rounded-lg overflow-hidden
|
||||
${(currentSketchIndex === index && !disabled && selectedView !== 'final') ? 'ring-2 ring-blue-500 z-10' : 'hover:ring-2 hover:ring-blue-500/50'}`}
|
||||
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'}
|
||||
${aspectRatio === 'VIDEO_ASPECT_RATIO_LANDSCAPE' ? 'min-w-[210px]' : 'min-w-[70px]'}
|
||||
`}
|
||||
onClick={() => !isDragging && !disabled && onSketchSelect(index)}
|
||||
>
|
||||
|
||||
@ -208,7 +213,7 @@ export function ThumbnailGrid({
|
||||
|
||||
{taskObject.videos.data[index].urls && taskObject.videos.data[index].urls.length > 0 ? (
|
||||
// <video
|
||||
// className="w-full h-full object-cover"
|
||||
// className="w-full h-full object-contain"
|
||||
// src={taskObject.videos.data[index].urls[0]}
|
||||
// playsInline
|
||||
// loop
|
||||
@ -220,14 +225,14 @@ export function ThumbnailGrid({
|
||||
onMouseLeave={() => handleMouseLeave(index)}
|
||||
>
|
||||
<img
|
||||
className="w-full h-full object-cover"
|
||||
className="w-full h-full object-contain"
|
||||
src={getFirstFrame(taskObject.videos.data[index].urls[0])}
|
||||
draggable="false"
|
||||
alt="video thumbnail"
|
||||
/>
|
||||
{hoveredIndex === index && (
|
||||
<video
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
className="absolute inset-0 w-full h-full object-contain"
|
||||
src={taskObject.videos.data[index].urls[0]}
|
||||
autoPlay
|
||||
muted
|
||||
@ -249,7 +254,7 @@ export function ThumbnailGrid({
|
||||
<div className='absolute bottom-0 left-0 right-0 p-2'>
|
||||
<div className="inline-flex items-center px-2 py-1 rounded-full bg-green-500/20 backdrop-blur-sm">
|
||||
<Video className="w-3 h-3 text-green-400 mr-1" />
|
||||
<span className="text-xs text-green-400">Shot {index + 1}</span>
|
||||
<span className="text-xs text-green-400">{index + 1}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -269,8 +274,10 @@ export function ThumbnailGrid({
|
||||
return (
|
||||
<div
|
||||
key={sketch.uniqueId || `sketch-${sketch.url}-${index}`}
|
||||
className={`relative aspect-video rounded-lg overflow-hidden
|
||||
${currentSketchIndex === index ? 'ring-2 ring-blue-500 z-10' : 'hover:ring-2 hover:ring-blue-500/50'}`}
|
||||
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'}
|
||||
${aspectRatio === 'VIDEO_ASPECT_RATIO_LANDSCAPE' ? 'min-w-[210px]' : 'min-w-[70px]'}
|
||||
`}
|
||||
onClick={() => !isDragging && onSketchSelect(index)}
|
||||
>
|
||||
|
||||
@ -293,7 +300,7 @@ export function ThumbnailGrid({
|
||||
{(sketch.status === 1) && (
|
||||
<div className="w-full h-full transform hover:scale-105 transition-transform duration-500">
|
||||
<img
|
||||
className="w-full h-full object-cover select-none"
|
||||
className="w-full h-full object-contain select-none"
|
||||
src={sketch.url}
|
||||
draggable="false"
|
||||
alt={sketch.type ? String(sketch.type) : 'sketch'}
|
||||
@ -319,7 +326,7 @@ export function ThumbnailGrid({
|
||||
{(!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" />
|
||||
<span className="text-xs text-cyan-400">Shot {index + 1}</span>
|
||||
<span className="text-xs text-cyan-400">{index + 1}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -337,7 +344,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 ${className}`}
|
||||
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`}
|
||||
autoFocus
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
|
||||
@ -6,6 +6,7 @@ import { detailScriptEpisodeNew, getScriptTitle, getRunningStreamData, pauseMovi
|
||||
import { useScriptService } from "@/app/service/Interaction/ScriptService";
|
||||
import { useUpdateEffect } from '@/app/hooks/useUpdateEffect';
|
||||
import { LOADING_TEXT_MAP, TaskObject, Status, Stage } from '@/api/DTO/movieEdit';
|
||||
import { AspectRatioValue } from '@/components/ChatInputBox/AspectRatioSelector';
|
||||
|
||||
interface UseWorkflowDataProps {
|
||||
onEditPlanGenerated?: () => void;
|
||||
@ -91,6 +92,7 @@ export function useWorkflowData({ onEditPlanGenerated, editingStatus, onExportFa
|
||||
const [isLoadingGenerateEditPlan, setIsLoadingGenerateEditPlan] = useState(false);
|
||||
const [state, setState] = useState({
|
||||
mode: 'automatic' as 'automatic' | 'manual' | 'auto',
|
||||
aspectRatio: 'VIDEO_ASPECT_RATIO_LANDSCAPE' as AspectRatioValue,
|
||||
originalText: '',
|
||||
isLoading: true
|
||||
});
|
||||
@ -529,6 +531,7 @@ export function useWorkflowData({ onEditPlanGenerated, editingStatus, onExportFa
|
||||
try {
|
||||
setState({
|
||||
mode: 'automatic' as 'automatic' | 'manual' | 'auto',
|
||||
aspectRatio: 'VIDEO_ASPECT_RATIO_LANDSCAPE' as AspectRatioValue,
|
||||
originalText: '',
|
||||
isLoading: true
|
||||
});
|
||||
@ -540,7 +543,7 @@ export function useWorkflowData({ onEditPlanGenerated, editingStatus, onExportFa
|
||||
throw new Error(response.message);
|
||||
}
|
||||
|
||||
const { status, data, tags, mode, original_text, title, name, final_simple_video, final_video } = response.data;
|
||||
const { status, data, tags, mode, original_text, aspect_ratio, name, final_simple_video, final_video } = response.data;
|
||||
|
||||
const { current: taskCurrent } = tempTaskObject;
|
||||
|
||||
@ -681,6 +684,7 @@ export function useWorkflowData({ onEditPlanGenerated, editingStatus, onExportFa
|
||||
|
||||
setState({
|
||||
mode: mode as 'automatic' | 'manual' | 'auto',
|
||||
aspectRatio: aspect_ratio as AspectRatioValue,
|
||||
originalText: original_text,
|
||||
isLoading: false
|
||||
});
|
||||
@ -701,6 +705,7 @@ export function useWorkflowData({ onEditPlanGenerated, editingStatus, onExportFa
|
||||
setNeedStreamData(true);
|
||||
setState({
|
||||
mode: 'automatic' as 'automatic' | 'manual' | 'auto',
|
||||
aspectRatio: 'VIDEO_ASPECT_RATIO_LANDSCAPE' as AspectRatioValue,
|
||||
originalText: '',
|
||||
isLoading: false
|
||||
});
|
||||
@ -781,6 +786,7 @@ export function useWorkflowData({ onEditPlanGenerated, editingStatus, onExportFa
|
||||
showGotoCutButton: (canGoToCut && (isGenerateEditPlan || taskObject.currentStage === 'final_video') || isShowError) ? true : false,
|
||||
generateEditPlan: openEditPlan,
|
||||
handleRetryVideo,
|
||||
isShowAutoEditing: canGoToCut && taskObject.currentStage !== 'final_video' && isGenerateEditPlan && !isShowError ? true : false
|
||||
isShowAutoEditing: canGoToCut && taskObject.currentStage !== 'final_video' && isGenerateEditPlan && !isShowError ? true : false,
|
||||
aspectRatio: state.aspectRatio
|
||||
};
|
||||
}
|
||||
|
||||
@ -69,7 +69,7 @@ function calculateCurvePath({
|
||||
export function calculateInputPosition(
|
||||
editPointPosition: { x: number; y: number },
|
||||
containerSize: { width: number; height: number },
|
||||
inputBoxSize: { width: number; height: number } = { width: 200, height: 80 }
|
||||
inputBoxSize: { width: number; height: number } = { width: 300, height: 50 }
|
||||
): InputBoxPosition {
|
||||
const { x: pointX, y: pointY } = editPointPosition;
|
||||
const { width: containerWidth, height: containerHeight } = containerSize;
|
||||
@ -151,26 +151,79 @@ export const EditConnection: React.FC<EditConnectionProps> = ({
|
||||
animated = true
|
||||
}) => {
|
||||
const {
|
||||
color = 'rgba(255, 255, 255, 0.8)',
|
||||
color = 'rgba(255, 255, 255, 0.9)', // White color to match the reference image
|
||||
strokeWidth = 2,
|
||||
dashArray = '5,5'
|
||||
dashArray = '8,4' // Dashed line to match the reference image
|
||||
} = style;
|
||||
|
||||
// 计算路径
|
||||
// 计算箭头几何参数
|
||||
const arrowSize = 8;
|
||||
const arrowHalfHeight = 4;
|
||||
|
||||
// 计算连接方向和角度
|
||||
const connectionVector = useMemo(() => {
|
||||
const dx = endPoint.x - startPoint.x;
|
||||
const dy = endPoint.y - startPoint.y;
|
||||
const length = Math.sqrt(dx * dx + dy * dy);
|
||||
return {
|
||||
dx: dx / length,
|
||||
dy: dy / length,
|
||||
angle: Math.atan2(dy, dx)
|
||||
};
|
||||
}, [startPoint, endPoint]);
|
||||
|
||||
// 计算箭头的正确位置和线条终点
|
||||
const arrowGeometry = useMemo(() => {
|
||||
const { dx, dy, angle } = connectionVector;
|
||||
|
||||
// 箭头尖端位置(原endPoint)
|
||||
const arrowTip = { x: endPoint.x, y: endPoint.y };
|
||||
|
||||
// 箭头底部中心点(线条应该连接到这里)
|
||||
const arrowBase = {
|
||||
x: endPoint.x - dx * arrowSize,
|
||||
y: endPoint.y - dy * arrowSize
|
||||
};
|
||||
|
||||
// 计算箭头三角形的三个顶点
|
||||
const perpX = -dy; // 垂直向量X
|
||||
const perpY = dx; // 垂直向量Y
|
||||
|
||||
const arrowPoints = [
|
||||
arrowTip, // 尖端
|
||||
{
|
||||
x: arrowBase.x + perpX * arrowHalfHeight,
|
||||
y: arrowBase.y + perpY * arrowHalfHeight
|
||||
},
|
||||
{
|
||||
x: arrowBase.x - perpX * arrowHalfHeight,
|
||||
y: arrowBase.y - perpY * arrowHalfHeight
|
||||
}
|
||||
];
|
||||
|
||||
return {
|
||||
tip: arrowTip,
|
||||
base: arrowBase,
|
||||
points: arrowPoints,
|
||||
angle
|
||||
};
|
||||
}, [endPoint, connectionVector, arrowSize, arrowHalfHeight]);
|
||||
|
||||
// 计算路径(线条终止于箭头底部中心)
|
||||
const path = useMemo(() =>
|
||||
calculateCurvePath({
|
||||
start: startPoint,
|
||||
end: endPoint,
|
||||
end: arrowGeometry.base, // 连接到箭头底部中心而不是尖端
|
||||
containerSize,
|
||||
curvature
|
||||
}), [startPoint, endPoint, containerSize, curvature]);
|
||||
}), [startPoint, arrowGeometry.base, containerSize, curvature]);
|
||||
|
||||
// 计算路径长度用于动画
|
||||
const pathLength = useMemo(() => {
|
||||
const dx = endPoint.x - startPoint.x;
|
||||
const dy = endPoint.y - startPoint.y;
|
||||
const dx = arrowGeometry.base.x - startPoint.x;
|
||||
const dy = arrowGeometry.base.y - startPoint.y;
|
||||
return Math.sqrt(dx * dx + dy * dy) * 1.2; // 弧线比直线长约20%
|
||||
}, [startPoint, endPoint]);
|
||||
}, [startPoint, arrowGeometry.base]);
|
||||
|
||||
return (
|
||||
<svg
|
||||
@ -179,7 +232,7 @@ export const EditConnection: React.FC<EditConnectionProps> = ({
|
||||
height={containerSize.height}
|
||||
style={{ zIndex: 10 }}
|
||||
>
|
||||
{/* 连接线路径 */}
|
||||
{/* Curved dashed line - properly aligned to arrow base center */}
|
||||
<motion.path
|
||||
d={path}
|
||||
fill="none"
|
||||
@ -201,15 +254,13 @@ export const EditConnection: React.FC<EditConnectionProps> = ({
|
||||
opacity: { duration: 0.3 }
|
||||
} : {}}
|
||||
style={{
|
||||
filter: 'drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2))'
|
||||
filter: 'drop-shadow(2px 2px 4px rgba(0, 0, 0, 0.8))'
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 连接线末端的小圆点 */}
|
||||
<motion.circle
|
||||
cx={endPoint.x}
|
||||
cy={endPoint.y}
|
||||
r={3}
|
||||
{/* Properly aligned arrow head with geometric precision */}
|
||||
<motion.polygon
|
||||
points={arrowGeometry.points.map(p => `${p.x},${p.y}`).join(' ')}
|
||||
fill={color}
|
||||
initial={animated ? {
|
||||
scale: 0,
|
||||
@ -227,32 +278,32 @@ export const EditConnection: React.FC<EditConnectionProps> = ({
|
||||
damping: 25
|
||||
} : {}}
|
||||
style={{
|
||||
filter: 'drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))'
|
||||
filter: 'drop-shadow(2px 2px 4px rgba(0, 0, 0, 0.8))'
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 动画流动效果(可选) */}
|
||||
{animated && (
|
||||
<motion.circle
|
||||
r={2}
|
||||
fill="rgba(255, 255, 255, 0.9)"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{
|
||||
opacity: [0, 1, 0],
|
||||
offsetDistance: ["0%", "100%"]
|
||||
}}
|
||||
transition={{
|
||||
duration: 2,
|
||||
repeat: Infinity,
|
||||
ease: "linear",
|
||||
delay: 0.6
|
||||
}}
|
||||
style={{
|
||||
offsetPath: `path('${path}')`,
|
||||
offsetRotate: "0deg"
|
||||
}}
|
||||
/>
|
||||
{/* Debug visualization (remove in production) */}
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<>
|
||||
{/* Arrow base center point */}
|
||||
<circle
|
||||
cx={arrowGeometry.base.x}
|
||||
cy={arrowGeometry.base.y}
|
||||
r={1}
|
||||
fill="red"
|
||||
opacity={0.5}
|
||||
/>
|
||||
{/* Arrow tip point */}
|
||||
<circle
|
||||
cx={arrowGeometry.tip.x}
|
||||
cy={arrowGeometry.tip.y}
|
||||
r={1}
|
||||
fill="blue"
|
||||
opacity={0.5}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
@ -101,7 +101,7 @@ export const EditDescription: React.FC<EditDescriptionProps> = ({
|
||||
<AnimatePresence>
|
||||
{editPoint.description && editPoint.status !== EditPointStatus.PENDING && (
|
||||
<>
|
||||
{/* 连接线 */}
|
||||
{/* White dashed connection line to match reference image */}
|
||||
<motion.svg
|
||||
className="absolute pointer-events-none"
|
||||
style={{
|
||||
@ -118,30 +118,41 @@ export const EditDescription: React.FC<EditDescriptionProps> = ({
|
||||
>
|
||||
<motion.path
|
||||
d={connectionPath}
|
||||
stroke={statusColor}
|
||||
stroke="rgba(255, 255, 255, 0.9)"
|
||||
strokeWidth={2}
|
||||
fill="none"
|
||||
strokeDasharray="4,4"
|
||||
strokeDasharray="8,4"
|
||||
strokeLinecap="round"
|
||||
initial={{ pathLength: 0, opacity: 0 }}
|
||||
animate={{
|
||||
pathLength: 1,
|
||||
opacity: 0.8,
|
||||
strokeDashoffset: [0, -8]
|
||||
opacity: 1
|
||||
}}
|
||||
exit={{ pathLength: 0, opacity: 0 }}
|
||||
transition={{
|
||||
pathLength: { duration: 0.8, ease: "easeOut" },
|
||||
opacity: { duration: 0.5 },
|
||||
strokeDashoffset: {
|
||||
duration: 2,
|
||||
repeat: Infinity,
|
||||
ease: "linear"
|
||||
}
|
||||
opacity: { duration: 0.5 }
|
||||
}}
|
||||
style={{
|
||||
filter: 'drop-shadow(2px 2px 4px rgba(0, 0, 0, 0.8))'
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Arrow head */}
|
||||
<motion.polygon
|
||||
points={`${connectionEnd.x},${connectionEnd.y} ${connectionEnd.x-8},${connectionEnd.y-4} ${connectionEnd.x-8},${connectionEnd.y+4}`}
|
||||
fill="rgba(255, 255, 255, 0.9)"
|
||||
initial={{ scale: 0, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0, opacity: 0 }}
|
||||
transition={{ delay: 0.4, duration: 0.3 }}
|
||||
style={{
|
||||
filter: 'drop-shadow(2px 2px 4px rgba(0, 0, 0, 0.8))'
|
||||
}}
|
||||
/>
|
||||
</motion.svg>
|
||||
|
||||
{/* 描述内容框 */}
|
||||
{/* Consistent white text display matching EditInput component */}
|
||||
<motion.div
|
||||
className="absolute cursor-pointer group"
|
||||
data-edit-description="true"
|
||||
@ -149,8 +160,6 @@ export const EditDescription: React.FC<EditDescriptionProps> = ({
|
||||
left: position.x,
|
||||
top: position.y,
|
||||
zIndex: 25,
|
||||
maxWidth: '300px',
|
||||
minWidth: '200px'
|
||||
}}
|
||||
initial={{
|
||||
opacity: 0,
|
||||
@ -174,98 +183,67 @@ export const EditDescription: React.FC<EditDescriptionProps> = ({
|
||||
duration: 0.4
|
||||
}}
|
||||
onClick={() => onClick?.(editPoint)}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
{/* 玻璃态背景 */}
|
||||
<div className="relative bg-white/90 dark:bg-gray-800/90 backdrop-blur-md rounded-lg shadow-lg border border-white/20 dark:border-gray-700/30 overflow-hidden">
|
||||
{/* 状态指示条 */}
|
||||
{/* White text display with exact same styling as EditInput */}
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className="h-1 w-full"
|
||||
style={{ backgroundColor: statusColor }}
|
||||
/>
|
||||
|
||||
{/* 内容区域 */}
|
||||
<div className="p-3">
|
||||
{/* 状态标签 */}
|
||||
{statusText && (
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div
|
||||
className="w-2 h-2 rounded-full"
|
||||
style={{ backgroundColor: statusColor }}
|
||||
/>
|
||||
<span
|
||||
className="text-xs font-medium"
|
||||
style={{ color: statusColor }}
|
||||
>
|
||||
{statusText}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 描述文本 */}
|
||||
<div className="text-sm text-gray-800 dark:text-gray-200 leading-relaxed">
|
||||
{editPoint.description}
|
||||
</div>
|
||||
|
||||
{/* 时间戳 */}
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-2 flex items-center justify-between">
|
||||
<span>
|
||||
{Math.floor(editPoint.timestamp)}s
|
||||
</span>
|
||||
<span>
|
||||
{new Date(editPoint.updatedAt).toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
className="text-white font-bold text-lg tracking-wide uppercase"
|
||||
style={{
|
||||
textShadow: '2px 2px 4px rgba(0, 0, 0, 0.8)',
|
||||
fontFamily: 'Arial, sans-serif',
|
||||
letterSpacing: '0.1em'
|
||||
}}
|
||||
>
|
||||
{editPoint.description}
|
||||
</div>
|
||||
|
||||
{/* 悬停时显示的操作按钮 */}
|
||||
<motion.div
|
||||
className="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 0 }}
|
||||
whileHover={{ opacity: 1 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
{/* Interactive edit/delete buttons on hover */}
|
||||
<div className="ml-3 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-center gap-2">
|
||||
{onEdit && (
|
||||
<button
|
||||
className="w-6 h-6 rounded-full bg-blue-500/80 hover:bg-blue-500 text-white text-xs flex items-center justify-center transition-colors"
|
||||
<motion.button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit(editPoint.id);
|
||||
}}
|
||||
title="编辑"
|
||||
className="w-6 h-6 rounded-full bg-white/20 hover:bg-white/30 backdrop-blur-sm flex items-center justify-center text-white text-xs transition-colors"
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
title="Edit description"
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
</motion.button>
|
||||
)}
|
||||
{onDelete && (
|
||||
<button
|
||||
className="w-6 h-6 rounded-full bg-red-500/80 hover:bg-red-500 text-white text-xs flex items-center justify-center transition-colors"
|
||||
<motion.button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(editPoint.id);
|
||||
}}
|
||||
title="删除"
|
||||
className="w-6 h-6 rounded-full bg-red-500/20 hover:bg-red-500/30 backdrop-blur-sm flex items-center justify-center text-white text-xs transition-colors"
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
title="Delete edit point"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</motion.button>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* 装饰性光效 */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-white/10 to-transparent pointer-events-none" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 连接点指示器 */}
|
||||
<div
|
||||
className="absolute w-2 h-2 rounded-full border-2 border-white shadow-sm"
|
||||
style={{
|
||||
backgroundColor: statusColor,
|
||||
left: connectionEnd.x - position.x - 4,
|
||||
top: connectionEnd.y - position.y - 4,
|
||||
}}
|
||||
/>
|
||||
{/* Status indicator for processing states */}
|
||||
{editPoint.status === EditPointStatus.PROCESSING && (
|
||||
<div className="mt-1 flex items-center">
|
||||
<div className="w-2 h-2 bg-blue-400 rounded-full animate-pulse mr-2"></div>
|
||||
<span className="text-white/70 text-xs uppercase tracking-wide">Processing...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editPoint.status === EditPointStatus.FAILED && (
|
||||
<div className="mt-1 flex items-center">
|
||||
<div className="w-2 h-2 bg-red-400 rounded-full mr-2"></div>
|
||||
<span className="text-white/70 text-xs uppercase tracking-wide">Failed - Click to retry</span>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@ -37,11 +37,10 @@ export const EditInput: React.FC<EditInputProps> = ({
|
||||
isSubmitting = false,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
size = { width: 280, height: 120 },
|
||||
placeholder = "Describe your edit request..."
|
||||
size = { width: 300, height: 50 },
|
||||
placeholder = "Describe your edit..."
|
||||
}) => {
|
||||
const [description, setDescription] = useState(editPoint.description || '');
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@ -116,7 +115,6 @@ export const EditInput: React.FC<EditInputProps> = ({
|
||||
style={{
|
||||
left: position.x,
|
||||
top: position.y,
|
||||
width: size.width,
|
||||
}}
|
||||
initial={{
|
||||
opacity: 0,
|
||||
@ -140,105 +138,33 @@ export const EditInput: React.FC<EditInputProps> = ({
|
||||
duration: 0.3
|
||||
}}
|
||||
>
|
||||
{/* 玻璃态背景容器 */}
|
||||
<div className="bg-black/60 backdrop-blur-md rounded-lg border border-white/20 shadow-2xl overflow-hidden">
|
||||
{/* 头部 */}
|
||||
<div className="px-3 py-2 border-b border-white/10 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-blue-400"></div>
|
||||
<span className="text-xs text-white/80 font-medium">
|
||||
Edit Request
|
||||
</span>
|
||||
<span className="text-xs text-white/50">
|
||||
{Math.floor(editPoint.timestamp)}s
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="w-5 h-5 rounded-full bg-white/10 hover:bg-white/20 flex items-center justify-center text-white/70 hover:text-white transition-colors"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
{/* Input interface - focused on input functionality only */}
|
||||
<div className="flex items-center bg-white/90 backdrop-blur-sm rounded-lg px-3 py-2 shadow-lg">
|
||||
<input
|
||||
ref={textareaRef as any}
|
||||
type="text"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
disabled={isSubmitting}
|
||||
className="flex-1 bg-transparent text-gray-800 placeholder-gray-400 text-sm border-none outline-none min-w-[200px]"
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
{/* 输入区域 */}
|
||||
<div className="p-3">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
placeholder={placeholder}
|
||||
disabled={isSubmitting}
|
||||
className="w-full bg-transparent text-white placeholder-white/40 text-sm resize-none border-none outline-none min-h-[60px] max-h-[120px]"
|
||||
style={{ lineHeight: '1.4' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 底部操作区 */}
|
||||
<div className="px-3 py-2 border-t border-white/10 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-xs text-white/50">
|
||||
<span>Ctrl+Enter to submit</span>
|
||||
<span>•</span>
|
||||
<span>Esc to cancel</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 字符计数 */}
|
||||
<span className={`text-xs ${
|
||||
description.length > 500 ? 'text-red-400' : 'text-white/50'
|
||||
}`}>
|
||||
{description.length}/500
|
||||
</span>
|
||||
|
||||
{/* 提交按钮 */}
|
||||
<motion.button
|
||||
onClick={handleSubmit}
|
||||
disabled={!description.trim() || isSubmitting || description.length > 500}
|
||||
className="flex items-center gap-1 px-3 py-1.5 bg-blue-500 hover:bg-blue-600 disabled:bg-gray-500 disabled:cursor-not-allowed text-white text-xs rounded-md transition-colors"
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
<span>Submitting...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send size={12} />
|
||||
<span>Submit</span>
|
||||
</>
|
||||
)}
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 聚焦时的发光边框 */}
|
||||
<AnimatePresence>
|
||||
{isFocused && (
|
||||
<motion.div
|
||||
className="absolute inset-0 rounded-lg border-2 border-blue-400/50 pointer-events-none"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
/>
|
||||
{/* Submit button */}
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!description.trim() || isSubmitting}
|
||||
className="ml-2 w-8 h-8 rounded-full bg-blue-500 hover:bg-blue-600 disabled:bg-gray-400 disabled:cursor-not-allowed text-white flex items-center justify-center flex-shrink-0"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<div className="w-3 h-3 border border-white border-t-transparent rounded-full animate-spin"></div>
|
||||
) : (
|
||||
<span className="text-xs">→</span>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 输入框指示箭头(可选) */}
|
||||
<div
|
||||
className="absolute w-3 h-3 bg-black/60 border-l border-t border-white/20 transform rotate-45"
|
||||
style={{
|
||||
left: 12,
|
||||
top: -6,
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
@ -71,44 +71,7 @@ export const EditPoint: React.FC<EditPointProps> = ({
|
||||
onEdit(editPoint.id);
|
||||
}, [onEdit, editPoint.id]);
|
||||
|
||||
// 根据状态获取颜色
|
||||
const getStatusColor = useCallback(() => {
|
||||
switch (editPoint.status) {
|
||||
case EditPointStatus.PENDING:
|
||||
return '#f59e0b'; // 黄色
|
||||
case EditPointStatus.EDITED:
|
||||
return '#10b981'; // 绿色
|
||||
case EditPointStatus.PROCESSING:
|
||||
return '#3b82f6'; // 蓝色
|
||||
case EditPointStatus.COMPLETED:
|
||||
return '#059669'; // 深绿色
|
||||
case EditPointStatus.FAILED:
|
||||
return '#ef4444'; // 红色
|
||||
default:
|
||||
return color;
|
||||
}
|
||||
}, [editPoint.status, color]);
|
||||
|
||||
// 根据状态获取图标
|
||||
const getStatusIcon = useCallback(() => {
|
||||
switch (editPoint.status) {
|
||||
case EditPointStatus.PENDING:
|
||||
return Edit3;
|
||||
case EditPointStatus.EDITED:
|
||||
return Check;
|
||||
case EditPointStatus.PROCESSING:
|
||||
return Loader2;
|
||||
case EditPointStatus.COMPLETED:
|
||||
return Check;
|
||||
case EditPointStatus.FAILED:
|
||||
return X;
|
||||
default:
|
||||
return Edit3;
|
||||
}
|
||||
}, [editPoint.status]);
|
||||
|
||||
const StatusIcon = getStatusIcon();
|
||||
const statusColor = getStatusColor();
|
||||
// Simplified for the image design - just use blue color
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
@ -119,7 +82,7 @@ export const EditPoint: React.FC<EditPointProps> = ({
|
||||
top: absolutePosition.y - size / 2,
|
||||
}}
|
||||
initial={{ scale: 0, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
animate={{ scale: 1, opacity: 0 }} // Make invisible to match reference image
|
||||
exit={{ scale: 0, opacity: 0 }}
|
||||
transition={{
|
||||
type: "spring",
|
||||
@ -129,136 +92,17 @@ export const EditPoint: React.FC<EditPointProps> = ({
|
||||
}}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{/* 脉冲动画背景 */}
|
||||
<AnimatePresence>
|
||||
{(editPoint.status === EditPointStatus.PENDING || isSelected) && (
|
||||
<motion.div
|
||||
className="absolute inset-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: pulseColor,
|
||||
width: size * 3,
|
||||
height: size * 3,
|
||||
left: -size,
|
||||
top: -size,
|
||||
}}
|
||||
initial={{ scale: 0, opacity: 0 }}
|
||||
animate={{
|
||||
scale: [1, 1.5, 1],
|
||||
opacity: [0.6, 0.2, 0.6],
|
||||
}}
|
||||
exit={{ scale: 0, opacity: 0 }}
|
||||
transition={{
|
||||
duration: 2,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut"
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* 主编辑点 */}
|
||||
{/* Invisible edit point - just for click handling */}
|
||||
<motion.div
|
||||
className="relative rounded-full flex items-center justify-center shadow-lg backdrop-blur-sm"
|
||||
className="relative rounded-full"
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
backgroundColor: statusColor,
|
||||
border: `2px solid rgba(255, 255, 255, 0.3)`,
|
||||
width: size * 2, // Larger click area
|
||||
height: size * 2,
|
||||
backgroundColor: 'transparent', // Invisible
|
||||
}}
|
||||
whileHover={{ scale: 1.2 }}
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
animate={editPoint.status === EditPointStatus.PROCESSING ? {
|
||||
rotate: 360,
|
||||
transition: { duration: 1, repeat: Infinity, ease: "linear" }
|
||||
} : {}}
|
||||
>
|
||||
<StatusIcon
|
||||
size={size * 0.5}
|
||||
color="white"
|
||||
className={editPoint.status === EditPointStatus.PROCESSING ? "animate-spin" : ""}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
{/* 选中状态的操作按钮 */}
|
||||
<AnimatePresence>
|
||||
{isSelected && editPoint.status !== EditPointStatus.PROCESSING && (
|
||||
<motion.div
|
||||
className="absolute flex gap-1"
|
||||
style={{
|
||||
left: size + 8,
|
||||
top: -4,
|
||||
}}
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -10 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
{/* 编辑按钮 */}
|
||||
<motion.button
|
||||
className="w-6 h-6 rounded-full bg-blue-500/80 backdrop-blur-sm flex items-center justify-center text-white hover:bg-blue-600/80 transition-colors"
|
||||
onClick={handleEdit}
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
title="编辑描述"
|
||||
>
|
||||
<Edit3 size={12} />
|
||||
</motion.button>
|
||||
|
||||
{/* 删除按钮 */}
|
||||
<motion.button
|
||||
className="w-6 h-6 rounded-full bg-red-500/80 backdrop-blur-sm flex items-center justify-center text-white hover:bg-red-600/80 transition-colors"
|
||||
onClick={handleDelete}
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
title="删除编辑点"
|
||||
>
|
||||
<X size={12} />
|
||||
</motion.button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* 状态提示文本 */}
|
||||
<AnimatePresence>
|
||||
{isSelected && editPoint.description && (
|
||||
<motion.div
|
||||
className="absolute whitespace-nowrap text-xs text-white bg-black/60 backdrop-blur-sm rounded px-2 py-1 pointer-events-none"
|
||||
style={{
|
||||
left: size + 8,
|
||||
top: size + 8,
|
||||
maxWidth: 200,
|
||||
}}
|
||||
initial={{ opacity: 0, y: -5 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -5 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
{editPoint.description.length > 50
|
||||
? `${editPoint.description.substring(0, 50)}...`
|
||||
: editPoint.description
|
||||
}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* 时间戳显示 */}
|
||||
<AnimatePresence>
|
||||
{isSelected && (
|
||||
<motion.div
|
||||
className="absolute text-xs text-white/70 bg-black/40 backdrop-blur-sm rounded px-1 py-0.5 pointer-events-none"
|
||||
style={{
|
||||
left: -20,
|
||||
top: size + 8,
|
||||
}}
|
||||
initial={{ opacity: 0, y: -5 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -5 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
{Math.floor(editPoint.timestamp)}s
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
/>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -275,7 +275,8 @@ export const VideoEditOverlay: React.FC<VideoEditOverlayProps> = ({
|
||||
<AnimatePresence>
|
||||
{editPoints.map(editPoint => {
|
||||
const elementPosition = elementPositions[editPoint.id];
|
||||
// 只显示已提交且有描述的编辑点
|
||||
|
||||
// 只显示已提交且有描述的编辑点,且不在输入模式
|
||||
if (
|
||||
!editPoint.description ||
|
||||
editPoint.description.trim() === '' ||
|
||||
|
||||
@ -18,20 +18,20 @@ import { createEditPoint, updateEditPoint, deleteEditPoint, getEditPoints } from
|
||||
import { debounce } from './utils';
|
||||
|
||||
/**
|
||||
* 默认编辑配置
|
||||
* 默认编辑配置 - 更新为匹配参考图像的白色样式
|
||||
*/
|
||||
const DEFAULT_CONFIG: VideoEditConfig = {
|
||||
enabled: true,
|
||||
maxEditPoints: 10,
|
||||
connectionStyle: {
|
||||
color: 'rgba(255, 255, 255, 0.8)',
|
||||
color: 'rgba(255, 255, 255, 0.9)', // 白色连接线匹配参考图像
|
||||
strokeWidth: 2,
|
||||
dashArray: '5,5'
|
||||
dashArray: '8,4' // 虚线样式匹配参考图像
|
||||
},
|
||||
pointStyle: {
|
||||
size: 12,
|
||||
color: '#3b82f6',
|
||||
pulseColor: 'rgba(59, 130, 246, 0.3)'
|
||||
color: 'transparent', // 透明编辑点匹配参考图像
|
||||
pulseColor: 'transparent' // 透明脉冲动画
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
241
docs/jiekou.md
Normal file
241
docs/jiekou.md
Normal file
@ -0,0 +1,241 @@
|
||||
# Google OAuth 前端改造指南
|
||||
|
||||
## 📋 改造目标
|
||||
让Google OAuth注册用户也能享受积分发放和邀请奖励功能。
|
||||
|
||||
## 🔄 新的流程设计
|
||||
|
||||
### **改造后流程**:
|
||||
```
|
||||
1. 前端接收Google OAuth回调
|
||||
2. 前端 → Java验证接口(只验证,不创建用户)
|
||||
3. 前端 → Python注册接口(创建用户 + 积分发放)
|
||||
4. 返回完整的用户信息和token
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 前端修改步骤
|
||||
|
||||
### 📍 修改文件
|
||||
**文件**: `video-flow/app/api/auth/google/callback/route.ts`
|
||||
|
||||
### 🔧 核心修改
|
||||
|
||||
#### **第一步:调用Java验证接口**
|
||||
```typescript
|
||||
// 调用Java callback进行Google Token验证(不创建用户)
|
||||
const javaBaseUrl = process.env.NEXT_PUBLIC_JAVA_URL || '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: code, // Google authorization code
|
||||
state: state, // state参数
|
||||
inviteCode: inviteCode, // 邀请码
|
||||
skipUserCreation: true // 🔑 关键:只验证不创建用户
|
||||
})
|
||||
});
|
||||
|
||||
const verifyResult = await verifyResponse.json();
|
||||
```
|
||||
|
||||
#### **第二步:调用Python注册接口**
|
||||
```typescript
|
||||
// 调用Python注册接口进行用户创建和积分发放
|
||||
const smartvideoBaseUrl = process.env.NEXT_PUBLIC_SMARTVIDEO_URL || 'https://smartvideo.test.movieflow.ai';
|
||||
|
||||
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: verifyResult.data,
|
||||
invite_code: inviteCode || stateData.inviteCode || undefined
|
||||
})
|
||||
});
|
||||
|
||||
const registerResult = await registerResponse.json();
|
||||
```
|
||||
|
||||
#### **第三步:返回统一格式结果**
|
||||
```typescript
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
token: registerResult.data.token,
|
||||
user: {
|
||||
userId: registerResult.data.user_id,
|
||||
userName: registerResult.data.name,
|
||||
name: registerResult.data.name,
|
||||
email: registerResult.data.email,
|
||||
authType: registerResult.data.auth_type,
|
||||
isNewUser: true
|
||||
},
|
||||
message: 'Google OAuth registration successful with credit rewards'
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📚 后端接口文档
|
||||
|
||||
### 🔵 Java验证接口
|
||||
|
||||
#### **接口地址**
|
||||
```
|
||||
POST /api/auth/google/callback
|
||||
```
|
||||
|
||||
#### **请求参数**
|
||||
```typescript
|
||||
{
|
||||
code: string, // Google authorization code
|
||||
state: string, // state参数(可包含邀请码信息)
|
||||
inviteCode?: string, // 邀请码(可选)
|
||||
skipUserCreation: boolean // true=只验证不创建用户
|
||||
}
|
||||
```
|
||||
|
||||
#### **成功响应**
|
||||
```typescript
|
||||
{
|
||||
success: true,
|
||||
message: "Google Token验证成功",
|
||||
data: {
|
||||
email: string, // 用户邮箱
|
||||
name: string, // 用户姓名
|
||||
picture: string, // 头像URL
|
||||
googleId: string, // Google用户ID
|
||||
verified: boolean, // 是否已验证
|
||||
inviteCode: string // 邀请码(如果有)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### **错误响应**
|
||||
```typescript
|
||||
{
|
||||
success: false,
|
||||
error: "TOKEN_EXCHANGE_FAILED" | "INVALID_ID_TOKEN",
|
||||
message: string
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 🔵 Python注册接口
|
||||
|
||||
#### **接口地址**
|
||||
```
|
||||
POST /api/user_fission/register_with_invite
|
||||
```
|
||||
|
||||
#### **请求参数**
|
||||
```typescript
|
||||
{
|
||||
email: string, // 用户邮箱
|
||||
name?: string, // 用户姓名(可选)
|
||||
auth_type: "GOOGLE", // 认证类型(固定为GOOGLE)
|
||||
google_user_info: { // Google用户信息(来自Java验证接口)
|
||||
email: string,
|
||||
name: string,
|
||||
picture: string,
|
||||
googleId: string,
|
||||
verified: boolean,
|
||||
inviteCode?: string
|
||||
},
|
||||
invite_code?: string // 邀请码(可选)
|
||||
}
|
||||
```
|
||||
|
||||
#### **成功响应**
|
||||
```typescript
|
||||
{
|
||||
successful: true, // 注意:是 successful 不是 success
|
||||
message: "Google OAuth注册成功",
|
||||
data: {
|
||||
user_id: string, // 用户ID(UUID格式)
|
||||
email: string, // 用户邮箱
|
||||
name: string, // 用户姓名
|
||||
auth_type: "GOOGLE", // 认证类型
|
||||
invite_code?: string, // 邀请码
|
||||
token?: string // 认证token(如果有)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### **错误响应**
|
||||
```typescript
|
||||
{
|
||||
successful: false,
|
||||
message: string, // 错误信息
|
||||
detail?: string // 详细错误信息
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 环境变量配置
|
||||
|
||||
确保以下环境变量已配置:
|
||||
|
||||
```bash
|
||||
# Java认证服务地址
|
||||
NEXT_PUBLIC_JAVA_URL=https://auth.test.movieflow.ai
|
||||
|
||||
# Python积分服务地址
|
||||
NEXT_PUBLIC_SMARTVIDEO_URL=https://smartvideo.test.movieflow.ai
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 重要注意事项
|
||||
|
||||
### 🔍 **关键变更**
|
||||
1. **接口变更**: `/api/auth/google/login` → `/api/auth/google/callback`
|
||||
2. **参数变更**:
|
||||
- 移除: `idToken`, `action`
|
||||
- 新增: `code`, `state`, `skipUserCreation: true`
|
||||
3. **响应字段**: Python返回 `successful`,Java返回 `success`
|
||||
4. **用户ID字段**: Python返回 `user_id`,前端映射为 `userId`
|
||||
|
||||
### 🚨 **错误处理**
|
||||
- Java验证失败 → 停止流程,返回错误
|
||||
- Python注册失败 → 返回详细错误信息
|
||||
- 网络异常 → 提供用户友好提示
|
||||
|
||||
### 🧪 **测试检查点**
|
||||
- [ ] Google OAuth验证是否成功
|
||||
- [ ] 用户是否正确创建并获得积分
|
||||
- [ ] 邀请码功能是否正常
|
||||
- [ ] 错误处理是否完善
|
||||
- [ ] 前端页面跳转是否正常
|
||||
|
||||
---
|
||||
|
||||
## 🎯 预期效果
|
||||
|
||||
改造完成后,Google OAuth用户将获得:
|
||||
|
||||
✅ **注册奖励积分**
|
||||
✅ **邀请码奖励功能**
|
||||
✅ **邀请人奖励积分**
|
||||
✅ **统一的用户体验**
|
||||
✅ **完整的积分记录**
|
||||
|
||||
---
|
||||
|
||||
*文档版本: v2.0*
|
||||
*更新时间: 2025-01-22*
|
||||
*适用范围: video-flow前端项目*
|
||||
Loading…
x
Reference in New Issue
Block a user