feat: add multi-video tutorial support with dropdown menu and video management

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Your Name 2026-01-31 20:48:14 +08:00
parent 024ec19c70
commit e861fc5203
9 changed files with 976 additions and 62 deletions

View File

@ -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}
/>

View File

@ -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<SiteSettings>("/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<any>(
"/api/auth-settings",
fetcher,
@ -562,6 +577,23 @@ export function AdminSettingsClient() {
const [showNavLinkDialog, setShowNavLinkDialog] = useState(false);
const [editingNavLink, setEditingNavLink] = useState<string | null>(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<string | null>(null);
const [tutorialVideoForm, setTutorialVideoForm] = useState({
title: "",
videoUrl: "",
coverUrl: "",
description: "",
order: 0,
isEnabled: true,
});
// 快捷短语状态
const [questions, setQuestions] = useState<string[]>([]);
const { data: questionsData, mutate: mutateQuestions } = useSWR<string[]>("/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() {
</div>
{/* 功能教程设置 */}
<div className="rounded-lg border p-4 space-y-4">
<div className="flex items-center justify-between">
<div className="flex-1 space-y-1">
<Label className="text-base">功能教程</Label>
<div className="text-sm text-muted-foreground">
启用后在导航栏显示功能教程按钮,点击后弹窗播放视频
</div>
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="flex-1 space-y-1">
<Label className="text-base">功能教程</Label>
<div className="text-sm text-muted-foreground">
启用后在导航栏显示功能教程按钮,点击后下拉显示视频列表
</div>
<Switch
checked={siteSettings.tutorialEnabled}
onCheckedChange={(checked) => setSiteSettings({ ...siteSettings, tutorialEnabled: checked })}
/>
</div>
{siteSettings.tutorialEnabled && (
<div className="space-y-2">
<Label>视频链接</Label>
<Input
value={siteSettings.tutorialVideoUrl || ""}
onChange={(e) => setSiteSettings({ ...siteSettings, tutorialVideoUrl: e.target.value || null })}
placeholder="输入视频播放链接,支持 MP4、YouTube、Bilibili 等"
/>
<div className="text-xs text-muted-foreground">
支持直接视频链接(如 .mp4)或嵌入式播放链接
</div>
</div>
)}
<Switch
checked={siteSettings.tutorialEnabled}
onCheckedChange={(checked) => setSiteSettings({ ...siteSettings, tutorialEnabled: checked })}
/>
</div>
<Button onClick={handleSaveSiteSettings}>
@ -2595,6 +2660,295 @@ export function AdminSettingsClient() {
</CardContent>
</Card>
{/* 视频教程管理 */}
{siteSettings.tutorialEnabled && (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<Video className="h-5 w-5" />
视频教程管理
</CardTitle>
<CardDescription>添加和管理功能教程视频,用户可以在导航栏查看</CardDescription>
</div>
<Dialog open={showTutorialVideoDialog} onOpenChange={setShowTutorialVideoDialog}>
<DialogTrigger asChild>
<Button onClick={() => { resetTutorialVideoForm(); setShowTutorialVideoDialog(true); }}>
<Plus className="mr-2 h-4 w-4" /> 添加视频
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{editingTutorialVideo ? "编辑视频" : "添加视频"}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>视频标题 *</Label>
<Input
value={tutorialVideoForm.title}
onChange={(e) => setTutorialVideoForm({ ...tutorialVideoForm, title: e.target.value })}
placeholder="例如:快速入门教程"
/>
</div>
{/* 视频上传或链接 */}
<div className="space-y-3">
<Label>视频文件</Label>
<div className="flex items-center gap-3">
<Select
value={videoQuality}
onValueChange={(v) => setVideoQuality(v as "low" | "medium" | "high")}
>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="压缩质量" />
</SelectTrigger>
<SelectContent>
<SelectItem value="low">低质量 (小)</SelectItem>
<SelectItem value="medium">中等 (推荐)</SelectItem>
<SelectItem value="high">高质量 (大)</SelectItem>
</SelectContent>
</Select>
<label className="flex-1">
<input
type="file"
accept="video/*"
className="hidden"
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
setVideoUploading(true);
setVideoUploadProgress("上传中...");
try {
const formData = new FormData();
formData.append("file", file);
formData.append("quality", videoQuality);
formData.append("compress", "true");
const response = await fetch("/api/video/upload", {
method: "POST",
body: formData,
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.error || "上传失败");
}
setTutorialVideoForm({ ...tutorialVideoForm, videoUrl: result.url });
if (result.compression?.compressed) {
toast.success(`压缩率: ${result.compression.ratio}%`);
} else {
toast.success("上传成功!");
}
} catch (error) {
toast.error(error instanceof Error ? error.message : "上传失败");
} finally {
setVideoUploading(false);
setVideoUploadProgress("");
e.target.value = "";
}
}}
disabled={videoUploading}
/>
<Button
type="button"
variant="outline"
className="w-full cursor-pointer"
disabled={videoUploading}
asChild
>
<span>
{videoUploading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{videoUploadProgress}
</>
) : (
<>
<Upload className="mr-2 h-4 w-4" />
上传视频
</>
)}
</span>
</Button>
</label>
</div>
</div>
<div className="space-y-2">
<Label>视频链接 *</Label>
<Input
value={tutorialVideoForm.videoUrl}
onChange={(e) => setTutorialVideoForm({ ...tutorialVideoForm, videoUrl: e.target.value })}
placeholder="视频URL(上传后自动填充或手动输入)"
/>
</div>
{/* 封面上传或链接 */}
<div className="space-y-2">
<Label>封面图片</Label>
<div className="flex items-center gap-2">
<Input
value={tutorialVideoForm.coverUrl}
onChange={(e) => setTutorialVideoForm({ ...tutorialVideoForm, coverUrl: e.target.value })}
placeholder="封面图片URL(可选)"
className="flex-1"
/>
<label>
<input
type="file"
accept="image/*"
className="hidden"
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
try {
const formData = new FormData();
formData.append("file", file);
const response = await fetch("/api/files/upload", {
method: "POST",
body: formData,
});
const result = await response.json();
if (result.url) {
setTutorialVideoForm({ ...tutorialVideoForm, coverUrl: result.url });
toast.success("封面上传成功");
}
} catch (error) {
toast.error("封面上传失败");
}
e.target.value = "";
}}
/>
<Button type="button" variant="outline" size="sm" asChild>
<span>
<Upload className="h-4 w-4" />
</span>
</Button>
</label>
</div>
</div>
<div className="space-y-2">
<Label>视频描述</Label>
<Input
value={tutorialVideoForm.description}
onChange={(e) => setTutorialVideoForm({ ...tutorialVideoForm, description: e.target.value })}
placeholder="简短描述(可选)"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>排序</Label>
<Input
type="number"
value={tutorialVideoForm.order}
onChange={(e) => setTutorialVideoForm({ ...tutorialVideoForm, order: parseInt(e.target.value) || 0 })}
/>
</div>
<div className="flex items-center gap-2 pt-6">
<Switch
checked={tutorialVideoForm.isEnabled}
onCheckedChange={(checked) => setTutorialVideoForm({ ...tutorialVideoForm, isEnabled: checked })}
/>
<Label>启用</Label>
</div>
</div>
{/* 预览 */}
{tutorialVideoForm.videoUrl && (
<div className="space-y-2">
<Label>预览</Label>
<div className="relative aspect-video bg-black rounded-lg overflow-hidden max-h-[200px]">
<video
src={tutorialVideoForm.videoUrl}
className="w-full h-full object-contain"
controls
/>
</div>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowTutorialVideoDialog(false)}>取消</Button>
<Button onClick={handleSaveTutorialVideo} disabled={!tutorialVideoForm.title || !tutorialVideoForm.videoUrl}>
保存
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</CardHeader>
<CardContent>
<div className="space-y-3">
{tutorialVideosData?.videos?.map((video) => (
<div key={video.id} className="flex items-center gap-4 p-3 border rounded-lg">
{/* 封面 */}
<div className="w-24 h-16 bg-muted rounded overflow-hidden flex-shrink-0">
{video.coverUrl ? (
<img src={video.coverUrl} alt={video.title} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center">
<Video className="h-6 w-6 text-muted-foreground" />
</div>
)}
</div>
{/* 信息 */}
<div className="flex-1 min-w-0">
<div className="font-medium flex items-center gap-2">
{video.title}
{!video.isEnabled && <span className="text-xs bg-gray-200 px-2 py-0.5 rounded">已禁用</span>}
</div>
{video.description && (
<div className="text-sm text-muted-foreground truncate">{video.description}</div>
)}
<div className="text-xs text-muted-foreground">排序: {video.order}</div>
</div>
{/* 操作 */}
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => {
setTutorialVideoForm({
title: video.title,
videoUrl: video.videoUrl,
coverUrl: video.coverUrl || "",
description: video.description || "",
order: video.order,
isEnabled: video.isEnabled,
});
setEditingTutorialVideo(video.id);
setShowTutorialVideoDialog(true);
}}
>
<Edit className="h-4 w-4" />
</Button>
<Button variant="destructive" size="sm" onClick={() => handleDeleteTutorialVideo(video.id)}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))}
{(!tutorialVideosData?.videos?.length) && (
<div className="text-center text-muted-foreground py-8">
暂无视频教程,点击上方按钮添加
</div>
)}
</div>
</CardContent>
</Card>
)}
{/* 导航链接列表 */}
<Card>
<CardHeader>

