Compare commits

...

2 Commits

Author SHA1 Message Date
Kevin Wong
7bfd6bf862 更新 2026-02-02 14:28:48 +08:00
Kevin Wong
569736d05b 更新代码 2026-02-02 11:49:22 +08:00
9 changed files with 335 additions and 272 deletions

View File

@@ -61,3 +61,8 @@ JWT_EXPIRE_HOURS=168
# 服务启动时自动创建的管理员账号 # 服务启动时自动创建的管理员账号
ADMIN_PHONE=15549380526 ADMIN_PHONE=15549380526
ADMIN_PASSWORD=lam1988324 ADMIN_PASSWORD=lam1988324
# =============== GLM AI 配置 ===============
# 智谱 GLM API 配置 (用于生成标题和标签)
GLM_API_KEY=32440cd3f3444d1f8fe721304acea8bd.YXNLrk7eIJMKcg4t
GLM_MODEL=glm-4.7-flash

View File

@@ -39,6 +39,10 @@ class Settings(BaseSettings):
ADMIN_PHONE: str = "" ADMIN_PHONE: str = ""
ADMIN_PASSWORD: str = "" ADMIN_PASSWORD: str = ""
# GLM AI 配置
GLM_API_KEY: str = ""
GLM_MODEL: str = "glm-4.7-flash"
@property @property
def LATENTSYNC_DIR(self) -> Path: def LATENTSYNC_DIR(self) -> Path:
"""LatentSync 目录路径 (动态计算)""" """LatentSync 目录路径 (动态计算)"""

View File

@@ -1,19 +1,29 @@
""" """
GLM AI 服务 GLM AI 服务
使用智谱 GLM-4.7-Flash 生成标题和标签 使用智谱 GLM 生成标题和标签
""" """
import json import json
import re import re
import httpx
from loguru import logger from loguru import logger
from zai import ZhipuAiClient
from app.core.config import settings
class GLMService: class GLMService:
"""GLM AI 服务""" """GLM AI 服务"""
API_URL = "https://open.bigmodel.cn/api/paas/v4/chat/completions" def __init__(self):
API_KEY = "5915240ea48d4e93b454bc2412d1cc54.e054ej4pPqi9G6rc" self.client = None
def _get_client(self):
"""获取或创建 ZhipuAI 客户端"""
if self.client is None:
if not settings.GLM_API_KEY:
raise Exception("GLM_API_KEY 未配置")
self.client = ZhipuAiClient(api_key=settings.GLM_API_KEY)
return self.client
async def generate_title_tags(self, text: str) -> dict: async def generate_title_tags(self, text: str) -> dict:
""" """
@@ -38,34 +48,25 @@ class GLMService:
{{"title": "标题", "tags": ["标签1", "标签2", "标签3"]}}""" {{"title": "标题", "tags": ["标签1", "标签2", "标签3"]}}"""
try: try:
async with httpx.AsyncClient(timeout=30.0) as client: client = self._get_client()
response = await client.post( logger.info(f"Calling GLM API with model: {settings.GLM_MODEL}")
self.API_URL,
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {self.API_KEY}"
},
json={
"model": "glm-4-flash",
"messages": [{"role": "user", "content": prompt}],
"max_tokens": 500,
"temperature": 0.7
}
)
response.raise_for_status()
data = response.json()
# 提取生成的内容 response = client.chat.completions.create(
content = data["choices"][0]["message"]["content"] model=settings.GLM_MODEL,
logger.info(f"GLM response: {content}") messages=[{"role": "user", "content": prompt}],
thinking={"type": "disabled"}, # 禁用思考模式,加快响应
max_tokens=500,
temperature=0.7
)
# 解析 JSON # 提取生成的内容
result = self._parse_json_response(content) content = response.choices[0].message.content
return result logger.info(f"GLM response (model: {settings.GLM_MODEL}): {content}")
# 解析 JSON
result = self._parse_json_response(content)
return result
except httpx.HTTPError as e:
logger.error(f"GLM API request failed: {e}")
raise Exception(f"AI 服务请求失败: {str(e)}")
except Exception as e: except Exception as e:
logger.error(f"GLM service error: {e}") logger.error(f"GLM service error: {e}")
raise Exception(f"AI 生成失败: {str(e)}") raise Exception(f"AI 生成失败: {str(e)}")

