This commit is contained in:
qikongjian 2025-09-07 06:21:59 +08:00
commit 055732593a
38 changed files with 465 additions and 757 deletions

View File

@ -1,8 +1,9 @@
# NEXT_PUBLIC_JAVA_URL = https://77.app.java.auth.qikongjian.com
# NEXT_PUBLIC_BASE_URL = https://77.smartvideo.py.qikongjian.com
NEXT_PUBLIC_JAVA_URL = http://test.java.movieflow.ai
NEXT_PUBLIC_BASE_URL = http://test.video.movieflow.ai
NEXT_PUBLIC_JAVA_URL = https://77.app.java.auth.qikongjian.com
NEXT_PUBLIC_BASE_URL = https://77.smartvideo.py.qikongjian.com
NEXT_PUBLIC_CUT_URL = https://smartcut.movieflow.ai
# NEXT_PUBLIC_JAVA_URL = https://test.java.movieflow.ai
# NEXT_PUBLIC_BASE_URL = https://test.video.movieflow.ai
# 失败率
NEXT_PUBLIC_ERROR_CONFIG = 0.2
NEXT_PUBLIC_ERROR_CONFIG = 0.5

View File

@ -1,7 +1,9 @@
# NEXT_PUBLIC_JAVA_URL = https://77.app.java.auth.qikongjian.com
# NEXT_PUBLIC_BASE_URL = https://77.smartvideo.py.qikongjian.com
NEXT_PUBLIC_JAVA_URL = http://test.java.movieflow.ai
NEXT_PUBLIC_BASE_URL = http://test.video.movieflow.ai
#NEXT_PUBLIC_JAVA_URL = https://77.app.java.auth.qikongjian.com
#NEXT_PUBLIC_BASE_URL = https://77.smartvideo.py.qikongjian.com
NEXT_PUBLIC_JAVA_URL = https://auth.movieflow.ai
NEXT_PUBLIC_BASE_URL = https://api.video.movieflow.ai
NEXT_PUBLIC_CUT_URL = https://smartcut.movieflow.ai
# 失败率
NEXT_PUBLIC_ERROR_CONFIG = 0.2

View File

@ -363,6 +363,10 @@ export interface VideoFlowProjectResponse {
last_message: string;
/** 项目内容 */
data: ProjectContentData;
/** 最终简单视频 */
final_simple_video: string;
/** 最终视频 */
final_video: string;
}
/**
*

View File

@ -1,4 +1,3 @@
import { message } from "antd";
import { debounce } from "lodash";
/**
@ -8,6 +7,7 @@ const HTTP_ERROR_MESSAGES: Record<number, string> = {
0: "Please try again if the network is abnormal. If it happens again, please contact us.",
400: "Request parameter error, please check your input.",
401: "Login expired, please log in again.",
402: "Insufficient points, please recharge.",
403: "Insufficient permissions to access this resource.",
404: "Requested resource does not exist.",
408: "Request timeout, please try again.",
@ -32,12 +32,6 @@ const ERROR_HANDLERS: Record<number, () => void> = {
localStorage.removeItem('token');
// 跳转到登录页面
window.location.href = '/login';
},
4001: () => {
// 显示积分不足通知
import('../utils/notifications').then(({ showInsufficientPointsNotification }) => {
showInsufficientPointsNotification();
});
}
};
@ -52,11 +46,10 @@ export const errorHandle = debounce(
customMessage || HTTP_ERROR_MESSAGES[code] || DEFAULT_ERROR_MESSAGE;
// 显示错误提示
message.error({
content: errorMessage,
duration: 3,
className: 'custom-error-message'
});
// 402 状态码 不需要显示提示
if (code !== 402) {
window.msg.error(errorMessage);
}
// 执行特殊错误码的处理函数
const handler = ERROR_HANDLERS[code];

View File

@ -1,5 +1,4 @@
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse, InternalAxiosRequestConfig, AxiosHeaders } from 'axios';
import { message } from "antd";
import { BASE_URL } from './constants'
import { errorHandle } from './errorHandle';
@ -11,15 +10,24 @@ import { errorHandle } from './errorHandle';
const handleRequestError = (error: any, defaultMessage: string = '请求失败') => {
if (error.response) {
const { status, data } = error.response;
const errorMessage = data?.message || defaultMessage;
const errorMessage = data?.message || data?.detail?.message || defaultMessage;
// 处理 402 状态码的特殊情况
if (status === 402 && data?.detail) {
// 只显示通知,不调用 errorHandle
import('../utils/notifications').then(({ showInsufficientPointsNotification }) => {
showInsufficientPointsNotification(data.detail);
});
return; // 直接返回,不再抛出错误
}
errorHandle(status, errorMessage);
} else if (error.request) {
// 请求已发出但没有收到响应
errorHandle(0 );
errorHandle(0);
} else {
// 请求配置出错
errorHandle(0, error.message || defaultMessage);
}
return Promise.reject(error); // 将 reject 移到这里,避免 402 时重复处理
};
// 创建 axios 实例
const request: AxiosInstance = axios.create({

View File

@ -292,8 +292,8 @@ export const getRunningStreamData = async (data: {
};
// 获取 生成剪辑计划 接口
export const getGenerateEditPlan = async (data: GenerateEditPlanRequest): Promise<ApiResponse<GenerateEditPlanResponse>> => {
return post<ApiResponse<GenerateEditPlanResponse>>("/edit-plan/generate-by-project", data);
export const getGenerateEditPlan = async (data: GenerateEditPlanRequest): Promise<ApiResponse<GenerateEditPlanResponseData>> => {
return post<ApiResponse<GenerateEditPlanResponseData>>("/edit-plan/generate-by-project", data);
};
/**

View File

@ -1,13 +1,11 @@
import { TopBar } from "@/components/layout/top-bar";
import { HomePage2 } from "@/components/pages/home-page2";
import OAuthCallbackHandler from "@/components/ui/oauth-callback-handler";
export default function Home() {
return (
<>
<TopBar collapsed={true} />
<OAuthCallbackHandler />
<HomePage2 />
</>
);

View File

@ -1,7 +1,6 @@
import { CreateMovieProjectV2Request, CreateMovieProjectV3Request } from "@/api/DTO/movie_start_dto";
import { createMovieProject, createMovieProjectV2, createMovieProjectV3 } from "@/api/create_movie";
import { QueueResponse, QueueStatus, withQueuePolling, QueueResponseData } from "@/api/movie_queue";
import { message } from "antd";
import { QueueResponse, withQueuePolling, QueueResponseData } from "@/api/movie_queue";
/**
*
@ -56,13 +55,13 @@ export class MovieProjectService {
},
onError: (error: Error) => {
if (error.message === '操作已取消') {
message.info('Queue cancelled');
window.msg.info('Queue cancelled');
} else {
message.error(error instanceof Error ? error.message : "Failed to create project");
window.msg.error(error instanceof Error ? error.message : "Failed to create project");
}
},
onCancel: () => {
message.info('Queue cancelled');
window.msg.info('Queue cancelled');
}
});
@ -80,7 +79,7 @@ export class MovieProjectService {
if (error instanceof Error && error.message === '操作已取消') {
throw error;
}
message.error(error instanceof Error ? error.message : "Failed to create project");
window.msg.error(error instanceof Error ? error.message : "Failed to create project");
throw error;
}
}

View File

@ -1,4 +1,3 @@
import { message } from "antd";
import { StoryTemplateEntity } from "../domain/Entities";
import { useUploadFile } from "../domain/service";
import { debounce } from "lodash";
@ -69,6 +68,15 @@ export const useTemplateStoryServiceHook = (): UseTemplateStoryService => {
setIsLoading(true);
const templates = await templateStoryUseCase.getTemplateStoryList();
templates.forEach(template => {
if (template.template_id === '69') {
template.freeInputItem = {
user_tips: "How is coffee made?",
constraints: "",
free_input_text: ""
};
}
});
setTemplateStoryList(templates);
setSelectedTemplate(templates[0]);
@ -238,7 +246,7 @@ export const useTemplateStoryServiceHook = (): UseTemplateStoryService => {
setIsLoading(true);
const params: CreateMovieProjectV3Request = {
script: selectedTemplate?.generateText || "",
script: selectedTemplate?.freeInputItem?.free_input_text || selectedTemplate?.generateText || "",
category: selectedTemplate?.category || "",
user_id,
mode,

View File

@ -172,4 +172,13 @@ export interface StoryTemplateEntity {
/** 道具照片URL */
photo_url: string;
}[];
/** 自由输入文字 */
freeInputItem?: {
/** 用户提示,提示给用户需要输入什么内容 */
user_tips: string;
/** 约束,可选,用于传给ai让ai去拦截用户不符合约束的输入内容 */
constraints: string;
/** 自由输入文字 */
free_input_text: string;
}
}

