求片增加开关和冷却

This commit is contained in:
mtvpls
2026-01-12 21:06:45 +08:00
parent 3807f4cf60
commit 2c805e2522
8 changed files with 194 additions and 42 deletions

View File

@@ -8726,7 +8726,7 @@ const XiaoyaConfigComponent = ({
};
// 求片列表组件
const MovieRequestsComponent = ({ config, refreshConfig }: { config: AdminConfig | null; refreshConfig: () => void }) => {
const MovieRequestsComponent = ({ config, refreshConfig }: { config: AdminConfig | null; refreshConfig: () => Promise<void> }) => {
const { alertModal, showAlert, hideAlert } = useAlertModal();
const { isLoading, withLoading } = useLoadingState();
const [requests, setRequests] = useState<any[]>([]);
@@ -8735,6 +8735,11 @@ const MovieRequestsComponent = ({ config, refreshConfig }: { config: AdminConfig
const [fulfilledCount, setFulfilledCount] = useState(0);
const [loading, setLoading] = useState(true);
// 求片功能设置
const [enableMovieRequest, setEnableMovieRequest] = useState(config?.SiteConfig?.EnableMovieRequest ?? true);
const [movieRequestCooldown, setMovieRequestCooldown] = useState(config?.SiteConfig?.MovieRequestCooldown ?? 3600);
const [savingSettings, setSavingSettings] = useState(false);
useEffect(() => {
loadRequests();
loadCounts();
@@ -8795,9 +8800,100 @@ const MovieRequestsComponent = ({ config, refreshConfig }: { config: AdminConfig
});
};
const handleSaveSettings = async () => {
setSavingSettings(true);
try {
if (!config) throw new Error('配置未加载');
const updatedConfig = {
...config,
SiteConfig: {
...config.SiteConfig,
EnableMovieRequest: enableMovieRequest,
MovieRequestCooldown: movieRequestCooldown,
},
};
const response = await fetch('/api/admin/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updatedConfig),
});
if (!response.ok) throw new Error('保存失败');
showSuccess('求片设置已保存', showAlert);
await refreshConfig();
} catch (err) {
showError(err instanceof Error ? err.message : '保存失败', showAlert);
} finally {
setSavingSettings(false);
}
};
return (
<div className='space-y-4'>
<div className='flex gap-2 mb-4'>
{/* 求片功能设置 */}
<div className='p-4 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700'>
<h3 className='text-lg font-medium text-gray-900 dark:text-gray-100 mb-4'></h3>
<div className='space-y-4'>
<div className='flex items-center justify-between'>
<div>
<label className='text-sm font-medium text-gray-700 dark:text-gray-300'>
</label>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
访
</p>
</div>
<label className='relative inline-flex items-center cursor-pointer'>
<input
type='checkbox'
checked={enableMovieRequest}
onChange={(e) => setEnableMovieRequest(e.target.checked)}
className='sr-only peer'
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
</label>
</div>
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
</label>
<p className='text-xs text-gray-500 dark:text-gray-400 mb-2'>
36001
</p>
<input
type='number'
min='0'
value={movieRequestCooldown}
onChange={(e) => setMovieRequestCooldown(parseInt(e.target.value) || 0)}
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100'
/>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
{movieRequestCooldown >= 3600
? `${Math.floor(movieRequestCooldown / 3600)} 小时 ${Math.floor((movieRequestCooldown % 3600) / 60)} 分钟`
: movieRequestCooldown >= 60
? `${Math.floor(movieRequestCooldown / 60)} 分钟`
: `${movieRequestCooldown}`}
</p>
</div>
<button
onClick={handleSaveSettings}
disabled={savingSettings}
className={buttonStyles.primary}
>
{savingSettings ? '保存中...' : '保存设置'}
</button>
</div>
</div>
{/* 求片列表 */}
<div className='p-4 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700'>
<h3 className='text-lg font-medium text-gray-900 dark:text-gray-100 mb-4'></h3>
<div className='flex gap-2 mb-4'>
<button
onClick={() => setFilter('pending')}
className={`px-4 py-2 rounded-lg ${
@@ -8882,6 +8978,7 @@ const MovieRequestsComponent = ({ config, refreshConfig }: { config: AdminConfig
))}
</div>
)}
</div>
<AlertModal
isOpen={alertModal.isOpen}
@@ -10419,7 +10516,7 @@ function AdminPageClient() {
isExpanded={expandedTabs.movieRequests}
onToggle={() => toggleTab('movieRequests')}
>
<MovieRequestsComponent />
<MovieRequestsComponent config={config} refreshConfig={fetchConfig} />
</CollapsibleTab>
</div>
</CollapsibleTab>

View File

@@ -71,6 +71,14 @@ export async function POST(request: NextRequest) {
}
try {
// 检查求片功能是否启用并获取冷却时间
const { getConfig } = await import('@/lib/config');
const config = await getConfig();
if (config.SiteConfig.EnableMovieRequest === false) {
return NextResponse.json({ error: '求片功能已关闭' }, { status: 403 });
}
const body = await request.json();
const { tmdbId, title, year, mediaType, season, poster, overview } = body;
@@ -80,10 +88,11 @@ export async function POST(request: NextRequest) {
const storage = getStorage();
// 检查频率限制
const userInfo = await storage.getUserInfoV2(authInfo.username);
const rateLimit = parseInt(process.env.MOVIE_REQUEST_RATE_LIMIT || '3600') * 1000;
// 检查频率限制 - 使用配置中的冷却时间
const cooldownSeconds = config.SiteConfig.MovieRequestCooldown ?? 3600;
const rateLimit = cooldownSeconds * 1000;
const userInfo = await storage.getUserInfoV2(authInfo.username);
if (userInfo?.last_movie_request_time) {
const elapsed = Date.now() - userInfo.last_movie_request_time;
if (elapsed < rateLimit) {
@@ -163,12 +172,17 @@ export async function POST(request: NextRequest) {
await storage.createMovieRequest(newRequest);
await storage.addUserMovieRequest(authInfo.username, newRequest.id);
// 更新频率限制
await storage.setGlobalValue(
`user:${authInfo.username}:info:last_movie_request_time`,
// 更新频率限制 - 保存到用户信息的 hash 中
await storage.client.hSet(
`user:${authInfo.username}:info`,
'last_movie_request_time',
Date.now().toString()
);
// 清除用户信息缓存,确保下次读取到最新数据
const { userInfoCache } = await import('@/lib/user-cache');
userInfoCache?.delete(authInfo.username);
// 给站长发送通知
const ownerUsername = process.env.USERNAME;
if (ownerUsername) {

View File

@@ -82,6 +82,7 @@ export default async function RootLayout({
let aiEnablePlayPageEntry = false;
let aiDefaultMessageNoVideo = '';
let aiDefaultMessageWithVideo = '';
let enableMovieRequest = true;
let customCategories = [] as {
name: string;
type: 'movie' | 'tv';
@@ -124,6 +125,8 @@ export default async function RootLayout({
aiEnablePlayPageEntry = config.AIConfig?.EnablePlayPageEntry || false;
aiDefaultMessageNoVideo = config.AIConfig?.DefaultMessageNoVideo || '';
aiDefaultMessageWithVideo = config.AIConfig?.DefaultMessageWithVideo || '';
// 求片功能配置
enableMovieRequest = config.SiteConfig.EnableMovieRequest ?? true;
// 检查是否启用了 OpenList 功能
openListEnabled = !!(
config.OpenListConfig?.Enabled &&
@@ -178,7 +181,7 @@ export default async function RootLayout({
AI_ENABLE_PLAYPAGE_ENTRY: aiEnablePlayPageEntry,
AI_DEFAULT_MESSAGE_NO_VIDEO: aiDefaultMessageNoVideo,
AI_DEFAULT_MESSAGE_WITH_VIDEO: aiDefaultMessageWithVideo,
ENABLE_SOURCE_SEARCH: process.env.NEXT_PUBLIC_ENABLE_SOURCE_SEARCH !== 'false',
ENABLE_MOVIE_REQUEST: enableMovieRequest,
};
return (

View File

@@ -49,6 +49,15 @@ export default function MovieRequestPage() {
const [myRequests, setMyRequests] = useState<MovieRequest[]>([]);
const [loadingMyRequests, setLoadingMyRequests] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [isFeatureEnabled, setIsFeatureEnabled] = useState(true);
// 检查求片功能是否启用
useEffect(() => {
const runtimeConfig = (window as any).RUNTIME_CONFIG;
if (runtimeConfig && runtimeConfig.ENABLE_MOVIE_REQUEST === false) {
setIsFeatureEnabled(false);
}
}, []);
// TMDB搜索
const handleSearch = async () => {
@@ -199,32 +208,43 @@ export default function MovieRequestPage() {
</h1>
<p className='text-sm text-gray-500 dark:text-gray-400 mt-1'>
{isFeatureEnabled ? '搜索并提交您想看的影片' : '求片功能已关闭,仅可查看已求片列表'}
</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>
{/* 功能关闭提示 */}
{!isFeatureEnabled && (
<div className='mb-6 p-4 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg'>
<p className='text-sm text-yellow-800 dark:text-yellow-200'>
</p>
</div>
</div>
)}
{/* 搜索框 - 仅在功能启用时显示 */}
{isFeatureEnabled && (
<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 && (
@@ -308,10 +328,10 @@ export default function MovieRequestPage() {
</p>
<button
onClick={() => handleRequest(item)}
disabled={submitting}
disabled={submitting || !isFeatureEnabled}
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 ? '处理中...' : '求片'}
{submitting ? '处理中...' : !isFeatureEnabled ? '功能已关闭' : '求片'}
</button>
</div>
</div>

View File

@@ -457,13 +457,15 @@ export default function PrivateLibraryPage() {
</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>
{mounted && (
<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

@@ -40,6 +40,9 @@ export interface AdminConfig {
TurnstileSiteKey?: string; // Cloudflare Turnstile Site Key
TurnstileSecretKey?: string; // Cloudflare Turnstile Secret Key
DefaultUserTags?: string[]; // 新注册用户的默认用户组
// 求片功能配置
EnableMovieRequest?: boolean; // 启用求片功能
MovieRequestCooldown?: number; // 求片冷却时间默认3600
// OIDC配置
EnableOIDCLogin?: boolean; // 启用OIDC登录
EnableOIDCRegistration?: boolean; // 启用OIDC注册

View File

@@ -659,6 +659,7 @@ export abstract class BaseRedisStorage implements IStorage {
playrecord_migrated?: boolean;
favorite_migrated?: boolean;
skip_migrated?: boolean;
last_movie_request_time?: number;
} | null> {
const userInfo = await this.withRetry(() =>
this.client.hGetAll(this.userInfoKey(userName))
@@ -678,6 +679,7 @@ export abstract class BaseRedisStorage implements IStorage {
playrecord_migrated: userInfo.playrecord_migrated === 'true',
favorite_migrated: userInfo.favorite_migrated === 'true',
skip_migrated: userInfo.skip_migrated === 'true',
last_movie_request_time: userInfo.last_movie_request_time ? parseInt(userInfo.last_movie_request_time, 10) : undefined,
};
}

View File

@@ -65,7 +65,12 @@ export class UpstashRedisStorage implements IStorage {
this._client = getUpstashRedisClient();
// 创建兼容Redis API的client包装器支持camelCase和lowercase
this.client = {
hSet: (key: string, data: Record<string, any>) => this._client.hset(key, data),
hSet: (key: string, field: string | Record<string, any>, value?: string) => {
if (typeof field === 'string' && value !== undefined) {
return this._client.hset(key, { [field]: value });
}
return this._client.hset(key, field as Record<string, any>);
},
hset: (key: string, data: Record<string, any>) => this._client.hset(key, data),
zAdd: (key: string, member: { score: number; value: string }) => this._client.zadd(key, { score: member.score, member: member.value }),
zadd: (key: string, member: { score: number; value: string }) => this._client.zadd(key, { score: member.score, member: member.value }),
@@ -584,6 +589,7 @@ export class UpstashRedisStorage implements IStorage {
playrecord_migrated?: boolean;
favorite_migrated?: boolean;
skip_migrated?: boolean;
last_movie_request_time?: number;
} | null> {
// 先从缓存获取
const cached = userInfoCache?.get(userName);
@@ -677,6 +683,11 @@ export class UpstashRedisStorage implements IStorage {
playrecord_migrated,
favorite_migrated,
skip_migrated,
last_movie_request_time: userInfo.last_movie_request_time
? (typeof userInfo.last_movie_request_time === 'number'
? userInfo.last_movie_request_time
: parseInt(userInfo.last_movie_request_time as string, 10))
: undefined,
};
// 存入缓存