feat: add multi-video tutorial support with dropdown menu and video management
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
024ec19c70
commit
e861fc5203
@ -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}
|
||||
/>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
167
app/api/tutorial-videos/route.ts
Normal file
167
app/api/tutorial-videos/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
264
app/api/video/upload/route.ts
Normal file
264
app/api/video/upload/route.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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 [];
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user