16
app/types/global.d.ts vendored Normal file
View File

@ -0,0 +1,16 @@
import { GlobalMessage } from '@/components/common/GlobalMessage';
import { toast } from 'sonner';
declare global {
interface Window {
msg: GlobalMessage;
$message: {
success: (message: string, duration?: number) => void;
error: (message: string, duration?: number) => void;
warning: (message: string, duration?: number) => void;
info: (message: string, duration?: number) => void;
loading: (message: string) => ReturnType<typeof toast.promise>;
dismiss: () => void;
};
}
}

View File

@ -1,69 +0,0 @@
#!/bin/bash
BRANCH_NAME="dev"
# 修改项目名称
PROJECT_NAME="video-flow-frontend"
# 设置日志文件路径
LOGFILE="build_and_copy.log"
# 记录开始时间
echo "Build process started at $(date)" | tee $LOGFILE
# 获取当前分支名
current_branch=$(git rev-parse --abbrev-ref HEAD)
# 打包之前,需要检查是否在 dev 分支,工作区是否干净,是否和远程分支一致
if [ "$(git branch --show-current)" != "$BRANCH_NAME" ]; then
echo "当前分支不是 dev 分支"
exit 1
fi
# 检查工作区是否干净
if [ -n "$(git status --porcelain)" ]; then
echo "工作区不干净"
exit 1
fi
# 检查远程分支是否和本地分支一致
if [ "$(git rev-parse HEAD)" != "$(git rev-parse origin/$BRANCH_NAME)" ]; then
echo "本地分支和远程分支不一致"
exit 1
fi
# 检查当前分支并运行相应的 npm 命令
if [ "$current_branch" = "$BRANCH_NAME" ]; then
echo "On dev branch, building project..." | tee -a $LOGFILE
PROFILE_ENV=$BRANCH_NAME
# 安装依赖并构建
yarn install
yarn build
# 准备dist目录
mkdir -p dist
cp -r .next dist/
cp -r public dist/
cp package.json dist/
cp package-lock.json dist/
else
echo "On non-dev branch ($current_branch), exiting"
exit 1
fi
# 创建tar包
tar -czvf $PROJECT_NAME-$PROFILE_ENV.tar.gz dist
# 记录结束时间
echo "Build process completed at $(date)" | tee -a $LOGFILE
# 上传到 nexus
echo "upload to nexus at $(date)" | tee -a $LOGFILE
curl -u 'admin':'YZ9Gq6=8\*G|?:,' --upload-file $PROJECT_NAME-$PROFILE_ENV.tar.gz https://repo.qikongjian.com/repository/frontend-tar-files/
# 清理构建文件
rm -rf dist
rm $PROJECT_NAME-$PROFILE_ENV.tar.gz

View File

@ -1,69 +0,0 @@
#!/bin/bash
BRANCH_NAME="dev"
# 修改项目名称
PROJECT_NAME="video-flow-$BRANCH_NAME-frontend"
# 设置日志文件路径
LOGFILE="build_and_copy.log"
# 记录开始时间
echo "Build process started at $(date)" | tee $LOGFILE
# 获取当前分支名
current_branch=$(git rev-parse --abbrev-ref HEAD)
# 打包之前,需要检查是否在 dev 分支,工作区是否干净,是否和远程分支一致
if [ "$(git branch --show-current)" != "$BRANCH_NAME" ]; then
echo "当前分支不是 dev 分支"
exit 1
fi
# 检查工作区是否干净
if [ -n "$(git status --porcelain)" ]; then
echo "工作区不干净"
exit 1
fi
# 检查远程分支是否和本地分支一致
if [ "$(git rev-parse HEAD)" != "$(git rev-parse origin/$BRANCH_NAME)" ]; then
echo "本地分支和远程分支不一致"
exit 1
fi
# 检查当前分支并运行相应的 npm 命令
if [ "$current_branch" = "$BRANCH_NAME" ]; then
echo "On dev branch, building project..." | tee -a $LOGFILE
PROFILE_ENV=$BRANCH_NAME
# 安装依赖并构建
yarn install
yarn build
# 准备dist目录
mkdir -p dist
cp -r .next dist/
cp -r public dist/
cp package.json dist/
cp package-lock.json dist/
else
echo "On non-dev branch ($current_branch), exiting"
exit 1
fi
# 创建tar包
tar -czvf $PROJECT_NAME-$PROFILE_ENV.tar.gz dist
# 记录结束时间
echo "Build process completed at $(date)" | tee -a $LOGFILE
# 上传到 nexus
echo "upload to nexus at $(date)" | tee -a $LOGFILE
curl -u 'admin':'YZ9Gq6=8\*G|?:,' --upload-file $PROJECT_NAME-$PROFILE_ENV.tar.gz https://repo.qikongjian.com/repository/frontend-tar-files/
# 清理构建文件
rm -rf dist
rm $PROJECT_NAME-$PROFILE_ENV.tar.gz

