diff --git a/package.json b/package.json index d16d2e3..29f2cda 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 70c30ed..dc08a03 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {} diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 784238b..1fee446 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -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([]); + 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 ( +
+
+ + +
+ + {loading ? ( +
+
+
+ ) : requests.length === 0 ? ( +
+ 暂无求片 +
+ ) : ( +
+ {requests.map((req) => ( +
+
+ {req.poster && ( + {req.title} + )} +
+

+ {req.title} {req.year && `(${req.year})`} +

+

+ 求片人数: {req.requestCount} 人 +

+

+ {new Date(req.createdAt).toLocaleString('zh-CN')} +

+ {req.requestedBy && ( +

+ 求片用户: {req.requestedBy.join(', ')} +

+ )} +
+
+ {filter === 'pending' && ( + + )} + +
+
+
+ ))} +
+ )} + + +
+ ); +}; + // AI配置组件 const AIConfigComponent = ({ config, @@ -10238,6 +10409,18 @@ function AdminPageClient() { > + + {/* 求片管理子标签 */} + + } + isExpanded={expandedTabs.movieRequests} + onToggle={() => toggleTab('movieRequests')} + > + +
diff --git a/src/app/api/movie-requests/[id]/route.ts b/src/app/api/movie-requests/[id]/route.ts new file mode 100644 index 0000000..19d9ad3 --- /dev/null +++ b/src/app/api/movie-requests/[id]/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/movie-requests/route.ts b/src/app/api/movie-requests/route.ts new file mode 100644 index 0000000..4ea8879 --- /dev/null +++ b/src/app/api/movie-requests/route.ts @@ -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 } + ); + } +} diff --git a/src/app/movie-request/page.tsx b/src/app/movie-request/page.tsx new file mode 100644 index 0000000..f52ab9d --- /dev/null +++ b/src/app/movie-request/page.tsx @@ -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([]); + const [isSearching, setIsSearching] = useState(false); + const [showSeasonDialog, setShowSeasonDialog] = useState(false); + const [selectedItem, setSelectedItem] = useState(null); + const [seasons, setSeasons] = useState>([]); + const [loadingSeasons, setLoadingSeasons] = useState(false); + const [selectedSeason, setSelectedSeason] = useState(1); + const [alertModal, setAlertModal] = useState<{ + isOpen: boolean; + type: 'success' | 'error'; + title: string; + message: string; + }>({ isOpen: false, type: 'success', title: '', message: '' }); + const [myRequests, setMyRequests] = useState([]); + 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 ( + +
+
+

+ 求片 +

+

+ 搜索并提交您想看的影片 +

+
+ + {/* 搜索框 */} +
+
+ 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' + /> + +
+
+ + {/* 我的求片列表 */} + {searchResults.length === 0 && ( +
+

+ 我的求片 +

+ {loadingMyRequests ? ( +
+
+
+ ) : myRequests.length === 0 ? ( +
+ 暂无求片记录 +
+ ) : ( +
+ {myRequests.map((request) => ( +
+ {request.poster ? ( + {request.title} + ) : ( +
+ 无海报 +
+ )} +
+

+ {request.title} +

+

+ {request.year || '未知'} · {request.requestCount}人求片 +

+
+ {request.status === 'fulfilled' ? '已上架' : '待处理'} +
+
+
+ ))} +
+ )} +
+ )} + + {/* 搜索结果 */} + {searchResults.length > 0 ? ( +
+ {searchResults.map((item) => ( +
+ {item.poster_path ? ( + {item.title + ) : ( +
+ 无海报 +
+ )} +
+

+ {item.title || item.name} +

+

+ {(item.release_date || item.first_air_date)?.split('-')[0] || '未知'} +

+ +
+
+ ))} +
+ ) : searchKeyword && !isSearching ? ( +
+ 未找到相关影片 +
+ ) : null} +
+ + {/* 提示弹窗 */} + {alertModal.isOpen && typeof window !== 'undefined' && createPortal( +
+
+
+ {alertModal.type === 'success' ? ( + + ) : ( + + )} +
+

+ {alertModal.title} +

+

+ {alertModal.message} +

+ +
+
, + document.body + )} + + {/* 季度选择弹窗 */} + {showSeasonDialog && typeof window !== 'undefined' && createPortal( + <> +
setShowSeasonDialog(false)} + /> +
+

+ 选择季度 +

+

+ {selectedItem?.title || selectedItem?.name} +

+ {loadingSeasons ? ( +
+
+
+ ) : ( +
+ {seasons.map((season) => ( + + ))} +
+ )} +
+ + +
+
+ , + document.body + )} + + ); +} diff --git a/src/app/private-library/page.tsx b/src/app/private-library/page.tsx index 190e24a..9c25414 100644 --- a/src/app/private-library/page.tsx +++ b/src/app/private-library/page.tsx @@ -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 (
-
-

- 私人影库 -

-

- 观看自我收藏的高清视频吧 -

+
+
+

+ 私人影库 +

+

+ 观看自我收藏的高清视频吧 +

+
+
{/* 第一级:源类型选择(OpenList / Emby / 小雅) */} diff --git a/src/lib/types.ts b/src/lib/types.ts index a9a6026..8948020 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -117,6 +117,16 @@ export interface IStorage { // 收藏更新检查相关 getLastFavoriteCheckTime(userName: string): Promise; setLastFavoriteCheckTime(userName: string, timestamp: number): Promise; + + // 求片相关 + getAllMovieRequests(): Promise; + getMovieRequest(requestId: string): Promise; + createMovieRequest(request: MovieRequest): Promise; + updateMovieRequest(requestId: string, updates: Partial): Promise; + deleteMovieRequest(requestId: string): Promise; + getUserMovieRequests(userName: string): Promise; + addUserMovieRequest(userName: string, requestId: string): Promise; + removeUserMovieRequest(userName: string, requestId: string): Promise; } // 搜索结果数据结构 @@ -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; +} diff --git a/src/lib/upstash.db.ts b/src/lib/upstash.db.ts index 4c0fa2a..7e57024 100644 --- a/src/lib/upstash.db.ts +++ b/src/lib/upstash.db.ts @@ -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 { + 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 { + 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 { + await withRetry(() => this.client.hset(this.movieRequestsKey(), { [request.id]: request })); + } + + async updateMovieRequest(requestId: string, updates: Partial): Promise { + 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 { + await withRetry(() => this.client.hdel(this.movieRequestsKey(), requestId)); + } + + async getUserMovieRequests(userName: string): Promise { + const val = await withRetry(() => this.client.smembers(this.userMovieRequestsKey(userName))); + return val ? ensureStringArray(val) : []; + } + + async addUserMovieRequest(userName: string, requestId: string): Promise { + await withRetry(() => this.client.sadd(this.userMovieRequestsKey(userName), requestId)); + } + + async removeUserMovieRequest(userName: string, requestId: string): Promise { + await withRetry(() => this.client.srem(this.userMovieRequestsKey(userName), requestId)); + } } // 单例 Upstash Redis 客户端