feat: add tutorial video button and ranking visibility toggle in navbar settings

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Your Name 2026-01-31 20:37:29 +08:00
parent c44c8cff6a
commit 024ec19c70
8 changed files with 205 additions and 53 deletions

View File

@ -31,6 +31,9 @@ export default async function AdminLayout({
toolbox1Enabled={siteSettings.toolbox1Enabled ?? true}
toolbox2Name={siteSettings.toolbox2Name || "工具箱2"}
toolbox2Enabled={siteSettings.toolbox2Enabled ?? false}
tutorialVideoUrl={siteSettings.tutorialVideoUrl}
tutorialEnabled={siteSettings.tutorialEnabled ?? false}
rankingEnabled={siteSettings.rankingEnabled ?? true}
/>
<div className="flex-1 pt-16">{children}</div>
</div>

View File

@ -159,6 +159,9 @@ type SiteSettings = {
toolbox1Enabled: boolean;
toolbox2Name: string;
toolbox2Enabled: boolean;
tutorialVideoUrl: string | null;
tutorialEnabled: boolean;
rankingEnabled: boolean;
};
type NavLink = {
@ -357,6 +360,9 @@ export function AdminSettingsClient() {
toolbox1Enabled: true,
toolbox2Name: "工具箱2",
toolbox2Enabled: false,
tutorialVideoUrl: null,
tutorialEnabled: false,
rankingEnabled: true,
});
useEffect(() => {
@ -379,6 +385,9 @@ export function AdminSettingsClient() {
toolbox1Enabled: siteSettingsData.toolbox1Enabled ?? true,
toolbox2Name: siteSettingsData.toolbox2Name || "工具箱2",
toolbox2Enabled: siteSettingsData.toolbox2Enabled ?? false,
tutorialVideoUrl: siteSettingsData.tutorialVideoUrl || null,
tutorialEnabled: siteSettingsData.tutorialEnabled ?? false,
rankingEnabled: siteSettingsData.rankingEnabled ?? true,
});
}
}, [siteSettingsData]);
@ -2537,8 +2546,51 @@ export function AdminSettingsClient() {
/>
</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>
</div>
<Switch
checked={siteSettings.rankingEnabled}
onCheckedChange={(checked) => setSiteSettings({ ...siteSettings, rankingEnabled: checked })}
/>
</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>
<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>
)}
</div>
<Button onClick={handleSaveSiteSettings}>
<Save className="mr-2 h-4 w-4" /> 保存工具箱设置
<Save className="mr-2 h-4 w-4" /> 保存导航设置
</Button>
</CardContent>
</Card>

View File