View File

@ -46,6 +46,24 @@ import { HighlightEditor } from "../common/HighlightEditor";
import GlobalLoad from "../common/GlobalLoad";
/**模板故事模式弹窗组件 */
/**
*
* @param {Function} func -
* @param {number} wait - (ms)
* @returns {Function} -
*/
const debounce = (func: Function, wait: number) => {
let timeout: NodeJS.Timeout;
return function executedFunction(...args: any[]) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
};
const RenderTemplateStoryMode = ({
isTemplateCreating,
setIsTemplateCreating,
@ -93,6 +111,22 @@ const RenderTemplateStoryMode = ({
clearData,
} = useTemplateStoryServiceHook();
// 防抖处理的输入更新函数
const debouncedUpdateInput = debounce((value: string) => {
// 过滤特殊字符
const sanitizedValue = value.replace(/[<>]/g, '');
// 更新输入值
if (!selectedTemplate?.freeInputItem) return;
const updatedTemplate: StoryTemplateEntity = {
...selectedTemplate,
freeInputItem: {
...selectedTemplate.freeInputItem,
free_input_text: sanitizedValue
}
};
setSelectedTemplate(updatedTemplate);
}, 300); // 300ms 的防抖延迟
// 使用上传文件hook
const { uploadFile, isUploading } = useUploadFile();
// 本地加载状态,用于 UI 反馈
@ -258,6 +292,7 @@ const RenderTemplateStoryMode = ({
</div>
</div>
</div>
{/* 角色配置区域 */}
{selectedTemplate?.storyRole &&
selectedTemplate.storyRole.length > 0 && (
@ -755,7 +790,29 @@ const RenderTemplateStoryMode = ({
</div>
</div>
</div> */}
<div className=" absolute -bottom-8 right-0">
<div className=" absolute -bottom-8 right-0 w-full flex items-center justify-end gap-2">
{/** 自由输入文字 */}
{(selectedTemplate?.freeInputItem) && (
<div className="py-2 flex-1">
<input
type="text"
value={selectedTemplate?.freeInputItem?.free_input_text || ""}
placeholder="How is coffee made?"
className="w-full px-3 py-2 pr-16 bg-white/0 border border-white/10 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/30 transition-all duration-200 text-sm"
onChange={(e) => {
// 更新自由输入文字字段
const updatedTemplate = {
...selectedTemplate!,
freeInputItem: {
...selectedTemplate!.freeInputItem,
free_input_text: e.target.value
}
};
setSelectedTemplate(updatedTemplate as StoryTemplateEntity);
}}
/>
</div>
)}
<ActionButton
isCreating={isTemplateCreating || localLoading > 0}
handleCreateVideo={handleConfirm}

View File

@ -2,7 +2,7 @@
import { useEffect, useState } from 'react';
import { useRouter, usePathname } from 'next/navigation';
import { checkAuth, clearAuthData, getUserProfile, isAuthenticated } from '@/lib/auth';
import { clearAuthData, getUserProfile, isAuthenticated } from '@/lib/auth';
import GlobalLoad from '../common/GlobalLoad';
import { message } from 'antd';
import { errorHandle } from '@/api/errorHandle';

View File

@ -0,0 +1,92 @@
'use client';
import { toast, type ToastT } from 'sonner';
import {
CheckCircle2,
XCircle,
AlertCircle,
Info,
Loader2
} from 'lucide-react';
/**
*
* sonner toast API
*/
class GlobalMessage {
success(message: string, duration = 3000, options?: Partial<ToastT>) {
toast.success(message, {
icon: <CheckCircle2 className="h-4 w-4" />,
duration,
...options,
});
}
error(message: string, duration = 3000, options?: Partial<ToastT>) {
toast.error(message, {
icon: <XCircle className="h-4 w-4" />,
duration,
...options,
});
}
warning(message: string, duration = 3000, options?: Partial<ToastT>) {
toast.warning(message, {
icon: <AlertCircle className="h-4 w-4" />,
duration,
...options,
});
}
info(message: string, duration = 3000, options?: Partial<ToastT>) {
toast(message, {
icon: <Info className="h-4 w-4" />,
duration,
...options,
});
}
loading(message: string, options?: Partial<ToastT>) {
return toast.promise(
new Promise(() => {}),
{
loading: message,
icon: <Loader2 className="h-4 w-4 animate-spin" />,
...options,
}
);
}
dismiss() {
toast.dismiss();
}
}
// 导出单例实例
export const msg = new GlobalMessage();
// 为了方便使用,也导出单独的方法
export const { success, error, warning, info, loading, dismiss } = msg;
// 在文件末尾添加全局注册方法
export function registerGlobalMessage() {
if (typeof window !== 'undefined') {
// 注册完整实例
window.msg = msg;
// 注册便捷方法
window.$message = {
success: msg.success.bind(msg),
error: msg.error.bind(msg),
warning: msg.warning.bind(msg),
info: msg.info.bind(msg),
loading: msg.loading.bind(msg),
dismiss: msg.dismiss.bind(msg),
};
// 添加调试信息
console.log('GlobalMessage registered:', {
msg: window.msg,
$message: window.$message
});
}
}

View File

@ -3,6 +3,7 @@
import { useState } from 'react';
import { Sidebar } from './sidebar';
import { TopBar } from './top-bar';
import { useDeviceType } from '@/hooks/useDeviceType';
interface DashboardLayoutProps {
children: React.ReactNode;
@ -10,15 +11,38 @@ interface DashboardLayoutProps {
export function DashboardLayout({ children }: DashboardLayoutProps) {
const [sidebarCollapsed, setSidebarCollapsed] = useState(true); // 默认收起状态
const { deviceType, isMobile, isTablet, isDesktop } = useDeviceType();
// 根据设备类型设置布局样式
const getLayoutStyles = () => {
if (isMobile) {
return {
left: '0',
width: '100vw'
};
}
if (isTablet) {
return {
left: sidebarCollapsed ? '3rem' : '12rem',
width: sidebarCollapsed ? 'calc(100vw - 3rem)' : 'calc(100vw - 12rem)'
};
}
// 桌面端
return {
left: sidebarCollapsed ? '4rem' : '16rem',
width: sidebarCollapsed ? 'calc(100vw - 4rem)' : 'calc(100vw - 16rem)'
};
};
return (
<div className=" min-h-screen bg-background">
<TopBar collapsed={sidebarCollapsed} />
<Sidebar collapsed={sidebarCollapsed} onToggle={setSidebarCollapsed} />
<div className="h-[calc(100vh-4rem)] top-[4rem] fixed right-0 bottom-0 z-[999]" style={{
left: sidebarCollapsed ? '4rem' : '16rem',
width: sidebarCollapsed ? 'calc(100vw - 4rem)' : 'calc(100vw - 16rem)'
}}>
<TopBar collapsed={sidebarCollapsed} isDesktop={isDesktop} />
{isDesktop && <Sidebar collapsed={sidebarCollapsed} onToggle={setSidebarCollapsed} />}
<div
className="h-[calc(100vh-4rem)] top-[4rem] fixed right-0 bottom-0 z-[999] px-4"
style={getLayoutStyles()}>
{children}
</div>
</div>

View File

@ -25,6 +25,7 @@ import {
getUserSubscriptionInfo,
} from "@/lib/stripe";
import UserCard from "@/components/common/userCard";
import { showInsufficientPointsNotification } from "@/utils/notifications";
interface User {
id: string;
@ -35,7 +36,7 @@ interface User {
plan_name?: string;
}
export function TopBar({ collapsed }: { collapsed: boolean }) {
export function TopBar({ collapsed, isDesktop=true }: { collapsed: boolean, isDesktop?: boolean }) {
const router = useRouter();
const [isOpen, setIsOpen] = React.useState(false);
const menuRef = useRef<HTMLDivElement>(null);
@ -169,10 +170,10 @@ export function TopBar({ collapsed }: { collapsed: boolean }) {
className="fixed right-0 top-0 h-16 header z-[999]"
style={{
isolation: "isolate",
left: pathname === "/" ? "0" : (collapsed ? "2.5rem" : "16rem")
left: (pathname === "/" || !isDesktop) ? "0" : (collapsed ? "2.5rem" : "16rem")
}}
>
<div className="h-full flex items-center justify-between pr-6 pl-4">
<div className="h-full flex items-center justify-between px-4">
<div className="flex items-center space-x-4">
<div
className={`flex items-center cursor-pointer space-x-1 link-logo roll event-on`}
@ -239,7 +240,11 @@ export function TopBar({ collapsed }: { collapsed: boolean }) {
)}
{/* Notifications */}
{/* <Button variant="ghost" size="sm" onClick={() => showQueueNotification(3, 10)}>
{/* <Button variant="ghost" size="sm" onClick={() => showInsufficientPointsNotification({
current_balance: 20,
required_tokens: 100,
message: 'Insufficient points'
})}>
<Bell className="h-4 w-4" />
</Button> */}
@ -273,7 +278,9 @@ export function TopBar({ collapsed }: { collapsed: boolean }) {
padding: "unset !important",
}}
>
<div className="h-10 w-10 rounded-full bg-[#C73BFF] flex items-center justify-center text-white font-semibold">
<div
className={`${isDesktop ? "h-10 w-10" : "h-8 w-8"} rounded-full bg-[#C73BFF] flex items-center justify-center text-white font-semibold`}
>
{currentUser.username ? currentUser.username.charAt(0) : "MF"}
</div>
</Button>

View File

@ -287,14 +287,21 @@ export default function CreateToVideo2() {
{/* 加载更多指示器 */}
{isLoadingMore && (
<div className="flex justify-center py-12">
<div className="flex items-center gap-3 px-6 py-3 bg-black/30 backdrop-blur-xl border border-white/10 rounded-full">
<div className="flex items-center">
<Loader2 className="w-5 h-5 animate-spin text-purple-400" />
<span className="text-white/90 font-medium">Loading more projects...</span>
</div>
</div>
)}
</div>
)}
{episodeList.length === 0 && isLoading && (
<div className="flex justify-center py-12 h-full">
<div className="flex items-center">
<Loader2 className="w-8 h-8 animate-spin text-purple-400" />
</div>
</div>
)}
</div>
{/* 视频工具组件 - 使用独立组件 */}

View File

@ -60,7 +60,7 @@ const WorkFlow = React.memo(function WorkFlow() {
showEditingNotification({
description: 'Performing intelligent editing...',
successDescription: 'Editing successful',
timeoutDescription: 'Editing failed, please try again',
timeoutDescription: 'Editing failed. Please click the edit button to go to the smart editing platform.',
timeout: 5 * 60 * 1000,
key: editingNotificationKey.current,
onFail: () => {

View File

@ -92,9 +92,11 @@ export const AIEditingIframe = React.forwardRef<AIEditingIframeHandle, AIEditing
const iframeRef = useRef<HTMLIFrameElement>(null);
const progressIntervalRef = useRef<NodeJS.Timeout | null>(null);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const cutUrl = process.env.NEXT_PUBLIC_CUT_URL || 'https://cut.movieflow.ai';
console.log('cutUrl', cutUrl);
// 构建智能剪辑URL
const aiEditingUrl = `https://smartcut.movieflow.ai/ai-editor/${projectId}?token=${token}&user_id=${userId}&auto=true&embedded=true`;
const aiEditingUrl = `${cutUrl}/ai-editor/${projectId}?token=${token}&user_id=${userId}&auto=true&embedded=true`;
/**
* iframe消息
@ -102,7 +104,7 @@ export const AIEditingIframe = React.forwardRef<AIEditingIframeHandle, AIEditing
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
// 验证消息来源
if (!event.origin.includes('smartcut.movieflow.ai')) {
if (!event.origin.includes(cutUrl)) {
return;
}

View File

@ -427,6 +427,7 @@ export const MediaViewer = React.memo(function MediaViewer({
// 渲染视频内容
const renderVideoContent = (onGotoCut: () => void) => {
if (!taskObject.videos.data[currentSketchIndex]) return null;
const urls = taskObject.videos.data[currentSketchIndex]?.urls ? taskObject.videos.data[currentSketchIndex]?.urls.join(',') : '';
return (
<div

View File

@ -8,7 +8,6 @@ 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 { msg } from '@/utils/message';
interface UseWorkflowDataProps {
onEditPlanGenerated?: () => void;
@ -22,6 +21,9 @@ export function useWorkflowData({ onEditPlanGenerated }: UseWorkflowDataProps =
const useid = JSON.parse(localStorage.getItem("currentUser") || '{}').id || NaN;
const notificationKey = useMemo(() => `video-workflow-${episodeId}`, [episodeId]);
const cutUrl = process.env.NEXT_PUBLIC_CUT_URL || 'https://cut.movieflow.ai';
console.log('cutUrl', cutUrl);
useEffect(() => {
console.log("init-useWorkflowData");
return () => {
@ -74,6 +76,8 @@ export function useWorkflowData({ onEditPlanGenerated }: UseWorkflowDataProps =
const [isShowError, setIsShowError] = useState(false);
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [isGenerateEditPlan, setIsGenerateEditPlan] = useState(false);
const [retryCount, setRetryCount] = useState(0);
const [isLoadingGenerateEditPlan, setIsLoadingGenerateEditPlan] = useState(false);
const [state, setState] = useState({
mode: 'automatic' as 'automatic' | 'manual' | 'auto',
originalText: '',
@ -135,7 +139,7 @@ export function useWorkflowData({ onEditPlanGenerated }: UseWorkflowDataProps =
}
}, [taskObject.currentStage]);
const generateEditPlan = useCallback(async () => {
const generateEditPlan = useCallback(async (retryCount: number) => {
if (isLoadedRef.current) {
return;
}
@ -147,9 +151,9 @@ export function useWorkflowData({ onEditPlanGenerated }: UseWorkflowDataProps =
// 更新通知内容
showEditingNotification({
key: notificationKey,
description: 'Generating intelligent editing plan...',
successDescription: '剪辑计划生成完成',
timeoutDescription: '剪辑计划生成失败,请重试',
description: `Generating intelligent editing plan... ${retryCount ? 'Retry Time: ' + retryCount : ''}`,
successDescription: 'Editing plan generated successfully.',
timeoutDescription: 'Editing plan generation failed. Please refresh and try again.',
timeout: 3 * 60 * 1000
});
// 先停止轮询
@ -157,8 +161,13 @@ export function useWorkflowData({ onEditPlanGenerated }: UseWorkflowDataProps =
setNeedStreamData(false);
resolve(true);
});
setIsLoadingGenerateEditPlan(true);
try {
await getGenerateEditPlan({ project_id: episodeId });
const response = await getGenerateEditPlan({ project_id: episodeId });
if (!response.data.editing_plan) {
throw new Error(response.message);
}
console.error('生成剪辑计划成功');
setIsGenerateEditPlan(true);
isLoadedRef.current = 'true';
@ -168,8 +177,8 @@ export function useWorkflowData({ onEditPlanGenerated }: UseWorkflowDataProps =
showEditingNotification({
key: notificationKey,
isCompleted: true,
description: '正在生成剪辑计划...',
successDescription: '剪辑计划生成完成',
description: `Generating intelligent editing plan... ${retryCount ? 'Retry Time: ' + retryCount : ''}`,
successDescription: 'Editing plan generated successfully.',
timeout: 3000
});
setTimeout(() => {
@ -178,6 +187,7 @@ export function useWorkflowData({ onEditPlanGenerated }: UseWorkflowDataProps =
// 触发回调,通知父组件计划生成完成
onEditPlanGenerated?.();
setIsLoadingGenerateEditPlan(false);
} catch (error) {
console.error('生成剪辑计划失败:', error);
setNeedStreamData(true);
@ -186,30 +196,38 @@ export function useWorkflowData({ onEditPlanGenerated }: UseWorkflowDataProps =
// 显示失败通知3秒
showEditingNotification({
key: notificationKey,
description: '正在生成剪辑计划...',
timeoutDescription: '剪辑计划生成失败,请重试',
description: `Generating intelligent editing plan... ${retryCount ? 'Retry Time: ' + retryCount : ''}`,
timeoutDescription: 'Editing plan generation failed. Retrying later.',
timeout: 3000
});
setTimeout(() => {
notification.destroy(notificationKey);
}, 3000);
setIsLoadingGenerateEditPlan(false);
}
}, [episodeId, onEditPlanGenerated, notificationKey]);
const openEditPlan = useCallback(async () => {
window.open(`https://smartcut.movieflow.ai/ai-editor/${episodeId}?token=${token}&user_id=${useid}`, '_target');
window.open(`${cutUrl}/ai-editor/${episodeId}?token=${token}&user_id=${useid}`, '_target');
}, [episodeId]);
useEffect(() => {
// 主动触发剪辑
if (canGoToCut && taskObject.currentStage === 'video') {
generateEditPlan();
generateEditPlan(retryCount);
}
}, [canGoToCut, taskObject.currentStage]);
}, [canGoToCut, taskObject.currentStage, retryCount]);
useEffect(() => {
// 加载剪辑计划结束 并且 失败了 重试
if (!isLoadingGenerateEditPlan && !isGenerateEditPlan) {
setRetryCount(retryCount + 1);
}
}, [isLoadingGenerateEditPlan, isGenerateEditPlan]);
useEffect(() => {
if (isShowError) {
msg.error('Too many failed storyboards, unable to execute automatic editing.', 3000);
window.msg.error('Too many failed storyboards, unable to execute automatic editing.', 3000);
}
}, [isShowError]);
@ -252,7 +270,7 @@ export function useWorkflowData({ onEditPlanGenerated }: UseWorkflowDataProps =
loadingText.current = LOADING_TEXT_MAP.postProduction('AI-powered video editing in progress…');
}
}
if (taskObject.currentStage === 'final_video') {
if (taskObject.currentStage === 'final_video' && taskObject.status !== 'COMPLETED') {
loadingText.current = LOADING_TEXT_MAP.postProduction('generating fine-grained video clips...');
}
if (taskObject.status === 'COMPLETED') {
@ -498,7 +516,7 @@ export function useWorkflowData({ onEditPlanGenerated }: UseWorkflowDataProps =
throw new Error(response.message);
}
const { status, data, tags, mode, original_text, title, name } = response.data;
const { status, data, tags, mode, original_text, title, name, final_simple_video, final_video } = response.data;
const { current: taskCurrent } = tempTaskObject;
@ -611,6 +629,21 @@ export function useWorkflowData({ onEditPlanGenerated }: UseWorkflowDataProps =
}
}
// 粗剪
if (final_simple_video) {
taskCurrent.currentStage = 'final_video';
taskCurrent.final.url = final_simple_video;
taskCurrent.final.note = 'simple';
taskCurrent.status = 'COMPLETED';
}
if (final_video) {
taskCurrent.currentStage = 'final_video';
taskCurrent.final.url = final_video;
taskCurrent.final.note = 'final';
taskCurrent.status = 'COMPLETED';
}
console.log('---look-taskData', taskCurrent);
if (taskCurrent.currentStage === 'script') {
@ -632,7 +665,7 @@ export function useWorkflowData({ onEditPlanGenerated }: UseWorkflowDataProps =
});
// 设置是否需要获取流式数据
setNeedStreamData(status !== 'COMPLETED');
setNeedStreamData(taskCurrent.status !== 'COMPLETED');
} catch (error) {
console.error('初始化失败:', error);

View File

@ -3,15 +3,11 @@
import { Provider } from 'react-redux';
import { store } from '@/lib/store/store';
import { ThemeProvider } from 'next-themes';
import { Toaster } from '@/components/ui/sonner';
import { Toaster } from 'sonner';
import AuthGuard from './auth/auth-guard';
import dynamic from 'next/dynamic';
// 动态导入 OAuthCallbackHandler 和 DevHelper
const OAuthCallbackHandler = dynamic(
() => import('./ui/oauth-callback-handler').then(mod => mod.default),
{ ssr: false }
);
import { registerGlobalMessage } from '@/components/common/GlobalMessage';
import { useEffect } from 'react';
const DevHelper = dynamic(
() => import('@/utils/dev-helper').then(mod => (mod as any).default),
@ -19,6 +15,11 @@ const DevHelper = dynamic(
);
export function Providers({ children }: { children: React.ReactNode }) {
// 注册全局消息提醒
useEffect(() => {
registerGlobalMessage();
}, []);
return (
<Provider store={store}>
<ThemeProvider
@ -30,8 +31,23 @@ export function Providers({ children }: { children: React.ReactNode }) {
<AuthGuard>
{children}
</AuthGuard>
<Toaster />
<OAuthCallbackHandler />
<Toaster
position="bottom-left"
theme="dark"
richColors
toastOptions={{
// 统一配置所有 toast 的样式
classNames: {
toast: "dark:bg-zinc-900 dark:text-zinc-100 dark:border-zinc-800",
title: "dark:text-zinc-100 font-medium",
description: "dark:text-zinc-400",
actionButton: "dark:bg-zinc-700 dark:text-zinc-100",
cancelButton: "dark:bg-zinc-600 dark:text-zinc-100",
closeButton: "dark:text-zinc-300 hover:text-zinc-100"
},
duration: 3000,
}}
/>
{process.env.NODE_ENV === 'development' && <DevHelper />}
</ThemeProvider>
</Provider>

View File

@ -5,7 +5,6 @@ import { ScriptData, ScriptBlock, ScriptContent, ThemeTagBgColor, ThemeType } fr
import ContentEditable, { ContentEditableEvent } from 'react-contenteditable';
import { SelectDropdown } from '@/components/ui/select-dropdown';
import { TypewriterText } from '@/components/workflow/work-office/common/TypewriterText';
import { msg } from '@/utils/message';
interface ScriptRendererProps {
data: any[];
@ -126,7 +125,7 @@ export const ScriptRenderer: React.FC<ScriptRendererProps> = ({ data, setIsPause
const handleThemeTagChange = (value: string[]) => {
console.log('主题标签更改', value);
if (value.length > 5) {
msg.error('最多可选择5个主题标签', 3000);
window.msg.error('max 5 theme tags', 3000);
return;
}
setAddThemeTag(value);
@ -217,7 +216,7 @@ export const ScriptRenderer: React.FC<ScriptRendererProps> = ({ data, setIsPause
className="w-6 h-6 p-1 cursor-pointer text-gray-600 hover:text-blue-500 transition-colors"
onClick={() => {
// 提示权限不够
msg.error('No permission!');
window.msg.error('No permission!');
return;
handleEditBlock(block);
}}
@ -226,7 +225,7 @@ export const ScriptRenderer: React.FC<ScriptRendererProps> = ({ data, setIsPause
className="w-6 h-6 p-1 cursor-pointer text-gray-600 hover:text-blue-500 transition-colors"
onClick={() => {
navigator.clipboard.writeText(block.content.map(item => item.text).join('\n'));
msg.success('Copied!');
window.msg.success('Copied!');
}}
/>
</motion.div>

View File

@ -12,7 +12,6 @@ import { useEditData } from '@/components/pages/work-flow/use-edit-data';
import { useSearchParams } from 'next/navigation';
import { RoleEntity } from '@/app/service/domain/Entities';
import { Role } from '@/api/DTO/movieEdit';
import { msg } from '@/utils/message';
interface CharacterTabContentProps {
originalRoles: Role[];
@ -113,7 +112,7 @@ CharacterTabContentProps
const handleSmartPolish = (text: string) => {
// 然后调用优化角色文本
msg.error('No permission!');
window.msg.error('No permission!');
return;
optimizeRoleText(text);
};
@ -212,7 +211,7 @@ CharacterTabContentProps
};
const handleOpenReplaceLibrary = async () => {
msg.error('No permission!');
window.msg.error('No permission!');
return;
setIsLoadingLibrary(true);
setIsReplaceLibraryOpen(true);
@ -222,7 +221,7 @@ CharacterTabContentProps
};
const handleRegenerate = async () => {
msg.error('No permission!');
window.msg.error('No permission!');
return;
console.log('Regenerate');
setIsRegenerate(true);
@ -237,7 +236,7 @@ CharacterTabContentProps
};
const handleUploadClick = () => {
msg.error('No permission!');
window.msg.error('No permission!');
return;
fileInputRef.current?.click();
};

View File

@ -13,7 +13,6 @@ import { MusicTabContent } from './music-tab-content';
import FloatingGlassPanel from './FloatingGlassPanel';
import { SaveEditUseCase } from '@/app/service/usecase/SaveEditUseCase';
import { TaskObject } from '@/api/DTO/movieEdit';
import { msg } from '@/utils/message';
interface EditModalProps {
isOpen: boolean;
@ -124,7 +123,7 @@ export function EditModal({
}
const handleSave = () => {
msg.error('No permission!');
window.msg.error('No permission!');
return;
console.log('handleSave');
// setIsRemindFallbackOpen(true);
@ -143,7 +142,7 @@ export function EditModal({
}
const handleConfirmGotoFallback = () => {
msg.error('No permission!');
window.msg.error('No permission!');
return;
setDisabledBtn(true);
console.log('handleConfirmGotoFallback');
@ -170,7 +169,7 @@ export function EditModal({
}
const handleReset = () => {
msg.error('No permission!');
window.msg.error('No permission!');
return;
console.log('handleReset');
// 重置当前tab修改的数据

View File

@ -1,90 +0,0 @@
'use client';
import { useEffect } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { validateOAuthState } from '@/lib/auth';
import { toast } from '@/hooks/use-toast';
export default function OAuthCallbackHandler() {
const searchParams = useSearchParams();
const router = useRouter();
useEffect(() => {
// Check if this is an OAuth callback
const state = searchParams.get('state');
const session = searchParams.get('session');
const userJson = searchParams.get('user');
const error = searchParams.get('error');
// Log debug information
console.log('OAuth callback handler running with params:', {
state: state || 'not present',
session: session ? 'present' : 'not present',
userJson: userJson ? 'present' : 'not present',
error: error || 'none'
});
// Handle error case
if (error) {
console.error('OAuth error:', error);
toast({
title: 'Authentication Error',
description: `Google sign-in failed: ${error}`,
variant: 'destructive',
});
router.push(`/login?error=${error}`);
return;
}
// If we have state and session, this might be an OAuth callback
if (state && session) {
// Validate the state parameter to prevent CSRF
const isValid = validateOAuthState(state);
if (!isValid) {
// State validation failed, possible CSRF attack
console.error('OAuth state validation failed');
toast({
title: 'Authentication Error',
description: 'Security validation failed. Please try signing in again.',
variant: 'destructive',
});
router.push('/login?error=invalid_state');
return;
}
console.log('OAuth state validation successful');
// State is valid, process the login
if (userJson) {
try {
const user = JSON.parse(decodeURIComponent(userJson));
console.log('OAuth user data parsed successfully');
// Store the user in session
sessionStorage.setItem('currentUser', JSON.stringify(user));
// Show success message
toast({
title: 'Signed in successfully',
description: `Welcome ${user.name}!`,
});
// Remove the query parameters from the URL
router.replace('/');
} catch (error) {
console.error('Failed to parse user data', error);
toast({
title: 'Authentication Error',
description: 'Failed to process authentication data',
variant: 'destructive',
});
router.push('/login?error=invalid_user_data');
}
}
}
}, [searchParams, router]);
// This is a utility component that doesn't render anything
return null;
}

View File

@ -2,7 +2,6 @@ import React, { forwardRef, useRef, useState } from "react";
import { useDeepCompareEffect } from "@/hooks/useDeepCompareEffect";
import { Plus, X, UserRoundPlus, MessageCirclePlus, MessageCircleMore, ClipboardType } from "lucide-react";
import ShotEditor from "./ShotEditor";
import { toast } from "sonner";
import { TextToShotAdapter } from "@/app/service/adapter/textToShot";
@ -90,11 +89,7 @@ export const ShotsEditor = forwardRef<any, ShotsEditorProps>(function ShotsEdito
const addShot = () => {
if (shots.length > 3) {
toast.error('No more than 4 shots', {
duration: 3000,
position: 'top-center',
richColors: true,
});
window.msg.error('max 4 shots');
return;
}
const newShot = createEmptyShot();

View File

@ -13,7 +13,6 @@ import HorizontalScroller from './HorizontalScroller';
import { useEditData } from '@/components/pages/work-flow/use-edit-data';
import { RoleEntity, VideoSegmentEntity } from '@/app/service/domain/Entities';
import { ShotVideo } from '@/api/DTO/movieEdit';
import { msg } from '@/utils/message';
interface ShotTabContentProps {
currentSketchIndex: number;
@ -448,7 +447,7 @@ export const ShotTabContent = forwardRef<
{/* 人物替换按钮 */}
<motion.button
onClick={() => {
msg.error('No permission!');
window.msg.error('No permission!');
return;
handleScan()
}}
@ -504,7 +503,7 @@ export const ShotTabContent = forwardRef<
</motion.button> */}
<motion.button
onClick={() => {
msg.error('No permission!');
window.msg.error('No permission!');
return;
handleRegenerate();
}}

View File

@ -1,31 +0,0 @@
'use client';
import { useTheme } from 'next-themes';
import { Toaster as Sonner } from 'sonner';
type ToasterProps = React.ComponentProps<typeof Sonner>;
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = 'system' } = useTheme();
return (
<Sonner
theme={theme as ToasterProps['theme']}
className="toaster group"
toastOptions={{
classNames: {
toast:
'group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg',
description: 'group-[.toast]:text-muted-foreground',
actionButton:
'group-[.toast]:bg-primary group-[.toast]:text-primary-foreground',
cancelButton:
'group-[.toast]:bg-muted group-[.toast]:text-muted-foreground',
},
}}
{...props}
/>
);
};
export { Toaster };

View File

@ -1,129 +0,0 @@
'use client';
import * as React from 'react';
import * as ToastPrimitives from '@radix-ui/react-toast';
import { cva, type VariantProps } from 'class-variance-authority';
import { X } from 'lucide-react';
import { cn } from '@/public/lib/utils';
const ToastProvider = ToastPrimitives.Provider;
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
'fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]',
className
)}
{...props}
/>
));
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
const toastVariants = cva(
'group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full',
{
variants: {
variant: {
default: 'border bg-background text-foreground',
destructive:
'destructive group border-destructive bg-destructive text-destructive-foreground',
},
},
defaultVariants: {
variant: 'default',
},
}
);
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
);
});
Toast.displayName = ToastPrimitives.Root.displayName;
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
'inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive',
className
)}
{...props}
/>
));
ToastAction.displayName = ToastPrimitives.Action.displayName;
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
'absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600',
className
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
));
ToastClose.displayName = ToastPrimitives.Close.displayName;
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn('text-sm font-semibold', className)}
{...props}
/>
));
ToastTitle.displayName = ToastPrimitives.Title.displayName;
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn('text-sm opacity-90', className)}
{...props}
/>
));
ToastDescription.displayName = ToastPrimitives.Description.displayName;
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>;
type ToastActionElement = React.ReactElement<typeof ToastAction>;
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
};

