增加求片功能
This commit is contained in:
@@ -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
22
pnpm-lock.yaml
generated
@@ -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: {}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
147
src/app/api/movie-requests/[id]/route.ts
Normal file
147
src/app/api/movie-requests/[id]/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
200
src/app/api/movie-requests/route.ts
Normal file
200
src/app/api/movie-requests/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
410
src/app/movie-request/page.tsx
Normal file
410
src/app/movie-request/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 / 小雅) */}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 客户端
|
||||
|
||||
Reference in New Issue
Block a user