From e363aadf4c8dbcd3d229b7ed55a1b9b8c29654d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9D=9E=E5=87=A1=E4=B8=BB=E5=84=BF?= <15541157+extraordinary-lord@user.noreply.gitee.com> Date: Mon, 20 Oct 2025 19:16:40 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=80=E9=94=AE=E8=BD=AC=E5=8F=91=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E5=BC=80=E5=8F=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/video-share/x/auth/callback/page.tsx | 22 ++ components/common/ShareModal.tsx | 316 +++++++++++++++++++ components/common/TwitterCallbackModal.tsx | 223 +++++++++++++ components/pages/create-to-video2.tsx | 43 ++- lib/env.ts | 14 + 5 files changed, 614 insertions(+), 4 deletions(-) create mode 100644 app/api/video-share/x/auth/callback/page.tsx create mode 100644 components/common/ShareModal.tsx create mode 100644 components/common/TwitterCallbackModal.tsx diff --git a/app/api/video-share/x/auth/callback/page.tsx b/app/api/video-share/x/auth/callback/page.tsx new file mode 100644 index 0000000..21bc270 --- /dev/null +++ b/app/api/video-share/x/auth/callback/page.tsx @@ -0,0 +1,22 @@ +'use client'; + +import { useEffect } from 'react'; +import { useRouter } from 'next/navigation'; + +export default function TwitterAuthCallbackPage() { + const router = useRouter(); + + useEffect(() => { + // 此页面主要是为了 Next.js 路由捕获回调 URL。 + // 在 ShareModal 检测到参数后会打开 TwitterCallbackModal。 + // 重定向到主页面或相关仪表板页面。 + // 参数将由 ShareModal 接收处理。 + router.push('/movies?twitterCallback=true'); + }, [router]); + + return ( +
+

处理 Twitter 授权回调...

+
+ ); +} diff --git a/components/common/ShareModal.tsx b/components/common/ShareModal.tsx new file mode 100644 index 0000000..0f4289f --- /dev/null +++ b/components/common/ShareModal.tsx @@ -0,0 +1,316 @@ +'use client'; + +import React, { useState, useEffect } from 'react'; +import { Modal, Button, Spin, QRCode } from 'antd'; +import { + Youtube, + Instagram, + Share2, + Copy, + ExternalLink, + Loader2 +} from 'lucide-react'; +import { shareApiUrl } from '@/lib/env'; +import TwitterCallbackModal from './TwitterCallbackModal'; + +interface ShareModalProps { + /** 是否显示弹框 */ + visible: boolean; + /** 关闭弹框回调 */ + onClose: () => void; + /** 项目信息 */ + project: { + project_id: string; + name?: string; + final_video_url?: string; + final_simple_video_url?: string; + }; +} + +interface SharePlatform { + id: string; + name: string; + icon: React.ReactNode; + color: string; + description: string; + apiPath: string | null; +} + +const sharePlatforms: SharePlatform[] = [ + { + id: 'youtube', + name: 'YouTube', + icon: , + color: 'text-red-500', + description: 'Share to YouTube', + apiPath: '/api/video-share/youtube/auth/url' + }, + { + id: 'tiktok', + name: 'TikTok', + icon: , + color: 'text-black', + description: 'Share to TikTok', + apiPath: '/api/video-share/tiktok/auth/url' + }, + { + id: 'instagram', + name: 'Instagram', + icon: , + color: 'text-pink-500', + description: 'Share to Instagram', + apiPath: null // 开发中 + }, + { + id: 'reddit', + name: 'Reddit', + icon: , + color: 'text-orange-500', + description: 'Share to Reddit', + apiPath: '/api/video-share/reddit/auth/url' + }, + { + id: 'twitter', + name: 'X (Twitter)', + icon: , + color: 'text-blue-400', + description: 'Share to X (Twitter)', + apiPath: '/api/video-share/x/auth/url' + }, + { + id: 'copy', + name: 'Copy Link', + icon: , + color: 'text-gray-400', + description: 'Copy video link to clipboard', + apiPath: null // 本地功能 + } +]; + +export default function ShareModal({ visible, onClose, project }: ShareModalProps) { + const [loadingPlatform, setLoadingPlatform] = useState(null); + const [twitterCallbackVisible, setTwitterCallbackVisible] = useState(false); + const [twitterCallbackParams, setTwitterCallbackParams] = useState<{ + state: string; + code: string; + } | null>(null); + + /** + * 检查 URL 参数并处理 Twitter 回调 + */ + useEffect(() => { + if (typeof window !== 'undefined') { + const urlParams = new URLSearchParams(window.location.search); + const state = urlParams.get('state'); + const code = urlParams.get('code'); + + // 检查是否是 Twitter 授权回调 + if (state && code && window.location.pathname.includes('/api/video-share/x/auth/callback')) { + setTwitterCallbackParams({ state, code }); + setTwitterCallbackVisible(true); + + // 清理 URL 参数 + const newUrl = window.location.pathname; + window.history.replaceState({}, document.title, newUrl); + } + } + }, []); + + /** + * 获取用户ID + */ + const getUserId = () => { + const currentUser = JSON.parse(localStorage.getItem('currentUser') || '{}'); + return currentUser.id || currentUser.userId; + }; + + /** + * 通用平台授权检查 + */ + const checkPlatformAuth = async (apiPath: string, platformName: string) => { + const userId = getUserId(); + + if (!userId) { + console.error('用户ID不存在,请先登录'); + return; + } + + // Twitter 平台保存项目信息到 localStorage + if (platformName.toLowerCase().includes('twitter') || platformName.toLowerCase().includes('x')) { + localStorage.setItem('currentShareProject', JSON.stringify(project)); + } + + const response = await fetch(`${shareApiUrl}${apiPath}?user_id=${userId}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${localStorage.getItem('token') || ''}` + } + }); + + const data = await response.json(); + + if (data.successful && data.code === 0) { + const { has_valid_token, auth_url, access_token } = data.data; + + if (!has_valid_token) { + // 需要用户授权,跳转到授权页面 + window.open(auth_url, '_blank'); + } else { + // 用户已授权,直接使用access_token进行第三方登录 + console.log(`${platformName}用户已授权,access_token:`, access_token); + // TODO: 实现视频上传逻辑 + } + } else { + console.error(`获取${platformName}授权信息失败:`, data.message); + } + }; + + /** + * 处理平台分享 + */ + const handlePlatformShare = async (platformId: string) => { + setLoadingPlatform(platformId); + + try { + const platform = sharePlatforms.find(p => p.id === platformId); + if (!platform) return; + + if (platformId === 'copy') { + await handleCopyLink(); + } else if (platformId === 'instagram') { + alert('Instagram分享功能正在开发中,敬请期待!'); + } else if (platform.apiPath) { + await checkPlatformAuth(platform.apiPath, platform.name); + } else { + console.log(`Share to ${platformId} not implemented yet`); + } + } catch (error) { + console.error(`Share to ${platformId} failed:`, error); + } finally { + setLoadingPlatform(null); + } + }; + + + /** + * 复制链接处理 + */ + const handleCopyLink = async () => { + try { + const videoUrl = project.final_video_url || project.final_simple_video_url; + if (videoUrl) { + await navigator.clipboard.writeText(videoUrl); + console.log('视频链接已复制到剪贴板'); + // TODO: 显示复制成功提示 + } else { + console.error('视频链接不存在'); + } + } catch (error) { + console.error('复制链接失败:', error); + } + }; + + // 获取视频链接用于生成二维码 + const getVideoUrl = () => { + return project.final_video_url || project.final_simple_video_url || ''; + }; + + return ( + + + Share Video + + } + open={visible} + onCancel={onClose} + footer={null} + width={720} + className="share-modal" + styles={{ + content: { + backgroundColor: 'rgba(27, 27, 27, 0.8)', + backdropFilter: 'blur(20px)', + border: '1px solid rgba(255, 255, 255, 0.1)', + }, + header: { + backgroundColor: 'rgba(27, 27, 27, 0.6)', + backdropFilter: 'blur(20px)', + borderBottom: '1px solid rgba(255, 255, 255, 0.1)', + }, + body: { + backgroundColor: 'transparent', + } + }} + > +
+ {/* 左侧:二维码区域 */} +
+
+ +
+
+ + {/* 右侧:平台选择区域 */} +
+
+

Share to Platform

+

+ Choose a platform to share your video +

+
+ + {/* 分享平台选择 */} +
+ {sharePlatforms.map((platform) => ( + + ))} +
+
+
+ + {/* Twitter 授权回调弹框 */} + {twitterCallbackParams && ( + { + setTwitterCallbackVisible(false); + setTwitterCallbackParams(null); + }} + project={project} + urlParams={twitterCallbackParams} + /> + )} +
+ ); + } diff --git a/components/common/TwitterCallbackModal.tsx b/components/common/TwitterCallbackModal.tsx new file mode 100644 index 0000000..c8c4697 --- /dev/null +++ b/components/common/TwitterCallbackModal.tsx @@ -0,0 +1,223 @@ +'use client'; + +import React, { useState, useEffect } from 'react'; +import { Modal, Button, Spin, message } from 'antd'; +import { CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons'; +import { Loader2 } from 'lucide-react'; +import { shareApiUrl } from '@/lib/env'; + +interface TwitterCallbackModalProps { + /** 是否显示弹框 */ + visible: boolean; + /** 关闭弹框回调 */ + onClose: () => void; + /** 项目信息 */ + project: { + project_id: string; + name?: string; + final_video_url?: string; + final_simple_video_url?: string; + }; + /** URL 参数 */ + urlParams: { + state: string; + code: string; + }; +} + +enum CallbackStatus { + LOADING = 'loading', + SUCCESS = 'success', + FAILED = 'failed' +} + +export default function TwitterCallbackModal({ + visible, + onClose, + project, + urlParams +}: TwitterCallbackModalProps) { + const [callbackStatus, setCallbackStatus] = useState(CallbackStatus.LOADING); + const [callbackData, setCallbackData] = useState(null); + + /** + * 处理 Twitter 授权回调 + */ + const handleTwitterCallback = async () => { + try { + setCallbackStatus(CallbackStatus.LOADING); + + // 从localStorage获取用户信息 + const currentUser = JSON.parse(localStorage.getItem('currentUser') || '{}'); + const userId = currentUser.id || currentUser.userId; + + if (!userId) { + throw new Error('用户ID不存在,请先登录'); + } + + // 调用 Twitter 授权回调接口 + const response = await fetch(`${shareApiUrl}/api/video-share/x/auth/callback`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${localStorage.getItem('token') || ''}` + }, + body: JSON.stringify({ + user_id: userId, + state: urlParams.state, + code: urlParams.code, + }) + }); + + const data = await response.json(); + + if (data.successful && data.code === 0) { + setCallbackData(data.data); + setCallbackStatus(CallbackStatus.SUCCESS); + message.success('Twitter 授权成功!'); + } else { + throw new Error(data.message || 'Twitter 授权回调失败'); + } + } catch (error) { + console.error('Twitter 授权回调失败:', error); + setCallbackStatus(CallbackStatus.FAILED); + message.error(`Twitter 授权失败: ${error instanceof Error ? error.message : '未知错误'}`); + } + }; + + // 组件挂载时自动处理回调 + useEffect(() => { + if (visible && urlParams.state && urlParams.code) { + handleTwitterCallback(); + } + }, [visible, urlParams.state, urlParams.code]); + + /** + * 重新尝试授权 + */ + const handleRetry = () => { + setCallbackStatus(CallbackStatus.LOADING); + handleTwitterCallback(); + }; + + /** + * 关闭弹框 + */ + const handleClose = () => { + setCallbackStatus(CallbackStatus.LOADING); + setCallbackData(null); + onClose(); + }; + + return ( + +
+ X +
+ Twitter 授权回调 + + } + open={visible} + onCancel={handleClose} + footer={null} + width={480} + className="twitter-callback-modal" + styles={{ + content: { + backgroundColor: 'rgba(27, 27, 27, 0.8)', + backdropFilter: 'blur(20px)', + border: '1px solid rgba(255, 255, 255, 0.1)', + }, + header: { + backgroundColor: 'rgba(27, 27, 27, 0.6)', + backdropFilter: 'blur(20px)', + borderBottom: '1px solid rgba(255, 255, 255, 0.1)', + }, + body: { + backgroundColor: 'transparent', + } + }} + > +
+ {/* 项目信息 */} +
+