View File

@ -1,35 +0,0 @@
'use client';
import { useToast } from '@/hooks/use-toast';
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from '@/components/ui/toast';
export function Toaster() {
const { toasts } = useToast();
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>
);
})}
<ToastViewport />
</ToastProvider>
);
}

View File

@ -1,191 +0,0 @@
'use client';
// Inspired by react-hot-toast library
import * as React from 'react';
import type { ToastActionElement, ToastProps } from '@/components/ui/toast';
const TOAST_LIMIT = 1;
const TOAST_REMOVE_DELAY = 1000000;
type ToasterToast = ToastProps & {
id: string;
title?: React.ReactNode;
description?: React.ReactNode;
action?: ToastActionElement;
};
const actionTypes = {
ADD_TOAST: 'ADD_TOAST',
UPDATE_TOAST: 'UPDATE_TOAST',
DISMISS_TOAST: 'DISMISS_TOAST',
REMOVE_TOAST: 'REMOVE_TOAST',
} as const;
let count = 0;
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER;
return count.toString();
}
type ActionType = typeof actionTypes;
type Action =
| {
type: ActionType['ADD_TOAST'];
toast: ToasterToast;
}
| {
type: ActionType['UPDATE_TOAST'];
toast: Partial<ToasterToast>;
}
| {
type: ActionType['DISMISS_TOAST'];
toastId?: ToasterToast['id'];
}
| {
type: ActionType['REMOVE_TOAST'];
toastId?: ToasterToast['id'];
};
interface State {
toasts: ToasterToast[];
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return;
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId);
dispatch({
type: 'REMOVE_TOAST',
toastId: toastId,
});
}, TOAST_REMOVE_DELAY);
toastTimeouts.set(toastId, timeout);
};
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case 'ADD_TOAST':
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
};
case 'UPDATE_TOAST':
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t
),
};
case 'DISMISS_TOAST': {
const { toastId } = action;
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId);
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id);
});
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t
),
};
}
case 'REMOVE_TOAST':
if (action.toastId === undefined) {
return {
...state,
toasts: [],
};
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
};
}
};
const listeners: Array<(state: State) => void> = [];
let memoryState: State = { toasts: [] };
function dispatch(action: Action) {
memoryState = reducer(memoryState, action);
listeners.forEach((listener) => {
listener(memoryState);
});
}
type Toast = Omit<ToasterToast, 'id'>;
function toast({ ...props }: Toast) {
const id = genId();
const update = (props: ToasterToast) =>
dispatch({
type: 'UPDATE_TOAST',
toast: { ...props, id },
});
const dismiss = () => dispatch({ type: 'DISMISS_TOAST', toastId: id });
dispatch({
type: 'ADD_TOAST',
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss();
},
},
});
return {
id: id,
dismiss,
update,
};
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState);
React.useEffect(() => {
listeners.push(setState);
return () => {
const index = listeners.indexOf(setState);
if (index > -1) {
listeners.splice(index, 1);
}
};
}, [state]);
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: 'DISMISS_TOAST', toastId }),
};
}
export { useToast, toast };

