diff --git a/app/(admin)/layout.tsx b/app/(admin)/layout.tsx index 28b38f8..5878854 100644 --- a/app/(admin)/layout.tsx +++ b/app/(admin)/layout.tsx @@ -1,16 +1,17 @@ import { auth } from "@/app/(auth)/auth"; import { TopNavbar } from "@/components/top-navbar"; -import { getSiteSettings, getNavLinks } from "@/lib/db/queries"; +import { getSiteSettings, getNavLinks, getTutorialVideos } from "@/lib/db/queries"; export default async function AdminLayout({ children, }: { children: React.ReactNode; }) { - const [session, siteSettings, navLinks] = await Promise.all([ + const [session, siteSettings, navLinks, tutorialVideos] = await Promise.all([ auth(), getSiteSettings(), getNavLinks(), + getTutorialVideos(), ]); return ( @@ -31,7 +32,14 @@ export default async function AdminLayout({ toolbox1Enabled={siteSettings.toolbox1Enabled ?? true} toolbox2Name={siteSettings.toolbox2Name || "工具箱2"} toolbox2Enabled={siteSettings.toolbox2Enabled ?? false} - tutorialVideoUrl={siteSettings.tutorialVideoUrl} + tutorialVideos={tutorialVideos.map(v => ({ + id: v.id, + title: v.title, + videoUrl: v.videoUrl, + coverUrl: v.coverUrl, + description: v.description, + order: v.order, + }))} tutorialEnabled={siteSettings.tutorialEnabled ?? false} rankingEnabled={siteSettings.rankingEnabled ?? true} /> diff --git a/app/(admin)/settings/admin-settings-client.tsx b/app/(admin)/settings/admin-settings-client.tsx index 1ce59d6..47e5275 100644 --- a/app/(admin)/settings/admin-settings-client.tsx +++ b/app/(admin)/settings/admin-settings-client.tsx @@ -51,6 +51,10 @@ import { Scissors, Expand, Move3D, + Upload, + Video, + Loader2, + Check, } from "lucide-react"; import { Dialog, @@ -173,6 +177,16 @@ type NavLink = { toolboxGroup: number; // 1 或 2 }; +type TutorialVideo = { + id: string; + title: string; + videoUrl: string; + coverUrl: string | null; + description: string | null; + order: number; + isEnabled: boolean; +}; + type DingTalkOrganization = { id: string; name: string; @@ -317,6 +331,7 @@ export function AdminSettingsClient() { const { data: storageData, mutate: mutateStorage } = useSWR<{ configs: StorageConfig[] }>("/api/admin/storage", fetcher); const { data: siteSettingsData, mutate: mutateSiteSettings } = useSWR("/api/site-settings", fetcher); const { data: navLinksData, mutate: mutateNavLinks } = useSWR<{ links: NavLink[] }>("/api/nav-links", fetcher); + const { data: tutorialVideosData, mutate: mutateTutorialVideos } = useSWR<{ videos: TutorialVideo[] }>("/api/tutorial-videos", fetcher); const { data: authSettingsData, mutate: mutateAuthSettings } = useSWR( "/api/auth-settings", fetcher, @@ -562,6 +577,23 @@ export function AdminSettingsClient() { const [showNavLinkDialog, setShowNavLinkDialog] = useState(false); const [editingNavLink, setEditingNavLink] = useState(null); + // 视频上传状态 + const [videoUploading, setVideoUploading] = useState(false); + const [videoUploadProgress, setVideoUploadProgress] = useState(""); + const [videoQuality, setVideoQuality] = useState<"low" | "medium" | "high">("medium"); + + // 视频教程管理状态 + const [showTutorialVideoDialog, setShowTutorialVideoDialog] = useState(false); + const [editingTutorialVideo, setEditingTutorialVideo] = useState(null); + const [tutorialVideoForm, setTutorialVideoForm] = useState({ + title: "", + videoUrl: "", + coverUrl: "", + description: "", + order: 0, + isEnabled: true, + }); + // 快捷短语状态 const [questions, setQuestions] = useState([]); const { data: questionsData, mutate: mutateQuestions } = useSWR("/api/starter-questions", fetcher); @@ -1069,6 +1101,54 @@ export function AdminSettingsClient() { } }; + // --- 视频教程管理逻辑 --- + const resetTutorialVideoForm = () => { + setTutorialVideoForm({ + title: "", + videoUrl: "", + coverUrl: "", + description: "", + order: 0, + isEnabled: true, + }); + setEditingTutorialVideo(null); + }; + + const handleSaveTutorialVideo = async () => { + try { + const method = editingTutorialVideo ? "PUT" : "POST"; + const body = editingTutorialVideo + ? { ...tutorialVideoForm, id: editingTutorialVideo } + : tutorialVideoForm; + + const response = await fetch("/api/tutorial-videos", { + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + + if (!response.ok) throw new Error("保存失败"); + toast.success(editingTutorialVideo ? "视频更新成功" : "视频创建成功"); + mutateTutorialVideos(); + setShowTutorialVideoDialog(false); + resetTutorialVideoForm(); + } catch (error) { + toast.error("保存视频失败"); + } + }; + + const handleDeleteTutorialVideo = async (id: string) => { + if (!confirm("确定要删除此视频吗?")) return; + try { + const response = await fetch(`/api/tutorial-videos?id=${id}`, { method: "DELETE" }); + if (!response.ok) throw new Error("删除失败"); + toast.success("视频删除成功"); + mutateTutorialVideos(); + } catch (error) { + toast.error("删除视频失败"); + } + }; + // --- 认证设置逻辑 --- const handleSaveAuthSettings = async () => { try { @@ -2561,32 +2641,17 @@ export function AdminSettingsClient() { {/* 功能教程设置 */} -
-
-
- -
- 启用后在导航栏显示功能教程按钮,点击后弹窗播放视频 -
+
+
+ +
+ 启用后在导航栏显示功能教程按钮,点击后下拉显示视频列表
- setSiteSettings({ ...siteSettings, tutorialEnabled: checked })} - />
- {siteSettings.tutorialEnabled && ( -
- - setSiteSettings({ ...siteSettings, tutorialVideoUrl: e.target.value || null })} - placeholder="输入视频播放链接,支持 MP4、YouTube、Bilibili 等" - /> -
- 支持直接视频链接(如 .mp4)或嵌入式播放链接 -
-
- )} + setSiteSettings({ ...siteSettings, tutorialEnabled: checked })} + />
+ + + + {editingTutorialVideo ? "编辑视频" : "添加视频"} + +
+
+ + setTutorialVideoForm({ ...tutorialVideoForm, title: e.target.value })} + placeholder="例如:快速入门教程" + /> +
+ + {/* 视频上传或链接 */} +
+ +
+ + +
+
+ +
+ + setTutorialVideoForm({ ...tutorialVideoForm, videoUrl: e.target.value })} + placeholder="视频URL(上传后自动填充或手动输入)" + /> +
+ + {/* 封面上传或链接 */} +
+ +
+ setTutorialVideoForm({ ...tutorialVideoForm, coverUrl: e.target.value })} + placeholder="封面图片URL(可选)" + className="flex-1" + /> + +
+
+ +
+ + setTutorialVideoForm({ ...tutorialVideoForm, description: e.target.value })} + placeholder="简短描述(可选)" + /> +
+ +
+
+ + setTutorialVideoForm({ ...tutorialVideoForm, order: parseInt(e.target.value) || 0 })} + /> +
+
+ setTutorialVideoForm({ ...tutorialVideoForm, isEnabled: checked })} + /> + +
+
+ + {/* 预览 */} + {tutorialVideoForm.videoUrl && ( +
+ +
+
+
+ )} +
+ + + + +
+ +
+ + +
+ {tutorialVideosData?.videos?.map((video) => ( +
+ {/* 封面 */} +
+ {video.coverUrl ? ( + {video.title} + ) : ( +
+
+ )} +
+ {/* 信息 */} +
+
+ {video.title} + {!video.isEnabled && 已禁用} +
+ {video.description && ( +
{video.description}
+ )} +
排序: {video.order}
+
+ {/* 操作 */} +
+ + +
+
+ ))} + {(!tutorialVideosData?.videos?.length) && ( +
+ 暂无视频教程,点击上方按钮添加 +
+ )} +
+
+ + )} + {/* 导航链接列表 */} diff --git a/app/(chat)/layout.tsx b/app/(chat)/layout.tsx index faa6134..142db4e 100644 --- a/app/(chat)/layout.tsx +++ b/app/(chat)/layout.tsx @@ -6,7 +6,7 @@ import { DataStreamProvider } from "@/components/data-stream-provider"; import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"; import { auth } from "../(auth)/auth"; import { TopNavbar } from "@/components/top-navbar"; -import { getSiteSettings, getNavLinks } from "@/lib/db/queries"; +import { getSiteSettings, getNavLinks, getTutorialVideos } from "@/lib/db/queries"; export default function Layout({ children }: { children: React.ReactNode }) { return ( @@ -25,11 +25,12 @@ export default function Layout({ children }: { children: React.ReactNode }) { } async function SidebarWrapper({ children }: { children: React.ReactNode }) { - const [session, cookieStore, siteSettings, navLinks] = await Promise.all([ + const [session, cookieStore, siteSettings, navLinks, tutorialVideos] = await Promise.all([ auth(), cookies(), getSiteSettings(), getNavLinks(), + getTutorialVideos(), ]); const isCollapsed = cookieStore.get("sidebar_state")?.value !== "true"; @@ -51,7 +52,14 @@ async function SidebarWrapper({ children }: { children: React.ReactNode }) { toolbox1Enabled={siteSettings.toolbox1Enabled ?? true} toolbox2Name={siteSettings.toolbox2Name || "工具箱2"} toolbox2Enabled={siteSettings.toolbox2Enabled ?? false} - tutorialVideoUrl={siteSettings.tutorialVideoUrl} + tutorialVideos={tutorialVideos.map(v => ({ + id: v.id, + title: v.title, + videoUrl: v.videoUrl, + coverUrl: v.coverUrl, + description: v.description, + order: v.order, + }))} tutorialEnabled={siteSettings.tutorialEnabled ?? false} rankingEnabled={siteSettings.rankingEnabled ?? true} /> diff --git a/app/api/tutorial-videos/route.ts b/app/api/tutorial-videos/route.ts new file mode 100644 index 0000000..d0f3f05 --- /dev/null +++ b/app/api/tutorial-videos/route.ts @@ -0,0 +1,167 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { db } from "@/lib/db"; +import { tutorialVideos } from "@/lib/db/schema"; +import { eq, asc } from "drizzle-orm"; +import { auth } from "@/app/(auth)/auth"; + +// GET /api/tutorial-videos - 获取所有视频教程 (公开) +export async function GET() { + try { + const videos = await db + .select() + .from(tutorialVideos) + .where(eq(tutorialVideos.isEnabled, true)) + .orderBy(asc(tutorialVideos.order)); + + return NextResponse.json({ videos }); + } catch (error) { + console.error("[Tutorial Videos API] Get error:", error); + return NextResponse.json( + { error: "Failed to fetch tutorial videos" }, + { status: 500 } + ); + } +} + +const createSchema = z.object({ + title: z.string().min(1).max(128), + videoUrl: z.string().url(), + coverUrl: z.string().url().nullable().optional(), + description: z.string().nullable().optional(), + order: z.number().int().default(0), + isEnabled: z.boolean().default(true), +}); + +// POST /api/tutorial-videos - 创建视频教程(仅管理员) +export async function POST(request: NextRequest) { + try { + const session = await auth(); + + if (!session?.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + if (session.user.type !== "admin") { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const body = await request.json(); + const data = createSchema.parse(body); + + const [created] = await db + .insert(tutorialVideos) + .values({ + ...data, + coverUrl: data.coverUrl || null, + description: data.description || null, + createdBy: session.user.id, + }) + .returning(); + + return NextResponse.json(created); + } catch (error) { + console.error("[Tutorial Videos API] Create error:", error); + + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: "Invalid request data", details: error.errors }, + { status: 400 } + ); + } + + return NextResponse.json( + { error: "Failed to create tutorial video" }, + { status: 500 } + ); + } +} + +const updateSchema = z.object({ + id: z.string().uuid(), + title: z.string().min(1).max(128).optional(), + videoUrl: z.string().url().optional(), + coverUrl: z.string().url().nullable().optional(), + description: z.string().nullable().optional(), + order: z.number().int().optional(), + isEnabled: z.boolean().optional(), +}); + +// PUT /api/tutorial-videos - 更新视频教程(仅管理员) +export async function PUT(request: NextRequest) { + try { + const session = await auth(); + + if (!session?.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + if (session.user.type !== "admin") { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const body = await request.json(); + const { id, ...data } = updateSchema.parse(body); + + const [updated] = await db + .update(tutorialVideos) + .set({ + ...data, + updatedAt: new Date(), + }) + .where(eq(tutorialVideos.id, id)) + .returning(); + + if (!updated) { + return NextResponse.json({ error: "Tutorial video not found" }, { status: 404 }); + } + + return NextResponse.json(updated); + } catch (error) { + console.error("[Tutorial Videos API] Update error:", error); + + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: "Invalid request data", details: error.errors }, + { status: 400 } + ); + } + + return NextResponse.json( + { error: "Failed to update tutorial video" }, + { status: 500 } + ); + } +} + +// DELETE /api/tutorial-videos - 删除视频教程(仅管理员) +export async function DELETE(request: NextRequest) { + try { + const session = await auth(); + + if (!session?.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + if (session.user.type !== "admin") { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const { searchParams } = new URL(request.url); + const id = searchParams.get("id"); + + if (!id) { + return NextResponse.json({ error: "ID is required" }, { status: 400 }); + } + + await db.delete(tutorialVideos).where(eq(tutorialVideos.id, id)); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("[Tutorial Videos API] Delete error:", error); + return NextResponse.json( + { error: "Failed to delete tutorial video" }, + { status: 500 } + ); + } +} diff --git a/app/api/video/upload/route.ts b/app/api/video/upload/route.ts new file mode 100644 index 0000000..1099a32 --- /dev/null +++ b/app/api/video/upload/route.ts @@ -0,0 +1,264 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/app/(auth)/auth"; +import { uploadFile } from "@/lib/storage/service"; +import { exec } from "child_process"; +import { promisify } from "util"; +import { writeFile, unlink, readFile, mkdir } from "fs/promises"; +import { existsSync } from "fs"; +import path from "path"; +import os from "os"; + +const execAsync = promisify(exec); + +// 最大文件大小:500MB +const MAX_FILE_SIZE = 500 * 1024 * 1024; + +// 支持的视频格式 +const SUPPORTED_FORMATS = [ + "video/mp4", + "video/quicktime", + "video/x-msvideo", + "video/x-matroska", + "video/webm", + "video/mpeg", + "video/x-m4v", +]; + +// 检查 FFmpeg 是否可用 +async function checkFFmpeg(): Promise { + try { + await execAsync("ffmpeg -version"); + return true; + } catch { + return false; + } +} + +// 获取视频信息 +async function getVideoInfo( + inputPath: string +): Promise<{ duration: number; width: number; height: number; bitrate: number }> { + try { + const { stdout } = await execAsync( + `ffprobe -v quiet -print_format json -show_format -show_streams "${inputPath}"` + ); + const info = JSON.parse(stdout); + const videoStream = info.streams?.find((s: any) => s.codec_type === "video"); + + return { + duration: parseFloat(info.format?.duration || "0"), + width: videoStream?.width || 0, + height: videoStream?.height || 0, + bitrate: parseInt(info.format?.bit_rate || "0"), + }; + } catch (error) { + console.error("[Video Upload] Failed to get video info:", error); + return { duration: 0, width: 0, height: 0, bitrate: 0 }; + } +} + +// 使用 FFmpeg 压缩视频 +async function compressVideo( + inputPath: string, + outputPath: string, + options: { + targetWidth?: number; + crf?: number; // 质量因子,18-28 推荐,越低质量越高 + preset?: string; // 编码速度预设 + audioBitrate?: string; + } = {} +): Promise<{ success: boolean; originalSize: number; compressedSize: number }> { + const { + targetWidth = 1280, // 默认 720p + crf = 23, // 默认质量 + preset = "medium", // 编码速度 + audioBitrate = "128k", + } = options; + + const videoInfo = await getVideoInfo(inputPath); + const originalSize = (await readFile(inputPath)).length; + + // 构建 FFmpeg 命令 + // 使用 H.264 (libx264) 编码器 + AAC 音频 + // -movflags +faststart 让视频可以边下载边播放 + let scaleFilter = ""; + if (videoInfo.width > targetWidth) { + scaleFilter = `-vf "scale=${targetWidth}:-2"`; + } + + const ffmpegCmd = `ffmpeg -i "${inputPath}" \ + -c:v libx264 \ + -preset ${preset} \ + -crf ${crf} \ + ${scaleFilter} \ + -c:a aac \ + -b:a ${audioBitrate} \ + -movflags +faststart \ + -y \ + "${outputPath}"`; + + console.log("[Video Compress] Running FFmpeg:", ffmpegCmd); + + try { + await execAsync(ffmpegCmd, { maxBuffer: 50 * 1024 * 1024 }); + const compressedSize = (await readFile(outputPath)).length; + + console.log( + `[Video Compress] Original: ${(originalSize / 1024 / 1024).toFixed(2)}MB, ` + + `Compressed: ${(compressedSize / 1024 / 1024).toFixed(2)}MB, ` + + `Ratio: ${((1 - compressedSize / originalSize) * 100).toFixed(1)}%` + ); + + return { success: true, originalSize, compressedSize }; + } catch (error) { + console.error("[Video Compress] FFmpeg error:", error); + return { success: false, originalSize, compressedSize: 0 }; + } +} + +export async function POST(request: NextRequest) { + try { + // 验证用户身份 + const session = await auth(); + if (!session?.user) { + return NextResponse.json({ error: "未授权" }, { status: 401 }); + } + + // 只允许管理员上传教程视频 + if (session.user.type !== "admin") { + return NextResponse.json({ error: "仅管理员可上传视频" }, { status: 403 }); + } + + // 检查 FFmpeg + const hasFFmpeg = await checkFFmpeg(); + console.log("[Video Upload] FFmpeg available:", hasFFmpeg); + + // 解析上传的文件 + const formData = await request.formData(); + const file = formData.get("file") as File | null; + const compress = formData.get("compress") !== "false"; // 默认压缩 + const quality = formData.get("quality") as string || "medium"; // low, medium, high + + if (!file) { + return NextResponse.json({ error: "未找到视频文件" }, { status: 400 }); + } + + // 验证文件类型 + if (!SUPPORTED_FORMATS.includes(file.type)) { + return NextResponse.json( + { error: `不支持的视频格式: ${file.type}` }, + { status: 400 } + ); + } + + // 验证文件大小 + if (file.size > MAX_FILE_SIZE) { + return NextResponse.json( + { error: `文件过大,最大支持 ${MAX_FILE_SIZE / 1024 / 1024}MB` }, + { status: 400 } + ); + } + + console.log( + `[Video Upload] Received file: ${file.name}, size: ${(file.size / 1024 / 1024).toFixed(2)}MB, type: ${file.type}` + ); + + // 创建临时目录 + const tempDir = path.join(os.tmpdir(), "video-upload"); + if (!existsSync(tempDir)) { + await mkdir(tempDir, { recursive: true }); + } + + const timestamp = Date.now(); + const randomStr = Math.random().toString(36).slice(2, 8); + const inputPath = path.join(tempDir, `input-${timestamp}-${randomStr}.mp4`); + const outputPath = path.join(tempDir, `output-${timestamp}-${randomStr}.mp4`); + + // 写入临时文件 + const bytes = await file.arrayBuffer(); + await writeFile(inputPath, Buffer.from(bytes)); + + let finalBuffer: Buffer; + let compressionInfo = { + compressed: false, + originalSize: file.size, + compressedSize: file.size, + ratio: 0, + }; + + // 如果有 FFmpeg 且需要压缩 + if (hasFFmpeg && compress) { + // 根据质量选择 CRF 值 + const crfMap: Record = { + low: 28, // 较低质量,文件更小 + medium: 23, // 中等质量(推荐) + high: 18, // 高质量,文件较大 + }; + const crf = crfMap[quality] || 23; + + const result = await compressVideo(inputPath, outputPath, { + crf, + preset: quality === "low" ? "fast" : quality === "high" ? "slow" : "medium", + targetWidth: quality === "low" ? 720 : quality === "high" ? 1920 : 1280, + }); + + if (result.success) { + finalBuffer = await readFile(outputPath); + compressionInfo = { + compressed: true, + originalSize: result.originalSize, + compressedSize: result.compressedSize, + ratio: Math.round((1 - result.compressedSize / result.originalSize) * 100), + }; + } else { + // 压缩失败,使用原文件 + finalBuffer = Buffer.from(bytes); + } + } else { + // 不压缩,直接使用原文件 + finalBuffer = Buffer.from(bytes); + } + + // 上传到存储 + const filename = `tutorial-${timestamp}-${randomStr}.mp4`; + // 将 Buffer 转换为 ArrayBuffer + const uint8Array = new Uint8Array(finalBuffer); + const arrayBuffer = uint8Array.buffer.slice( + uint8Array.byteOffset, + uint8Array.byteOffset + uint8Array.byteLength + ) as ArrayBuffer; + const uploadResult = await uploadFile(filename, arrayBuffer); + + console.log(`[Video Upload] Uploaded to: ${uploadResult.url}`); + + // 清理临时文件 + try { + await unlink(inputPath); + if (existsSync(outputPath)) { + await unlink(outputPath); + } + } catch { + // 忽略清理错误 + } + + return NextResponse.json({ + success: true, + url: uploadResult.url, + filename: filename, + compression: compressionInfo, + }); + } catch (error) { + console.error("[Video Upload] Error:", error); + return NextResponse.json( + { error: error instanceof Error ? error.message : "上传失败" }, + { status: 500 } + ); + } +} + +// 配置较大的请求体限制 +export const config = { + api: { + bodyParser: false, + }, +}; diff --git a/app/canvas/[id]/layout.tsx b/app/canvas/[id]/layout.tsx index f574350..7da871c 100644 --- a/app/canvas/[id]/layout.tsx +++ b/app/canvas/[id]/layout.tsx @@ -3,7 +3,7 @@ import { Suspense } from "react"; import { cookies } from "next/headers"; import { auth } from "@/app/(auth)/auth"; import { TopNavbar } from "@/components/top-navbar"; -import { getSiteSettings, getNavLinks } from "@/lib/db/queries"; +import { getSiteSettings, getNavLinks, getTutorialVideos } from "@/lib/db/queries"; export const metadata: Metadata = { title: "无限画布", @@ -23,10 +23,11 @@ export default function CanvasEditorLayout({ } async function CanvasLayoutWrapper({ children }: { children: React.ReactNode }) { - const [session, siteSettings, navLinks] = await Promise.all([ + const [session, siteSettings, navLinks, tutorialVideos] = await Promise.all([ auth(), getSiteSettings(), getNavLinks(), + getTutorialVideos(), ]); return ( @@ -47,7 +48,14 @@ async function CanvasLayoutWrapper({ children }: { children: React.ReactNode }) toolbox1Enabled={siteSettings.toolbox1Enabled ?? true} toolbox2Name={siteSettings.toolbox2Name || "工具箱2"} toolbox2Enabled={siteSettings.toolbox2Enabled ?? false} - tutorialVideoUrl={siteSettings.tutorialVideoUrl} + tutorialVideos={tutorialVideos.map(v => ({ + id: v.id, + title: v.title, + videoUrl: v.videoUrl, + coverUrl: v.coverUrl, + description: v.description, + order: v.order, + }))} tutorialEnabled={siteSettings.tutorialEnabled ?? false} rankingEnabled={siteSettings.rankingEnabled ?? true} /> diff --git a/components/top-navbar.tsx b/components/top-navbar.tsx index ae37db5..0e95217 100644 --- a/components/top-navbar.tsx +++ b/components/top-navbar.tsx @@ -30,6 +30,15 @@ type CustomLink = { toolboxGroup?: number; // 1 或 2 }; +type TutorialVideo = { + id: string; + title: string; + videoUrl: string; + coverUrl: string | null; + description: string | null; + order: number; +}; + type TopNavbarProps = { siteName?: string; siteLogo?: string; @@ -40,7 +49,7 @@ type TopNavbarProps = { toolbox1Enabled?: boolean; toolbox2Name?: string; toolbox2Enabled?: boolean; - tutorialVideoUrl?: string | null; + tutorialVideos?: TutorialVideo[]; tutorialEnabled?: boolean; rankingEnabled?: boolean; }; @@ -55,7 +64,7 @@ export function TopNavbar({ toolbox1Enabled = true, toolbox2Name = "工具箱2", toolbox2Enabled = false, - tutorialVideoUrl, + tutorialVideos = [], tutorialEnabled = false, rankingEnabled = true, }: TopNavbarProps) { @@ -64,7 +73,9 @@ export function TopNavbar({ const [mounted, setMounted] = useState(false); const [ranking, setRanking] = useState([]); const [rankingOpen, setRankingOpen] = useState(false); + const [tutorialMenuOpen, setTutorialMenuOpen] = useState(false); const [showTutorialModal, setShowTutorialModal] = useState(false); + const [currentTutorialVideo, setCurrentTutorialVideo] = useState(null); const [showImageEditor, setShowImageEditor] = useState(false); const [editorImageUrl, setEditorImageUrl] = useState(""); // 移除 fileInputRef,不再需要外部上传 @@ -250,18 +261,64 @@ export function TopNavbar({ {/* 功能教程 */} - {tutorialEnabled && tutorialVideoUrl && ( - + {tutorialEnabled && tutorialVideos.length > 0 && ( + + + + + +
+ 选择教程视频 +
+
+ {tutorialVideos + .sort((a, b) => a.order - b.order) + .map((video) => ( + { + setCurrentTutorialVideo(video); + setShowTutorialModal(true); + setTutorialMenuOpen(false); + }} + > +
+ {/* 封面 */} +
+ {video.coverUrl ? ( + {video.title} + ) : ( +
+ +
+ )} +
+ {/* 标题和描述 */} +
+
{video.title}
+ {video.description && ( +
{video.description}
+ )} +
+
+
+ ))} +
+
+
)} {/* 精选榜单 */} @@ -393,51 +450,65 @@ export function TopNavbar({ )} {/* 功能教程弹窗 */} - {showTutorialModal && tutorialVideoUrl && ( + {showTutorialModal && currentTutorialVideo && (
setShowTutorialModal(false)} + onClick={() => { + setShowTutorialModal(false); + setCurrentTutorialVideo(null); + }} >
e.stopPropagation()} > - {/* 关闭按钮 */} - + {/* 标题和关闭按钮 */} +
+

{currentTutorialVideo.title}

+ +
{/* 视频播放器 */}
- {tutorialVideoUrl.includes('youtube.com') || tutorialVideoUrl.includes('youtu.be') ? ( + {currentTutorialVideo.videoUrl.includes('youtube.com') || currentTutorialVideo.videoUrl.includes('youtu.be') ? ( // YouTube 嵌入