@ -51,6 +51,9 @@ async function SidebarWrapper({ children }: { children: React.ReactNode }) {
toolbox1Enabled={siteSettings.toolbox1Enabled ?? true}
toolbox2Name={siteSettings.toolbox2Name || "工具箱2"}
toolbox2Enabled={siteSettings.toolbox2Enabled ?? false}
tutorialVideoUrl={siteSettings.tutorialVideoUrl}
tutorialEnabled={siteSettings.tutorialEnabled ?? false}
rankingEnabled={siteSettings.rankingEnabled ?? true}
/>
<div className="pt-14 flex h-full w-full overflow-hidden"> {/* Add padding-top for fixed navbar */}
<AppSidebar user={session?.user} />

View File

@ -38,6 +38,9 @@ export async function GET() {
toolbox1Enabled: true,
toolbox2Name: "工具箱2",
toolbox2Enabled: false,
tutorialVideoUrl: null,
tutorialEnabled: false,
rankingEnabled: true,
};
const response = NextResponse.json({
@ -78,6 +81,9 @@ const updateSchema = z.object({
toolbox1Enabled: z.boolean().optional(),
toolbox2Name: z.string().max(64).optional(),
toolbox2Enabled: z.boolean().optional(),
tutorialVideoUrl: z.string().nullable().optional(),
tutorialEnabled: z.boolean().optional(),
rankingEnabled: z.boolean().optional(),
});
// PUT /api/site-settings - 更新网站设置(仅管理员)
@ -134,6 +140,9 @@ export async function PUT(request: NextRequest) {
toolbox1Enabled: data.toolbox1Enabled ?? true,
toolbox2Name: data.toolbox2Name ?? "工具箱2",
toolbox2Enabled: data.toolbox2Enabled ?? false,
tutorialVideoUrl: data.tutorialVideoUrl,
tutorialEnabled: data.tutorialEnabled ?? false,
rankingEnabled: data.rankingEnabled ?? true,
updatedBy: session.user.id,
})
.returning();

View File

@ -47,6 +47,9 @@ async function CanvasLayoutWrapper({ children }: { children: React.ReactNode })
toolbox1Enabled={siteSettings.toolbox1Enabled ?? true}
toolbox2Name={siteSettings.toolbox2Name || "工具箱2"}
toolbox2Enabled={siteSettings.toolbox2Enabled ?? false}
tutorialVideoUrl={siteSettings.tutorialVideoUrl}
tutorialEnabled={siteSettings.tutorialEnabled ?? false}
rankingEnabled={siteSettings.rankingEnabled ?? true}
/>
<div className="pt-12 h-full">
{children}

View File

@ -2,7 +2,7 @@
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Settings, ExternalLink, Briefcase, Images, Sparkles, Trophy, Building2, Crown, Medal, Award, Edit, LayoutGrid } from "lucide-react";
import { Settings, ExternalLink, Briefcase, Images, Sparkles, Trophy, Building2, Crown, Medal, Award, Edit, LayoutGrid, PlayCircle, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTheme } from "next-themes";
import { useState, useEffect, useRef } from "react";
@ -40,6 +40,9 @@ type TopNavbarProps = {
toolbox1Enabled?: boolean;
toolbox2Name?: string;
toolbox2Enabled?: boolean;
tutorialVideoUrl?: string | null;
tutorialEnabled?: boolean;
rankingEnabled?: boolean;
};
export function TopNavbar({
@ -52,12 +55,16 @@ export function TopNavbar({
toolbox1Enabled = true,
toolbox2Name = "工具箱2",
toolbox2Enabled = false,
tutorialVideoUrl,
tutorialEnabled = false,
rankingEnabled = true,
}: TopNavbarProps) {
const router = useRouter();
const { resolvedTheme } = useTheme();
const [mounted, setMounted] = useState(false);
const [ranking, setRanking] = useState<RankingItem[]>([]);
const [rankingOpen, setRankingOpen] = useState(false);
const [showTutorialModal, setShowTutorialModal] = useState(false);
const [showImageEditor, setShowImageEditor] = useState(false);
const [editorImageUrl, setEditorImageUrl] = useState<string>("");
// 移除 fileInputRef,不再需要外部上传
@ -242,7 +249,23 @@ export function TopNavbar({
<span>AI精选</span>
</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>
)}
{/* 精选榜单 */}
{rankingEnabled && (
<DropdownMenu open={rankingOpen} onOpenChange={setRankingOpen}>
<DropdownMenuTrigger asChild>
<Button
@ -296,6 +319,7 @@ export function TopNavbar({
)}
</DropdownMenuContent>
</DropdownMenu>
)}
{/* 图片编辑 */}
<Button
@ -367,6 +391,56 @@ export function TopNavbar({
onCancel={handleImageEditorCancel}
/>
)}
{/* 功能教程弹窗 */}
{showTutorialModal && tutorialVideoUrl && (
<div
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/80"
onClick={() => setShowTutorialModal(false)}
>
<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="relative w-full aspect-video bg-black rounded-lg overflow-hidden shadow-2xl">
{tutorialVideoUrl.includes('youtube.com') || tutorialVideoUrl.includes('youtu.be') ? (
// YouTube 嵌入
<iframe
src={tutorialVideoUrl.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') ? (
// Bilibili 嵌入
<iframe
src={tutorialVideoUrl.replace('www.bilibili.com/video/', 'player.bilibili.com/player.html?bvid=')}
className="w-full h-full"
allowFullScreen
/>
) : (
// 普通视频链接
<video
src={tutorialVideoUrl}
className="w-full h-full"
controls
autoPlay
/>
)}
</div>
</div>
</div>
)}
</nav>
);
}

View File

@ -1521,6 +1521,9 @@ export async function getSiteSettings() {
toolbox1Enabled: true,
toolbox2Name: "工具箱2",
toolbox2Enabled: false,
tutorialVideoUrl: null,
tutorialEnabled: false,
rankingEnabled: true,
};
try {
const [settings] = await db.select().from(siteSettings).limit(1);

View File

@ -301,6 +301,11 @@ export const siteSettings = pgTable("SiteSettings", {
toolbox1Enabled: boolean("toolbox1Enabled").notNull().default(true), // 是否显示工具箱1
toolbox2Name: varchar("toolbox2Name", { length: 64 }).default("工具箱2"), // 工具箱2名称
toolbox2Enabled: boolean("toolbox2Enabled").notNull().default(false), // 是否显示工具箱2
// 功能教程设置
tutorialVideoUrl: text("tutorialVideoUrl"), // 功能教程视频链接
tutorialEnabled: boolean("tutorialEnabled").notNull().default(false), // 是否显示功能教程按钮
// 精选榜设置
rankingEnabled: boolean("rankingEnabled").notNull().default(true), // 是否显示精选榜按钮
updatedAt: timestamp("updatedAt").notNull().defaultNow(),
updatedBy: uuid("updatedBy").references(() => user.id),
});