64
hooks/useDeviceType.ts Normal file
View File

@ -0,0 +1,64 @@
import { useState, useEffect } from 'react';
// 定义设备类型枚举
export enum DeviceType {
MOBILE = 'mobile', // 手机
TABLET = 'tablet', // 平板
DESKTOP = 'desktop' // 桌面端
}
// 定义屏幕断点
const BREAKPOINTS = {
MOBILE: 480, // 0-480px 为手机
TABLET: 1024, // 481-1024px 为平板
DESKTOP: 1025 // 1025px 及以上为桌面端
};
export function useDeviceType() {
const [deviceType, setDeviceType] = useState<DeviceType>(DeviceType.DESKTOP);
const [windowSize, setWindowSize] = useState({
width: typeof window !== 'undefined' ? window.innerWidth : 0,
height: typeof window !== 'undefined' ? window.innerHeight : 0
});
useEffect(() => {
/**
*
*/
const getDeviceType = (width: number): DeviceType => {
if (width <= BREAKPOINTS.MOBILE) return DeviceType.MOBILE;
if (width <= BREAKPOINTS.TABLET) return DeviceType.TABLET;
return DeviceType.DESKTOP;
};
/**
*
*/
const handleResize = () => {
const width = window.innerWidth;
const height = window.innerHeight;
setWindowSize({ width, height });
setDeviceType(getDeviceType(width));
};
// 初始化设备类型
handleResize();
// 添加窗口大小变化监听
window.addEventListener('resize', handleResize);
// 清理监听器
return () => {
window.removeEventListener('resize', handleResize);
};
}, []);
return {
deviceType,
windowSize,
isMobile: deviceType === DeviceType.MOBILE,
isTablet: deviceType === DeviceType.TABLET,
isDesktop: deviceType === DeviceType.DESKTOP
};
}

