增加求片功能

This commit is contained in:
mtvpls
2026-01-12 18:13:14 +08:00
parent 1053f2968e
commit 83b3315fba
9 changed files with 1049 additions and 20 deletions

View File

@@ -44,6 +44,7 @@
"lucide-react": "^0.438.0",
"media-icons": "^1.1.5",
"mux.js": "^6.3.0",
"nanoid": "^5.1.6",
"next": "^14.2.33",
"next-pwa": "^5.6.0",
"next-themes": "^0.4.6",

22
pnpm-lock.yaml generated
View File

@@ -80,6 +80,9 @@ importers:
mux.js:
specifier: ^6.3.0
version: 6.3.0
nanoid:
specifier: ^5.1.6
version: 5.1.6
next:
specifier: ^14.2.33
version: 14.2.35(@babel/core@7.28.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -1232,28 +1235,24 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@next/swc-linux-arm64-musl@14.2.33':
resolution: {integrity: sha512-Bm+QulsAItD/x6Ih8wGIMfRJy4G73tu1HJsrccPW6AfqdZd0Sfm5Imhgkgq2+kly065rYMnCOxTBvmvFY1BKfg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@next/swc-linux-x64-gnu@14.2.33':
resolution: {integrity: sha512-FnFn+ZBgsVMbGDsTqo8zsnRzydvsGV8vfiWwUo1LD8FTmPTdV+otGSWKc4LJec0oSexFnCYVO4hX8P8qQKaSlg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@next/swc-linux-x64-musl@14.2.33':
resolution: {integrity: sha512-345tsIWMzoXaQndUTDv1qypDRiebFxGYx9pYkhwY4hBRaOLt8UGfiWKr9FSSHs25dFIf8ZqIFaPdy5MljdoawA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@next/swc-win32-arm64-msvc@14.2.33':
resolution: {integrity: sha512-nscpt0G6UCTkrT2ppnJnFsYbPDQwmum4GNXYTeoTIdsmMydSKFz9Iny2jpaRupTb+Wl298+Rh82WKzt9LCcqSQ==}
@@ -1822,49 +1821,41 @@ packages:
resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-arm64-musl@1.11.1':
resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@unrs/resolver-binding-linux-ppc64-gnu@1.11.1':
resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-riscv64-gnu@1.11.1':
resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-riscv64-musl@1.11.1':
resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@unrs/resolver-binding-linux-s390x-gnu@1.11.1':
resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-x64-gnu@1.11.1':
resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-x64-musl@1.11.1':
resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==}
cpu: [x64]
os: [linux]
libc: [musl]
'@unrs/resolver-binding-wasm32-wasi@1.11.1':
resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==}
@@ -4420,6 +4411,11 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
nanoid@5.1.6:
resolution: {integrity: sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==}
engines: {node: ^18 || >=20}
hasBin: true
napi-postinstall@0.3.4:
resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==}
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
@@ -11386,6 +11382,8 @@ snapshots:
nanoid@3.3.11: {}
nanoid@5.1.6: {}
napi-postinstall@0.3.4: {}
natural-compare-lite@1.4.0: {}

View File

@@ -8725,6 +8725,177 @@ const XiaoyaConfigComponent = ({
);
};
// 求片列表组件
const MovieRequestsComponent = ({ config, refreshConfig }: { config: AdminConfig | null; refreshConfig: () => void }) => {
const { alertModal, showAlert, hideAlert } = useAlertModal();
const { isLoading, withLoading } = useLoadingState();
const [requests, setRequests] = useState<any[]>([]);
const [filter, setFilter] = useState<'pending' | 'fulfilled'>('pending');
const [pendingCount, setPendingCount] = useState(0);
const [fulfilledCount, setFulfilledCount] = useState(0);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadRequests();
loadCounts();
}, [filter]);
const loadCounts = async () => {
try {
const response = await fetch('/api/movie-requests');
const data = await response.json();
const allRequests = data.requests || [];
setPendingCount(allRequests.filter((r: any) => r.status === 'pending').length);
setFulfilledCount(allRequests.filter((r: any) => r.status === 'fulfilled').length);
} catch (error) {
console.error('加载求片数量失败:', error);
}
};
const loadRequests = async () => {
setLoading(true);
try {
const response = await fetch(`/api/movie-requests?status=${filter}&detail=true`);
const data = await response.json();
setRequests(data.requests || []);
} catch (error) {
console.error('加载求片列表失败:', error);
} finally {
setLoading(false);
}
};
const handleFulfill = async (id: string) => {
await withLoading(`fulfill_${id}`, async () => {
try {
const response = await fetch(`/api/movie-requests/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'fulfilled' }),
});
if (!response.ok) throw new Error('操作失败');
showSuccess('已标记为已上架', showAlert);
await loadRequests();
} catch (err) {
showError(err instanceof Error ? err.message : '操作失败', showAlert);
}
});
};
const handleDelete = async (id: string) => {
await withLoading(`delete_${id}`, async () => {
try {
const response = await fetch(`/api/movie-requests/${id}`, { method: 'DELETE' });
if (!response.ok) throw new Error('删除失败');
showSuccess('删除成功', showAlert);
await loadRequests();
} catch (err) {
showError(err instanceof Error ? err.message : '删除失败', showAlert);
}
});
};
return (
<div className='space-y-4'>
<div className='flex gap-2 mb-4'>
<button
onClick={() => setFilter('pending')}
className={`px-4 py-2 rounded-lg ${
filter === 'pending'
? 'bg-blue-600 text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
}`}
>
({pendingCount})
</button>
<button
onClick={() => setFilter('fulfilled')}
className={`px-4 py-2 rounded-lg ${
filter === 'fulfilled'
? 'bg-blue-600 text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
}`}
>
({fulfilledCount})
</button>
</div>
{loading ? (
<div className='flex justify-center py-8'>
<div className='w-6 h-6 border-2 border-blue-600 border-t-transparent rounded-full animate-spin' />
</div>
) : requests.length === 0 ? (
<div className='text-center py-8 text-gray-500 dark:text-gray-400'>
</div>
) : (
<div className='space-y-3'>
{requests.map((req) => (
<div
key={req.id}
className='p-4 bg-gray-50 dark:bg-gray-800 rounded-lg'
>
<div className='flex gap-4'>
{req.poster && (
<img
src={req.poster}
alt={req.title}
className='w-16 h-24 object-cover rounded'
/>
)}
<div className='flex-1'>
<h3 className='font-medium text-gray-900 dark:text-gray-100'>
{req.title} {req.year && `(${req.year})`}
</h3>
<p className='text-sm text-gray-600 dark:text-gray-400 mt-1'>
: {req.requestCount}
</p>
<p className='text-xs text-gray-500 dark:text-gray-500 mt-1'>
{new Date(req.createdAt).toLocaleString('zh-CN')}
</p>
{req.requestedBy && (
<p className='text-xs text-gray-500 dark:text-gray-500 mt-1'>
: {req.requestedBy.join(', ')}
</p>
)}
</div>
<div className='flex flex-col gap-2'>
{filter === 'pending' && (
<button
onClick={() => handleFulfill(req.id)}
disabled={isLoading(`fulfill_${req.id}`)}
className={buttonStyles.successSmall}
>
{isLoading(`fulfill_${req.id}`) ? '处理中...' : '标记已上架'}
</button>
)}
<button
onClick={() => handleDelete(req.id)}
disabled={isLoading(`delete_${req.id}`)}
className={buttonStyles.dangerSmall}
>
{isLoading(`delete_${req.id}`) ? '删除中...' : '删除'}
</button>
</div>
</div>
</div>
))}
</div>
)}
<AlertModal
isOpen={alertModal.isOpen}
onClose={hideAlert}
type={alertModal.type}
title={alertModal.title}
message={alertModal.message}
timer={alertModal.timer}
showConfirm={alertModal.showConfirm}
/>
</div>
);
};
// AI配置组件
const AIConfigComponent = ({
config,
@@ -10238,6 +10409,18 @@ function AdminPageClient() {
>
<XiaoyaConfigComponent config={config} refreshConfig={fetchConfig} />
</CollapsibleTab>
{/* 求片管理子标签 */}
<CollapsibleTab
title='求片管理'
icon={
<Video size={20} className='text-gray-600 dark:text-gray-400' />
}
isExpanded={expandedTabs.movieRequests}
onToggle={() => toggleTab('movieRequests')}
>
<MovieRequestsComponent />
</CollapsibleTab>
</div>
</CollapsibleTab>

View File

@@ -0,0 +1,147 @@
import { NextRequest, NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '@/lib/auth';
import { getStorage } from '@/lib/db';
export const runtime = 'nodejs';
// GET: 获取单个求片详情
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
try {
const storage = getStorage();
const movieRequest = await storage.getMovieRequest(params.id);
if (!movieRequest) {
return NextResponse.json({ error: '求片不存在' }, { status: 404 });
}
return NextResponse.json({ request: movieRequest });
} catch (error) {
console.error('获取求片详情失败:', error);
return NextResponse.json(
{ error: (error as Error).message },
{ status: 500 }
);
}
}
// PATCH: 更新求片状态(标记已上架)
export async function PATCH(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
try {
const storage = getStorage();
const userInfo = await storage.getUserInfoV2(authInfo.username);
// 检查权限:只有管理员和站长可以操作
if (userInfo?.role !== 'admin' && userInfo?.role !== 'owner') {
return NextResponse.json({ error: '无权限操作' }, { status: 403 });
}
const body = await request.json();
const { status, fulfilledSource, fulfilledId } = body;
const movieRequest = await storage.getMovieRequest(params.id);
if (!movieRequest) {
return NextResponse.json({ error: '求片不存在' }, { status: 404 });
}
// 更新状态
const updates: any = {
status,
updatedAt: Date.now(),
};
if (status === 'fulfilled') {
updates.fulfilledAt = Date.now();
updates.fulfilledSource = fulfilledSource;
updates.fulfilledId = fulfilledId;
// 给所有求片用户发送通知
for (const username of movieRequest.requestedBy) {
await storage.addNotification(username, {
id: `req_fulfilled_${params.id}_${Date.now()}`,
type: 'request_fulfilled',
title: '求片已上架',
message: `您求的《${movieRequest.title}》已上架`,
timestamp: Date.now(),
read: false,
metadata: {
requestId: params.id,
source: fulfilledSource,
id: fulfilledId,
},
});
}
}
await storage.updateMovieRequest(params.id, updates);
return NextResponse.json({
message: '更新成功',
request: { ...movieRequest, ...updates },
});
} catch (error) {
console.error('更新求片失败:', error);
return NextResponse.json(
{ error: (error as Error).message },
{ status: 500 }
);
}
}
// DELETE: 删除求片
export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
try {
const storage = getStorage();
const userInfo = await storage.getUserInfoV2(authInfo.username);
// 检查权限:只有管理员和站长可以删除
if (userInfo?.role !== 'admin' && userInfo?.role !== 'owner') {
return NextResponse.json({ error: '无权限操作' }, { status: 403 });
}
const movieRequest = await storage.getMovieRequest(params.id);
if (!movieRequest) {
return NextResponse.json({ error: '求片不存在' }, { status: 404 });
}
// 删除求片
await storage.deleteMovieRequest(params.id);
// 从所有用户的求片列表中移除
for (const username of movieRequest.requestedBy) {
await storage.removeUserMovieRequest(username, params.id);
}
return NextResponse.json({ message: '删除成功' });
} catch (error) {
console.error('删除求片失败:', error);
return NextResponse.json(
{ error: (error as Error).message },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,200 @@
import { NextRequest, NextResponse } from 'next/server';
import { nanoid } from 'nanoid';
import { getAuthInfoFromCookie } from '@/lib/auth';
import { getStorage } from '@/lib/db';
import { MovieRequest } from '@/lib/types';
export const runtime = 'nodejs';
// GET: 获取求片列表
export async function GET(request: NextRequest) {
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
try {
const { searchParams } = new URL(request.url);
const status = searchParams.get('status') as 'pending' | 'fulfilled' | null;
const detail = searchParams.get('detail') !== 'false';
const myRequests = searchParams.get('my') === 'true';
const storage = getStorage();
if (myRequests) {
// 获取用户自己的求片
const requestIds = await storage.getUserMovieRequests(authInfo.username);
const requests = await Promise.all(
requestIds.map(id => storage.getMovieRequest(id))
);
const filtered = requests.filter(r => r !== null) as MovieRequest[];
return NextResponse.json({ requests: filtered });
}
// 获取所有求片
let requests = await storage.getAllMovieRequests();
// 按状态筛选
if (status) {
requests = requests.filter(r => r.status === status);
}
// 列表页不返回 requestedBy
if (!detail) {
requests = requests.map(r => ({ ...r, requestedBy: undefined }));
}
// 按求片人数和时间排序
requests.sort((a, b) => {
if (b.requestCount !== a.requestCount) {
return b.requestCount - a.requestCount;
}
return b.createdAt - a.createdAt;
});
return NextResponse.json({ requests });
} catch (error) {
console.error('获取求片列表失败:', error);
return NextResponse.json(
{ error: (error as Error).message },
{ status: 500 }
);
}
}
// POST: 创建或加入求片
export async function POST(request: NextRequest) {
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
try {
const body = await request.json();
const { tmdbId, title, year, mediaType, season, poster, overview } = body;
if (!title || !mediaType) {
return NextResponse.json({ error: '缺少必要参数' }, { status: 400 });
}
const storage = getStorage();
// 检查频率限制
const userInfo = await storage.getUserInfoV2(authInfo.username);
const rateLimit = parseInt(process.env.MOVIE_REQUEST_RATE_LIMIT || '3600') * 1000;
if (userInfo?.last_movie_request_time) {
const elapsed = Date.now() - userInfo.last_movie_request_time;
if (elapsed < rateLimit) {
const remaining = Math.ceil((rateLimit - elapsed) / 60000);
return NextResponse.json(
{ error: `操作太频繁,请${remaining}分钟后再试` },
{ status: 429 }
);
}
}
// 查重(剧集需要匹配季度)
const allRequests = await storage.getAllMovieRequests();
const existing = allRequests.find(r =>
(tmdbId && r.tmdbId === tmdbId && r.season === season) ||
(r.title === title && r.year === year && r.season === season)
);
if (existing) {
// 如果已上架,不允许再求
if (existing.status === 'fulfilled') {
return NextResponse.json({ error: '该影片已上架' }, { status: 400 });
}
// 检查用户是否已经求过
if (existing.requestedBy.includes(authInfo.username)) {
return NextResponse.json({ error: '您已经求过这部影片了' }, { status: 400 });
}
// 加入求片
existing.requestedBy.push(authInfo.username);
existing.requestCount++;
existing.updatedAt = Date.now();
await storage.updateMovieRequest(existing.id, existing);
await storage.addUserMovieRequest(authInfo.username, existing.id);
// 给站长发送通知
const ownerUsername = process.env.USERNAME;
if (ownerUsername) {
await storage.addNotification(ownerUsername, {
id: `movie_request_join_${existing.id}_${Date.now()}`,
type: 'movie_request',
title: '求片人数增加',
message: `${authInfo.username} 也想看:${existing.title}${existing.season ? `${existing.season}` : ''} (${existing.requestCount}人)`,
timestamp: Date.now(),
read: false,
metadata: {
requestId: existing.id,
username: authInfo.username,
},
});
}
return NextResponse.json({
message: '已加入求片',
request: existing
});
}
// 创建新求片
const newRequest: MovieRequest = {
id: nanoid(),
tmdbId,
title,
year,
mediaType,
season,
poster,
overview,
requestedBy: [authInfo.username],
requestCount: 1,
status: 'pending',
createdAt: Date.now(),
updatedAt: Date.now(),
};
await storage.createMovieRequest(newRequest);
await storage.addUserMovieRequest(authInfo.username, newRequest.id);
// 更新频率限制
await storage.setGlobalValue(
`user:${authInfo.username}:info:last_movie_request_time`,
Date.now().toString()
);
// 给站长发送通知
const ownerUsername = process.env.USERNAME;
if (ownerUsername) {
await storage.addNotification(ownerUsername, {
id: `movie_request_${newRequest.id}_${Date.now()}`,
type: 'movie_request',
title: '新求片请求',
message: `${authInfo.username} 求片:${title}${season ? `${season}` : ''}`,
timestamp: Date.now(),
read: false,
metadata: {
requestId: newRequest.id,
username: authInfo.username,
},
});
}
return NextResponse.json({
message: '求片成功',
request: newRequest
});
} catch (error) {
console.error('创建求片失败:', error);
return NextResponse.json(
{ error: (error as Error).message },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,410 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { createPortal } from 'react-dom';
import { CheckCircle, AlertCircle, Plus } from 'lucide-react';
import PageLayout from '@/components/PageLayout';
interface TMDBResult {
id: number;
title?: string;
name?: string;
release_date?: string;
first_air_date?: string;
poster_path?: string;
overview?: string;
media_type: 'movie' | 'tv';
}
interface MovieRequest {
id: string;
title: string;
year?: string;
mediaType: 'movie' | 'tv';
season?: number;
poster?: string;
requestCount: number;
status: 'pending' | 'fulfilled';
createdAt: number;
}
export default function MovieRequestPage() {
const router = useRouter();
const [searchKeyword, setSearchKeyword] = useState('');
const [searchResults, setSearchResults] = useState<TMDBResult[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [showSeasonDialog, setShowSeasonDialog] = useState(false);
const [selectedItem, setSelectedItem] = useState<TMDBResult | null>(null);
const [seasons, setSeasons] = useState<Array<{ season_number: number; name: string; poster_path?: string | null }>>([]);
const [loadingSeasons, setLoadingSeasons] = useState(false);
const [selectedSeason, setSelectedSeason] = useState<number>(1);
const [alertModal, setAlertModal] = useState<{
isOpen: boolean;
type: 'success' | 'error';
title: string;
message: string;
}>({ isOpen: false, type: 'success', title: '', message: '' });
const [myRequests, setMyRequests] = useState<MovieRequest[]>([]);
const [loadingMyRequests, setLoadingMyRequests] = useState(true);
const [submitting, setSubmitting] = useState(false);
// TMDB搜索
const handleSearch = async () => {
if (!searchKeyword.trim()) return;
setIsSearching(true);
try {
const response = await fetch(`/api/tmdb/search?query=${encodeURIComponent(searchKeyword)}`);
const data = await response.json();
if (data.results) {
setSearchResults(data.results.slice(0, 20));
} else {
setSearchResults([]);
}
} catch (err) {
console.error('搜索失败:', err);
setAlertModal({ isOpen: true, type: 'error', title: '搜索失败', message: '请稍后重试' });
} finally {
setIsSearching(false);
}
};
// 提交求片
const handleRequest = async (item: TMDBResult) => {
if (item.media_type === 'tv') {
setSelectedItem(item);
setLoadingSeasons(true);
setShowSeasonDialog(true);
try {
const response = await fetch(`/api/tmdb/seasons?tvId=${item.id}`);
const data = await response.json();
if (data.seasons) {
const validSeasons = data.seasons.filter((s: any) => s.season_number > 0);
setSeasons(validSeasons);
if (validSeasons.length === 1) {
// 只有一季,自动提交
setShowSeasonDialog(false);
submitRequest(item, validSeasons[0].season_number);
} else {
setSelectedSeason(1);
}
}
} catch (err) {
console.error('加载季度失败:', err);
} finally {
setLoadingSeasons(false);
}
} else {
submitRequest(item);
}
};
const submitRequest = async (item: TMDBResult, season?: number) => {
setSubmitting(true);
try {
let poster = item.poster_path ? `https://image.tmdb.org/t/p/w500${item.poster_path}` : undefined;
let title = item.title || item.name || '';
if (season && seasons.length > 0) {
const seasonData = seasons.find(s => s.season_number === season);
if (seasonData) {
title = `${title} ${seasonData.name}`;
if (seasonData.poster_path) {
poster = `https://image.tmdb.org/t/p/w500${seasonData.poster_path}`;
}
}
}
const response = await fetch('/api/movie-requests', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tmdbId: item.id,
title,
year: (item.release_date || item.first_air_date)?.split('-')[0],
mediaType: item.media_type,
season,
poster,
overview: item.overview,
}),
});
const data = await response.json();
if (response.ok) {
setShowSeasonDialog(false);
setAlertModal({ isOpen: true, type: 'success', title: '求片成功', message: data.message });
refreshMyRequests();
} else {
setAlertModal({ isOpen: true, type: 'error', title: '求片失败', message: data.error || '请稍后重试' });
}
} catch (err) {
console.error('求片失败:', err);
setAlertModal({ isOpen: true, type: 'error', title: '求片失败', message: '请稍后重试' });
} finally {
setSubmitting(false);
}
};
const handleSeasonConfirm = () => {
if (selectedItem) {
submitRequest(selectedItem, selectedSeason);
}
setShowSeasonDialog(false);
setSelectedItem(null);
};
// 加载我的求片列表
useEffect(() => {
const fetchMyRequests = async () => {
try {
const response = await fetch('/api/movie-requests?my=true');
const data = await response.json();
if (data.requests) {
setMyRequests(data.requests);
}
} catch (err) {
console.error('加载求片列表失败:', err);
} finally {
setLoadingMyRequests(false);
}
};
fetchMyRequests();
}, []);
// 刷新我的求片列表
const refreshMyRequests = async () => {
try {
const response = await fetch('/api/movie-requests?my=true');
const data = await response.json();
if (data.requests) {
setMyRequests(data.requests);
}
} catch (err) {
console.error('刷新求片列表失败:', err);
}
};
return (
<PageLayout activePath='/movie-request'>
<div className='container mx-auto px-4 py-6'>
<div className='mb-6'>
<h1 className='text-2xl font-bold text-gray-900 dark:text-gray-100'>
</h1>
<p className='text-sm text-gray-500 dark:text-gray-400 mt-1'>
</p>
</div>
{/* 搜索框 */}
<div className='mb-6'>
<div className='flex gap-2'>
<input
type='text'
placeholder='搜索影片名称...'
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSearch();
}}
className='flex-1 px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500'
/>
<button
onClick={handleSearch}
disabled={!searchKeyword.trim() || isSearching}
className='px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg disabled:opacity-50 disabled:cursor-not-allowed'
>
{isSearching ? '搜索中...' : '搜索'}
</button>
</div>
</div>
{/* 我的求片列表 */}
{searchResults.length === 0 && (
<div className='mb-8'>
<h2 className='text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4'>
</h2>
{loadingMyRequests ? (
<div className='flex justify-center py-8'>
<div className='w-5 h-5 border-2 border-blue-600 border-t-transparent rounded-full animate-spin' />
</div>
) : myRequests.length === 0 ? (
<div className='text-center py-8 text-gray-500 dark:text-gray-400'>
</div>
) : (
<div className='grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4'>
{myRequests.map((request) => (
<div
key={request.id}
className='bg-white dark:bg-gray-800 rounded-lg overflow-hidden shadow hover:shadow-lg transition-shadow'
>
{request.poster ? (
<img
src={request.poster}
alt={request.title}
className='w-full aspect-[2/3] object-cover'
/>
) : (
<div className='w-full aspect-[2/3] bg-gray-200 dark:bg-gray-700 flex items-center justify-center'>
<span className='text-gray-400'></span>
</div>
)}
<div className='p-3'>
<h3 className='text-sm font-medium text-gray-900 dark:text-gray-100 truncate mb-1'>
{request.title}
</h3>
<p className='text-xs text-gray-500 dark:text-gray-400 mb-2'>
{request.year || '未知'} · {request.requestCount}
</p>
<div className={`text-xs px-2 py-1 rounded text-center ${
request.status === 'fulfilled'
? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300'
: 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-300'
}`}>
{request.status === 'fulfilled' ? '已上架' : '待处理'}
</div>
</div>
</div>
))}
</div>
)}
</div>
)}
{/* 搜索结果 */}
{searchResults.length > 0 ? (
<div className='grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4'>
{searchResults.map((item) => (
<div
key={item.id}
className='bg-white dark:bg-gray-800 rounded-lg overflow-hidden shadow hover:shadow-lg transition-shadow'
>
{item.poster_path ? (
<img
src={`https://image.tmdb.org/t/p/w500${item.poster_path}`}
alt={item.title || item.name}
className='w-full aspect-[2/3] object-cover'
/>
) : (
<div className='w-full aspect-[2/3] bg-gray-200 dark:bg-gray-700 flex items-center justify-center'>
<span className='text-gray-400'></span>
</div>
)}
<div className='p-3'>
<h3 className='text-sm font-medium text-gray-900 dark:text-gray-100 truncate mb-1'>
{item.title || item.name}
</h3>
<p className='text-xs text-gray-500 dark:text-gray-400 mb-2'>
{(item.release_date || item.first_air_date)?.split('-')[0] || '未知'}
</p>
<button
onClick={() => handleRequest(item)}
disabled={submitting}
className='w-full px-3 py-1.5 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed'
>
{submitting ? '处理中...' : '求片'}
</button>
</div>
</div>
))}
</div>
) : searchKeyword && !isSearching ? (
<div className='text-center py-12 text-gray-500 dark:text-gray-400'>
</div>
) : null}
</div>
{/* 提示弹窗 */}
{alertModal.isOpen && typeof window !== 'undefined' && createPortal(
<div className='fixed inset-0 bg-black/50 z-[1002] flex items-center justify-center p-4'>
<div className='bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-sm w-full p-6'>
<div className='flex justify-center mb-4'>
{alertModal.type === 'success' ? (
<CheckCircle className='w-12 h-12 text-green-500' />
) : (
<AlertCircle className='w-12 h-12 text-red-500' />
)}
</div>
<h3 className='text-lg font-semibold text-gray-900 dark:text-gray-100 text-center mb-2'>
{alertModal.title}
</h3>
<p className='text-gray-600 dark:text-gray-400 text-center mb-4'>
{alertModal.message}
</p>
<button
onClick={() => setAlertModal({ ...alertModal, isOpen: false })}
className='w-full px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg'
>
</button>
</div>
</div>,
document.body
)}
{/* 季度选择弹窗 */}
{showSeasonDialog && typeof window !== 'undefined' && createPortal(
<>
<div
className='fixed inset-0 bg-black/50 backdrop-blur-sm z-[1000]'
onClick={() => setShowSeasonDialog(false)}
/>
<div className='fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-md bg-white dark:bg-gray-900 rounded-xl shadow-xl z-[1001] p-6'>
<h3 className='text-lg font-bold text-gray-800 dark:text-gray-200 mb-4'>
</h3>
<p className='text-sm text-gray-600 dark:text-gray-400 mb-4'>
{selectedItem?.title || selectedItem?.name}
</p>
{loadingSeasons ? (
<div className='flex justify-center py-8'>
<div className='w-6 h-6 border-2 border-blue-600 border-t-transparent rounded-full animate-spin' />
</div>
) : (
<div className='space-y-2 mb-4 max-h-60 overflow-y-auto'>
{seasons.map((season) => (
<button
key={season.season_number}
onClick={() => setSelectedSeason(season.season_number)}
className={`w-full p-3 rounded-lg text-left transition-colors ${
selectedSeason === season.season_number
? 'bg-blue-600 text-white'
: 'bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100 hover:bg-gray-200 dark:hover:bg-gray-700'
}`}
>
{season.name}
</button>
))}
</div>
)}
<div className='flex gap-2'>
<button
onClick={() => setShowSeasonDialog(false)}
className='flex-1 px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600'
>
</button>
<button
onClick={handleSeasonConfirm}
className='flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg'
>
</button>
</div>
</div>
</>,
document.body
)}
</PageLayout>
);
}