+ 分享视频: {project.name || 'Unnamed Project'} +

+

+ 视频链接: {project.final_video_url || project.final_simple_video_url || 'N/A'} +

+
+ + {/* 回调状态显示 */} +
+ {callbackStatus === CallbackStatus.LOADING && ( + <> + +
+

正在处理 Twitter 授权...

+

请稍候,正在验证授权信息

+
+ + )} + + {callbackStatus === CallbackStatus.SUCCESS && ( + <> + +
+

授权成功!

+

+ Twitter 授权已完成,可以开始分享视频 +

+ {callbackData && ( +
+

+ 授权 Token: {callbackData.access_token ? '已获取' : '未获取'} +

+
+ )} + +
+ + )} + + {callbackStatus === CallbackStatus.FAILED && ( + <> + +
+

授权失败

+

+ Twitter 授权处理失败,请重试 +

+
+ + +
+
+ + )} +
+
+
+ ); +} diff --git a/components/pages/create-to-video2.tsx b/components/pages/create-to-video2.tsx index f0c1152..cb36648 100644 --- a/components/pages/create-to-video2.tsx +++ b/components/pages/create-to-video2.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import type { MouseEvent } from 'react'; -import { Loader2, Download } from 'lucide-react'; +import { Loader2, Download, Share } from 'lucide-react'; import { useRouter } from 'next/navigation'; import './style/create-to-video2.css'; @@ -14,7 +14,9 @@ import { motion } from 'framer-motion'; import { Tooltip, Button } from 'antd'; import { downloadVideo, downloadAllVideos, getFirstFrame } from '@/utils/tools'; import { showDownloadOptionsModal } from '@/components/pages/work-flow/download-options-modal'; -import { post } from '@/api/request'; +import { post, get } from '@/api/request'; +import { shareApiUrl } from '@/lib/env'; +import ShareModal from '@/components/common/ShareModal'; import Masonry from 'react-masonry-css'; import debounce from 'lodash/debounce'; @@ -35,6 +37,9 @@ export default function CreateToVideo2() { const scrollContainerRef = useRef(null); const [userId, setUserId] = useState(0); const [isLoadingDownloadBtn, setIsLoadingDownloadBtn] = useState(false); + const [isLoadingShareBtn, setIsLoadingShareBtn] = useState(false); + const [shareModalVisible, setShareModalVisible] = useState(false); + const [selectedProject, setSelectedProject] = useState(null); const masonryRef = useRef(null); interface PreloadedData { page: number; @@ -295,6 +300,12 @@ export default function CreateToVideo2() { }); }; + const handleShareClick = (e: MouseEvent, project: MovieProject) => { + e.stopPropagation(); + setSelectedProject(project); + setShareModalVisible(true); + }; + const getPosterUrl = (project: MovieProject): string => { if (project.video_snapshot_url && project.video_snapshot_url.trim() !== '') { return project.video_snapshot_url; @@ -360,9 +371,21 @@ export default function CreateToVideo2() { /> )} - {/* 下载按钮 右上角 */} + {/* 转发和下载按钮 右上角 */} {(project.final_video_url || project.final_simple_video_url) && ( -
+
+ {/* 转发按钮 */} + + + + {/* 下载按钮 */}
); } diff --git a/lib/env.ts b/lib/env.ts index 280a755..a57b7ab 100644 --- a/lib/env.ts +++ b/lib/env.ts @@ -17,6 +17,7 @@ export interface EnvConfig { javaUrl: string; cutUrl: string; cutUrlTo: string; + shareApiUrl: string; // Google OAuth 配置 googleClientId: string; @@ -52,6 +53,7 @@ export const getEnvConfig = (): EnvConfig => { javaUrl: process.env.NEXT_PUBLIC_JAVA_URL || 'https://77.app.java.auth.qikongjian.com', cutUrl: process.env.NEXT_PUBLIC_CUT_URL || 'https://smartcut.api.movieflow.ai', cutUrlTo: process.env.NEXT_PUBLIC_CUT_URL_TO || 'https://smartcut.api.movieflow.ai', + shareApiUrl: process.env.NEXT_PUBLIC_SHARE_API_URL || 'http://39.97.48.225:8000', // Google OAuth 配置 googleClientId: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || '847079918888-o1nne8d3ij80dn20qurivo987pv07225.apps.googleusercontent.com', @@ -90,6 +92,7 @@ export const { javaUrl, cutUrl, cutUrlTo, + shareApiUrl, // Google OAuth 配置 googleClientId, @@ -155,6 +158,10 @@ export const validateEnvConfig = (): { isValid: boolean; errors: string[] } => { errors.push('NEXT_PUBLIC_JAVA_URL is required'); } + if (!shareApiUrl) { + errors.push('NEXT_PUBLIC_SHARE_API_URL is required'); + } + if (!googleClientId) { errors.push('NEXT_PUBLIC_GOOGLE_CLIENT_ID is required'); } @@ -172,6 +179,12 @@ export const validateEnvConfig = (): { isValid: boolean; errors: string[] } => { errors.push('NEXT_PUBLIC_JAVA_URL must be a valid URL'); } + try { + new URL(shareApiUrl); + } catch { + errors.push('NEXT_PUBLIC_SHARE_API_URL must be a valid URL'); + } + return { isValid: errors.length === 0, errors, @@ -189,6 +202,7 @@ export const logEnvConfig = (): void => { javaUrl, cutUrl, cutUrlTo, + shareApiUrl, googleClientId, googleRedirectUri: getGoogleRedirectUri(), gaEnabled,