View File

@ -41,40 +41,6 @@ export const loginUser = async (email: string, password: string) => {
}
};
/**
*
*/
export const checkAuth = async (): Promise<boolean> => {
const token = getToken();
if (!token) {
return false;
}
try {
const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/admin/login/auth`, {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'X-EASE-ADMIN-TOKEN': token,
},
});
const data = await response.json();
return true;
// if (data.code === '401' || data.status === 401) {
// // Token无效清除本地存储
// // clearAuthData();
// // return false;
// }
// return data.code === '200' && data.status === 200;
} catch (error) {
console.error('Auth check failed:', error);
return false;
}
};
/**
* token
*/

View File

@ -25,6 +25,12 @@
"useDefineForClassFields": true,
"forceConsistentCasingInFileNames": true
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
"app/types/global.d.ts" //
],
"exclude": ["node_modules", "**/*.test.ts", "**/*.spec.ts", ".next"]
}

View File

@ -58,15 +58,33 @@ const btnStyle = {
*
* @description
*/
export const showInsufficientPointsNotification = () => {
export const showInsufficientPointsNotification = (detail?: {
current_balance?: number;
required_tokens?: number;
message?: string;
}) => {
// 生成唯一的 key
const key = `insufficient-points-${Date.now()}`;
notification.warning({
key, // 添加唯一的 key
message: null,
description: (
<div data-alt="insufficient-points-notification" style={{ minWidth: '280px' }}>
<h3 style={messageStyle}>
Insufficient credits reminder
</h3>
<p style={descriptionStyle}>Your credits are insufficient, please upgrade to continue.</p>
<p style={descriptionStyle}>
{detail?.message || 'Your credits are insufficient, please upgrade to continue.'}
{detail?.current_balance !== undefined && detail?.required_tokens !== undefined && (
<>
<br />
<span style={{ color: 'rgba(255, 255, 255, 0.85)' }}>
Current balance: {detail.current_balance} / Required: {detail.required_tokens}
</span>
</>
)}
</p>
<button
onClick={() => window.location.href = '/pricing'}
style={btnStyle}
@ -76,7 +94,7 @@ export const showInsufficientPointsNotification = () => {
</button>
</div>
),
duration: 5,
duration: 0,
placement: 'topRight',
style: darkGlassStyle,
className: 'dark-glass-notification',