View File

@@ -38,6 +38,7 @@ body {
font-family: Arial, Helvetica, sans-serif; font-family: Arial, Helvetica, sans-serif;
padding-top: env(safe-area-inset-top); padding-top: env(safe-area-inset-top);
padding-bottom: env(safe-area-inset-bottom); padding-bottom: env(safe-area-inset-bottom);
background: linear-gradient(to bottom, #0f172a 0%, #0f172a 5%, #581c87 50%, #0f172a 95%, #0f172a 100%);
} }
/* 自定义滚动条样式 - 深色主题 */ /* 自定义滚动条样式 - 深色主题 */

View File

@@ -33,14 +33,9 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( return (
<html lang="en" style={{ backgroundColor: '#0f172a' }}> <html lang="en">
<body <body
className={`${geistSans.variable} ${geistMono.variable} antialiased`} className={`${geistSans.variable} ${geistMono.variable} antialiased`}
style={{
margin: 0,
minHeight: '100dvh',
background: 'linear-gradient(to bottom, #0f172a 0%, #0f172a 5%, #581c87 50%, #0f172a 95%, #0f172a 100%)',
}}
> >
<AuthProvider> <AuthProvider>
<TaskProvider> <TaskProvider>

View File

@@ -6,6 +6,8 @@ import Link from "next/link";
import api from "@/lib/axios"; import api from "@/lib/axios";
import { useAuth } from "@/contexts/AuthContext"; import { useAuth } from "@/contexts/AuthContext";
import { useTask } from "@/contexts/TaskContext"; import { useTask } from "@/contexts/TaskContext";
import AccountSettingsDropdown from "@/components/AccountSettingsDropdown";
import VideoPreviewModal from "@/components/VideoPreviewModal";
const API_BASE = typeof window === 'undefined' const API_BASE = typeof window === 'undefined'
? 'http://localhost:8006' ? 'http://localhost:8006'
@@ -56,215 +58,12 @@ const formatDate = (timestamp: number) => {
return `${year}/${month}/${day} ${hour}:${minute}`; return `${year}/${month}/${day} ${hour}:${minute}`;
}; };
// 账户设置下拉菜单组件
function AccountSettingsDropdown() {
const { user } = useAuth();
const [isOpen, setIsOpen] = useState(false);
const [showPasswordModal, setShowPasswordModal] = useState(false);
const [oldPassword, setOldPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [loading, setLoading] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
// 点击外部关闭菜单
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen]);
// 格式化有效期
const formatExpiry = (expiresAt: string | null) => {
if (!expiresAt) return '永久有效';
const date = new Date(expiresAt);
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
};
const handleLogout = async () => {
if (confirm('确定要退出登录吗?')) {
try {
await api.post('/api/auth/logout');
} catch (e) { }
window.location.href = '/login';
}
};
const handleChangePassword = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setSuccess('');
if (newPassword !== confirmPassword) {
setError('两次输入的新密码不一致');
return;
}
if (newPassword.length < 6) {
setError('新密码长度至少6位');
return;
}
setLoading(true);
try {
const res = await api.post('/api/auth/change-password', {
old_password: oldPassword,
new_password: newPassword
});
if (res.data.success) {
setSuccess('密码修改成功,正在跳转登录页...');
// 清除登录状态并跳转
setTimeout(async () => {
try {
await api.post('/api/auth/logout');
} catch (e) { }
window.location.href = '/login';
}, 1500);
} else {
setError(res.data.message || '修改失败');
}
} catch (err: any) {
setError(err.response?.data?.detail || '修改失败,请重试');
} finally {
setLoading(false);
}
};
return (
<div className="relative">
<button
onClick={() => setIsOpen(!isOpen)}
className="px-2 sm:px-4 py-1 sm:py-2 text-sm sm:text-base bg-white/10 hover:bg-white/20 text-white rounded-lg transition-colors flex items-center gap-1"
>
<span></span>
<span className="hidden sm:inline"></span>
<svg className={`w-4 h-4 transition-transform ${isOpen ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{/* 下拉菜单 */}
{isOpen && (
<div ref={dropdownRef} className="absolute right-0 mt-2 bg-gray-800 border border-white/10 rounded-lg shadow-xl z-[160] overflow-hidden whitespace-nowrap">
{/* 有效期显示 */}
<div className="px-3 py-2 border-b border-white/10 text-center">
<div className="text-xs text-gray-400"></div>
<div className="text-sm text-white font-medium">
{user?.expires_at ? formatExpiry(user.expires_at) : '永久有效'}
</div>
</div>
<button
onClick={() => {
setIsOpen(false);
setShowPasswordModal(true);
}}
className="w-full px-3 py-2 text-left text-sm text-white hover:bg-white/10 flex items-center gap-2"
>
🔐
</button>
<button
onClick={handleLogout}
className="w-full px-3 py-2 text-left text-sm text-red-300 hover:bg-red-500/20 flex items-center gap-2"
>
🚪 退
</button>
</div>
)}
{/* 修改密码弹窗 */}
{showPasswordModal && (
<div className="fixed inset-0 z-[200] flex items-start justify-center pt-20 bg-black/60 backdrop-blur-sm p-4">
<div className="w-full max-w-md p-6 bg-gray-900 border border-white/10 rounded-2xl shadow-2xl mx-4">
<h3 className="text-xl font-bold text-white mb-4"></h3>
<form onSubmit={handleChangePassword} className="space-y-4">
<div>
<label className="block text-sm text-gray-300 mb-1"></label>
<input
type="password"
value={oldPassword}
onChange={(e) => setOldPassword(e.target.value)}
required
className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-purple-500"
placeholder="输入当前密码"
/>
</div>
<div>
<label className="block text-sm text-gray-300 mb-1"></label>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-purple-500"
placeholder="至少6位"
/>
</div>
<div>
<label className="block text-sm text-gray-300 mb-1"></label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-purple-500"
placeholder="再次输入新密码"
/>
</div>
{error && (
<div className="p-2 bg-red-500/20 border border-red-500/50 rounded text-red-200 text-sm">
{error}
</div>
)}
{success && (
<div className="p-2 bg-green-500/20 border border-green-500/50 rounded text-green-200 text-sm">
{success}
</div>
)}
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={() => {
setShowPasswordModal(false);
setError('');
setOldPassword('');
setNewPassword('');
setConfirmPassword('');
}}
className="flex-1 py-2 bg-white/10 hover:bg-white/20 text-white rounded-lg transition-colors"
>
</button>
<button
type="submit"
disabled={loading}
className="flex-1 py-2 bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700 text-white rounded-lg transition-colors disabled:opacity-50"
>
{loading ? '修改中...' : '确认修改'}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}
export default function Home() { export default function Home() {
const [materials, setMaterials] = useState<Material[]>([]); const [materials, setMaterials] = useState<Material[]>([]);
const [selectedMaterial, setSelectedMaterial] = useState<string>(""); const [selectedMaterial, setSelectedMaterial] = useState<string>("");
const [previewMaterial, setPreviewMaterial] = useState<string | null>(null);
const [text, setText] = useState<string>(""); const [text, setText] = useState<string>("");
const [voice, setVoice] = useState<string>("zh-CN-YunxiNeural"); const [voice, setVoice] = useState<string>("zh-CN-YunxiNeural");
@@ -856,6 +655,18 @@ export default function Home() {
{m.size_mb.toFixed(1)} MB {m.size_mb.toFixed(1)} MB
</div> </div>
</button> </button>
<button
onClick={(e) => {
e.stopPropagation();
if (m.path) {
setPreviewMaterial(`${API_BASE}${m.path}`);
}
}}
className="absolute top-2 right-10 p-1 text-gray-500 hover:text-white opacity-0 group-hover:opacity-100 transition-opacity"
title="预览视频"
>
👁
</button>
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
@@ -1290,6 +1101,10 @@ export default function Home() {
</div> </div>
</div> </div>
</main > </main >
<VideoPreviewModal
videoUrl={previewMaterial}
onClose={() => setPreviewMaterial(null)}
/>
</div > </div >
); );
} }

View File

@@ -0,0 +1,211 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { useAuth } from "@/contexts/AuthContext";
import api from "@/lib/axios";
// 账户设置下拉菜单组件
export default function AccountSettingsDropdown() {
const { user } = useAuth();
const [isOpen, setIsOpen] = useState(false);
const [showPasswordModal, setShowPasswordModal] = useState(false);
const [oldPassword, setOldPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [loading, setLoading] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
// 点击外部关闭菜单
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen]);
// 格式化有效期
const formatExpiry = (expiresAt: string | null) => {
if (!expiresAt) return '永久有效';
const date = new Date(expiresAt);
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
};
const handleLogout = async () => {
if (confirm('确定要退出登录吗?')) {
try {
await api.post('/api/auth/logout');
} catch (e) { }
window.location.href = '/login';
}
};
const handleChangePassword = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setSuccess('');
if (newPassword !== confirmPassword) {
setError('两次输入的新密码不一致');
return;
}
if (newPassword.length < 6) {
setError('新密码长度至少6位');
return;
}
setLoading(true);
try {
const res = await api.post('/api/auth/change-password', {
old_password: oldPassword,
new_password: newPassword
});
if (res.data.success) {
setSuccess('密码修改成功,正在跳转登录页...');
// 清除登录状态并跳转
setTimeout(async () => {
try {
await api.post('/api/auth/logout');
} catch (e) { }
window.location.href = '/login';
}, 1500);
} else {
setError(res.data.message || '修改失败');
}
} catch (err: any) {
setError(err.response?.data?.detail || '修改失败,请重试');
} finally {
setLoading(false);
}
};
return (
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setIsOpen(!isOpen)}
className="px-2 sm:px-4 py-1 sm:py-2 text-sm sm:text-base bg-white/10 hover:bg-white/20 text-white rounded-lg transition-colors flex items-center gap-1"
>
<span></span>
<span className="hidden sm:inline"></span>
<svg className={`w-4 h-4 transition-transform ${isOpen ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{/* 下拉菜单 */}
{isOpen && (
<div className="absolute right-0 mt-2 bg-gray-800 border border-white/10 rounded-lg shadow-xl z-[160] overflow-hidden whitespace-nowrap">
{/* 有效期显示 */}
<div className="px-3 py-2 border-b border-white/10 text-center">
<div className="text-xs text-gray-400"></div>
<div className="text-sm text-white font-medium">
{user?.expires_at ? formatExpiry(user.expires_at) : '永久有效'}
</div>
</div>
<button
onClick={() => {
setIsOpen(false);
setShowPasswordModal(true);
}}
className="w-full px-3 py-2 text-left text-sm text-white hover:bg-white/10 flex items-center gap-2"
>
🔐
</button>
<button
onClick={handleLogout}
className="w-full px-3 py-2 text-left text-sm text-red-300 hover:bg-red-500/20 flex items-center gap-2"
>
🚪 退
</button>
</div>
)}
{/* 修改密码弹窗 */}
{showPasswordModal && (
<div className="fixed inset-0 z-[200] flex items-start justify-center pt-20 bg-black/60 backdrop-blur-sm p-4">
<div className="w-full max-w-md p-6 bg-gray-900 border border-white/10 rounded-2xl shadow-2xl mx-4">
<h3 className="text-xl font-bold text-white mb-4"></h3>
<form onSubmit={handleChangePassword} className="space-y-4">
<div>
<label className="block text-sm text-gray-300 mb-1"></label>
<input
type="password"
value={oldPassword}
onChange={(e) => setOldPassword(e.target.value)}
required
className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-purple-500"
placeholder="输入当前密码"
/>
</div>
<div>
<label className="block text-sm text-gray-300 mb-1"></label>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-purple-500"
placeholder="至少6位"
/>
</div>
<div>
<label className="block text-sm text-gray-300 mb-1"></label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-purple-500"
placeholder="再次输入新密码"
/>
</div>
{error && (
<div className="p-2 bg-red-500/20 border border-red-500/50 rounded text-red-200 text-sm">
{error}
</div>
)}
{success && (
<div className="p-2 bg-green-500/20 border border-green-500/50 rounded text-green-200 text-sm">
{success}
</div>
)}
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={() => {
setShowPasswordModal(false);
setError('');
setOldPassword('');
setNewPassword('');
setConfirmPassword('');
}}
className="flex-1 py-2 bg-white/10 hover:bg-white/20 text-white rounded-lg transition-colors"
>
</button>
<button
type="submit"
disabled={loading}
className="flex-1 py-2 bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700 text-white rounded-lg transition-colors disabled:opacity-50"
>
{loading ? '修改中...' : '确认修改'}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,64 @@
"use client";
import { useEffect } from "react";
interface VideoPreviewModalProps {
videoUrl: string | null;
onClose: () => void;
}
export default function VideoPreviewModal({ videoUrl, onClose }: VideoPreviewModalProps) {
useEffect(() => {
// 按 ESC 关闭
const handleEsc = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
if (videoUrl) {
document.addEventListener('keydown', handleEsc);
// 禁止背景滚动
document.body.style.overflow = 'hidden';
}
return () => {
document.removeEventListener('keydown', handleEsc);
document.body.style.overflow = 'unset';
};
}, [videoUrl, onClose]);
if (!videoUrl) return null;
return (
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/80 backdrop-blur-sm p-4 animate-in fade-in duration-200">
<div className="relative w-full max-w-4xl bg-gray-900 border border-white/10 rounded-2xl shadow-2xl overflow-hidden flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-6 py-2 border-b border-white/10 bg-white/5">
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
🎥
</h3>
<button
onClick={onClose}
className="p-2 text-gray-400 hover:text-white hover:bg-white/10 rounded-lg transition-colors"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Video Player */}
<div className="bg-black flex items-center justify-center min-h-[50vh] max-h-[80vh]">
<video
src={videoUrl}
controls
autoPlay
className="w-full h-full max-h-[80vh] object-contain"
/>
</div>
</div>
{/* Click outside to close */}
<div className="absolute inset-0 -z-10" onClick={onClose}></div>
</div>
);
}

View File

@@ -1,33 +0,0 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
// 需要登录才能访问的路径
const protectedPaths = ['/', '/publish', '/admin'];
// 公开路径 (无需登录)
const publicPaths = ['/login', '/register'];
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// 检查是否有 access_token cookie
const token = request.cookies.get('access_token');
// 访问受保护页面但未登录 → 重定向到登录页
if (protectedPaths.some(path => pathname === path || pathname.startsWith(path + '/')) && !token) {
const loginUrl = new URL('/login', request.url);
loginUrl.searchParams.set('from', pathname);
return NextResponse.redirect(loginUrl);
}
// 已登录用户访问登录/注册页 → 重定向到首页
if (publicPaths.includes(pathname) && token) {
return NextResponse.redirect(new URL('/', request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ['/', '/publish/:path*', '/admin/:path*', '/login', '/register']
};