Files
youwei-business/src/pages/Admin.tsx
Ebenezer 123f82e92c Initialize project scaffold with full web and API setup.
Add the Vite frontend, Express API server, and supporting configuration files so the app can run locally on a complete development stack.

Made-with: Cursor
2026-04-15 17:41:46 +08:00

582 lines
25 KiB
TypeScript

import { useState, useEffect, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { useLang } from "@/contexts/LanguageContext";
import { isAdminSession, clearAdminSession } from "@/lib/adminSession";
import { toast } from "sonner";
import {
type CourseVideo,
fetchAllCourseVideos,
createCourseVideo,
deleteCourseVideo,
updateCourseVideoStatus,
} from "@/lib/api";
import { motion, AnimatePresence } from "framer-motion";
import {
Upload, Video, Trash2, ArrowLeft, Eye,
Plus, FileVideo, Clock, Users, Search, Filter,
CheckCircle2, AlertCircle, Sparkles
} from "lucide-react";
import heroBg from "@/assets/hero-bg.jpg";
import logo from "@/assets/logo.png";
interface VideoItem {
id: string;
title: string;
course: string;
courseKey: string;
module: string;
duration: string;
uploadDate: string;
status: "published" | "draft" | "processing";
views: number;
thumbnail: string;
videoUrl?: string;
}
function thumbForCourseKey(key: string): string {
const m: Record<string, string> = {
biz: "📊",
product: "💡",
marketing: "📢",
ops: "⚙️",
};
return m[key] || "🎞";
}
function mapCourseVideoToItem(v: CourseVideo, lang: "en" | "zh"): VideoItem {
return {
id: v.id,
title: v.title,
course: lang === "zh" ? v.courseLabelZh : v.courseLabelEn,
courseKey: v.courseKey,
module: v.moduleName,
duration: v.duration,
uploadDate: v.uploadDate,
status: v.status as VideoItem["status"],
views: v.views,
thumbnail: thumbForCourseKey(v.courseKey),
videoUrl: v.videoUrl,
};
}
const courseOptions = [
{ key: "biz", label: { zh: "商业模式设计", en: "Business Model Design" } },
{ key: "product", label: { zh: "产品开发方法论", en: "Product Development" } },
{ key: "marketing", label: { zh: "营销策略", en: "Marketing Strategy" } },
{ key: "ops", label: { zh: "运营管理体系", en: "Operations Management" } },
];
export default function Admin() {
const { lang } = useLang();
const navigate = useNavigate();
useEffect(() => {
if (!isAdminSession()) {
navigate("/admin", { replace: true });
}
}, [navigate]);
const [videos, setVideos] = useState<VideoItem[]>([]);
const [listLoading, setListLoading] = useState(true);
const [showUpload, setShowUpload] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [filterCourse, setFilterCourse] = useState("all");
const [dragActive, setDragActive] = useState(false);
const [uploadTitle, setUploadTitle] = useState("");
const [uploadCourse, setUploadCourse] = useState("biz");
const [uploadModule, setUploadModule] = useState("");
const [uploadFile, setUploadFile] = useState<File | null>(null);
const [uploadBusy, setUploadBusy] = useState(false);
const refreshVideos = useCallback(async () => {
setListLoading(true);
try {
const list = await fetchAllCourseVideos();
setVideos(list.map((v) => mapCourseVideoToItem(v, lang)));
} catch {
toast.error(lang === "zh" ? "无法加载视频列表" : "Could not load videos");
} finally {
setListLoading(false);
}
}, [lang]);
useEffect(() => {
refreshVideos();
}, [refreshVideos]);
const resetUploadForm = () => {
setUploadTitle("");
setUploadCourse("biz");
setUploadModule(lang === "zh" ? "模块1" : "Module 1");
setUploadFile(null);
};
const buildUploadFormData = (status: "published" | "draft"): FormData => {
const co = courseOptions.find((c) => c.key === uploadCourse);
const fd = new FormData();
fd.append("file", uploadFile as File);
fd.append("title", uploadTitle.trim());
fd.append("courseKey", uploadCourse);
fd.append("courseLabelZh", co?.label.zh ?? "");
fd.append("courseLabelEn", co?.label.en ?? "");
fd.append("moduleName", uploadModule || (lang === "zh" ? "模块1" : "Module 1"));
fd.append("status", status);
return fd;
};
const handleSaveAsDraft = async () => {
if (!uploadFile) {
toast.error(lang === "zh" ? "请选择视频文件" : "Choose a video file");
return;
}
setUploadBusy(true);
try {
const r = await createCourseVideo(buildUploadFormData("draft"));
if (!r.ok) {
toast.error(r.error || (lang === "zh" ? "保存失败" : "Save failed"));
return;
}
toast.success(lang === "zh" ? "已保存草稿" : "Draft saved");
setShowUpload(false);
resetUploadForm();
await refreshVideos();
} catch {
toast.error(lang === "zh" ? "上传失败" : "Upload failed");
} finally {
setUploadBusy(false);
}
};
const handleUpload = async (e: React.FormEvent) => {
e.preventDefault();
if (!uploadFile) {
toast.error(lang === "zh" ? "请选择视频文件" : "Choose a video file");
return;
}
setUploadBusy(true);
try {
const r = await createCourseVideo(buildUploadFormData("published"));
if (!r.ok) {
toast.error(r.error || (lang === "zh" ? "发布失败" : "Publish failed"));
return;
}
toast.success(lang === "zh" ? "已发布,学员可在对应课程页观看" : "Published — visible on the course page");
setShowUpload(false);
resetUploadForm();
await refreshVideos();
} catch {
toast.error(lang === "zh" ? "上传失败" : "Upload failed");
} finally {
setUploadBusy(false);
}
};
const handleDelete = async (id: string) => {
const ok = await deleteCourseVideo(id);
if (!ok) {
toast.error(lang === "zh" ? "删除失败" : "Delete failed");
return;
}
setVideos((prev) => prev.filter((v) => v.id !== id));
toast.success(lang === "zh" ? "已删除" : "Deleted");
};
const handlePublish = async (id: string) => {
const r = await updateCourseVideoStatus(id, "published");
if (!r.ok) {
toast.error(lang === "zh" ? "发布失败" : "Publish failed");
return;
}
toast.success(lang === "zh" ? "已发布" : "Published");
await refreshVideos();
};
const filteredVideos = videos.filter(v => {
const matchSearch = v.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
v.course.toLowerCase().includes(searchQuery.toLowerCase());
const matchCourse = filterCourse === "all" || v.courseKey === filterCourse;
return matchSearch && matchCourse;
});
const statusConfig = {
published: { color: "text-emerald-600 bg-emerald-500/10", icon: CheckCircle2, label: { zh: "已发布", en: "Published" } },
draft: { color: "text-amber-600 bg-amber-500/10", icon: AlertCircle, label: { zh: "草稿", en: "Draft" } },
processing: { color: "text-blue-600 bg-blue-500/10", icon: Clock, label: { zh: "处理中", en: "Processing" } },
};
// Admin Dashboard
return (
<div className="min-h-screen bg-background">
{/* Header */}
<div className="relative">
<img src={heroBg} alt="" className="absolute inset-0 w-full h-full object-cover" />
<div className="absolute inset-0 bg-gradient-to-b from-background/20 to-background" />
<div className="relative z-10 max-w-6xl mx-auto px-4 pt-4 pb-16">
<nav className="glass-nav rounded-2xl px-5 py-2.5 flex items-center justify-between mb-8">
<div className="flex items-center gap-2">
<img src={logo} alt="有维商学" className="w-7 h-7 rounded-xl object-contain" />
<span className="font-semibold text-foreground text-sm">
{lang === "zh" ? "管理后台" : "Admin Dashboard"}
</span>
</div>
<div className="flex items-center gap-3">
<span className="section-tag !py-1">
<Sparkles className="w-3 h-3" />
Admin
</span>
<button
onClick={() => {
clearAdminSession();
navigate("/home");
}}
className="glass-pill px-4 py-1.5 text-xs font-medium text-foreground hover:scale-105 transition-transform"
>
<ArrowLeft className="w-3 h-3 inline mr-1" />
{lang === "zh" ? "返回前台" : "Back to Site"}
</button>
</div>
</nav>
{/* Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{[
{ label: lang === "zh" ? "总视频数" : "Total Videos", value: videos.length, icon: Video, color: "from-primary to-accent" },
{ label: lang === "zh" ? "已发布" : "Published", value: videos.filter(v => v.status === "published").length, icon: CheckCircle2, color: "from-emerald-500 to-teal-400" },
{ label: lang === "zh" ? "草稿" : "Drafts", value: videos.filter(v => v.status === "draft").length, icon: AlertCircle, color: "from-amber-500 to-orange-400" },
{ label: lang === "zh" ? "总播放量" : "Total Views", value: videos.reduce((sum, v) => sum + v.views, 0).toLocaleString(), icon: Users, color: "from-violet-500 to-purple-400" },
].map((stat, i) => (
<motion.div
key={i}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.1 }}
className="glass-card p-5"
>
<div className={`w-10 h-10 rounded-xl bg-gradient-to-br ${stat.color} flex items-center justify-center text-white mb-3`}>
<stat.icon className="w-5 h-5" />
</div>
<p className="text-2xl font-bold text-foreground">{stat.value}</p>
<p className="text-xs text-muted-foreground mt-1">{stat.label}</p>
</motion.div>
))}
</div>
</div>
</div>
{/* Main Content */}
<div className="max-w-6xl mx-auto px-4 -mt-6 pb-16">
{/* Toolbar */}
<div className="glass-card rounded-2xl p-4 mb-6 flex flex-col sm:flex-row items-stretch sm:items-center gap-3">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={lang === "zh" ? "搜索视频..." : "Search videos..."}
className="w-full h-10 rounded-xl border border-border/50 bg-muted/20 pl-10 pr-4 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/30 transition-all"
/>
</div>
<div className="flex items-center gap-2">
<div className="relative">
<Filter className="absolute left-3 top-1/2 -translate-y-1/2 w-3 h-3 text-muted-foreground" />
<select
value={filterCourse}
onChange={(e) => setFilterCourse(e.target.value)}
className="h-10 rounded-xl border border-border/50 bg-muted/20 pl-8 pr-4 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/30 transition-all appearance-none cursor-pointer"
>
<option value="all">{lang === "zh" ? "全部课程" : "All Courses"}</option>
{courseOptions.map(c => (
<option key={c.key} value={c.key}>{c.label[lang]}</option>
))}
</select>
</div>
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={() => {
resetUploadForm();
setShowUpload(true);
}}
className="h-10 px-5 rounded-xl bg-gradient-to-r from-primary to-accent text-primary-foreground text-sm font-medium shadow-lg shadow-primary/20 flex items-center gap-2"
>
<Plus className="w-4 h-4" />
{lang === "zh" ? "上传视频" : "Upload Video"}
</motion.button>
</div>
</div>
{/* Video Grid */}
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-4">
{listLoading && (
<div className="col-span-full glass-card p-12 text-center text-muted-foreground text-sm">
{lang === "zh" ? "加载中…" : "Loading…"}
</div>
)}
{!listLoading && filteredVideos.map((video, i) => {
const status = statusConfig[video.status];
return (
<motion.div
key={video.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.05 }}
className="glass-card overflow-hidden group"
whileHover={{ y: -4 }}
>
{/* Thumbnail */}
<div className="aspect-video bg-gradient-to-br from-muted/50 to-muted/30 flex items-center justify-center relative">
<span className="text-4xl">{video.thumbnail}</span>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors flex items-center justify-center">
<motion.div
initial={{ opacity: 0, scale: 0.5 }}
whileHover={{ opacity: 1, scale: 1 }}
className="w-12 h-12 rounded-full bg-primary/90 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
>
<FileVideo className="w-5 h-5 text-primary-foreground" />
</motion.div>
</div>
<span className="absolute bottom-2 right-2 glass-pill px-2 py-0.5 text-[10px] font-medium text-foreground">
{video.duration}
</span>
</div>
{/* Info */}
<div className="p-4">
<div className="flex items-start justify-between gap-2 mb-2">
<h4 className="text-sm font-semibold text-foreground line-clamp-2">{video.title}</h4>
<button
onClick={() => handleDelete(video.id)}
className="p-1.5 rounded-lg hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors shrink-0"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
<p className="text-xs text-muted-foreground mb-3">
{video.course} · {video.module}
</p>
<div className="flex items-center justify-between gap-2 flex-wrap">
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium ${status.color}`}>
<status.icon className="w-3 h-3" />
{status.label[lang]}
</span>
<div className="flex items-center gap-3 text-[10px] text-muted-foreground">
<span className="flex items-center gap-1">
<Eye className="w-3 h-3" /> {video.views}
</span>
<span>{video.uploadDate}</span>
</div>
</div>
{(video.status === "draft" || video.status === "processing") && (
<button
type="button"
onClick={() => handlePublish(video.id)}
className="mt-3 w-full h-9 rounded-lg text-xs font-medium bg-emerald-500/15 text-emerald-700 dark:text-emerald-400 hover:bg-emerald-500/25 transition-colors"
>
{lang === "zh" ? "发布到学员端" : "Publish to learners"}
</button>
)}
</div>
</motion.div>
);
})}
</div>
{!listLoading && filteredVideos.length === 0 && (
<div className="glass-card p-12 text-center">
<Video className="w-12 h-12 text-muted-foreground/30 mx-auto mb-4" />
<p className="text-muted-foreground">
{lang === "zh" ? "暂无视频" : "No videos found"}
</p>
</div>
)}
</div>
{/* Upload Modal */}
<AnimatePresence>
{showUpload && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 bg-black/60 flex items-center justify-center p-4"
onClick={() => {
setShowUpload(false);
resetUploadForm();
}}
>
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }}
className="w-full max-w-lg p-8 rounded-2xl border border-white/30 bg-white/80 dark:bg-white/15 backdrop-blur-2xl shadow-2xl shadow-primary/10"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-lg font-bold text-foreground mb-6 flex items-center gap-2">
<Upload className="w-5 h-5 text-primary" />
{lang === "zh" ? "上传新视频" : "Upload New Video"}
</h3>
<form onSubmit={handleUpload} className="space-y-4">
{/* Video Title */}
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
{lang === "zh" ? "视频标题" : "Video Title"}
</label>
<input
type="text"
value={uploadTitle}
onChange={(e) => setUploadTitle(e.target.value)}
placeholder={lang === "zh" ? "输入视频标题" : "Enter video title"}
className="w-full h-11 rounded-xl border border-border bg-background px-4 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/30 shadow-sm transition-all"
required
/>
</div>
{/* Course Selection */}
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
{lang === "zh" ? "所属课程" : "Course"}
</label>
<select
value={uploadCourse}
onChange={(e) => setUploadCourse(e.target.value)}
className="w-full h-11 rounded-xl border border-border bg-background px-4 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/30 shadow-sm transition-all"
>
{courseOptions.map(c => (
<option key={c.key} value={c.key}>{c.label[lang]}</option>
))}
</select>
</div>
{/* Module */}
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
{lang === "zh" ? "所属模块" : "Module"}
</label>
<select
value={uploadModule}
onChange={(e) => setUploadModule(e.target.value)}
className="w-full h-11 rounded-xl border border-border bg-background px-4 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/30 shadow-sm transition-all"
>
{[1, 2, 3, 4, 5, 6].map(n => (
<option key={n} value={lang === "zh" ? `模块${n}` : `Module ${n}`}>
{lang === "zh" ? `模块${n}` : `Module ${n}`}
</option>
))}
</select>
</div>
{/* File Upload */}
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
{lang === "zh" ? "视频文件" : "Video File"}
</label>
<div
onDragOver={(e) => { e.preventDefault(); setDragActive(true); }}
onDragLeave={() => setDragActive(false)}
onDrop={(e) => {
e.preventDefault();
setDragActive(false);
if (e.dataTransfer.files[0]) setUploadFile(e.dataTransfer.files[0]);
}}
className={`relative border-2 border-dashed rounded-xl p-6 text-center transition-all cursor-pointer ${
dragActive
? "border-primary bg-primary/10"
: "border-border hover:border-primary/40 bg-background shadow-sm"
}`}
>
<input
type="file"
accept="video/*"
onChange={(e) => e.target.files?.[0] && setUploadFile(e.target.files[0])}
className="absolute inset-0 opacity-0 cursor-pointer"
/>
{uploadFile ? (
<div className="flex items-center justify-center gap-2">
<FileVideo className="w-5 h-5 text-primary" />
<span className="text-sm text-foreground font-medium">{uploadFile.name}</span>
</div>
) : (
<>
<Upload className="w-8 h-8 text-muted-foreground/40 mx-auto mb-2" />
<p className="text-sm text-muted-foreground">
{lang === "zh" ? "拖拽文件到这里或点击上传" : "Drag & drop or click to upload"}
</p>
<p className="text-xs text-muted-foreground/60 mt-1">MP4, MOV, AVI (max 2GB)</p>
</>
)}
</div>
</div>
{/* Draft Preview */}
{(uploadTitle || uploadFile) && (
<div className="rounded-xl border border-border bg-muted/30 p-4 space-y-2">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
{lang === "zh" ? "📋 上传预览" : "📋 Upload Preview"}
</p>
<div className="flex items-start gap-3">
<div className="w-20 h-14 rounded-lg bg-gradient-to-br from-muted/60 to-muted/30 flex items-center justify-center text-2xl shrink-0">
{courseOptions.find(c => c.key === uploadCourse)?.label.zh.charAt(0) === "商" ? "📊" : "📋"}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-foreground truncate">
{uploadTitle || (lang === "zh" ? "未命名视频" : "Untitled Video")}
</p>
<p className="text-xs text-muted-foreground">
{courseOptions.find(c => c.key === uploadCourse)?.label[lang]} · {uploadModule || (lang === "zh" ? "模块1" : "Module 1")}
</p>
{uploadFile && (
<p className="text-xs text-primary mt-1 flex items-center gap-1">
<FileVideo className="w-3 h-3" />
{uploadFile.name} ({(uploadFile.size / 1024 / 1024).toFixed(1)} MB)
</p>
)}
</div>
</div>
</div>
)}
{/* Actions */}
<div className="flex gap-3 pt-2">
<button
type="button"
disabled={uploadBusy}
onClick={() => {
setShowUpload(false);
resetUploadForm();
}}
className="h-11 px-4 rounded-xl border border-border bg-background text-sm font-medium text-foreground hover:bg-muted/40 shadow-sm transition-colors disabled:opacity-50"
>
{lang === "zh" ? "取消" : "Cancel"}
</button>
<button
type="button"
disabled={uploadBusy}
onClick={handleSaveAsDraft}
className="flex-1 h-11 rounded-xl border border-amber-500/50 bg-amber-500/10 text-sm font-medium text-amber-700 dark:text-amber-400 hover:bg-amber-500/20 shadow-sm transition-colors flex items-center justify-center gap-2 disabled:opacity-50"
>
<AlertCircle className="w-4 h-4" />
{lang === "zh" ? "保存草稿" : "Save as Draft"}
</button>
<button
type="submit"
disabled={uploadBusy}
className="flex-1 h-11 rounded-xl bg-gradient-to-r from-primary to-accent text-primary-foreground text-sm font-medium shadow-lg shadow-primary/20 hover:shadow-xl transition-all flex items-center justify-center gap-2 disabled:opacity-50"
>
<Upload className="w-4 h-4" />
{uploadBusy ? (lang === "zh" ? "上传中…" : "Uploading…") : lang === "zh" ? "上传发布" : "Publish"}
</button>
</div>
</form>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}