View File

@@ -4,6 +4,7 @@
import { useRouter, useSearchParams } from 'next/navigation';
import { useEffect, useState, useRef, useMemo } from 'react';
import { Film } from 'lucide-react';
import CapsuleSwitch from '@/components/CapsuleSwitch';
import PageLayout from '@/components/PageLayout';
@@ -447,13 +448,22 @@ export default function PrivateLibraryPage() {
return (
<PageLayout activePath='/private-library'>
<div className='container mx-auto px-4 py-6'>
<div className='mb-6'>
<h1 className='text-2xl font-bold text-gray-900 dark:text-gray-100'>
</h1>
<p className='text-sm text-gray-500 dark:text-gray-400 mt-1'>
</p>
<div className='mb-6 flex justify-between items-start'>
<div>
<h1 className='text-2xl font-bold text-gray-900 dark:text-gray-100'>
</h1>
<p className='text-sm text-gray-500 dark:text-gray-400 mt-1'>
</p>
</div>
<button
onClick={() => router.push('/movie-request')}
className='flex items-center gap-2 px-3 py-2 text-gray-600 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 transition-colors'
>
<Film size={20} />
<span></span>
</button>
</div>
{/* 第一级源类型选择OpenList / Emby / 小雅) */}

View File

@@ -117,6 +117,16 @@ export interface IStorage {
// 收藏更新检查相关
getLastFavoriteCheckTime(userName: string): Promise<number>;
setLastFavoriteCheckTime(userName: string, timestamp: number): Promise<void>;
// 求片相关
getAllMovieRequests(): Promise<MovieRequest[]>;
getMovieRequest(requestId: string): Promise<MovieRequest | null>;
createMovieRequest(request: MovieRequest): Promise<void>;
updateMovieRequest(requestId: string, updates: Partial<MovieRequest>): Promise<void>;
deleteMovieRequest(requestId: string): Promise<void>;
getUserMovieRequests(userName: string): Promise<string[]>;
addUserMovieRequest(userName: string, requestId: string): Promise<void>;
removeUserMovieRequest(userName: string, requestId: string): Promise<void>;
}
// 搜索结果数据结构
@@ -195,7 +205,9 @@ export interface EpisodeFilterConfig {
export type NotificationType =
| 'favorite_update' // 收藏更新
| 'system' // 系统通知
| 'announcement'; // 公告
| 'announcement' // 公告
| 'movie_request' // 新求片通知(给管理员)
| 'request_fulfilled'; // 求片已上架通知(给求片用户)
// 通知数据结构
export interface Notification {
@@ -219,3 +231,23 @@ export interface FavoriteUpdateCheck {
new_episodes: number;
}>;
}
// 求片请求数据结构
export interface MovieRequest {
id: string;
tmdbId?: number;
title: string;
year?: string;
mediaType: 'movie' | 'tv';
season?: number; // 季度(仅剧集)
poster?: string;
overview?: string;
requestedBy: string[];
requestCount: number;
status: 'pending' | 'fulfilled';
createdAt: number;
updatedAt: number;
fulfilledAt?: number;
fulfilledSource?: string;
fulfilledId?: string;
}

View File

@@ -1260,6 +1260,54 @@ export class UpstashRedisStorage implements IStorage {
this.client.set(this.lastFavoriteCheckKey(userName), timestamp)
);
}
// ---------- 求片相关 ----------
private movieRequestsKey() {
return 'movie_requests:all';
}
private userMovieRequestsKey(userName: string) {
return `u:${userName}:mr`;
}
async getAllMovieRequests(): Promise<import('./types').MovieRequest[]> {
const data = await withRetry(() => this.client.hgetall(this.movieRequestsKey()));
if (!data) return [];
return Object.values(data) as import('./types').MovieRequest[];
}
async getMovieRequest(requestId: string): Promise<import('./types').MovieRequest | null> {
const val = await withRetry(() => this.client.hget(this.movieRequestsKey(), requestId));
return val ? (val as import('./types').MovieRequest) : null;
}
async createMovieRequest(request: import('./types').MovieRequest): Promise<void> {
await withRetry(() => this.client.hset(this.movieRequestsKey(), { [request.id]: request }));
}
async updateMovieRequest(requestId: string, updates: Partial<import('./types').MovieRequest>): Promise<void> {
const existing = await this.getMovieRequest(requestId);
if (!existing) throw new Error('Movie request not found');
const updated = { ...existing, ...updates };
await withRetry(() => this.client.hset(this.movieRequestsKey(), { [requestId]: updated }));
}
async deleteMovieRequest(requestId: string): Promise<void> {
await withRetry(() => this.client.hdel(this.movieRequestsKey(), requestId));
}
async getUserMovieRequests(userName: string): Promise<string[]> {
const val = await withRetry(() => this.client.smembers(this.userMovieRequestsKey(userName)));
return val ? ensureStringArray(val) : [];
}
async addUserMovieRequest(userName: string, requestId: string): Promise<void> {
await withRetry(() => this.client.sadd(this.userMovieRequestsKey(userName), requestId));
}
async removeUserMovieRequest(userName: string, requestId: string): Promise<void> {
await withRetry(() => this.client.srem(this.userMovieRequestsKey(userName), requestId));
}
}
// 单例 Upstash Redis 客户端