求片增加开关和冷却
This commit is contained in:
@@ -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'>
|
||||
用户两次求片之间的最小间隔时间,默认3600秒(1小时)
|
||||
</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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 / 小雅) */}
|
||||
|
||||
@@ -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注册
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
// 存入缓存
|
||||
|
||||
Reference in New Issue
Block a user