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 { auth } from "@/app/(auth)/auth";
|
||||||
import { TopNavbar } from "@/components/top-navbar";
|
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({
|
export default async function AdminLayout({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const [session, siteSettings, navLinks] = await Promise.all([
|
const [session, siteSettings, navLinks, tutorialVideos] = await Promise.all([
|
||||||
auth(),
|
auth(),
|
||||||
getSiteSettings(),
|
getSiteSettings(),
|
||||||
getNavLinks(),
|
getNavLinks(),
|
||||||
|
getTutorialVideos(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -31,7 +32,14 @@ export default async function AdminLayout({
|
|||||||
toolbox1Enabled={siteSettings.toolbox1Enabled ?? true}
|
toolbox1Enabled={siteSettings.toolbox1Enabled ?? true}
|
||||||
toolbox2Name={siteSettings.toolbox2Name || "工具箱2"}
|
toolbox2Name={siteSettings.toolbox2Name || "工具箱2"}
|
||||||
toolbox2Enabled={siteSettings.toolbox2Enabled ?? false}
|
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}
|
tutorialEnabled={siteSettings.tutorialEnabled ?? false}
|
||||||
rankingEnabled={siteSettings.rankingEnabled ?? true}
|
rankingEnabled={siteSettings.rankingEnabled ?? true}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -51,6 +51,10 @@ import {
|
|||||||
Scissors,
|
Scissors,
|
||||||
Expand,
|
Expand,
|
||||||
Move3D,
|
Move3D,
|
||||||
|
Upload,
|
||||||
|
Video,
|
||||||
|
Loader2,
|
||||||
|
Check,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@ -173,6 +177,16 @@ type NavLink = {
|
|||||||
toolboxGroup: number; // 1 或 2
|
toolboxGroup: number; // 1 或 2
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type TutorialVideo = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
videoUrl: string;
|
||||||
|
coverUrl: string | null;
|
||||||
|
description: string | null;
|
||||||
|
order: number;
|
||||||
|
isEnabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
type DingTalkOrganization = {
|
type DingTalkOrganization = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@ -317,6 +331,7 @@ export function AdminSettingsClient() {
|
|||||||
const { data: storageData, mutate: mutateStorage } = useSWR<{ configs: StorageConfig[] }>("/api/admin/storage", fetcher);
|
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: siteSettingsData, mutate: mutateSiteSettings } = useSWR<SiteSettings>("/api/site-settings", fetcher);
|
||||||
const { data: navLinksData, mutate: mutateNavLinks } = useSWR<{ links: NavLink[] }>("/api/nav-links", 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>(
|
const { data: authSettingsData, mutate: mutateAuthSettings } = useSWR<any>(
|
||||||
"/api/auth-settings",
|
"/api/auth-settings",
|
||||||
fetcher,
|
fetcher,
|
||||||
@ -562,6 +577,23 @@ export function AdminSettingsClient() {
|
|||||||
const [showNavLinkDialog, setShowNavLinkDialog] = useState(false);
|
const [showNavLinkDialog, setShowNavLinkDialog] = useState(false);
|
||||||
const [editingNavLink, setEditingNavLink] = useState<string | null>(null);
|
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 [questions, setQuestions] = useState<string[]>([]);
|
||||||
const { data: questionsData, mutate: mutateQuestions } = useSWR<string[]>("/api/starter-questions", fetcher);
|
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 () => {
|
const handleSaveAuthSettings = async () => {
|
||||||
try {
|
try {
|
||||||
@ -2561,32 +2641,17 @@ export function AdminSettingsClient() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 功能教程设置 */}
|
{/* 功能教程设置 */}
|
||||||
<div className="rounded-lg border p-4 space-y-4">
|
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex-1 space-y-1">
|
||||||
<div className="flex-1 space-y-1">
|
<Label className="text-base">功能教程</Label>
|
||||||
<Label className="text-base">功能教程</Label>
|
<div className="text-sm text-muted-foreground">
|
||||||
<div className="text-sm text-muted-foreground">
|
启用后在导航栏显示功能教程按钮,点击后下拉显示视频列表
|
||||||
启用后在导航栏显示功能教程按钮,点击后弹窗播放视频
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
|
||||||
checked={siteSettings.tutorialEnabled}
|
|
||||||
onCheckedChange={(checked) => setSiteSettings({ ...siteSettings, tutorialEnabled: checked })}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
{siteSettings.tutorialEnabled && (
|
<Switch
|
||||||
<div className="space-y-2">
|
checked={siteSettings.tutorialEnabled}
|
||||||
<Label>视频链接</Label>
|
onCheckedChange={(checked) => setSiteSettings({ ...siteSettings, tutorialEnabled: checked })}
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button onClick={handleSaveSiteSettings}>
|
<Button onClick={handleSaveSiteSettings}>
|
||||||
@ -2595,6 +2660,295 @@ export function AdminSettingsClient() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import { DataStreamProvider } from "@/components/data-stream-provider";
|
|||||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
||||||
import { auth } from "../(auth)/auth";
|
import { auth } from "../(auth)/auth";
|
||||||
import { TopNavbar } from "@/components/top-navbar";
|
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 }) {
|
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
@ -25,11 +25,12 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function SidebarWrapper({ 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(),
|
auth(),
|
||||||
cookies(),
|
cookies(),
|
||||||
getSiteSettings(),
|
getSiteSettings(),
|
||||||
getNavLinks(),
|
getNavLinks(),
|
||||||
|
getTutorialVideos(),
|
||||||
]);
|
]);
|
||||||
const isCollapsed = cookieStore.get("sidebar_state")?.value !== "true";
|
const isCollapsed = cookieStore.get("sidebar_state")?.value !== "true";
|
||||||
|
|
||||||
@ -51,7 +52,14 @@ async function SidebarWrapper({ children }: { children: React.ReactNode }) {
|
|||||||
toolbox1Enabled={siteSettings.toolbox1Enabled ?? true}
|
toolbox1Enabled={siteSettings.toolbox1Enabled ?? true}
|
||||||
toolbox2Name={siteSettings.toolbox2Name || "工具箱2"}
|
toolbox2Name={siteSettings.toolbox2Name || "工具箱2"}
|
||||||
toolbox2Enabled={siteSettings.toolbox2Enabled ?? false}
|
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}
|
tutorialEnabled={siteSettings.tutorialEnabled ?? false}
|
||||||
rankingEnabled={siteSettings.rankingEnabled ?? true}
|
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 { cookies } from "next/headers";
|
||||||
import { auth } from "@/app/(auth)/auth";
|
import { auth } from "@/app/(auth)/auth";
|
||||||
import { TopNavbar } from "@/components/top-navbar";
|
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 = {
|
export const metadata: Metadata = {
|
||||||
title: "无限画布",
|
title: "无限画布",
|
||||||
@ -23,10 +23,11 @@ export default function CanvasEditorLayout({
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function CanvasLayoutWrapper({ children }: { children: React.ReactNode }) {
|
async function CanvasLayoutWrapper({ children }: { children: React.ReactNode }) {
|
||||||
const [session, siteSettings, navLinks] = await Promise.all([
|
const [session, siteSettings, navLinks, tutorialVideos] = await Promise.all([
|
||||||
auth(),
|
auth(),
|
||||||
getSiteSettings(),
|
getSiteSettings(),
|
||||||
getNavLinks(),
|
getNavLinks(),
|
||||||
|
getTutorialVideos(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -47,7 +48,14 @@ async function CanvasLayoutWrapper({ children }: { children: React.ReactNode })
|
|||||||
toolbox1Enabled={siteSettings.toolbox1Enabled ?? true}
|
toolbox1Enabled={siteSettings.toolbox1Enabled ?? true}
|
||||||
toolbox2Name={siteSettings.toolbox2Name || "工具箱2"}
|
toolbox2Name={siteSettings.toolbox2Name || "工具箱2"}
|
||||||
toolbox2Enabled={siteSettings.toolbox2Enabled ?? false}
|
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}
|
tutorialEnabled={siteSettings.tutorialEnabled ?? false}
|
||||||
rankingEnabled={siteSettings.rankingEnabled ?? true}
|
rankingEnabled={siteSettings.rankingEnabled ?? true}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -30,6 +30,15 @@ type CustomLink = {
|
|||||||
toolboxGroup?: number; // 1 或 2
|
toolboxGroup?: number; // 1 或 2
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type TutorialVideo = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
videoUrl: string;
|
||||||
|
coverUrl: string | null;
|
||||||
|
description: string | null;
|
||||||
|
order: number;
|
||||||
|
};
|
||||||
|
|
||||||
type TopNavbarProps = {
|
type TopNavbarProps = {
|
||||||
siteName?: string;
|
siteName?: string;
|
||||||
siteLogo?: string;
|
siteLogo?: string;
|
||||||
@ -40,7 +49,7 @@ type TopNavbarProps = {
|
|||||||
toolbox1Enabled?: boolean;
|
toolbox1Enabled?: boolean;
|
||||||
toolbox2Name?: string;
|
toolbox2Name?: string;
|
||||||
toolbox2Enabled?: boolean;
|
toolbox2Enabled?: boolean;
|
||||||
tutorialVideoUrl?: string | null;
|
tutorialVideos?: TutorialVideo[];
|
||||||
tutorialEnabled?: boolean;
|
tutorialEnabled?: boolean;
|
||||||
rankingEnabled?: boolean;
|
rankingEnabled?: boolean;
|
||||||
};
|
};
|
||||||
@ -55,7 +64,7 @@ export function TopNavbar({
|
|||||||
toolbox1Enabled = true,
|
toolbox1Enabled = true,
|
||||||
toolbox2Name = "工具箱2",
|
toolbox2Name = "工具箱2",
|
||||||
toolbox2Enabled = false,
|
toolbox2Enabled = false,
|
||||||
tutorialVideoUrl,
|
tutorialVideos = [],
|
||||||
tutorialEnabled = false,
|
tutorialEnabled = false,
|
||||||
rankingEnabled = true,
|
rankingEnabled = true,
|
||||||
}: TopNavbarProps) {
|
}: TopNavbarProps) {
|
||||||
@ -64,7 +73,9 @@ export function TopNavbar({
|
|||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
const [ranking, setRanking] = useState<RankingItem[]>([]);
|
const [ranking, setRanking] = useState<RankingItem[]>([]);
|
||||||
const [rankingOpen, setRankingOpen] = useState(false);
|
const [rankingOpen, setRankingOpen] = useState(false);
|
||||||
|
const [tutorialMenuOpen, setTutorialMenuOpen] = useState(false);
|
||||||
const [showTutorialModal, setShowTutorialModal] = useState(false);
|
const [showTutorialModal, setShowTutorialModal] = useState(false);
|
||||||
|
const [currentTutorialVideo, setCurrentTutorialVideo] = useState<TutorialVideo | null>(null);
|
||||||
const [showImageEditor, setShowImageEditor] = useState(false);
|
const [showImageEditor, setShowImageEditor] = useState(false);
|
||||||
const [editorImageUrl, setEditorImageUrl] = useState<string>("");
|
const [editorImageUrl, setEditorImageUrl] = useState<string>("");
|
||||||
// 移除 fileInputRef,不再需要外部上传
|
// 移除 fileInputRef,不再需要外部上传
|
||||||
@ -250,18 +261,64 @@ export function TopNavbar({
|
|||||||
</a>
|
</a>
|
||||||
|
|
||||||
{/* 功能教程 */}
|
{/* 功能教程 */}
|
||||||
{tutorialEnabled && tutorialVideoUrl && (
|
{tutorialEnabled && tutorialVideos.length > 0 && (
|
||||||
<Button
|
<DropdownMenu open={tutorialMenuOpen} onOpenChange={setTutorialMenuOpen}>
|
||||||
variant="ghost"
|
<DropdownMenuTrigger asChild>
|
||||||
size="sm"
|
<Button
|
||||||
className="flex items-center gap-1 h-8 px-3 text-white hover:bg-white/15 hover:text-white rounded-full transition-colors"
|
variant="ghost"
|
||||||
onClick={() => setShowTutorialModal(true)}
|
size="sm"
|
||||||
type="button"
|
className="flex items-center gap-1 h-8 px-3 text-white hover:bg-white/15 hover:text-white rounded-full transition-colors"
|
||||||
title="功能教程"
|
type="button"
|
||||||
>
|
>
|
||||||
<PlayCircle className="h-4 w-4 text-green-400" />
|
<PlayCircle className="h-4 w-4 text-green-400" />
|
||||||
<span className="text-sm font-medium">功能教程</span>
|
<span className="text-sm font-medium">功能教程</span>
|
||||||
</Button>
|
</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
|
<div
|
||||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/80"
|
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/80"
|
||||||
onClick={() => setShowTutorialModal(false)}
|
onClick={() => {
|
||||||
|
setShowTutorialModal(false);
|
||||||
|
setCurrentTutorialVideo(null);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="relative w-full max-w-4xl mx-4"
|
className="relative w-full max-w-4xl mx-4"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{/* 关闭按钮 */}
|
{/* 标题和关闭按钮 */}
|
||||||
<button
|
<div className="flex items-center justify-between mb-4">
|
||||||
onClick={() => setShowTutorialModal(false)}
|
<h3 className="text-white text-lg font-medium">{currentTutorialVideo.title}</h3>
|
||||||
className="absolute -top-12 right-0 text-white hover:text-gray-300 transition-colors"
|
<button
|
||||||
title="关闭"
|
onClick={() => {
|
||||||
>
|
setShowTutorialModal(false);
|
||||||
<X className="h-8 w-8" />
|
setCurrentTutorialVideo(null);
|
||||||
</button>
|
}}
|
||||||
|
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">
|
<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 嵌入
|
// YouTube 嵌入
|
||||||
<iframe
|
<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"
|
className="w-full h-full"
|
||||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||||
allowFullScreen
|
allowFullScreen
|
||||||
/>
|
/>
|
||||||
) : tutorialVideoUrl.includes('bilibili.com') ? (
|
) : currentTutorialVideo.videoUrl.includes('bilibili.com') ? (
|
||||||
// Bilibili 嵌入
|
// Bilibili 嵌入
|
||||||
<iframe
|
<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"
|
className="w-full h-full"
|
||||||
allowFullScreen
|
allowFullScreen
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
// 普通视频链接
|
// 普通视频链接
|
||||||
<video
|
<video
|
||||||
src={tutorialVideoUrl}
|
src={currentTutorialVideo.videoUrl}
|
||||||
className="w-full h-full"
|
className="w-full h-full"
|
||||||
controls
|
controls
|
||||||
autoPlay
|
autoPlay
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 视频描述 */}
|
||||||
|
{currentTutorialVideo.description && (
|
||||||
|
<p className="text-gray-300 text-sm mt-3">{currentTutorialVideo.description}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1637,3 +1637,21 @@ export async function checkUserImageQuota(userId: string): Promise<{
|
|||||||
return { allowed: true, used: 0, limit: null, remaining: null };
|
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 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", {
|
export const authSettings = pgTable("AuthSettings", {
|
||||||
id: uuid("id").primaryKey().notNull().defaultRandom(),
|
id: uuid("id").primaryKey().notNull().defaultRandom(),
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user