View File

@ -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}
/>

View File

@ -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 }
);
}
}

View File

@ -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<boolean> {
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<string, number> = {
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,
},
};

View File

@ -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}
/>

View File

@ -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<RankingItem[]>([]);
const [rankingOpen, setRankingOpen] = useState(false);
const [tutorialMenuOpen, setTutorialMenuOpen] = useState(false);
const [showTutorialModal, setShowTutorialModal] = useState(false);
const [currentTutorialVideo, setCurrentTutorialVideo] = useState<TutorialVideo | null>(null);
const [showImageEditor, setShowImageEditor] = useState(false);
const [editorImageUrl, setEditorImageUrl] = useState<string>("");
// 移除 fileInputRef,不再需要外部上传
@ -250,18 +261,64 @@ export function TopNavbar({
</a>
{/* 功能教程 */}
{tutorialEnabled && tutorialVideoUrl && (
<Button
variant="ghost"
size="sm"
className="flex items-center gap-1 h-8 px-3 text-white hover:bg-white/15 hover:text-white rounded-full transition-colors"
onClick={() => setShowTutorialModal(true)}
type="button"
title="功能教程"
>
<PlayCircle className="h-4 w-4 text-green-400" />
<span className="text-sm font-medium">功能教程</span>
</Button>
{tutorialEnabled && tutorialVideos.length > 0 && (
<DropdownMenu open={tutorialMenuOpen} onOpenChange={setTutorialMenuOpen}>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="flex items-center gap-1 h-8 px-3 text-white hover:bg-white/15 hover:text-white rounded-full transition-colors"
type="button"
>
<PlayCircle className="h-4 w-4 text-green-400" />
<span className="text-sm font-medium">功能教程</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[320px] p-2">
<div className="text-sm font-medium text-muted-foreground mb-2 px-2">
选择教程视频
</div>
<div className="space-y-1 max-h-[400px] overflow-y-auto">
{tutorialVideos
.sort((a, b) => a.order - b.order)
.map((video) => (
<DropdownMenuItem
key={video.id}
className="cursor-pointer p-0"
onClick={() => {
setCurrentTutorialVideo(video);
setShowTutorialModal(true);
setTutorialMenuOpen(false);
}}
>
<div className="flex items-center gap-3 p-2 w-full hover:bg-accent rounded-md">
{/* 封面 */}
<div className="w-16 h-10 bg-muted rounded overflow-hidden flex-shrink-0">
{video.coverUrl ? (
<img
src={video.coverUrl}
alt={video.title}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-green-500/20 to-blue-500/20">
<PlayCircle className="h-5 w-5 text-green-500" />
</div>
)}
</div>
{/* 标题和描述 */}
<div className="flex-1 min-w-0">
<div className="font-medium text-sm truncate">{video.title}</div>
{video.description && (
<div className="text-xs text-muted-foreground truncate">{video.description}</div>
)}
</div>
</div>
</DropdownMenuItem>
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
)}
{/* 精选榜单 */}
@ -393,51 +450,65 @@ export function TopNavbar({
)}
{/* 功能教程弹窗 */}
{showTutorialModal && tutorialVideoUrl && (
{showTutorialModal && currentTutorialVideo && (
<div
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/80"
onClick={() => setShowTutorialModal(false)}
onClick={() => {
setShowTutorialModal(false);
setCurrentTutorialVideo(null);
}}
>
<div
className="relative w-full max-w-4xl mx-4"
onClick={(e) => e.stopPropagation()}
>
{/* 关闭按钮 */}
<button
onClick={() => setShowTutorialModal(false)}
className="absolute -top-12 right-0 text-white hover:text-gray-300 transition-colors"
title="关闭"
>
<X className="h-8 w-8" />
</button>
{/* 标题和关闭按钮 */}
<div className="flex items-center justify-between mb-4">
<h3 className="text-white text-lg font-medium">{currentTutorialVideo.title}</h3>
<button
onClick={() => {
setShowTutorialModal(false);
setCurrentTutorialVideo(null);
}}
className="text-white hover:text-gray-300 transition-colors"
title="关闭"
>
<X className="h-8 w-8" />
</button>
</div>
{/* 视频播放器 */}
<div className="relative w-full aspect-video bg-black rounded-lg overflow-hidden shadow-2xl">
{tutorialVideoUrl.includes('youtube.com') || tutorialVideoUrl.includes('youtu.be') ? (
{currentTutorialVideo.videoUrl.includes('youtube.com') || currentTutorialVideo.videoUrl.includes('youtu.be') ? (
// YouTube 嵌入
<iframe
src={tutorialVideoUrl.replace('watch?v=', 'embed/').replace('youtu.be/', 'youtube.com/embed/')}
src={currentTutorialVideo.videoUrl.replace('watch?v=', 'embed/').replace('youtu.be/', 'youtube.com/embed/')}
className="w-full h-full"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
) : tutorialVideoUrl.includes('bilibili.com') ? (
) : currentTutorialVideo.videoUrl.includes('bilibili.com') ? (
// Bilibili 嵌入
<iframe
src={tutorialVideoUrl.replace('www.bilibili.com/video/', 'player.bilibili.com/player.html?bvid=')}
src={currentTutorialVideo.videoUrl.replace('www.bilibili.com/video/', 'player.bilibili.com/player.html?bvid=')}
className="w-full h-full"
allowFullScreen
/>
) : (
// 普通视频链接
<video
src={tutorialVideoUrl}
src={currentTutorialVideo.videoUrl}
className="w-full h-full"
controls
autoPlay
/>
)}
</div>
{/* 视频描述 */}
{currentTutorialVideo.description && (
<p className="text-gray-300 text-sm mt-3">{currentTutorialVideo.description}</p>
)}
</div>
</div>
)}

View File

@ -1637,3 +1637,21 @@ export async function checkUserImageQuota(userId: string): Promise<{
return { allowed: true, used: 0, limit: null, remaining: null };
}
}
// 获取视频教程列表
export async function getTutorialVideos() {
try {
const { tutorialVideos } = await import("./schema");
const { eq, asc } = await import("drizzle-orm");
const videos = await db
.select()
.from(tutorialVideos)
.where(eq(tutorialVideos.isEnabled, true))
.orderBy(asc(tutorialVideos.order));
return videos;
} catch (_error) {
return [];
}
}

View File

@ -327,6 +327,22 @@ export const navLinks = pgTable("NavLinks", {
export type NavLink = InferSelectModel<typeof navLinks>;
// 视频教程表
export const tutorialVideos = pgTable("TutorialVideos", {
id: uuid("id").primaryKey().notNull().defaultRandom(),
title: varchar("title", { length: 128 }).notNull(), // 视频标题
videoUrl: text("videoUrl").notNull(), // 视频播放链接
coverUrl: text("coverUrl"), // 视频封面图片链接
description: text("description"), // 视频描述
order: integer("order").notNull().default(0), // 排序
isEnabled: boolean("isEnabled").notNull().default(true), // 是否启用
createdAt: timestamp("createdAt").notNull().defaultNow(),
updatedAt: timestamp("updatedAt").notNull().defaultNow(),
createdBy: uuid("createdBy").references(() => user.id),
});
export type TutorialVideo = InferSelectModel<typeof tutorialVideos>;
// 认证配置表
export const authSettings = pgTable("AuthSettings", {
id: uuid("id").primaryKey().notNull().defaultRandom(),