@@ -1026,11 +1228,12 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
{(() => {
// 检查是否有权限操作任何用户
- const hasAnyPermission = config?.UserConfig?.Users?.some(user =>
- (role === 'owner' ||
- (role === 'admin' &&
- (user.role === 'user' ||
- user.username === currentUsername)))
+ const hasAnyPermission = config?.UserConfig?.Users?.some(
+ (user) =>
+ role === 'owner' ||
+ (role === 'admin' &&
+ (user.role === 'user' ||
+ user.username === currentUsername))
);
return hasAnyPermission ? (
@@ -1038,7 +1241,7 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
type='checkbox'
checked={selectAllUsers}
onChange={(e) => handleSelectAllUsers(e.target.checked)}
- className='w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600'
+ className='w-4 h-4 text-theme-primary bg-gray-100 border-gray-300 rounded focus:ring-theme-primary dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600'
/>
) : (
@@ -1124,15 +1327,20 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
>
- {(role === 'owner' ||
- (role === 'admin' &&
- (user.role === 'user' ||
- user.username === currentUsername))) ? (
+ {role === 'owner' ||
+ (role === 'admin' &&
+ (user.role === 'user' ||
+ user.username === currentUsername)) ? (
handleSelectUser(user.username, e.target.checked)}
- className='w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600'
+ onChange={(e) =>
+ handleSelectUser(
+ user.username,
+ e.target.checked
+ )
+ }
+ className='w-4 h-4 text-theme-primary bg-gray-100 border-gray-300 rounded focus:ring-theme-primary dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600'
/>
) : (
@@ -1143,26 +1351,28 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
{user.role === 'owner'
? '站长'
: user.role === 'admin'
- ? '管理员'
- : '普通用户'}
+ ? '管理员'
+ : '普通用户'}
{!user.banned ? '正常' : '已封禁'}
@@ -1179,20 +1389,22 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
(role === 'admin' &&
(user.role === 'user' ||
user.username === currentUsername))) && (
- handleConfigureUserGroup(user)}
- className={buttonStyles.roundedPrimary}
- >
- 配置
-
- )}
+ handleConfigureUserGroup(user)}
+ className={buttonStyles.roundedPrimary}
+ >
+ 配置
+
+ )}
- {user.enabledApis && user.enabledApis.length > 0
- ? `${user.enabledApis.length} 个源`
+ {getEffectiveUserApiCount(user) !== null
+ ? `${getEffectiveUserApiCount(user)} 个源`
+ : user.tags && user.tags.length > 0
+ ? '用户组上限'
: '无限制'}
{/* 配置采集源权限按钮 */}
@@ -1200,13 +1412,13 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
(role === 'admin' &&
(user.role === 'user' ||
user.username === currentUsername))) && (
- handleConfigureUserApis(user)}
- className={buttonStyles.roundedPrimary}
- >
- 配置
-
- )}
+ handleConfigureUserApis(user)}
+ className={buttonStyles.roundedPrimary}
+ >
+ 配置
+
+ )}
@@ -1227,8 +1439,14 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
{user.role === 'user' && (
handleSetAdmin(user.username)}
- disabled={isLoading(`setAdmin_${user.username}`)}
- className={`${buttonStyles.roundedPurple} ${isLoading(`setAdmin_${user.username}`) ? 'opacity-50 cursor-not-allowed' : ''}`}
+ disabled={isLoading(
+ `setAdmin_${user.username}`
+ )}
+ className={`${buttonStyles.roundedPurple} ${
+ isLoading(`setAdmin_${user.username}`)
+ ? 'opacity-50 cursor-not-allowed'
+ : ''
+ }`}
>
设为管理
@@ -1238,8 +1456,16 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
onClick={() =>
handleRemoveAdmin(user.username)
}
- disabled={isLoading(`removeAdmin_${user.username}`)}
- className={`${buttonStyles.roundedSecondary} ${isLoading(`removeAdmin_${user.username}`) ? 'opacity-50 cursor-not-allowed' : ''}`}
+ disabled={isLoading(
+ `removeAdmin_${user.username}`
+ )}
+ className={`${
+ buttonStyles.roundedSecondary
+ } ${
+ isLoading(`removeAdmin_${user.username}`)
+ ? 'opacity-50 cursor-not-allowed'
+ : ''
+ }`}
>
取消管理
@@ -1248,8 +1474,14 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
(!user.banned ? (
handleBanUser(user.username)}
- disabled={isLoading(`banUser_${user.username}`)}
- className={`${buttonStyles.roundedDanger} ${isLoading(`banUser_${user.username}`) ? 'opacity-50 cursor-not-allowed' : ''}`}
+ disabled={isLoading(
+ `banUser_${user.username}`
+ )}
+ className={`${buttonStyles.roundedDanger} ${
+ isLoading(`banUser_${user.username}`)
+ ? 'opacity-50 cursor-not-allowed'
+ : ''
+ }`}
>
封禁
@@ -1258,8 +1490,16 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
onClick={() =>
handleUnbanUser(user.username)
}
- disabled={isLoading(`unbanUser_${user.username}`)}
- className={`${buttonStyles.roundedSuccess} ${isLoading(`unbanUser_${user.username}`) ? 'opacity-50 cursor-not-allowed' : ''}`}
+ disabled={isLoading(
+ `unbanUser_${user.username}`
+ )}
+ className={`${
+ buttonStyles.roundedSuccess
+ } ${
+ isLoading(`unbanUser_${user.username}`)
+ ? 'opacity-50 cursor-not-allowed'
+ : ''
+ }`}
>
解封
@@ -1287,202 +1527,100 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
{/* 配置用户采集源权限弹窗 */}
- {showConfigureApisModal && selectedUser && createPortal(
- {
- setShowConfigureApisModal(false);
- setSelectedUser(null);
- setSelectedApis([]);
- }}>
-
e.stopPropagation()}>
-
-
-
- 配置用户采集源权限 - {selectedUser.username}
-
-
{
- setShowConfigureApisModal(false);
- setSelectedUser(null);
- setSelectedApis([]);
- }}
- className='text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors'
- >
-
-
-
-
-
-
-
-
-
-
- 提示:全不选为无限制,选中的采集源将限制用户只能访问这些源
-
-
-
-
- {/* 采集源选择 - 多列布局 */}
-
-
- 选择可用的采集源:
-
-
- {config?.SourceConfig?.map((source) => (
-
- {
- if (e.target.checked) {
- setSelectedApis([...selectedApis, source.key]);
- } else {
- setSelectedApis(selectedApis.filter(api => api !== source.key));
- }
- }}
- className='rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700'
- />
-
-
- {source.name}
-
- {source.api && (
-
- {extractDomain(source.api)}
-
- )}
-
-
- ))}
-
-
-
- {/* 快速操作按钮 */}
-
-
-
setSelectedApis([])}
- className={buttonStyles.quickAction}
- >
- 全不选(无限制)
-
+ {showConfigureApisModal &&
+ selectedUser &&
+ createPortal(
+
{
+ setShowConfigureApisModal(false);
+ setSelectedUser(null);
+ setSelectedApis([]);
+ }}
+ >
+
e.stopPropagation()}
+ >
+
+
+
+ 配置用户采集源权限 - {selectedUser.username}
+
{
- const allApis = config?.SourceConfig?.filter(source => !source.disabled).map(s => s.key) || [];
- setSelectedApis(allApis);
+ setShowConfigureApisModal(false);
+ setSelectedUser(null);
+ setSelectedApis([]);
}}
- className={buttonStyles.quickAction}
+ className='text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors'
>
- 全选
+
+
+
-
- 已选择:
- {selectedApis.length > 0 ? `${selectedApis.length} 个源` : '无限制'}
-
-
-
-
- {/* 操作按钮 */}
-
- {
- setShowConfigureApisModal(false);
- setSelectedUser(null);
- setSelectedApis([]);
- }}
- className={`px-6 py-2.5 text-sm font-medium ${buttonStyles.secondary}`}
- >
- 取消
-
-
- {isLoading(`saveUserApis_${selectedUser?.username}`) ? '配置中...' : '确认配置'}
-
-
-
-
-
,
- document.body
- )}
-
- {/* 添加用户组弹窗 */}
- {showAddUserGroupForm && createPortal(
-
{
- setShowAddUserGroupForm(false);
- setNewUserGroup({ name: '', enabledApis: [] });
- }}>
-
e.stopPropagation()}>
-
-
-
- 添加新用户组
-
-
{
- setShowAddUserGroupForm(false);
- setNewUserGroup({ name: '', enabledApis: [] });
- }}
- className='text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors'
- >
-
-
-
-
-
-
- {/* 用户组名称 */}
-
-
- 用户组名称
-
-
- setNewUserGroup((prev) => ({ ...prev, name: e.target.value }))
- }
- className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent'
- />
+
+
+
+
+ 提示:用户组是采集源上限;未选择个人采集源时,不额外收窄用户组权限
+
+
- {/* 可用视频源 */}
-
-
- 可用视频源
-
-
- {config?.SourceConfig?.map((source) => (
-
+ {/* 采集源选择 - 多列布局 */}
+
+
+ 选择可用的采集源:
+
+
+ {getSelectableSourcesForUser(selectedUser).map((source) => (
+
{
if (e.target.checked) {
- setNewUserGroup(prev => ({
- ...prev,
- enabledApis: [...prev.enabledApis, source.key]
- }));
+ setSelectedApis([...selectedApis, source.key]);
} else {
- setNewUserGroup(prev => ({
- ...prev,
- enabledApis: prev.enabledApis.filter(api => api !== source.key)
- }));
+ setSelectedApis(
+ selectedApis.filter((api) => api !== source.key)
+ );
}
}}
- className='rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700'
+ className='rounded border-gray-300 text-theme-primary focus:ring-theme-primary dark:border-gray-600 dark:bg-gray-700'
/>
@@ -1497,512 +1635,998 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
))}
+
- {/* 快速操作按钮 */}
-
+ {/* 快速操作按钮 */}
+
+
setNewUserGroup(prev => ({ ...prev, enabledApis: [] }))}
+ onClick={() => setSelectedApis([])}
className={buttonStyles.quickAction}
>
- 全不选(无限制)
+ 清除个人限制
{
- const allApis = config?.SourceConfig?.filter(source => !source.disabled).map(s => s.key) || [];
- setNewUserGroup(prev => ({ ...prev, enabledApis: allApis }));
+ const allApis = getSelectableSourcesForUser(
+ selectedUser
+ )
+ .filter((source) => !source.disabled)
+ .map((s) => s.key);
+ setSelectedApis(allApis);
}}
className={buttonStyles.quickAction}
>
全选
+
+ 已选择:
+
+ {selectedApis.length > 0
+ ? `${selectedApis.length} 个源`
+ : selectedUser?.tags && selectedUser.tags.length > 0
+ ? getSelectableSourcesForUser(selectedUser).length > 0
+ ? '使用用户组全部源'
+ : '用户组未允许任何源'
+ : '无限制'}
+
+
{/* 操作按钮 */}
-
+
{
- setShowAddUserGroupForm(false);
- setNewUserGroup({ name: '', enabledApis: [] });
+ setShowConfigureApisModal(false);
+ setSelectedUser(null);
+ setSelectedApis([]);
}}
className={`px-6 py-2.5 text-sm font-medium ${buttonStyles.secondary}`}
>
取消
- {isLoading('userGroup_add_new') ? '添加中...' : '添加用户组'}
+ {isLoading(`saveUserApis_${selectedUser?.username}`)
+ ? '配置中...'
+ : '确认配置'}
-
-
,
- document.body
- )}
+ ,
+ document.body
+ )}
- {/* 编辑用户组弹窗 */}
- {showEditUserGroupForm && editingUserGroup && createPortal(
-
{
- setShowEditUserGroupForm(false);
- setEditingUserGroup(null);
- }}>
-
e.stopPropagation()}>
-
-
-
- 编辑用户组 - {editingUserGroup.name}
-
-
{
- setShowEditUserGroupForm(false);
- setEditingUserGroup(null);
- }}
- className='text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors'
- >
-
-
-
-
-
+ {/* 添加用户组弹窗 */}
+ {showAddUserGroupForm &&
+ createPortal(
+
{
+ setShowAddUserGroupForm(false);
+ setNewUserGroup({
+ name: '',
+ enabledApis: [],
+ safeSearchEnabled: false,
+ });
+ }}
+ >
+
e.stopPropagation()}
+ >
+
+
+
+ 添加新用户组
+
+
{
+ setShowAddUserGroupForm(false);
+ setNewUserGroup({
+ name: '',
+ enabledApis: [],
+ safeSearchEnabled: false,
+ });
+ }}
+ className='text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors'
+ >
+
+
+
+
+
-
- {/* 可用视频源 */}
-
-
- 可用视频源
-
-
- {config?.SourceConfig?.map((source) => (
-
- {
- if (e.target.checked) {
- setEditingUserGroup(prev => prev ? {
- ...prev,
- enabledApis: [...prev.enabledApis, source.key]
- } : null);
- } else {
- setEditingUserGroup(prev => prev ? {
- ...prev,
- enabledApis: prev.enabledApis.filter(api => api !== source.key)
- } : null);
- }
- }}
- className='rounded border-gray-300 text-purple-600 focus:ring-purple-500 dark:border-gray-600 dark:bg-gray-700'
- />
-
-
- {source.name}
-
- {source.api && (
-
- {extractDomain(source.api)}
+
+ {/* 用户组名称 */}
+
+
+ 用户组名称
+
+
+ setNewUserGroup((prev) => ({
+ ...prev,
+ name: e.target.value,
+ }))
+ }
+ className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-theme-primary focus:border-transparent'
+ />
+
+
+ {/* 可用视频源 */}
+
+
+ 可用视频源
+
+
+ {config?.SourceConfig?.map((source) => (
+
+ {
+ if (e.target.checked) {
+ setNewUserGroup((prev) => ({
+ ...prev,
+ enabledApis: [
+ ...prev.enabledApis,
+ source.key,
+ ],
+ }));
+ } else {
+ setNewUserGroup((prev) => ({
+ ...prev,
+ enabledApis: prev.enabledApis.filter(
+ (api) => api !== source.key
+ ),
+ }));
+ }
+ }}
+ className='rounded border-gray-300 text-theme-primary focus:ring-theme-primary dark:border-gray-600 dark:bg-gray-700'
+ />
+
-
- ))}
+ {source.api && (
+
+ {extractDomain(source.api)}
+
+ )}
+
+
+ ))}
+
+
+ {/* 快速操作按钮 */}
+
+
+ setNewUserGroup((prev) => ({
+ ...prev,
+ enabledApis: [],
+ }))
+ }
+ className={buttonStyles.quickAction}
+ >
+ 全不选(不允许任何源)
+
+ {
+ const allApis =
+ config?.SourceConfig?.filter(
+ (source) => !source.disabled
+ ).map((s) => s.key) || [];
+ setNewUserGroup((prev) => ({
+ ...prev,
+ enabledApis: allApis,
+ }));
+ }}
+ className={buttonStyles.quickAction}
+ >
+ 全选
+
+
- {/* 快速操作按钮 */}
-
+
+
+ setNewUserGroup((prev) => ({
+ ...prev,
+ safeSearchEnabled: event.target.checked,
+ }))
+ }
+ className='mt-0.5 rounded border-gray-300 text-theme-primary focus:ring-theme-primary dark:border-gray-600 dark:bg-gray-700'
+ />
+
+
+ 启用影视库安全搜索
+
+
+ 优先使用豆瓣影视库名称;豆瓣无结果时由 TMDB
+ 排除成人内容后再过滤播放源。
+
+
+
+
+
setEditingUserGroup(prev => prev ? { ...prev, enabledApis: [] } : null)}
- className={buttonStyles.quickAction}
+ onClick={() => {
+ setShowAddUserGroupForm(false);
+ setNewUserGroup({
+ name: '',
+ enabledApis: [],
+ safeSearchEnabled: false,
+ });
+ }}
+ className={`px-6 py-2.5 text-sm font-medium ${buttonStyles.secondary}`}
>
- 全不选(无限制)
+ 取消
{
- const allApis = config?.SourceConfig?.filter(source => !source.disabled).map(s => s.key) || [];
- setEditingUserGroup(prev => prev ? { ...prev, enabledApis: allApis } : null);
- }}
- className={buttonStyles.quickAction}
+ onClick={handleAddUserGroup}
+ disabled={
+ !newUserGroup.name.trim() ||
+ isLoading('userGroup_add_new')
+ }
+ className={`px-6 py-2.5 text-sm font-medium ${
+ !newUserGroup.name.trim() ||
+ isLoading('userGroup_add_new')
+ ? buttonStyles.disabled
+ : buttonStyles.primary
+ }`}
>
- 全选
+ {isLoading('userGroup_add_new')
+ ? '添加中...'
+ : '添加用户组'}
+
+
+ ,
+ document.body
+ )}
- {/* 操作按钮 */}
-
+ {/* 编辑用户组弹窗 */}
+ {showEditUserGroupForm &&
+ editingUserGroup &&
+ createPortal(
+
{
+ setShowEditUserGroupForm(false);
+ setEditingUserGroup(null);
+ }}
+ >
+
e.stopPropagation()}
+ >
+
+
+
+ 编辑用户组 - {editingUserGroup.name}
+
{
setShowEditUserGroupForm(false);
setEditingUserGroup(null);
}}
- className={`px-6 py-2.5 text-sm font-medium ${buttonStyles.secondary}`}
+ className='text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors'
>
- 取消
-
-
- {isLoading(`userGroup_edit_${editingUserGroup?.name}`) ? '保存中...' : '保存修改'}
+
+
+
-
-
-
-
,
- document.body
- )}
- {/* 配置用户组弹窗 */}
- {showConfigureUserGroupModal && selectedUserForGroup && createPortal(
-
{
- setShowConfigureUserGroupModal(false);
- setSelectedUserForGroup(null);
- setSelectedUserGroups([]);
- }}>
-
e.stopPropagation()}>
-
-
-
- 配置用户组 - {selectedUserForGroup.username}
-
-
{
- setShowConfigureUserGroupModal(false);
- setSelectedUserForGroup(null);
- setSelectedUserGroups([]);
- }}
- className='text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors'
- >
-
-
-
-
-
+
+ {/* 可用视频源 */}
+
+
+ 可用视频源
+
+
+ {config?.SourceConfig?.map((source) => (
+
+ {
+ if (e.target.checked) {
+ setEditingUserGroup((prev) =>
+ prev
+ ? {
+ ...prev,
+ enabledApis: [
+ ...prev.enabledApis,
+ source.key,
+ ],
+ }
+ : null
+ );
+ } else {
+ setEditingUserGroup((prev) =>
+ prev
+ ? {
+ ...prev,
+ enabledApis: prev.enabledApis.filter(
+ (api) => api !== source.key
+ ),
+ }
+ : null
+ );
+ }
+ }}
+ className='rounded border-gray-300 text-purple-600 focus:ring-purple-500 dark:border-gray-600 dark:bg-gray-700'
+ />
+
+
+ {source.name}
+
+ {source.api && (
+
+ {extractDomain(source.api)}
+
+ )}
+
+
+ ))}
+
-
-
-
-
-
-
-
- 配置说明
+ {/* 快速操作按钮 */}
+
+
+ setEditingUserGroup((prev) =>
+ prev ? { ...prev, enabledApis: [] } : null
+ )
+ }
+ className={buttonStyles.quickAction}
+ >
+ 全不选(不允许任何源)
+
+ {
+ const allApis =
+ config?.SourceConfig?.filter(
+ (source) => !source.disabled
+ ).map((s) => s.key) || [];
+ setEditingUserGroup((prev) =>
+ prev ? { ...prev, enabledApis: allApis } : null
+ );
+ }}
+ className={buttonStyles.quickAction}
+ >
+ 全选
+
+
+
+
+
+
+ setEditingUserGroup((prev) =>
+ prev
+ ? {
+ ...prev,
+ safeSearchEnabled: event.target.checked,
+ }
+ : null
+ )
+ }
+ className='mt-0.5 rounded border-gray-300 text-purple-600 focus:ring-purple-500 dark:border-gray-600 dark:bg-gray-700'
+ />
+
+
+ 启用影视库安全搜索
+
+
+ 优先使用豆瓣影视库名称;豆瓣无结果时由 TMDB
+ 排除成人内容后再过滤播放源。
+
+
+
+
+ {
+ setShowEditUserGroupForm(false);
+ setEditingUserGroup(null);
+ }}
+ className={`px-6 py-2.5 text-sm font-medium ${buttonStyles.secondary}`}
+ >
+ 取消
+
+
+ {isLoading(`userGroup_edit_${editingUserGroup?.name}`)
+ ? '保存中...'
+ : '保存修改'}
+
-
- 提示:选择"无用户组"为无限制,选择特定用户组将限制用户只能访问该用户组允许的采集源
-
+
+
,
+ document.body
+ )}
- {/* 用户组选择 - 下拉选择器 */}
-
-
- 选择用户组:
-
-
0 ? selectedUserGroups[0] : ''}
- onChange={(e) => {
- const value = e.target.value;
- setSelectedUserGroups(value ? [value] : []);
- }}
- className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors'
- >
- 无用户组(无限制)
- {userGroups.map((group) => (
-
- {group.name} {group.enabledApis && group.enabledApis.length > 0 ? `(${group.enabledApis.length} 个源)` : ''}
-
- ))}
-
-
- 选择"无用户组"为无限制,选择特定用户组将限制用户只能访问该用户组允许的采集源
-
-
+ {/* 配置用户组弹窗 */}
+ {showConfigureUserGroupModal &&
+ selectedUserForGroup &&
+ createPortal(
+
{
+ setShowConfigureUserGroupModal(false);
+ setSelectedUserForGroup(null);
+ setSelectedUserGroups([]);
+ }}
+ >
+
e.stopPropagation()}
+ >
+
+
+
+ 配置用户组 - {selectedUserForGroup.username}
+
+
{
+ setShowConfigureUserGroupModal(false);
+ setSelectedUserForGroup(null);
+ setSelectedUserGroups([]);
+ }}
+ className='text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors'
+ >
+
+
+
+
+
+
+
+
+
+ 提示:选择"无用户组"为无限制,选择特定用户组将限制用户只能访问该用户组允许的采集源
+
+
+
+ {/* 用户组选择 - 下拉选择器 */}
+
+
+ 选择用户组:
+
+
0 ? selectedUserGroups[0] : ''
+ }
+ onChange={(e) => {
+ const value = e.target.value;
+ setSelectedUserGroups(value ? [value] : []);
+ }}
+ className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-theme-primary focus:border-transparent transition-colors'
+ >
+ 无用户组(无限制)
+ {userGroups.map((group) => (
+
+ {group.name}{' '}
+ {group.enabledApis && group.enabledApis.length > 0
+ ? `(${group.enabledApis.length} 个源)`
+ : '(0 个源)'}
+
+ ))}
+
+
+ 选择"无用户组"为无限制,选择特定用户组将限制用户只能访问该用户组允许的采集源
+
+
- {/* 操作按钮 */}
-
-
{
- setShowConfigureUserGroupModal(false);
- setSelectedUserForGroup(null);
- setSelectedUserGroups([]);
- }}
- className={`px-6 py-2.5 text-sm font-medium ${buttonStyles.secondary}`}
- >
- 取消
-
-
- {isLoading(`saveUserGroups_${selectedUserForGroup?.username}`) ? '配置中...' : '确认配置'}
-
+ {/* 操作按钮 */}
+
+ {
+ setShowConfigureUserGroupModal(false);
+ setSelectedUserForGroup(null);
+ setSelectedUserGroups([]);
+ }}
+ className={`px-6 py-2.5 text-sm font-medium ${buttonStyles.secondary}`}
+ >
+ 取消
+
+
+ {isLoading(
+ `saveUserGroups_${selectedUserForGroup?.username}`
+ )
+ ? '配置中...'
+ : '确认配置'}
+
+
-
-
,
- document.body
- )}
+
,
+ document.body
+ )}
{/* 删除用户组确认弹窗 */}
- {showDeleteUserGroupModal && deletingUserGroup && createPortal(
-
{
- setShowDeleteUserGroupModal(false);
- setDeletingUserGroup(null);
- }}>
-
e.stopPropagation()}>
-
-
-
- 确认删除用户组
-
-
{
- setShowDeleteUserGroupModal(false);
- setDeletingUserGroup(null);
- }}
- className='text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors'
- >
-
-
-
-
-
-
-
-
-
-
-
+ {showDeleteUserGroupModal &&
+ deletingUserGroup &&
+ createPortal(
+ {
+ setShowDeleteUserGroupModal(false);
+ setDeletingUserGroup(null);
+ }}
+ >
+
e.stopPropagation()}
+ >
+
+
+
+ 确认删除用户组
+
+
{
+ setShowDeleteUserGroupModal(false);
+ setDeletingUserGroup(null);
+ }}
+ className='text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors'
+ >
+
+
-
- 危险操作警告
-
-
-
- 删除用户组 {deletingUserGroup.name} 将影响所有使用该组的用户,此操作不可恢复!
-
+
- {deletingUserGroup.affectedUsers.length > 0 ? (
-
+
+
-
-
+
+
-
- ⚠️ 将影响 {deletingUserGroup.affectedUsers.length} 个用户:
+
+ 危险操作警告
-
- {deletingUserGroup.affectedUsers.map((user, index) => (
-
- • {user.username} ({user.role})
-
- ))}
-
-
- 这些用户的用户组将被自动移除
+
+ 删除用户组 {deletingUserGroup.name} {' '}
+ 将影响所有使用该组的用户,此操作不可恢复!
- ) : (
-
-
-
-
-
-
- ✅ 当前没有用户使用此用户组
-
-
-
- )}
-
-
- {/* 操作按钮 */}
-
- {
- setShowDeleteUserGroupModal(false);
- setDeletingUserGroup(null);
- }}
- className={`px-6 py-2.5 text-sm font-medium ${buttonStyles.secondary}`}
- >
- 取消
-
-
- {isLoading(`userGroup_delete_${deletingUserGroup?.name}`) ? '删除中...' : '确认删除'}
-
-
-
-
-
,
- document.body
- )}
- {/* 删除用户确认弹窗 */}
- {showDeleteUserModal && deletingUser && createPortal(
- {
- setShowDeleteUserModal(false);
- setDeletingUser(null);
- }}>
-
e.stopPropagation()}>
-
-
-
- 确认删除用户
-
-
{
- setShowDeleteUserModal(false);
- setDeletingUser(null);
- }}
- className='text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors'
- >
-
-
-
-
-
-
-
-
-
-
- 删除用户 {deletingUser} 将同时删除其搜索历史、播放记录和收藏夹,此操作不可恢复!
-
+ {deletingUserGroup.affectedUsers.length > 0 ? (
+
+
+
+
+
+
+ ⚠️ 将影响 {deletingUserGroup.affectedUsers.length}{' '}
+ 个用户:
+
+
+
+ {deletingUserGroup.affectedUsers.map((user, index) => (
+
+ • {user.username} ({user.role})
+
+ ))}
+
+
+ 这些用户的用户组将被自动移除
+
+
+ ) : (
+
+
+
+
+
+
+ ✅ 当前没有用户使用此用户组
+
+
+
+ )}
{/* 操作按钮 */}
{
- setShowDeleteUserModal(false);
- setDeletingUser(null);
+ setShowDeleteUserGroupModal(false);
+ setDeletingUserGroup(null);
}}
className={`px-6 py-2.5 text-sm font-medium ${buttonStyles.secondary}`}
>
取消
- 确认删除
+ {isLoading(`userGroup_delete_${deletingUserGroup?.name}`)
+ ? '删除中...'
+ : '确认删除'}
-
-
,
- document.body
- )}
+ ,
+ document.body
+ )}
- {/* 批量设置用户组弹窗 */}
- {showBatchUserGroupModal && createPortal(
-
{
- setShowBatchUserGroupModal(false);
- setSelectedUserGroup('');
- }}>
-
e.stopPropagation()}>
-
-
-
- 批量设置用户组
-
-
{
- setShowBatchUserGroupModal(false);
- setSelectedUserGroup('');
- }}
- className='text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors'
- >
-
-
-
-
+ {/* 删除用户确认弹窗 */}
+ {showDeleteUserModal &&
+ deletingUser &&
+ createPortal(
+
{
+ setShowDeleteUserModal(false);
+ setDeletingUser(null);
+ }}
+ >
+
e.stopPropagation()}
+ >
+
+
+
+ 确认删除用户
+
+
{
+ setShowDeleteUserModal(false);
+ setDeletingUser(null);
+ }}
+ className='text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors'
+ >
+
+
+
+
+
+
+
+
+
+
+ 删除用户 {deletingUser} {' '}
+ 将同时删除其搜索历史、播放记录和收藏夹,此操作不可恢复!
+
+
+
+ {/* 操作按钮 */}
+
+ {
+ setShowDeleteUserModal(false);
+ setDeletingUser(null);
+ }}
+ className={`px-6 py-2.5 text-sm font-medium ${buttonStyles.secondary}`}
+ >
+ 取消
+
+
+ 确认删除
+
+
+
+
+
,
+ document.body
+ )}
-
-
-
-
-
+ {/* 批量设置用户组弹窗 */}
+ {showBatchUserGroupModal &&
+ createPortal(
+ {
+ setShowBatchUserGroupModal(false);
+ setSelectedUserGroup('');
+ }}
+ >
+
e.stopPropagation()}
+ >
+
+
+
+ 批量设置用户组
+
+
{
+ setShowBatchUserGroupModal(false);
+ setSelectedUserGroup('');
+ }}
+ className='text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors'
+ >
+
+
-
- 批量操作说明
-
+
+
+
+
+
+
+
+ 将为选中的 {selectedUsers.size} 个用户 {' '}
+ 设置用户组,选择"无用户组"为无限制
+
+
+
+
+
+ 选择用户组:
+
+
setSelectedUserGroup(e.target.value)}
+ className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-theme-primary focus:border-transparent transition-colors'
+ value={selectedUserGroup}
+ >
+ 无用户组(无限制)
+ {userGroups.map((group) => (
+
+ {group.name}{' '}
+ {group.enabledApis && group.enabledApis.length > 0
+ ? `(${group.enabledApis.length} 个源)`
+ : '(0 个源)'}
+
+ ))}
+
+
+ 选择"无用户组"为无限制,选择特定用户组将限制用户只能访问该用户组允许的采集源
+
-
- 将为选中的 {selectedUsers.size} 个用户 设置用户组,选择"无用户组"为无限制
-
-
-
- 选择用户组:
-
-
setSelectedUserGroup(e.target.value)}
- className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors'
- value={selectedUserGroup}
+ {/* 操作按钮 */}
+
+
{
+ setShowBatchUserGroupModal(false);
+ setSelectedUserGroup('');
+ }}
+ className={`px-6 py-2.5 text-sm font-medium ${buttonStyles.secondary}`}
>
- 无用户组(无限制)
- {userGroups.map((group) => (
-
- {group.name} {group.enabledApis && group.enabledApis.length > 0 ? `(${group.enabledApis.length} 个源)` : ''}
-
- ))}
-
-
- 选择"无用户组"为无限制,选择特定用户组将限制用户只能访问该用户组允许的采集源
-
+ 取消
+
+
handleBatchSetUserGroup(selectedUserGroup)}
+ disabled={isLoading('batchSetUserGroup')}
+ className={`px-6 py-2.5 text-sm font-medium ${
+ isLoading('batchSetUserGroup')
+ ? buttonStyles.disabled
+ : buttonStyles.primary
+ }`}
+ >
+ {isLoading('batchSetUserGroup') ? '设置中...' : '确认设置'}
+
-
- {/* 操作按钮 */}
-
- {
- setShowBatchUserGroupModal(false);
- setSelectedUserGroup('');
- }}
- className={`px-6 py-2.5 text-sm font-medium ${buttonStyles.secondary}`}
- >
- 取消
-
- handleBatchSetUserGroup(selectedUserGroup)}
- disabled={isLoading('batchSetUserGroup')}
- className={`px-6 py-2.5 text-sm font-medium ${isLoading('batchSetUserGroup') ? buttonStyles.disabled : buttonStyles.primary}`}
- >
- {isLoading('batchSetUserGroup') ? '设置中...' : '确认设置'}
-
-
-
-
,
- document.body
- )}
+ ,
+ document.body
+ )}
{/* 通用弹窗组件 */}
{
timer={alertModal.timer}
showConfirm={alertModal.showConfirm}
/>
-
-
);
-}
+};
// 视频源配置组件
const VideoSourceConfig = ({
@@ -2043,7 +2665,9 @@ const VideoSourceConfig = ({
});
// 批量操作相关状态
- const [selectedSources, setSelectedSources] = useState
>(new Set());
+ const [selectedSources, setSelectedSources] = useState>(
+ new Set()
+ );
// 使用 useMemo 计算全选状态,避免每次渲染都重新计算
const selectAll = useMemo(() => {
@@ -2061,21 +2685,23 @@ const VideoSourceConfig = ({
isOpen: false,
title: '',
message: '',
- onConfirm: () => { },
- onCancel: () => { }
+ onConfirm: () => {},
+ onCancel: () => {},
});
// 有效性检测相关状态
const [showValidationModal, setShowValidationModal] = useState(false);
const [searchKeyword, setSearchKeyword] = useState('');
const [isValidating, setIsValidating] = useState(false);
- const [validationResults, setValidationResults] = useState>([]);
+ const [validationResults, setValidationResults] = useState<
+ Array<{
+ key: string;
+ name: string;
+ status: 'valid' | 'no_results' | 'invalid' | 'validating';
+ message: string;
+ resultCount: number;
+ }>
+ >([]);
// dnd-kit 传感器
const sensors = useSensors(
@@ -2129,13 +2755,17 @@ const VideoSourceConfig = ({
const target = sources.find((s) => s.key === key);
if (!target) return;
const action = target.disabled ? 'enable' : 'disable';
- withLoading(`toggleSource_${key}`, () => callSourceApi({ action, key })).catch(() => {
+ withLoading(`toggleSource_${key}`, () =>
+ callSourceApi({ action, key })
+ ).catch(() => {
console.error('操作失败', action, key);
});
};
const handleDelete = (key: string) => {
- withLoading(`deleteSource_${key}`, () => callSourceApi({ action: 'delete', key })).catch(() => {
+ withLoading(`deleteSource_${key}`, () =>
+ callSourceApi({ action: 'delete', key })
+ ).catch(() => {
console.error('操作失败', 'delete', key);
});
};
@@ -2175,7 +2805,9 @@ const VideoSourceConfig = ({
const handleSaveOrder = () => {
const order = sources.map((s) => s.key);
- withLoading('saveSourceOrder', () => callSourceApi({ action: 'sort', order }))
+ withLoading('saveSourceOrder', () =>
+ callSourceApi({ action: 'sort', order })
+ )
.then(() => {
setOrderChanged(false);
})
@@ -2187,7 +2819,11 @@ const VideoSourceConfig = ({
// 有效性检测函数
const handleValidateSources = async () => {
if (!searchKeyword.trim()) {
- showAlert({ type: 'warning', title: '请输入搜索关键词', message: '搜索关键词不能为空' });
+ showAlert({
+ type: 'warning',
+ title: '请输入搜索关键词',
+ message: '搜索关键词不能为空',
+ });
return;
}
@@ -2197,18 +2833,22 @@ const VideoSourceConfig = ({
setShowValidationModal(false); // 立即关闭弹窗
// 初始化所有视频源为检测中状态
- const initialResults = sources.map(source => ({
+ const initialResults = sources.map((source) => ({
key: source.key,
name: source.name,
status: 'validating' as const,
message: '检测中...',
- resultCount: 0
+ resultCount: 0,
}));
setValidationResults(initialResults);
try {
// 使用EventSource接收流式数据
- const eventSource = new EventSource(`/api/admin/source/validate?q=${encodeURIComponent(searchKeyword.trim())}`);
+ const eventSource = new EventSource(
+ `/api/admin/source/validate?q=${encodeURIComponent(
+ searchKeyword.trim()
+ )}`
+ );
eventSource.onmessage = (event) => {
try {
@@ -2222,32 +2862,53 @@ const VideoSourceConfig = ({
case 'source_result':
case 'source_error':
// 更新验证结果
- setValidationResults(prev => {
- const existing = prev.find(r => r.key === data.source);
+ setValidationResults((prev) => {
+ const existing = prev.find((r) => r.key === data.source);
if (existing) {
- return prev.map(r => r.key === data.source ? {
- key: data.source,
- name: sources.find(s => s.key === data.source)?.name || data.source,
- status: data.status,
- message: data.status === 'valid' ? '搜索正常' :
- data.status === 'no_results' ? '无法搜索到结果' : '连接失败',
- resultCount: data.status === 'valid' ? 1 : 0
- } : r);
+ return prev.map((r) =>
+ r.key === data.source
+ ? {
+ key: data.source,
+ name:
+ sources.find((s) => s.key === data.source)
+ ?.name || data.source,
+ status: data.status,
+ message:
+ data.status === 'valid'
+ ? '搜索正常'
+ : data.status === 'no_results'
+ ? '无法搜索到结果'
+ : '连接失败',
+ resultCount: data.status === 'valid' ? 1 : 0,
+ }
+ : r
+ );
} else {
- return [...prev, {
- key: data.source,
- name: sources.find(s => s.key === data.source)?.name || data.source,
- status: data.status,
- message: data.status === 'valid' ? '搜索正常' :
- data.status === 'no_results' ? '无法搜索到结果' : '连接失败',
- resultCount: data.status === 'valid' ? 1 : 0
- }];
+ return [
+ ...prev,
+ {
+ key: data.source,
+ name:
+ sources.find((s) => s.key === data.source)?.name ||
+ data.source,
+ status: data.status,
+ message:
+ data.status === 'valid'
+ ? '搜索正常'
+ : data.status === 'no_results'
+ ? '无法搜索到结果'
+ : '连接失败',
+ resultCount: data.status === 'valid' ? 1 : 0,
+ },
+ ];
}
});
break;
case 'complete':
- console.log(`检测完成,共检测 ${data.completedSources} 个视频源`);
+ console.log(
+ `检测完成,共检测 ${data.completedSources} 个视频源`
+ );
eventSource.close();
setIsValidating(false);
break;
@@ -2261,7 +2922,11 @@ const VideoSourceConfig = ({
console.error('EventSource错误:', error);
eventSource.close();
setIsValidating(false);
- showAlert({ type: 'error', title: '验证失败', message: '连接错误,请重试' });
+ showAlert({
+ type: 'error',
+ title: '验证失败',
+ message: '连接错误,请重试',
+ });
};
// 设置超时,防止长时间等待
@@ -2269,13 +2934,20 @@ const VideoSourceConfig = ({
if (eventSource.readyState === EventSource.OPEN) {
eventSource.close();
setIsValidating(false);
- showAlert({ type: 'warning', title: '验证超时', message: '检测超时,请重试' });
+ showAlert({
+ type: 'warning',
+ title: '验证超时',
+ message: '检测超时,请重试',
+ });
}
}, 60000); // 60秒超时
-
} catch (error) {
setIsValidating(false);
- showAlert({ type: 'error', title: '验证失败', message: error instanceof Error ? error.message : '未知错误' });
+ showAlert({
+ type: 'error',
+ title: '验证失败',
+ message: error instanceof Error ? error.message : '未知错误',
+ });
throw error;
}
});
@@ -2283,37 +2955,41 @@ const VideoSourceConfig = ({
// 获取有效性状态显示
const getValidationStatus = (sourceKey: string) => {
- const result = validationResults.find(r => r.key === sourceKey);
+ const result = validationResults.find((r) => r.key === sourceKey);
if (!result) return null;
switch (result.status) {
case 'validating':
return {
text: '检测中',
- className: 'bg-blue-100 dark:bg-blue-900/20 text-blue-800 dark:text-blue-300',
+ className:
+ 'bg-blue-100 dark:bg-blue-900/20 text-blue-800 dark:text-blue-300',
icon: '⟳',
- message: result.message
+ message: result.message,
};
case 'valid':
return {
text: '有效',
- className: 'bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300',
+ className:
+ 'bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300',
icon: '✓',
- message: result.message
+ message: result.message,
};
case 'no_results':
return {
text: '无法搜索',
- className: 'bg-yellow-100 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-300',
+ className:
+ 'bg-yellow-100 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-300',
icon: '⚠',
- message: result.message
+ message: result.message,
};
case 'invalid':
return {
text: '无效',
- className: 'bg-red-100 dark:bg-red-900/20 text-red-800 dark:text-red-300',
+ className:
+ 'bg-red-100 dark:bg-red-900/20 text-red-800 dark:text-red-300',
icon: '✗',
- message: result.message
+ message: result.message,
};
default:
return null;
@@ -2349,7 +3025,7 @@ const VideoSourceConfig = ({
type='checkbox'
checked={selectedSources.has(source.key)}
onChange={(e) => handleSelectSource(source.key, e.target.checked)}
- className='w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600'
+ className='w-4 h-4 text-theme-primary bg-gray-100 border-gray-300 rounded focus:ring-theme-primary dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600'
/>
@@ -2372,10 +3048,11 @@ const VideoSourceConfig = ({
{!source.disabled ? '启用中' : '已禁用'}
@@ -2391,7 +3068,10 @@ const VideoSourceConfig = ({
);
}
return (
-
+
{status.icon} {status.text}
);
@@ -2401,10 +3081,15 @@ const VideoSourceConfig = ({
handleToggleEnable(source.key)}
disabled={isLoading(`toggleSource_${source.key}`)}
- className={`inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium ${!source.disabled
- ? buttonStyles.roundedDanger
- : buttonStyles.roundedSuccess
- } transition-colors ${isLoading(`toggleSource_${source.key}`) ? 'opacity-50 cursor-not-allowed' : ''}`}
+ className={`inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium ${
+ !source.disabled
+ ? buttonStyles.roundedDanger
+ : buttonStyles.roundedSuccess
+ } transition-colors ${
+ isLoading(`toggleSource_${source.key}`)
+ ? 'opacity-50 cursor-not-allowed'
+ : ''
+ }`}
>
{!source.disabled ? '禁用' : '启用'}
@@ -2412,7 +3097,11 @@ const VideoSourceConfig = ({
handleDelete(source.key)}
disabled={isLoading(`deleteSource_${source.key}`)}
- className={`${buttonStyles.roundedSecondary} ${isLoading(`deleteSource_${source.key}`) ? 'opacity-50 cursor-not-allowed' : ''}`}
+ className={`${buttonStyles.roundedSecondary} ${
+ isLoading(`deleteSource_${source.key}`)
+ ? 'opacity-50 cursor-not-allowed'
+ : ''
+ }`}
>
删除
@@ -2423,18 +3112,21 @@ const VideoSourceConfig = ({
};
// 全选/取消全选
- const handleSelectAll = useCallback((checked: boolean) => {
- if (checked) {
- const allKeys = sources.map(s => s.key);
- setSelectedSources(new Set(allKeys));
- } else {
- setSelectedSources(new Set());
- }
- }, [sources]);
+ const handleSelectAll = useCallback(
+ (checked: boolean) => {
+ if (checked) {
+ const allKeys = sources.map((s) => s.key);
+ setSelectedSources(new Set(allKeys));
+ } else {
+ setSelectedSources(new Set());
+ }
+ },
+ [sources]
+ );
// 单个选择
const handleSelectSource = useCallback((key: string, checked: boolean) => {
- setSelectedSources(prev => {
+ setSelectedSources((prev) => {
const newSelected = new Set(prev);
if (checked) {
newSelected.add(key);
@@ -2446,9 +3138,15 @@ const VideoSourceConfig = ({
}, []);
// 批量操作
- const handleBatchOperation = async (action: 'batch_enable' | 'batch_disable' | 'batch_delete') => {
+ const handleBatchOperation = async (
+ action: 'batch_enable' | 'batch_disable' | 'batch_delete'
+ ) => {
if (selectedSources.size === 0) {
- showAlert({ type: 'warning', title: '请先选择要操作的视频源', message: '请选择至少一个视频源' });
+ showAlert({
+ type: 'warning',
+ title: '请先选择要操作的视频源',
+ message: '请选择至少一个视频源',
+ });
return;
}
@@ -2478,18 +3176,41 @@ const VideoSourceConfig = ({
message: confirmMessage,
onConfirm: async () => {
try {
- await withLoading(`batchSource_${action}`, () => callSourceApi({ action, keys }));
- showAlert({ type: 'success', title: `${actionName}成功`, message: `${actionName}了 ${keys.length} 个视频源`, timer: 2000 });
+ await withLoading(`batchSource_${action}`, () =>
+ callSourceApi({ action, keys })
+ );
+ showAlert({
+ type: 'success',
+ title: `${actionName}成功`,
+ message: `${actionName}了 ${keys.length} 个视频源`,
+ timer: 2000,
+ });
// 重置选择状态
setSelectedSources(new Set());
} catch (err) {
- showAlert({ type: 'error', title: `${actionName}失败`, message: err instanceof Error ? err.message : '操作失败' });
+ showAlert({
+ type: 'error',
+ title: `${actionName}失败`,
+ message: err instanceof Error ? err.message : '操作失败',
+ });
}
- setConfirmModal({ isOpen: false, title: '', message: '', onConfirm: () => { }, onCancel: () => { } });
+ setConfirmModal({
+ isOpen: false,
+ title: '',
+ message: '',
+ onConfirm: () => {},
+ onCancel: () => {},
+ });
+ },
+ onCancel: () => {
+ setConfirmModal({
+ isOpen: false,
+ title: '',
+ message: '',
+ onConfirm: () => {},
+ onCancel: () => {},
+ });
},
- onCancel: () => {
- setConfirmModal({ isOpen: false, title: '', message: '', onConfirm: () => { }, onCancel: () => { } });
- }
});
};
@@ -2515,28 +3236,48 @@ const VideoSourceConfig = ({
已选 {selectedSources.size}
- 已选择 {selectedSources.size} 个视频源
+
+ 已选择 {selectedSources.size} 个视频源
+
handleBatchOperation('batch_enable')}
disabled={isLoading('batchSource_batch_enable')}
- className={`px-3 py-1 text-sm ${isLoading('batchSource_batch_enable') ? buttonStyles.disabled : buttonStyles.success}`}
+ className={`px-3 py-1 text-sm ${
+ isLoading('batchSource_batch_enable')
+ ? buttonStyles.disabled
+ : buttonStyles.success
+ }`}
>
- {isLoading('batchSource_batch_enable') ? '启用中...' : '批量启用'}
+ {isLoading('batchSource_batch_enable')
+ ? '启用中...'
+ : '批量启用'}
handleBatchOperation('batch_disable')}
disabled={isLoading('batchSource_batch_disable')}
- className={`px-3 py-1 text-sm ${isLoading('batchSource_batch_disable') ? buttonStyles.disabled : buttonStyles.warning}`}
+ className={`px-3 py-1 text-sm ${
+ isLoading('batchSource_batch_disable')
+ ? buttonStyles.disabled
+ : buttonStyles.warning
+ }`}
>
- {isLoading('batchSource_batch_disable') ? '禁用中...' : '批量禁用'}
+ {isLoading('batchSource_batch_disable')
+ ? '禁用中...'
+ : '批量禁用'}
handleBatchOperation('batch_delete')}
disabled={isLoading('batchSource_batch_delete')}
- className={`px-3 py-1 text-sm ${isLoading('batchSource_batch_delete') ? buttonStyles.disabled : buttonStyles.danger}`}
+ className={`px-3 py-1 text-sm ${
+ isLoading('batchSource_batch_delete')
+ ? buttonStyles.disabled
+ : buttonStyles.danger
+ }`}
>
- {isLoading('batchSource_batch_delete') ? '删除中...' : '批量删除'}
+ {isLoading('batchSource_batch_delete')
+ ? '删除中...'
+ : '批量删除'}
@@ -2546,10 +3287,9 @@ const VideoSourceConfig = ({
setShowValidationModal(true)}
disabled={isValidating}
- className={`px-3 py-1 text-sm rounded-lg transition-colors flex items-center space-x-1 ${isValidating
- ? buttonStyles.disabled
- : buttonStyles.primary
- }`}
+ className={`px-3 py-1 text-sm rounded-lg transition-colors flex items-center space-x-1 ${
+ isValidating ? buttonStyles.disabled : buttonStyles.primary
+ }`}
>
{isValidating ? (
<>
@@ -2562,7 +3302,9 @@ const VideoSourceConfig = ({
setShowAddForm(!showAddForm)}
- className={showAddForm ? buttonStyles.secondary : buttonStyles.success}
+ className={
+ showAddForm ? buttonStyles.secondary : buttonStyles.success
+ }
>
{showAddForm ? '取消' : '添加视频源'}
@@ -2613,8 +3355,20 @@ const VideoSourceConfig = ({
{isLoading('addSource') ? '添加中...' : '添加'}
@@ -2622,10 +3376,11 @@ const VideoSourceConfig = ({
)}
-
-
{/* 视频源表格 */}
-
+
@@ -2635,7 +3390,7 @@ const VideoSourceConfig = ({
type='checkbox'
checked={selectAll}
onChange={(e) => handleSelectAll(e.target.checked)}
- className='w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600'
+ className='w-4 h-4 text-theme-primary bg-gray-100 border-gray-300 rounded focus:ring-theme-primary dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600'
/>
@@ -2688,7 +3443,11 @@ const VideoSourceConfig = ({
{isLoading('saveSourceOrder') ? '保存中...' : '保存排序'}
@@ -2696,44 +3455,57 @@ const VideoSourceConfig = ({
)}
{/* 有效性检测弹窗 */}
- {showValidationModal && createPortal(
- setShowValidationModal(false)}>
-
e.stopPropagation()}>
-
- 视频源有效性检测
-
-
- 请输入检测用的搜索关键词
-
-
-
setSearchKeyword(e.target.value)}
- className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100'
- onKeyPress={(e) => e.key === 'Enter' && handleValidateSources()}
- />
-
-
setShowValidationModal(false)}
- className='px-4 py-2 text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 transition-colors'
- >
- 取消
-
-
- 开始检测
-
+ {showValidationModal &&
+ createPortal(
+
setShowValidationModal(false)}
+ >
+
e.stopPropagation()}
+ >
+
+ 视频源有效性检测
+
+
+ 请输入检测用的搜索关键词
+
+
+
setSearchKeyword(e.target.value)}
+ className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100'
+ onKeyPress={(e) =>
+ e.key === 'Enter' && handleValidateSources()
+ }
+ />
+
+ setShowValidationModal(false)}
+ className='px-4 py-2 text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 transition-colors'
+ >
+ 取消
+
+
+ 开始检测
+
+
-
-
,
- document.body
- )}
+
,
+ document.body
+ )}
{/* 通用弹窗组件 */}
{/* 批量操作确认弹窗 */}
- {confirmModal.isOpen && createPortal(
-
-
e.stopPropagation()}>
-
-
-
- {confirmModal.title}
-
-
-
-
-
-
-
+ {confirmModal.isOpen &&
+ createPortal(
+
+
e.stopPropagation()}
+ >
+
+
+
+ {confirmModal.title}
+
+
+
+
+
+
+
-
-
- {confirmModal.message}
-
-
+
+
+ {confirmModal.message}
+
+
- {/* 操作按钮 */}
-
-
- 取消
-
-
- {isLoading('batchSource_batch_enable') || isLoading('batchSource_batch_disable') || isLoading('batchSource_batch_delete') ? '操作中...' : '确认'}
-
+ {/* 操作按钮 */}
+
+
+ 取消
+
+
+ {isLoading('batchSource_batch_enable') ||
+ isLoading('batchSource_batch_disable') ||
+ isLoading('batchSource_batch_delete')
+ ? '操作中...'
+ : '确认'}
+
+
-
-
,
- document.body
- )}
+
,
+ document.body
+ )}
);
};
@@ -2867,13 +3670,17 @@ const CategoryConfig = ({
const target = categories.find((c) => c.query === query && c.type === type);
if (!target) return;
const action = target.disabled ? 'enable' : 'disable';
- withLoading(`toggleCategory_${query}_${type}`, () => callCategoryApi({ action, query, type })).catch(() => {
+ withLoading(`toggleCategory_${query}_${type}`, () =>
+ callCategoryApi({ action, query, type })
+ ).catch(() => {
console.error('操作失败', action, query, type);
});
};
const handleDelete = (query: string, type: 'movie' | 'tv') => {
- withLoading(`deleteCategory_${query}_${type}`, () => callCategoryApi({ action: 'delete', query, type })).catch(() => {
+ withLoading(`deleteCategory_${query}_${type}`, () =>
+ callCategoryApi({ action: 'delete', query, type })
+ ).catch(() => {
console.error('操作失败', 'delete', query, type);
});
};
@@ -2915,7 +3722,9 @@ const CategoryConfig = ({
const handleSaveOrder = () => {
const order = categories.map((c) => `${c.query}:${c.type}`);
- withLoading('saveCategoryOrder', () => callCategoryApi({ action: 'sort', order }))
+ withLoading('saveCategoryOrder', () =>
+ callCategoryApi({ action: 'sort', order })
+ )
.then(() => {
setOrderChanged(false);
})
@@ -2941,7 +3750,7 @@ const CategoryConfig = ({
className='hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors select-none'
>
@@ -2952,10 +3761,11 @@ const CategoryConfig = ({
{category.type === 'movie' ? '电影' : '电视剧'}
@@ -2968,32 +3778,44 @@ const CategoryConfig = ({
{!category.disabled ? '启用中' : '已禁用'}
- handleToggleEnable(category.query, category.type)
- }
- disabled={isLoading(`toggleCategory_${category.query}_${category.type}`)}
- className={`inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium ${!category.disabled
- ? buttonStyles.roundedDanger
- : buttonStyles.roundedSuccess
- } transition-colors ${isLoading(`toggleCategory_${category.query}_${category.type}`) ? 'opacity-50 cursor-not-allowed' : ''}`}
+ onClick={() => handleToggleEnable(category.query, category.type)}
+ disabled={isLoading(
+ `toggleCategory_${category.query}_${category.type}`
+ )}
+ className={`inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium ${
+ !category.disabled
+ ? buttonStyles.roundedDanger
+ : buttonStyles.roundedSuccess
+ } transition-colors ${
+ isLoading(`toggleCategory_${category.query}_${category.type}`)
+ ? 'opacity-50 cursor-not-allowed'
+ : ''
+ }`}
>
{!category.disabled ? '禁用' : '启用'}
{category.from !== 'config' && (
handleDelete(category.query, category.type)}
- disabled={isLoading(`deleteCategory_${category.query}_${category.type}`)}
- className={`${buttonStyles.roundedSecondary} ${isLoading(`deleteCategory_${category.query}_${category.type}`) ? 'opacity-50 cursor-not-allowed' : ''}`}
+ disabled={isLoading(
+ `deleteCategory_${category.query}_${category.type}`
+ )}
+ className={`${buttonStyles.roundedSecondary} ${
+ isLoading(`deleteCategory_${category.query}_${category.type}`)
+ ? 'opacity-50 cursor-not-allowed'
+ : ''
+ }`}
>
删除
@@ -3020,7 +3842,9 @@ const CategoryConfig = ({
setShowAddForm(!showAddForm)}
- className={`px-3 py-1 text-sm rounded-lg transition-colors ${showAddForm ? buttonStyles.secondary : buttonStyles.success}`}
+ className={`px-3 py-1 text-sm rounded-lg transition-colors ${
+ showAddForm ? buttonStyles.secondary : buttonStyles.success
+ }`}
>
{showAddForm ? '取消' : '添加分类'}
@@ -3064,8 +3888,18 @@ const CategoryConfig = ({
{isLoading('addCategory') ? '添加中...' : '添加'}
@@ -3126,7 +3960,11 @@ const CategoryConfig = ({
{isLoading('saveCategoryOrder') ? '保存中...' : '保存排序'}
@@ -3148,7 +3986,13 @@ const CategoryConfig = ({
};
// 新增配置文件组件
-const ConfigFileComponent = ({ config, refreshConfig }: { config: AdminConfig | null; refreshConfig: () => Promise
}) => {
+const ConfigFileComponent = ({
+ config,
+ refreshConfig,
+}: {
+ config: AdminConfig | null;
+ refreshConfig: () => Promise;
+}) => {
const { alertModal, showAlert, hideAlert } = useAlertModal();
const { isLoading, withLoading } = useLoadingState();
const [configContent, setConfigContent] = useState('');
@@ -3156,8 +4000,6 @@ const ConfigFileComponent = ({ config, refreshConfig }: { config: AdminConfig |
const [autoUpdate, setAutoUpdate] = useState(false);
const [lastCheckTime, setLastCheckTime] = useState('');
-
-
useEffect(() => {
if (config?.ConfigFile) {
setConfigContent(config.ConfigFile);
@@ -3169,8 +4011,6 @@ const ConfigFileComponent = ({ config, refreshConfig }: { config: AdminConfig |
}
}, [config]);
-
-
// 拉取订阅配置
const handleFetchConfig = async () => {
if (!subscriptionUrl.trim()) {
@@ -3219,7 +4059,7 @@ const ConfigFileComponent = ({ config, refreshConfig }: { config: AdminConfig |
configFile: configContent,
subscriptionUrl,
autoUpdate,
- lastCheckTime: lastCheckTime || new Date().toISOString()
+ lastCheckTime: lastCheckTime || new Date().toISOString(),
}),
});
@@ -3237,8 +4077,6 @@ const ConfigFileComponent = ({ config, refreshConfig }: { config: AdminConfig |
});
};
-
-
if (!config) {
return (
@@ -3256,7 +4094,10 @@ const ConfigFileComponent = ({ config, refreshConfig }: { config: AdminConfig |
配置订阅
- 最后更新: {lastCheckTime ? new Date(lastCheckTime).toLocaleString('zh-CN') : '从未更新'}
+ 最后更新:{' '}
+ {lastCheckTime
+ ? new Date(lastCheckTime).toLocaleString('zh-CN')
+ : '从未更新'}
@@ -3272,7 +4113,7 @@ const ConfigFileComponent = ({ config, refreshConfig }: { config: AdminConfig |
onChange={(e) => setSubscriptionUrl(e.target.value)}
placeholder='https://example.com/config.json'
disabled={false}
- className='w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent transition-all duration-200 shadow-sm hover:border-gray-400 dark:hover:border-gray-500'
+ className='w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-theme-primary focus:border-transparent transition-all duration-200 shadow-sm hover:border-gray-400 dark:hover:border-gray-500'
/>
输入配置文件的订阅地址,要求 JSON 格式,且使用 Base58 编码
@@ -3284,10 +4125,11 @@ const ConfigFileComponent = ({ config, refreshConfig }: { config: AdminConfig |
{isLoading('fetchConfig') ? (
@@ -3314,16 +4156,18 @@ const ConfigFileComponent = ({ config, refreshConfig }: { config: AdminConfig |
type='button'
onClick={() => setAutoUpdate(!autoUpdate)}
disabled={false}
- className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 ${autoUpdate
- ? buttonStyles.toggleOn
- : buttonStyles.toggleOff
- }`}
+ className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-theme-primary focus:ring-offset-2 ${
+ autoUpdate ? buttonStyles.toggleOn : buttonStyles.toggleOff
+ }`}
>
@@ -3339,9 +4183,10 @@ const ConfigFileComponent = ({ config, refreshConfig }: { config: AdminConfig |
rows={20}
placeholder='请输入配置文件内容(JSON 格式)...'
disabled={false}
- className='w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 font-mono text-sm leading-relaxed resize-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-200 hover:border-gray-400 dark:hover:border-gray-500'
+ className='w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 font-mono text-sm leading-relaxed resize-none focus:ring-2 focus:ring-theme-primary focus:border-transparent transition-all duration-200 hover:border-gray-400 dark:hover:border-gray-500'
style={{
- fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", Menlo, monospace'
+ fontFamily:
+ 'ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", Menlo, monospace',
}}
spellCheck={false}
data-gramm={false}
@@ -3355,10 +4200,11 @@ const ConfigFileComponent = ({ config, refreshConfig }: { config: AdminConfig |
{isLoading('saveConfig') ? '保存中…' : '保存'}
@@ -3380,7 +4226,13 @@ const ConfigFileComponent = ({ config, refreshConfig }: { config: AdminConfig |
};
// 新增站点配置组件
-const SiteConfigComponent = ({ config, refreshConfig }: { config: AdminConfig | null; refreshConfig: () => Promise }) => {
+const SiteConfigComponent = ({
+ config,
+ refreshConfig,
+}: {
+ config: AdminConfig | null;
+ refreshConfig: () => Promise;
+}) => {
const { alertModal, showAlert, hideAlert } = useAlertModal();
const { isLoading, withLoading } = useLoadingState();
const [siteSettings, setSiteSettings] = useState({
@@ -3392,7 +4244,6 @@ const SiteConfigComponent = ({ config, refreshConfig }: { config: AdminConfig |
DoubanProxy: '',
DoubanImageProxyType: 'cmliussss-cdn-tencent',
DoubanImageProxy: '',
- DisableYellowFilter: false,
FluidSearch: true,
EnableWebLive: false,
});
@@ -3448,14 +4299,15 @@ const SiteConfigComponent = ({ config, refreshConfig }: { config: AdminConfig |
if (config?.SiteConfig) {
setSiteSettings({
...config.SiteConfig,
- DoubanProxyType: config.SiteConfig.DoubanProxyType || 'cmliussss-cdn-tencent',
+ DoubanProxyType:
+ config.SiteConfig.DoubanProxyType || 'cmliussss-cdn-tencent',
DoubanProxy: config.SiteConfig.DoubanProxy || '',
DoubanImageProxyType:
- (config.SiteConfig.DoubanImageProxyType === 'direct' || config.SiteConfig.DoubanImageProxyType === 'img3')
+ config.SiteConfig.DoubanImageProxyType === 'direct' ||
+ config.SiteConfig.DoubanImageProxyType === 'img3'
? 'server'
- : (config.SiteConfig.DoubanImageProxyType || 'cmliussss-cdn-tencent'),
+ : config.SiteConfig.DoubanImageProxyType || 'cmliussss-cdn-tencent',
DoubanImageProxy: config.SiteConfig.DoubanImageProxy || '',
- DisableYellowFilter: config.SiteConfig.DisableYellowFilter || false,
FluidSearch: config.SiteConfig.FluidSearch || true,
EnableWebLive: config.SiteConfig.EnableWebLive ?? false,
});
@@ -3549,9 +4401,7 @@ const SiteConfigComponent = ({ config, refreshConfig }: { config: AdminConfig |
{/* 站点名称 */}
-
+
站点名称
setSiteSettings((prev) => ({ ...prev, SiteName: e.target.value }))
}
- className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent"
+ className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-theme-primary focus:border-transparent'
/>
{/* 站点公告 */}
-
+
站点公告
{/* 豆瓣数据源设置 */}
-
+
豆瓣数据代理
@@ -3597,7 +4443,7 @@ const SiteConfigComponent = ({ config, refreshConfig }: { config: AdminConfig |
setIsDoubanDropdownOpen(!isDoubanDropdownOpen)}
- className="w-full px-3 py-2.5 pr-10 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-all duration-200 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm hover:border-gray-400 dark:hover:border-gray-500 text-left"
+ className='w-full px-3 py-2.5 pr-10 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-theme-primary focus:border-theme-primary transition-all duration-200 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm hover:border-gray-400 dark:hover:border-gray-500 text-left'
>
{
doubanDataSourceOptions.find(
@@ -3609,8 +4455,9 @@ const SiteConfigComponent = ({ config, refreshConfig }: { config: AdminConfig |
{/* 下拉箭头 */}
@@ -3625,14 +4472,15 @@ const SiteConfigComponent = ({ config, refreshConfig }: { config: AdminConfig |
handleDoubanDataSourceChange(option.value);
setIsDoubanDropdownOpen(false);
}}
- className={`w-full px-3 py-2.5 text-left text-sm transition-colors duration-150 flex items-center justify-between hover:bg-gray-100 dark:hover:bg-gray-700 ${siteSettings.DoubanProxyType === option.value
- ? 'bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400'
- : 'text-gray-900 dark:text-gray-100'
- }`}
+ className={`w-full px-3 py-2.5 text-left text-sm transition-colors duration-150 flex items-center justify-between hover:bg-gray-100 dark:hover:bg-gray-700 ${
+ siteSettings.DoubanProxyType === option.value
+ ? 'bg-theme-primary-soft/70 text-theme-primary'
+ : 'text-gray-900 dark:text-gray-100'
+ }`}
>
{option.label}
{siteSettings.DoubanProxyType === option.value && (
-
+
)}
))}
@@ -3668,9 +4516,7 @@ const SiteConfigComponent = ({ config, refreshConfig }: { config: AdminConfig |
{/* 豆瓣代理地址设置 - 仅在选择自定义代理时显示 */}
{siteSettings.DoubanProxyType === 'custom' && (
-
+
豆瓣代理地址
自定义代理服务器地址
@@ -3695,9 +4541,7 @@ const SiteConfigComponent = ({ config, refreshConfig }: { config: AdminConfig |
{/* 豆瓣图片代理设置 */}
-
+
豆瓣图片代理
@@ -3709,7 +4553,7 @@ const SiteConfigComponent = ({ config, refreshConfig }: { config: AdminConfig |
!isDoubanImageProxyDropdownOpen
)
}
- className="w-full px-3 py-2.5 pr-10 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-all duration-200 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm hover:border-gray-400 dark:hover:border-gray-500 text-left"
+ className='w-full px-3 py-2.5 pr-10 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-theme-primary focus:border-theme-primary transition-all duration-200 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm hover:border-gray-400 dark:hover:border-gray-500 text-left'
>
{
doubanImageProxyTypeOptions.find(
@@ -3721,8 +4565,9 @@ const SiteConfigComponent = ({ config, refreshConfig }: { config: AdminConfig |
{/* 下拉箭头 */}
@@ -3737,14 +4582,15 @@ const SiteConfigComponent = ({ config, refreshConfig }: { config: AdminConfig |
handleDoubanImageProxyChange(option.value);
setIsDoubanImageProxyDropdownOpen(false);
}}
- className={`w-full px-3 py-2.5 text-left text-sm transition-colors duration-150 flex items-center justify-between hover:bg-gray-100 dark:hover:bg-gray-700 ${siteSettings.DoubanImageProxyType === option.value
- ? 'bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400'
- : 'text-gray-900 dark:text-gray-100'
- }`}
+ className={`w-full px-3 py-2.5 text-left text-sm transition-colors duration-150 flex items-center justify-between hover:bg-gray-100 dark:hover:bg-gray-700 ${
+ siteSettings.DoubanImageProxyType === option.value
+ ? 'bg-theme-primary-soft/70 text-theme-primary'
+ : 'text-gray-900 dark:text-gray-100'
+ }`}
>
{option.label}
{siteSettings.DoubanImageProxyType === option.value && (
-
+
)}
))}
@@ -3780,9 +4626,7 @@ const SiteConfigComponent = ({ config, refreshConfig }: { config: AdminConfig |
{/* 豆瓣代理地址设置 - 仅在选择自定义代理时显示 */}
{siteSettings.DoubanImageProxyType === 'custom' && (
@@ -3838,50 +4682,14 @@ const SiteConfigComponent = ({ config, refreshConfig }: { config: AdminConfig |
SiteInterfaceCacheTime: Number(e.target.value),
}))
}
- className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent'
+ className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-theme-primary focus:border-transparent'
/>
- {/* 禁用黄色过滤器 */}
-
-
-
- 禁用黄色过滤器
-
-
- setSiteSettings((prev) => ({
- ...prev,
- DisableYellowFilter: !prev.DisableYellowFilter,
- }))
- }
- className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 ${siteSettings.DisableYellowFilter
- ? buttonStyles.toggleOn
- : buttonStyles.toggleOff
- }`}
- >
-
-
-
-
- 禁用黄色内容的过滤功能,允许显示所有内容。
-
-
-
{/* 流式搜索 */}
-
+
启用流式搜索
@@ -3913,9 +4725,7 @@ const SiteConfigComponent = ({ config, refreshConfig }: { config: AdminConfig |
{/* 启用网页直播 */}
-
+
启用网页直播
@@ -3944,16 +4758,16 @@ const SiteConfigComponent = ({ config, refreshConfig }: { config: AdminConfig |
-
{/* 操作按钮 */}
{isLoading('saveSiteConfig') ? '保存中…' : '保存'}
@@ -3985,7 +4799,8 @@ const LiveSourceConfig = ({
const { isLoading, withLoading } = useLoadingState();
const [liveSources, setLiveSources] = useState([]);
const [showAddForm, setShowAddForm] = useState(false);
- const [editingLiveSource, setEditingLiveSource] = useState(null);
+ const [editingLiveSource, setEditingLiveSource] =
+ useState(null);
const [orderChanged, setOrderChanged] = useState(false);
const [isRefreshing, setIsRefreshing] = useState(false);
const [newLiveSource, setNewLiveSource] = useState({
@@ -4048,13 +4863,17 @@ const LiveSourceConfig = ({
const target = liveSources.find((s) => s.key === key);
if (!target) return;
const action = target.disabled ? 'enable' : 'disable';
- withLoading(`toggleLiveSource_${key}`, () => callLiveSourceApi({ action, key })).catch(() => {
+ withLoading(`toggleLiveSource_${key}`, () =>
+ callLiveSourceApi({ action, key })
+ ).catch(() => {
console.error('操作失败', action, key);
});
};
const handleDelete = (key: string) => {
- withLoading(`deleteLiveSource_${key}`, () => callLiveSourceApi({ action: 'delete', key })).catch(() => {
+ withLoading(`deleteLiveSource_${key}`, () =>
+ callLiveSourceApi({ action: 'delete', key })
+ ).catch(() => {
console.error('操作失败', 'delete', key);
});
};
@@ -4078,7 +4897,12 @@ const LiveSourceConfig = ({
// 刷新成功后重新获取配置
await refreshConfig();
- showAlert({ type: 'success', title: '刷新成功', message: '直播源已刷新', timer: 2000 });
+ showAlert({
+ type: 'success',
+ title: '刷新成功',
+ message: '直播源已刷新',
+ timer: 2000,
+ });
} catch (err) {
showError(err instanceof Error ? err.message : '刷新失败', showAlert);
throw err;
@@ -4115,7 +4939,8 @@ const LiveSourceConfig = ({
};
const handleEditLiveSource = () => {
- if (!editingLiveSource || !editingLiveSource.name || !editingLiveSource.url) return;
+ if (!editingLiveSource || !editingLiveSource.name || !editingLiveSource.url)
+ return;
withLoading('editLiveSource', async () => {
await callLiveSourceApi({
action: 'edit',
@@ -4146,7 +4971,9 @@ const LiveSourceConfig = ({
const handleSaveOrder = () => {
const order = liveSources.map((s) => s.key);
- withLoading('saveLiveSourceOrder', () => callLiveSourceApi({ action: 'sort', order }))
+ withLoading('saveLiveSourceOrder', () =>
+ callLiveSourceApi({ action: 'sort', order })
+ )
.then(() => {
setOrderChanged(false);
})
@@ -4204,14 +5031,17 @@ const LiveSourceConfig = ({
{liveSource.ua || '-'}
- {liveSource.channelNumber && liveSource.channelNumber > 0 ? liveSource.channelNumber : '-'}
+ {liveSource.channelNumber && liveSource.channelNumber > 0
+ ? liveSource.channelNumber
+ : '-'}
{!liveSource.disabled ? '启用中' : '已禁用'}
@@ -4220,10 +5050,15 @@ const LiveSourceConfig = ({
handleToggleEnable(liveSource.key)}
disabled={isLoading(`toggleLiveSource_${liveSource.key}`)}
- className={`inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium ${!liveSource.disabled
- ? buttonStyles.roundedDanger
- : buttonStyles.roundedSuccess
- } transition-colors ${isLoading(`toggleLiveSource_${liveSource.key}`) ? 'opacity-50 cursor-not-allowed' : ''}`}
+ className={`inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium ${
+ !liveSource.disabled
+ ? buttonStyles.roundedDanger
+ : buttonStyles.roundedSuccess
+ } transition-colors ${
+ isLoading(`toggleLiveSource_${liveSource.key}`)
+ ? 'opacity-50 cursor-not-allowed'
+ : ''
+ }`}
>
{!liveSource.disabled ? '禁用' : '启用'}
@@ -4232,14 +5067,22 @@ const LiveSourceConfig = ({
setEditingLiveSource(liveSource)}
disabled={isLoading(`editLiveSource_${liveSource.key}`)}
- className={`${buttonStyles.roundedPrimary} ${isLoading(`editLiveSource_${liveSource.key}`) ? 'opacity-50 cursor-not-allowed' : ''}`}
+ className={`${buttonStyles.roundedPrimary} ${
+ isLoading(`editLiveSource_${liveSource.key}`)
+ ? 'opacity-50 cursor-not-allowed'
+ : ''
+ }`}
>
编辑
handleDelete(liveSource.key)}
disabled={isLoading(`deleteLiveSource_${liveSource.key}`)}
- className={`${buttonStyles.roundedSecondary} ${isLoading(`deleteLiveSource_${liveSource.key}`) ? 'opacity-50 cursor-not-allowed' : ''}`}
+ className={`${buttonStyles.roundedSecondary} ${
+ isLoading(`deleteLiveSource_${liveSource.key}`)
+ ? 'opacity-50 cursor-not-allowed'
+ : ''
+ }`}
>
删除
@@ -4269,16 +5112,23 @@ const LiveSourceConfig = ({
- {isRefreshing || isLoading('refreshLiveSources') ? '刷新中...' : '刷新直播源'}
+
+ {isRefreshing || isLoading('refreshLiveSources')
+ ? '刷新中...'
+ : '刷新直播源'}
+
setShowAddForm(!showAddForm)}
- className={showAddForm ? buttonStyles.secondary : buttonStyles.success}
+ className={
+ showAddForm ? buttonStyles.secondary : buttonStyles.success
+ }
>
{showAddForm ? '取消' : '添加直播源'}
@@ -4333,13 +5183,24 @@ const LiveSourceConfig = ({
}
className='px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100'
/>
-
{isLoading('addLiveSource') ? '添加中...' : '添加'}
@@ -4370,7 +5231,9 @@ const LiveSourceConfig = ({
type='text'
value={editingLiveSource.name}
onChange={(e) =>
- setEditingLiveSource((prev) => prev ? ({ ...prev, name: e.target.value }) : null)
+ setEditingLiveSource((prev) =>
+ prev ? { ...prev, name: e.target.value } : null
+ )
}
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100'
/>
@@ -4394,7 +5257,9 @@ const LiveSourceConfig = ({
type='text'
value={editingLiveSource.url}
onChange={(e) =>
- setEditingLiveSource((prev) => prev ? ({ ...prev, url: e.target.value }) : null)
+ setEditingLiveSource((prev) =>
+ prev ? { ...prev, url: e.target.value } : null
+ )
}
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100'
/>
@@ -4407,7 +5272,9 @@ const LiveSourceConfig = ({
type='text'
value={editingLiveSource.epg}
onChange={(e) =>
- setEditingLiveSource((prev) => prev ? ({ ...prev, epg: e.target.value }) : null)
+ setEditingLiveSource((prev) =>
+ prev ? { ...prev, epg: e.target.value } : null
+ )
}
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100'
/>
@@ -4420,7 +5287,9 @@ const LiveSourceConfig = ({
type='text'
value={editingLiveSource.ua}
onChange={(e) =>
- setEditingLiveSource((prev) => prev ? ({ ...prev, ua: e.target.value }) : null)
+ setEditingLiveSource((prev) =>
+ prev ? { ...prev, ua: e.target.value } : null
+ )
}
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100'
/>
@@ -4435,8 +5304,18 @@ const LiveSourceConfig = ({
{isLoading('editLiveSource') ? '保存中...' : '保存'}
@@ -4445,7 +5324,10 @@ const LiveSourceConfig = ({
)}
{/* 直播源表格 */}
-
+
@@ -4503,7 +5385,11 @@ const LiveSourceConfig = ({
{isLoading('saveLiveSourceOrder') ? '保存中...' : '保存排序'}
@@ -4520,8 +5406,6 @@ const LiveSourceConfig = ({
timer={alertModal.timer}
showConfirm={alertModal.showConfirm}
/>
-
-
);
};
@@ -4667,7 +5551,10 @@ function AdminPageClient() {
isExpanded={expandedTabs.configFile}
onToggle={() => toggleTab('configFile')}
>
-
+
)}
@@ -4774,61 +5661,92 @@ function AdminPageClient() {
/>
{/* 重置配置确认弹窗 */}
- {showResetConfigModal && createPortal(
- setShowResetConfigModal(false)}>
-
e.stopPropagation()}>
-
-
-
- 确认重置配置
-
-
setShowResetConfigModal(false)}
- className='text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors'
- >
-
-
-
-
-
-
-
-
-
-
-
+ {showResetConfigModal &&
+ createPortal(
+ setShowResetConfigModal(false)}
+ >
+
e.stopPropagation()}
+ >
+
+
+
+ 确认重置配置
+
+
setShowResetConfigModal(false)}
+ className='text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors'
+ >
+
+
-
- ⚠️ 危险操作警告
-
+
+
+
+
+
+
+
+ 此操作将重置用户封禁和管理员设置、自定义视频源,站点配置将重置为默认值,是否继续?
+
-
- 此操作将重置用户封禁和管理员设置、自定义视频源,站点配置将重置为默认值,是否继续?
-
-
- {/* 操作按钮 */}
-
-
setShowResetConfigModal(false)}
- className={`px-6 py-2.5 text-sm font-medium ${buttonStyles.secondary}`}
- >
- 取消
-
-
- {isLoading('resetConfig') ? '重置中...' : '确认重置'}
-
+ {/* 操作按钮 */}
+
+ setShowResetConfigModal(false)}
+ className={`px-6 py-2.5 text-sm font-medium ${buttonStyles.secondary}`}
+ >
+ 取消
+
+
+ {isLoading('resetConfig') ? '重置中...' : '确认重置'}
+
+
-
- ,
- document.body
- )}
+
,
+ document.body
+ )}
);
}
diff --git a/src/app/api/admin/category/route.ts b/src/app/api/admin/category/route.ts
index 6fc2226254..2a7094bcef 100644
--- a/src/app/api/admin/category/route.ts
+++ b/src/app/api/admin/category/route.ts
@@ -7,6 +7,8 @@ import { getConfig } from '@/lib/config';
import { db } from '@/lib/db';
export const runtime = 'nodejs';
+export const dynamic = 'force-dynamic';
+
// 支持的操作类型
type Action = 'add' | 'disable' | 'enable' | 'delete' | 'sort';
diff --git a/src/app/api/admin/config/route.ts b/src/app/api/admin/config/route.ts
index 7544700a6e..daab082df1 100644
--- a/src/app/api/admin/config/route.ts
+++ b/src/app/api/admin/config/route.ts
@@ -7,6 +7,7 @@ import { getAuthInfoFromCookie } from '@/lib/auth';
import { getConfig } from '@/lib/config';
export const runtime = 'nodejs';
+export const dynamic = 'force-dynamic';
export async function GET(request: NextRequest) {
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
@@ -26,7 +27,9 @@ export async function GET(request: NextRequest) {
const username = authInfo.username;
try {
- const config = await getConfig();
+ // 管理页保存后的读取必须绕过进程内缓存。Vercel 的读写请求可能
+ // 落在不同实例上,否则刷新会读到另一个实例中的旧配置。
+ const config = await getConfig({ forceRefresh: true });
const result: AdminConfigResult = {
Role: 'owner',
Config: config,
diff --git a/src/app/api/admin/site/route.ts b/src/app/api/admin/site/route.ts
index 003ddb3abe..8ce062a1d9 100644
--- a/src/app/api/admin/site/route.ts
+++ b/src/app/api/admin/site/route.ts
@@ -37,7 +37,6 @@ export async function POST(request: NextRequest) {
DoubanProxy,
DoubanImageProxyType,
DoubanImageProxy,
- DisableYellowFilter,
FluidSearch,
EnableWebLive,
} = body as {
@@ -49,7 +48,6 @@ export async function POST(request: NextRequest) {
DoubanProxy: string;
DoubanImageProxyType: string;
DoubanImageProxy: string;
- DisableYellowFilter: boolean;
FluidSearch: boolean;
EnableWebLive: boolean;
};
@@ -64,8 +62,8 @@ export async function POST(request: NextRequest) {
typeof DoubanProxy !== 'string' ||
typeof DoubanImageProxyType !== 'string' ||
typeof DoubanImageProxy !== 'string' ||
- typeof DisableYellowFilter !== 'boolean' ||
- typeof FluidSearch !== 'boolean'
+ typeof FluidSearch !== 'boolean' ||
+ typeof EnableWebLive !== 'boolean'
) {
return NextResponse.json({ error: '参数格式错误' }, { status: 400 });
}
@@ -93,7 +91,6 @@ export async function POST(request: NextRequest) {
DoubanProxy,
DoubanImageProxyType,
DoubanImageProxy,
- DisableYellowFilter,
FluidSearch,
EnableWebLive: EnableWebLive ?? false,
};
diff --git a/src/app/api/admin/source/route.ts b/src/app/api/admin/source/route.ts
index dafacfbde6..42ced009b1 100644
--- a/src/app/api/admin/source/route.ts
+++ b/src/app/api/admin/source/route.ts
@@ -9,7 +9,15 @@ import { db } from '@/lib/db';
export const runtime = 'nodejs';
// 支持的操作类型
-type Action = 'add' | 'disable' | 'enable' | 'delete' | 'sort' | 'batch_disable' | 'batch_enable' | 'batch_delete';
+type Action =
+ | 'add'
+ | 'disable'
+ | 'enable'
+ | 'delete'
+ | 'sort'
+ | 'batch_disable'
+ | 'batch_enable'
+ | 'batch_delete';
interface BaseBody {
action?: Action;
@@ -37,7 +45,16 @@ export async function POST(request: NextRequest) {
const username = authInfo.username;
// 基础校验
- const ACTIONS: Action[] = ['add', 'disable', 'enable', 'delete', 'sort', 'batch_disable', 'batch_enable', 'batch_delete'];
+ const ACTIONS: Action[] = [
+ 'add',
+ 'disable',
+ 'enable',
+ 'delete',
+ 'sort',
+ 'batch_disable',
+ 'batch_enable',
+ 'batch_delete',
+ ];
if (!username || !action || !ACTIONS.includes(action)) {
return NextResponse.json({ error: '参数格式错误' }, { status: 400 });
}
@@ -115,17 +132,20 @@ export async function POST(request: NextRequest) {
// 检查并清理用户组和用户的权限数组
// 清理用户组权限
if (adminConfig.UserConfig.Tags) {
- adminConfig.UserConfig.Tags.forEach(tag => {
+ adminConfig.UserConfig.Tags.forEach((tag) => {
if (tag.enabledApis) {
- tag.enabledApis = tag.enabledApis.filter(api => api !== key);
+ tag.enabledApis = tag.enabledApis.filter((api) => api !== key);
+ }
+ if (tag.safeSearchApi === key) {
+ delete tag.safeSearchApi;
}
});
}
// 清理用户权限
- adminConfig.UserConfig.Users.forEach(user => {
+ adminConfig.UserConfig.Users.forEach((user) => {
if (user.enabledApis) {
- user.enabledApis = user.enabledApis.filter(api => api !== key);
+ user.enabledApis = user.enabledApis.filter((api) => api !== key);
}
});
break;
@@ -133,9 +153,12 @@ export async function POST(request: NextRequest) {
case 'batch_disable': {
const { keys } = body as { keys?: string[] };
if (!Array.isArray(keys) || keys.length === 0) {
- return NextResponse.json({ error: '缺少 keys 参数或为空' }, { status: 400 });
+ return NextResponse.json(
+ { error: '缺少 keys 参数或为空' },
+ { status: 400 }
+ );
}
- keys.forEach(key => {
+ keys.forEach((key) => {
const entry = adminConfig.SourceConfig.find((s) => s.key === key);
if (entry) {
entry.disabled = true;
@@ -146,9 +169,12 @@ export async function POST(request: NextRequest) {
case 'batch_enable': {
const { keys } = body as { keys?: string[] };
if (!Array.isArray(keys) || keys.length === 0) {
- return NextResponse.json({ error: '缺少 keys 参数或为空' }, { status: 400 });
+ return NextResponse.json(
+ { error: '缺少 keys 参数或为空' },
+ { status: 400 }
+ );
}
- keys.forEach(key => {
+ keys.forEach((key) => {
const entry = adminConfig.SourceConfig.find((s) => s.key === key);
if (entry) {
entry.disabled = false;
@@ -159,16 +185,19 @@ export async function POST(request: NextRequest) {
case 'batch_delete': {
const { keys } = body as { keys?: string[] };
if (!Array.isArray(keys) || keys.length === 0) {
- return NextResponse.json({ error: '缺少 keys 参数或为空' }, { status: 400 });
+ return NextResponse.json(
+ { error: '缺少 keys 参数或为空' },
+ { status: 400 }
+ );
}
// 过滤掉 from=config 的源,但不报错
- const keysToDelete = keys.filter(key => {
+ const keysToDelete = keys.filter((key) => {
const entry = adminConfig.SourceConfig.find((s) => s.key === key);
return entry && entry.from !== 'config';
});
// 批量删除
- keysToDelete.forEach(key => {
+ keysToDelete.forEach((key) => {
const idx = adminConfig.SourceConfig.findIndex((s) => s.key === key);
if (idx !== -1) {
adminConfig.SourceConfig.splice(idx, 1);
@@ -179,17 +208,27 @@ export async function POST(request: NextRequest) {
if (keysToDelete.length > 0) {
// 清理用户组权限
if (adminConfig.UserConfig.Tags) {
- adminConfig.UserConfig.Tags.forEach(tag => {
+ adminConfig.UserConfig.Tags.forEach((tag) => {
if (tag.enabledApis) {
- tag.enabledApis = tag.enabledApis.filter(api => !keysToDelete.includes(api));
+ tag.enabledApis = tag.enabledApis.filter(
+ (api) => !keysToDelete.includes(api)
+ );
+ }
+ if (
+ tag.safeSearchApi &&
+ keysToDelete.includes(tag.safeSearchApi)
+ ) {
+ delete tag.safeSearchApi;
}
});
}
// 清理用户权限
- adminConfig.UserConfig.Users.forEach(user => {
+ adminConfig.UserConfig.Users.forEach((user) => {
if (user.enabledApis) {
- user.enabledApis = user.enabledApis.filter(api => !keysToDelete.includes(api));
+ user.enabledApis = user.enabledApis.filter(
+ (api) => !keysToDelete.includes(api)
+ );
}
});
}
diff --git a/src/app/api/admin/source/validate/route.ts b/src/app/api/admin/source/validate/route.ts
index 12e63e4f0b..9353151141 100644
--- a/src/app/api/admin/source/validate/route.ts
+++ b/src/app/api/admin/source/validate/route.ts
@@ -30,6 +30,15 @@ export async function GET(request: NextRequest) {
}
const config = await getConfig();
+ if (authInfo.username !== process.env.USERNAME) {
+ const user = config.UserConfig.Users.find(
+ (u) => u.username === authInfo.username
+ );
+ if (!user || user.role !== 'admin' || user.banned) {
+ return NextResponse.json({ error: '权限不足' }, { status: 401 });
+ }
+ }
+
const apiSites = config.SourceConfig;
// 共享状态
@@ -189,7 +198,7 @@ export async function GET(request: NextRequest) {
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
- 'Cache-Control': 'no-cache',
+ 'Cache-Control': 'no-store',
'Connection': 'keep-alive',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET',
diff --git a/src/app/api/admin/user/route.ts b/src/app/api/admin/user/route.ts
index 9473f25beb..fb147e5f67 100644
--- a/src/app/api/admin/user/route.ts
+++ b/src/app/api/admin/user/route.ts
@@ -2,6 +2,7 @@
import { NextRequest, NextResponse } from 'next/server';
+import type { AdminConfig } from '@/lib/admin.types';
import { getAuthInfoFromCookie } from '@/lib/auth';
import { getConfig } from '@/lib/config';
import { db } from '@/lib/db';
@@ -23,6 +24,59 @@ const ACTIONS = [
'batchUpdateUserGroups',
] as const;
+type UserEntry = AdminConfig['UserConfig']['Users'][number];
+
+function normalizeEnabledApis(enabledApis: unknown, sourceKeys: Set
) {
+ return Array.isArray(enabledApis)
+ ? Array.from(new Set(enabledApis)).filter(
+ (apiKey): apiKey is string =>
+ typeof apiKey === 'string' && sourceKeys.has(apiKey)
+ )
+ : [];
+}
+
+function getGroupUpperBound(
+ adminConfig: AdminConfig,
+ groupNames?: string[]
+): Set | null {
+ if (!groupNames || groupNames.length === 0) {
+ return null;
+ }
+
+ const groupUpperBound = new Set();
+ groupNames.forEach((tagName) => {
+ const tagConfig = adminConfig.UserConfig.Tags?.find(
+ (t) => t.name === tagName
+ );
+ tagConfig?.enabledApis?.forEach((apiKey) => groupUpperBound.add(apiKey));
+ });
+
+ return groupUpperBound;
+}
+
+function pruneUserApisToGroupUpperBound(
+ adminConfig: AdminConfig,
+ user: UserEntry
+) {
+ if (user.username === process.env.USERNAME) {
+ delete user.enabledApis;
+ delete user.tags;
+ return;
+ }
+
+ const groupUpperBound = getGroupUpperBound(adminConfig, user.tags);
+ if (!groupUpperBound || !user.enabledApis) {
+ return;
+ }
+
+ user.enabledApis = user.enabledApis.filter((apiKey) =>
+ groupUpperBound.has(apiKey)
+ );
+ if (user.enabledApis.length === 0) {
+ delete user.enabledApis;
+ }
+}
+
export async function POST(request: NextRequest) {
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
if (storageType === 'localstorage') {
@@ -58,7 +112,10 @@ export async function POST(request: NextRequest) {
}
// 用户组操作和批量操作不需要targetUsername
- if (!targetUsername && !['userGroup', 'batchUpdateUserGroups'].includes(action)) {
+ if (
+ !targetUsername &&
+ !['userGroup', 'batchUpdateUserGroups'].includes(action)
+ ) {
return NextResponse.json({ error: '缺少目标用户名' }, { status: 400 });
}
@@ -78,7 +135,9 @@ export async function POST(request: NextRequest) {
}
// 获取配置与存储
- const adminConfig = await getConfig();
+ // 修改前读取持久化存储中的最新值,避免旧实例用缓存覆盖
+ // 其他实例刚保存的用户组配置。
+ const adminConfig = await getConfig({ forceRefresh: true });
// 判定操作者角色
let operatorRole: 'owner' | 'admin';
@@ -98,7 +157,10 @@ export async function POST(request: NextRequest) {
let targetEntry: any = null;
let isTargetAdmin = false;
- if (!['userGroup', 'batchUpdateUserGroups'].includes(action) && targetUsername) {
+ if (
+ !['userGroup', 'batchUpdateUserGroups'].includes(action) &&
+ targetUsername
+ ) {
targetEntry = adminConfig.UserConfig.Users.find(
(u) => u.username === targetUsername
);
@@ -106,7 +168,9 @@ export async function POST(request: NextRequest) {
if (
targetEntry &&
targetEntry.role === 'owner' &&
- !['changePassword', 'updateUserApis', 'updateUserGroups'].includes(action)
+ !['changePassword', 'updateUserApis', 'updateUserGroups'].includes(
+ action
+ )
) {
return NextResponse.json({ error: '无法操作站长' }, { status: 400 });
}
@@ -144,9 +208,7 @@ export async function POST(request: NextRequest) {
adminConfig.UserConfig.Users.push(newUser);
targetEntry =
- adminConfig.UserConfig.Users[
- adminConfig.UserConfig.Users.length - 1
- ];
+ adminConfig.UserConfig.Users[adminConfig.UserConfig.Users.length - 1];
break;
}
case 'ban': {
@@ -273,10 +335,7 @@ export async function POST(request: NextRequest) {
// 权限检查:站长可删除所有用户(除了自己),管理员可删除普通用户
if (username === targetUsername) {
- return NextResponse.json(
- { error: '不能删除自己' },
- { status: 400 }
- );
+ return NextResponse.json({ error: '不能删除自己' }, { status: 400 });
}
if (isTargetAdmin && operatorRole !== 'owner') {
@@ -320,9 +379,37 @@ export async function POST(request: NextRequest) {
);
}
+ if (targetEntry.username === process.env.USERNAME) {
+ delete targetEntry.enabledApis;
+ delete targetEntry.tags;
+ break;
+ }
+
+ const sourceKeys = new Set(adminConfig.SourceConfig.map((s) => s.key));
+ const normalizedEnabledApis = normalizeEnabledApis(
+ enabledApis,
+ sourceKeys
+ );
+ const groupUpperBound = getGroupUpperBound(
+ adminConfig,
+ targetEntry.tags
+ );
+
+ if (groupUpperBound) {
+ const disallowedApis = normalizedEnabledApis.filter(
+ (apiKey) => !groupUpperBound.has(apiKey)
+ );
+ if (disallowedApis.length > 0) {
+ return NextResponse.json(
+ { error: '采集源权限不能超出用户组允许的采集源范围' },
+ { status: 400 }
+ );
+ }
+ }
+
// 更新用户的采集源权限
- if (enabledApis && enabledApis.length > 0) {
- targetEntry.enabledApis = enabledApis;
+ if (normalizedEnabledApis.length > 0) {
+ targetEntry.enabledApis = normalizedEnabledApis;
} else {
// 如果为空数组或未提供,则删除该字段,表示无限制
delete targetEntry.enabledApis;
@@ -332,49 +419,81 @@ export async function POST(request: NextRequest) {
}
case 'userGroup': {
// 用户组管理操作
- const { groupAction, groupName, enabledApis } = body as {
- groupAction: 'add' | 'edit' | 'delete';
- groupName: string;
- enabledApis?: string[];
- };
+ const { groupAction, groupName, enabledApis, safeSearchEnabled } =
+ body as {
+ groupAction: 'add' | 'edit' | 'delete';
+ groupName: string;
+ enabledApis?: string[];
+ safeSearchEnabled?: boolean;
+ };
if (!adminConfig.UserConfig.Tags) {
adminConfig.UserConfig.Tags = [];
}
+ const sourceKeys = new Set(adminConfig.SourceConfig.map((s) => s.key));
+ const normalizedEnabledApis = normalizeEnabledApis(
+ enabledApis,
+ sourceKeys
+ );
+ const normalizedSafeSearchEnabled = safeSearchEnabled === true;
+
switch (groupAction) {
case 'add': {
// 检查用户组是否已存在
- if (adminConfig.UserConfig.Tags.find(t => t.name === groupName)) {
- return NextResponse.json({ error: '用户组已存在' }, { status: 400 });
+ if (adminConfig.UserConfig.Tags.find((t) => t.name === groupName)) {
+ return NextResponse.json(
+ { error: '用户组已存在' },
+ { status: 400 }
+ );
}
adminConfig.UserConfig.Tags.push({
name: groupName,
- enabledApis: enabledApis || [],
+ enabledApis: normalizedEnabledApis,
+ safeSearchEnabled: normalizedSafeSearchEnabled,
});
break;
}
case 'edit': {
- const groupIndex = adminConfig.UserConfig.Tags.findIndex(t => t.name === groupName);
+ const groupIndex = adminConfig.UserConfig.Tags.findIndex(
+ (t) => t.name === groupName
+ );
if (groupIndex === -1) {
- return NextResponse.json({ error: '用户组不存在' }, { status: 404 });
+ return NextResponse.json(
+ { error: '用户组不存在' },
+ { status: 404 }
+ );
}
- adminConfig.UserConfig.Tags[groupIndex].enabledApis = enabledApis || [];
+ adminConfig.UserConfig.Tags[groupIndex].enabledApis =
+ normalizedEnabledApis;
+ adminConfig.UserConfig.Tags[groupIndex].safeSearchEnabled =
+ normalizedSafeSearchEnabled;
+ delete adminConfig.UserConfig.Tags[groupIndex].safeSearchApi;
+ adminConfig.UserConfig.Users.filter((user) =>
+ user.tags?.includes(groupName)
+ ).forEach((user) =>
+ pruneUserApisToGroupUpperBound(adminConfig, user)
+ );
break;
}
case 'delete': {
- const groupIndex = adminConfig.UserConfig.Tags.findIndex(t => t.name === groupName);
+ const groupIndex = adminConfig.UserConfig.Tags.findIndex(
+ (t) => t.name === groupName
+ );
if (groupIndex === -1) {
- return NextResponse.json({ error: '用户组不存在' }, { status: 404 });
+ return NextResponse.json(
+ { error: '用户组不存在' },
+ { status: 404 }
+ );
}
// 查找使用该用户组的所有用户
const affectedUsers: string[] = [];
- adminConfig.UserConfig.Users.forEach(user => {
+ adminConfig.UserConfig.Users.forEach((user) => {
if (user.tags && user.tags.includes(groupName)) {
affectedUsers.push(user.username);
// 从用户的tags中移除该用户组
- user.tags = user.tags.filter(tag => tag !== groupName);
+ user.tags = user.tags.filter((tag) => tag !== groupName);
// 如果用户没有其他标签了,删除tags字段
if (user.tags.length === 0) {
delete user.tags;
@@ -386,18 +505,28 @@ export async function POST(request: NextRequest) {
adminConfig.UserConfig.Tags.splice(groupIndex, 1);
// 记录删除操作的影响
- console.log(`删除用户组 "${groupName}",影响用户: ${affectedUsers.length > 0 ? affectedUsers.join(', ') : '无'}`);
+ console.log(
+ `删除用户组 "${groupName}",影响用户: ${
+ affectedUsers.length > 0 ? affectedUsers.join(', ') : '无'
+ }`
+ );
break;
}
default:
- return NextResponse.json({ error: '未知的用户组操作' }, { status: 400 });
+ return NextResponse.json(
+ { error: '未知的用户组操作' },
+ { status: 400 }
+ );
}
break;
}
case 'updateUserGroups': {
if (!targetEntry) {
- return NextResponse.json({ error: '目标用户不存在' }, { status: 404 });
+ return NextResponse.json(
+ { error: '目标用户不存在' },
+ { status: 404 }
+ );
}
const { userGroups } = body as { userGroups: string[] };
@@ -408,10 +537,19 @@ export async function POST(request: NextRequest) {
operatorRole !== 'owner' &&
username !== targetUsername
) {
- return NextResponse.json({ error: '仅站长可配置其他管理员的用户组' }, { status: 400 });
+ return NextResponse.json(
+ { error: '仅站长可配置其他管理员的用户组' },
+ { status: 400 }
+ );
}
// 更新用户的用户组
+ if (targetEntry.username === process.env.USERNAME) {
+ delete targetEntry.enabledApis;
+ delete targetEntry.tags;
+ break;
+ }
+
if (userGroups && userGroups.length > 0) {
targetEntry.tags = userGroups;
} else {
@@ -419,28 +557,47 @@ export async function POST(request: NextRequest) {
delete targetEntry.tags;
}
+ pruneUserApisToGroupUpperBound(adminConfig, targetEntry);
+
break;
}
case 'batchUpdateUserGroups': {
- const { usernames, userGroups } = body as { usernames: string[]; userGroups: string[] };
+ const { usernames, userGroups } = body as {
+ usernames: string[];
+ userGroups: string[];
+ };
if (!usernames || !Array.isArray(usernames) || usernames.length === 0) {
- return NextResponse.json({ error: '缺少用户名列表' }, { status: 400 });
+ return NextResponse.json(
+ { error: '缺少用户名列表' },
+ { status: 400 }
+ );
}
// 权限检查:站长可批量配置所有人的用户组,管理员只能批量配置普通用户
if (operatorRole !== 'owner') {
for (const targetUsername of usernames) {
- const targetUser = adminConfig.UserConfig.Users.find(u => u.username === targetUsername);
- if (targetUser && targetUser.role === 'admin' && targetUsername !== username) {
- return NextResponse.json({ error: `管理员无法操作其他管理员 ${targetUsername}` }, { status: 400 });
+ const targetUser = adminConfig.UserConfig.Users.find(
+ (u) => u.username === targetUsername
+ );
+ if (
+ targetUser &&
+ targetUser.role === 'admin' &&
+ targetUsername !== username
+ ) {
+ return NextResponse.json(
+ { error: `管理员无法操作其他管理员 ${targetUsername}` },
+ { status: 400 }
+ );
}
}
}
// 批量更新用户组
for (const targetUsername of usernames) {
- const targetUser = adminConfig.UserConfig.Users.find(u => u.username === targetUsername);
+ const targetUser = adminConfig.UserConfig.Users.find(
+ (u) => u.username === targetUsername
+ );
if (targetUser) {
if (userGroups && userGroups.length > 0) {
targetUser.tags = userGroups;
@@ -448,6 +605,7 @@ export async function POST(request: NextRequest) {
// 如果为空数组或未提供,则删除该字段,表示无用户组
delete targetUser.tags;
}
+ pruneUserApisToGroupUpperBound(adminConfig, targetUser);
}
}
diff --git a/src/app/api/detail/route.ts b/src/app/api/detail/route.ts
index 59dba3bc70..ed1293f3ac 100644
--- a/src/app/api/detail/route.ts
+++ b/src/app/api/detail/route.ts
@@ -1,11 +1,15 @@
import { NextRequest, NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '@/lib/auth';
-import { getAvailableApiSites, getCacheTime } from '@/lib/config';
+import { getAvailableApiSites } from '@/lib/config';
import { getDetailFromApi } from '@/lib/downstream';
export const runtime = 'nodejs';
+const noStoreHeaders = {
+ 'Cache-Control': 'no-store',
+};
+
export async function GET(request: NextRequest) {
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
@@ -33,15 +37,9 @@ export async function GET(request: NextRequest) {
}
const result = await getDetailFromApi(apiSite, id);
- const cacheTime = await getCacheTime();
return NextResponse.json(result, {
- headers: {
- 'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`,
- 'CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
- 'Vercel-CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
- 'Netlify-Vary': 'query',
- },
+ headers: noStoreHeaders,
});
} catch (error) {
return NextResponse.json(
diff --git a/src/app/api/favorites/route.ts b/src/app/api/favorites/route.ts
index 6aa98603ce..2244b6ae2e 100644
--- a/src/app/api/favorites/route.ts
+++ b/src/app/api/favorites/route.ts
@@ -8,6 +8,8 @@ import { db } from '@/lib/db';
import { Favorite } from '@/lib/types';
export const runtime = 'nodejs';
+export const dynamic = 'force-dynamic';
+
/**
* GET /api/favorites
diff --git a/src/app/api/search/one/route.ts b/src/app/api/search/one/route.ts
index 61a4c30be7..28f8ec642a 100644
--- a/src/app/api/search/one/route.ts
+++ b/src/app/api/search/one/route.ts
@@ -1,12 +1,15 @@
import { NextRequest, NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '@/lib/auth';
-import { getAvailableApiSites, getCacheTime, getConfig } from '@/lib/config';
+import { getAvailableApiSites } from '@/lib/config';
import { searchFromApi } from '@/lib/downstream';
-import { yellowWords } from '@/lib/yellow';
export const runtime = 'nodejs';
+const noStoreHeaders = {
+ 'Cache-Control': 'no-store',
+};
+
// OrionTV 兼容接口
export async function GET(request: NextRequest) {
const authInfo = getAuthInfoFromCookie(request);
@@ -19,21 +22,14 @@ export async function GET(request: NextRequest) {
const resourceId = searchParams.get('resourceId');
if (!query || !resourceId) {
- const cacheTime = await getCacheTime();
return NextResponse.json(
{ result: null, error: '缺少必要参数: q 或 resourceId' },
{
- headers: {
- 'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`,
- 'CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
- 'Vercel-CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
- 'Netlify-Vary': 'query',
- },
+ headers: noStoreHeaders,
}
);
}
- const config = await getConfig();
const apiSites = await getAvailableApiSites(authInfo.username);
try {
@@ -50,15 +46,7 @@ export async function GET(request: NextRequest) {
}
const results = await searchFromApi(targetSite, query);
- let result = results.filter((r) => r.title === query);
- if (!config.SiteConfig.DisableYellowFilter) {
- result = result.filter((result) => {
- const typeName = result.type_name || '';
- return !yellowWords.some((word: string) => typeName.includes(word));
- });
- }
- const cacheTime = await getCacheTime();
-
+ const result = results.filter((r) => r.title === query);
if (result.length === 0) {
return NextResponse.json(
{
@@ -71,12 +59,7 @@ export async function GET(request: NextRequest) {
return NextResponse.json(
{ results: result },
{
- headers: {
- 'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`,
- 'CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
- 'Vercel-CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
- 'Netlify-Vary': 'query',
- },
+ headers: noStoreHeaders,
}
);
}
diff --git a/src/app/api/search/resources/route.ts b/src/app/api/search/resources/route.ts
index 58acb9bb76..e8f9d608d4 100644
--- a/src/app/api/search/resources/route.ts
+++ b/src/app/api/search/resources/route.ts
@@ -7,6 +7,10 @@ import { getAvailableApiSites } from '@/lib/config';
export const runtime = 'nodejs';
+const noStoreHeaders = {
+ 'Cache-Control': 'no-store',
+};
+
// OrionTV 兼容接口
export async function GET(request: NextRequest) {
const authInfo = getAuthInfoFromCookie(request);
@@ -16,7 +20,7 @@ export async function GET(request: NextRequest) {
try {
const apiSites = await getAvailableApiSites(authInfo.username);
- return NextResponse.json(apiSites);
+ return NextResponse.json(apiSites, { headers: noStoreHeaders });
} catch (error) {
return NextResponse.json({ error: '获取资源失败' }, { status: 500 });
}
diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts
index 229c62cb94..02ac065eeb 100644
--- a/src/app/api/search/route.ts
+++ b/src/app/api/search/route.ts
@@ -1,13 +1,15 @@
-/* eslint-disable @typescript-eslint/no-explicit-any,no-console */
-
import { NextRequest, NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '@/lib/auth';
-import { getAvailableApiSites, getCacheTime, getConfig } from '@/lib/config';
-import { searchFromApi } from '@/lib/downstream';
-import { yellowWords } from '@/lib/yellow';
+import { getAvailableApiSites, isSafeSearchEnabledForUser } from '@/lib/config';
+import { safeSearchFromApiSites } from '@/lib/safe-search';
export const runtime = 'nodejs';
+export const dynamic = 'force-dynamic';
+
+const noStoreHeaders = {
+ 'Cache-Control': 'no-store',
+};
export async function GET(request: NextRequest) {
const authInfo = getAuthInfoFromCookie(request);
@@ -16,67 +18,44 @@ export async function GET(request: NextRequest) {
}
const { searchParams } = new URL(request.url);
- const query = searchParams.get('q');
+ const query = searchParams.get('q')?.trim();
if (!query) {
- const cacheTime = await getCacheTime();
return NextResponse.json(
{ results: [] },
{
- headers: {
- 'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`,
- 'CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
- 'Vercel-CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
- 'Netlify-Vary': 'query',
- },
+ headers: noStoreHeaders,
}
);
}
- const config = await getConfig();
- const apiSites = await getAvailableApiSites(authInfo.username);
+ const trustedCanonicalTitles =
+ searchParams.get('catalog') === 'douban' ? [query] : undefined;
- // 添加超时控制和错误处理,避免慢接口拖累整体响应
- const searchPromises = apiSites.map((site) =>
- Promise.race([
- searchFromApi(site, query),
- new Promise((_, reject) =>
- setTimeout(() => reject(new Error(`${site.name} timeout`)), 20000)
- ),
- ]).catch((err) => {
- console.warn(`搜索失败 ${site.name}:`, err.message);
- return []; // 返回空数组而不是抛出错误
- })
- );
+ const [apiSites, safeSearchEnabled] = await Promise.all([
+ getAvailableApiSites(authInfo.username),
+ isSafeSearchEnabledForUser(authInfo.username),
+ ]);
try {
- const results = await Promise.allSettled(searchPromises);
- const successResults = results
- .filter((result) => result.status === 'fulfilled')
- .map((result) => (result as PromiseFulfilledResult).value);
- let flattenedResults = successResults.flat();
- if (!config.SiteConfig.DisableYellowFilter) {
- flattenedResults = flattenedResults.filter((result) => {
- const typeName = result.type_name || '';
- return !yellowWords.some((word: string) => typeName.includes(word));
- });
- }
- const cacheTime = await getCacheTime();
+ const results = await safeSearchFromApiSites(
+ apiSites,
+ query,
+ safeSearchEnabled,
+ trustedCanonicalTitles
+ );
- if (flattenedResults.length === 0) {
- // no cache if empty
- return NextResponse.json({ results: [] }, { status: 200 });
+ if (results.length === 0) {
+ return NextResponse.json(
+ { results: [] },
+ { status: 200, headers: noStoreHeaders }
+ );
}
return NextResponse.json(
- { results: flattenedResults },
+ { results },
{
- headers: {
- 'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`,
- 'CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
- 'Vercel-CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
- 'Netlify-Vary': 'query',
- },
+ headers: noStoreHeaders,
}
);
} catch (error) {
diff --git a/src/app/api/search/suggestions/route.ts b/src/app/api/search/suggestions/route.ts
index 88e97b440e..7f830baeea 100644
--- a/src/app/api/search/suggestions/route.ts
+++ b/src/app/api/search/suggestions/route.ts
@@ -2,13 +2,16 @@
import { NextRequest, NextResponse } from 'next/server';
-import { AdminConfig } from '@/lib/admin.types';
import { getAuthInfoFromCookie } from '@/lib/auth';
-import { getAvailableApiSites, getConfig } from '@/lib/config';
+import { getAvailableApiSites } from '@/lib/config';
import { searchFromApi } from '@/lib/downstream';
-import { yellowWords } from '@/lib/yellow';
export const runtime = 'nodejs';
+export const dynamic = 'force-dynamic';
+
+const noStoreHeaders = {
+ 'Cache-Control': 'no-store',
+};
export async function GET(request: NextRequest) {
try {
@@ -18,29 +21,20 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
- const config = await getConfig();
const { searchParams } = new URL(request.url);
const query = searchParams.get('q')?.trim();
if (!query) {
- return NextResponse.json({ suggestions: [] });
+ return NextResponse.json({ suggestions: [] }, { headers: noStoreHeaders });
}
// 生成建议
- const suggestions = await generateSuggestions(config, query, authInfo.username);
-
- // 从配置中获取缓存时间,如果没有配置则使用默认值300秒(5分钟)
- const cacheTime = config.SiteConfig.SiteInterfaceCacheTime || 300;
+ const suggestions = await generateSuggestions(query, authInfo.username);
return NextResponse.json(
{ suggestions },
{
- headers: {
- 'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`,
- 'CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
- 'Vercel-CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
- 'Netlify-Vary': 'query',
- },
+ headers: noStoreHeaders,
}
);
} catch (error) {
@@ -49,7 +43,7 @@ export async function GET(request: NextRequest) {
}
}
-async function generateSuggestions(config: AdminConfig, query: string, username: string): Promise<
+async function generateSuggestions(query: string, username: string): Promise<
Array<{
text: string;
type: 'exact' | 'related' | 'suggestion';
@@ -69,7 +63,6 @@ async function generateSuggestions(config: AdminConfig, query: string, username:
realKeywords = Array.from(
new Set(
results
- .filter((r: any) => config.SiteConfig.DisableYellowFilter || !yellowWords.some((word: string) => (r.type_name || '').includes(word)))
.map((r: any) => r.title)
.filter(Boolean)
.flatMap((title: string) => title.split(/[ -::·、-]/))
diff --git a/src/app/api/search/ws/route.ts b/src/app/api/search/ws/route.ts
index 4d7c675c89..7c6e576fee 100644
--- a/src/app/api/search/ws/route.ts
+++ b/src/app/api/search/ws/route.ts
@@ -1,11 +1,14 @@
-/* eslint-disable @typescript-eslint/no-explicit-any,no-console */
+/* eslint-disable no-console */
import { NextRequest, NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '@/lib/auth';
-import { getAvailableApiSites, getConfig } from '@/lib/config';
-import { searchFromApi } from '@/lib/downstream';
-import { yellowWords } from '@/lib/yellow';
+import { getAvailableApiSites, isSafeSearchEnabledForUser } from '@/lib/config';
+import {
+ getCanonicalSearchTitles,
+ searchExactTitlesFromSite,
+ searchFromApiSiteWithTimeout,
+} from '@/lib/safe-search';
export const runtime = 'nodejs';
@@ -16,170 +19,170 @@ export async function GET(request: NextRequest) {
}
const { searchParams } = new URL(request.url);
- const query = searchParams.get('q');
+ const query = searchParams.get('q')?.trim();
if (!query) {
- return new Response(
- JSON.stringify({ error: '搜索关键词不能为空' }),
- {
- status: 400,
- headers: {
- 'Content-Type': 'application/json',
- },
- }
- );
+ return new Response(JSON.stringify({ error: '搜索关键词不能为空' }), {
+ status: 400,
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ });
}
- const config = await getConfig();
- const apiSites = await getAvailableApiSites(authInfo.username);
-
- // 共享状态
+ const [apiSites, safeSearchEnabled] = await Promise.all([
+ getAvailableApiSites(authInfo.username),
+ isSafeSearchEnabledForUser(authInfo.username),
+ ]);
let streamClosed = false;
- // 创建可读流
const stream = new ReadableStream({
async start(controller) {
const encoder = new TextEncoder();
- // 辅助函数:安全地向控制器写入数据
const safeEnqueue = (data: Uint8Array) => {
try {
- if (streamClosed || (!controller.desiredSize && controller.desiredSize !== 0)) {
- // 流已标记为关闭或控制器已关闭
+ if (
+ streamClosed ||
+ (!controller.desiredSize && controller.desiredSize !== 0)
+ ) {
return false;
}
controller.enqueue(data);
return true;
} catch (error) {
- // 控制器已关闭或出现其他错误
console.warn('Failed to enqueue data:', error);
streamClosed = true;
return false;
}
};
- // 发送开始事件
- const startEvent = `data: ${JSON.stringify({
- type: 'start',
- query,
- totalSources: apiSites.length,
- timestamp: Date.now()
- })}\n\n`;
+ const sendEvent = (payload: Record) =>
+ safeEnqueue(encoder.encode(`data: ${JSON.stringify(payload)}\n\n`));
+
+ if (
+ !sendEvent({
+ type: 'start',
+ query,
+ totalSources: apiSites.length,
+ timestamp: Date.now(),
+ })
+ ) {
+ return;
+ }
- if (!safeEnqueue(encoder.encode(startEvent))) {
- return; // 连接已关闭,提前退出
+ if (apiSites.length === 0) {
+ sendEvent({
+ type: 'complete',
+ totalResults: 0,
+ completedSources: 0,
+ timestamp: Date.now(),
+ });
+ controller.close();
+ return;
+ }
+
+ let canonicalTitles: string[] | null = null;
+ if (safeSearchEnabled) {
+ canonicalTitles = [];
+ try {
+ canonicalTitles = await getCanonicalSearchTitles(query);
+ } catch (error) {
+ console.warn('TMDB canonical search failed:', error);
+ }
+
+ if (canonicalTitles.length === 0) {
+ sendEvent({
+ type: 'complete',
+ totalResults: 0,
+ completedSources: apiSites.length,
+ timestamp: Date.now(),
+ });
+ controller.close();
+ return;
+ }
}
- // 记录已完成的源数量
let completedSources = 0;
- const allResults: any[] = [];
+ const allResults: unknown[] = [];
- // 为每个源创建搜索 Promise
const searchPromises = apiSites.map(async (site) => {
try {
- // 添加超时控制
- const searchPromise = Promise.race([
- searchFromApi(site, query),
- new Promise((_, reject) =>
- setTimeout(() => reject(new Error(`${site.name} timeout`)), 20000)
- ),
- ]);
-
- const results = await searchPromise as any[];
-
- // 过滤黄色内容
- let filteredResults = results;
- if (!config.SiteConfig.DisableYellowFilter) {
- filteredResults = results.filter((result) => {
- const typeName = result.type_name || '';
- return !yellowWords.some((word: string) => typeName.includes(word));
- });
- }
-
- // 发送该源的搜索结果
+ const results = canonicalTitles
+ ? await searchExactTitlesFromSite(site, canonicalTitles)
+ : await searchFromApiSiteWithTimeout(site, query);
completedSources++;
if (!streamClosed) {
- const sourceEvent = `data: ${JSON.stringify({
- type: 'source_result',
- source: site.key,
- sourceName: site.name,
- results: filteredResults,
- timestamp: Date.now()
- })}\n\n`;
-
- if (!safeEnqueue(encoder.encode(sourceEvent))) {
+ if (
+ !sendEvent({
+ type: 'source_result',
+ source: site.key,
+ sourceName: site.name,
+ results,
+ timestamp: Date.now(),
+ })
+ ) {
streamClosed = true;
- return; // 连接已关闭,停止处理
+ return;
}
}
- if (filteredResults.length > 0) {
- allResults.push(...filteredResults);
+ if (results.length > 0) {
+ allResults.push(...results);
}
-
} catch (error) {
console.warn(`搜索失败 ${site.name}:`, error);
-
- // 发送源错误事件
completedSources++;
if (!streamClosed) {
- const errorEvent = `data: ${JSON.stringify({
- type: 'source_error',
- source: site.key,
- sourceName: site.name,
- error: error instanceof Error ? error.message : '搜索失败',
- timestamp: Date.now()
- })}\n\n`;
-
- if (!safeEnqueue(encoder.encode(errorEvent))) {
+ if (
+ !sendEvent({
+ type: 'source_error',
+ source: site.key,
+ sourceName: site.name,
+ error: error instanceof Error ? error.message : '搜索失败',
+ timestamp: Date.now(),
+ })
+ ) {
streamClosed = true;
- return; // 连接已关闭,停止处理
+ return;
}
}
}
- // 检查是否所有源都已完成
- if (completedSources === apiSites.length) {
- if (!streamClosed) {
- // 发送最终完成事件
- const completeEvent = `data: ${JSON.stringify({
+ if (completedSources === apiSites.length && !streamClosed) {
+ if (
+ sendEvent({
type: 'complete',
totalResults: allResults.length,
completedSources,
- timestamp: Date.now()
- })}\n\n`;
-
- if (safeEnqueue(encoder.encode(completeEvent))) {
- // 只有在成功发送完成事件后才关闭流
- try {
- controller.close();
- } catch (error) {
- console.warn('Failed to close controller:', error);
- }
+ timestamp: Date.now(),
+ })
+ ) {
+ try {
+ controller.close();
+ } catch (error) {
+ console.warn('Failed to close controller:', error);
}
}
}
});
- // 等待所有搜索完成
await Promise.allSettled(searchPromises);
},
cancel() {
- // 客户端断开连接时,标记流已关闭
streamClosed = true;
console.log('Client disconnected, cancelling search stream');
},
});
- // 返回流式响应
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
- 'Cache-Control': 'no-cache',
- 'Connection': 'keep-alive',
+ 'Cache-Control': 'no-store',
+ Connection: 'keep-alive',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET',
'Access-Control-Allow-Headers': 'Content-Type',
diff --git a/src/app/douban/page.tsx b/src/app/douban/page.tsx
index b8c355cfc7..f4188d082e 100644
--- a/src/app/douban/page.tsx
+++ b/src/app/douban/page.tsx
@@ -6,7 +6,6 @@ import { useSearchParams } from 'next/navigation';
import { Suspense } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
-import { GetBangumiCalendarData } from '@/lib/bangumi.client';
import {
getDoubanCategories,
getDoubanList,
@@ -39,7 +38,6 @@ function DoubanPageClient() {
primarySelection: '',
secondarySelection: '',
multiLevelSelection: {} as Record,
- selectedWeekday: '',
currentPage: 0,
});
@@ -54,7 +52,7 @@ function DoubanPageClient() {
const [primarySelection, setPrimarySelection] = useState(() => {
if (type === 'movie') return '热门';
if (type === 'tv' || type === 'show') return '最近热门';
- if (type === 'anime') return '每日放送';
+ if (type === 'anime') return '番剧';
return '';
});
const [secondarySelection, setSecondarySelection] = useState(() => {
@@ -76,9 +74,6 @@ function DoubanPageClient() {
sort: 'T',
});
- // 星期选择器状态
- const [selectedWeekday, setSelectedWeekday] = useState('');
-
// 获取自定义分类数据
useEffect(() => {
const runtimeConfig = (window as any).RUNTIME_CONFIG;
@@ -94,7 +89,6 @@ function DoubanPageClient() {
primarySelection,
secondarySelection,
multiLevelSelection: multiLevelValues,
- selectedWeekday,
currentPage,
};
}, [
@@ -102,7 +96,6 @@ function DoubanPageClient() {
primarySelection,
secondarySelection,
multiLevelValues,
- selectedWeekday,
currentPage,
]);
@@ -159,7 +152,7 @@ function DoubanPageClient() {
setPrimarySelection('最近热门');
setSecondarySelection('show');
} else if (type === 'anime') {
- setPrimarySelection('每日放送');
+ setPrimarySelection('番剧');
setSecondarySelection('全部');
} else {
setPrimarySelection('');
@@ -196,7 +189,6 @@ function DoubanPageClient() {
primarySelection: string;
secondarySelection: string;
multiLevelSelection: Record;
- selectedWeekday: string;
currentPage: number;
},
snapshot2: {
@@ -204,7 +196,6 @@ function DoubanPageClient() {
primarySelection: string;
secondarySelection: string;
multiLevelSelection: Record;
- selectedWeekday: string;
currentPage: number;
}
) => {
@@ -212,10 +203,9 @@ function DoubanPageClient() {
snapshot1.type === snapshot2.type &&
snapshot1.primarySelection === snapshot2.primarySelection &&
snapshot1.secondarySelection === snapshot2.secondarySelection &&
- snapshot1.selectedWeekday === snapshot2.selectedWeekday &&
snapshot1.currentPage === snapshot2.currentPage &&
JSON.stringify(snapshot1.multiLevelSelection) ===
- JSON.stringify(snapshot2.multiLevelSelection)
+ JSON.stringify(snapshot2.multiLevelSelection)
);
},
[]
@@ -255,7 +245,6 @@ function DoubanPageClient() {
primarySelection,
secondarySelection,
multiLevelSelection: multiLevelValues,
- selectedWeekday,
currentPage: 0,
};
@@ -286,31 +275,6 @@ function DoubanPageClient() {
} else {
throw new Error('没有找到对应的分类');
}
- } else if (type === 'anime' && primarySelection === '每日放送') {
- const calendarData = await GetBangumiCalendarData();
- const weekdayData = calendarData.find(
- (item) => item.weekday.en === selectedWeekday
- );
- if (weekdayData) {
- data = {
- code: 200,
- message: 'success',
- list: weekdayData.items.map((item) => ({
- id: item.id?.toString() || '',
- title: item.name_cn || item.name,
- poster:
- item.images.large ||
- item.images.common ||
- item.images.medium ||
- item.images.small ||
- item.images.grid,
- rate: item.rating?.score?.toFixed(1) || '',
- year: item.air_date?.split('-')?.[0] || '',
- })),
- };
- } else {
- throw new Error('没有找到对应的日期');
- }
} else if (type === 'anime') {
data = await getDoubanRecommends({
kind: primarySelection === '番剧' ? 'tv' : 'movie',
@@ -380,7 +344,6 @@ function DoubanPageClient() {
primarySelection,
secondarySelection,
multiLevelValues,
- selectedWeekday,
getRequestParams,
customCategories,
]);
@@ -414,7 +377,6 @@ function DoubanPageClient() {
primarySelection,
secondarySelection,
multiLevelValues,
- selectedWeekday,
loadInitialData,
]);
@@ -428,7 +390,6 @@ function DoubanPageClient() {
primarySelection,
secondarySelection,
multiLevelSelection: multiLevelValues,
- selectedWeekday,
currentPage,
};
@@ -454,13 +415,6 @@ function DoubanPageClient() {
} else {
throw new Error('没有找到对应的分类');
}
- } else if (type === 'anime' && primarySelection === '每日放送') {
- // 每日放送模式下,不进行数据请求,返回空数据
- data = {
- code: 200,
- message: 'success',
- list: [],
- };
} else if (type === 'anime') {
data = await getDoubanRecommends({
kind: primarySelection === '番剧' ? 'tv' : 'movie',
@@ -545,7 +499,6 @@ function DoubanPageClient() {
secondarySelection,
customCategories,
multiLevelValues,
- selectedWeekday,
]);
// 设置滚动监听
@@ -678,27 +631,20 @@ function DoubanPageClient() {
[multiLevelValues]
);
- const handleWeekdayChange = useCallback((weekday: string) => {
- setSelectedWeekday(weekday);
- }, []);
-
const getPageTitle = () => {
// 根据 type 生成标题
return type === 'movie'
? '电影'
: type === 'tv'
- ? '电视剧'
- : type === 'anime'
- ? '动漫'
- : type === 'show'
- ? '综艺'
- : '自定义';
+ ? '电视剧'
+ : type === 'anime'
+ ? '动漫'
+ : type === 'show'
+ ? '综艺'
+ : '自定义';
};
const getPageDescription = () => {
- if (type === 'anime' && primarySelection === '每日放送') {
- return '来自 Bangumi 番组计划的精选内容';
- }
return '来自豆瓣的精选内容';
};
@@ -736,7 +682,6 @@ function DoubanPageClient() {
onPrimaryChange={handlePrimaryChange}
onSecondaryChange={handleSecondaryChange}
onMultiLevelChange={handleMultiLevelChange}
- onWeekdayChange={handleWeekdayChange}
/>
) : (
@@ -755,12 +700,15 @@ function DoubanPageClient() {
{/* 内容展示区域 */}
{/* 内容网格 */}
- {loading || !selectorsReady
- ? // 显示骨架屏
+ {loading || !selectorsReady ? (
+ // 显示骨架屏
- {skeletonData.map((index) => )}
+ {skeletonData.map((index) => (
+
+ ))}
- : // 显示实际数据
+ ) : (
+ // 显示实际数据
)}
/>
- }
+ )}
{/* 加载更多指示器 */}
{hasMore && !loading && (
@@ -799,7 +744,7 @@ function DoubanPageClient() {
>
{isLoadingMore && (
)}
diff --git a/src/app/globals.css b/src/app/globals.css
index ba4907bac7..f0f3ecf09e 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -14,7 +14,31 @@
}
:root {
- --foreground-rgb: 255, 255, 255;
+ color-scheme: light;
+ --theme-background: 244 247 246;
+ --theme-surface: 255 255 255;
+ --theme-elevated: 237 243 241;
+ --theme-border: 216 226 223;
+ --theme-foreground: 20 32 29;
+ --theme-muted: 82 99 94;
+ --theme-primary: 15 143 120;
+ --theme-primary-hover: 11 116 98;
+ --theme-primary-soft: 216 243 237;
+ --theme-accent: 242 184 75;
+}
+
+html.dark {
+ color-scheme: dark;
+ --theme-background: 11 11 18;
+ --theme-surface: 23 20 29;
+ --theme-elevated: 33 28 39;
+ --theme-border: 52 44 57;
+ --theme-foreground: 250 247 248;
+ --theme-muted: 185 175 186;
+ --theme-primary: 225 29 72;
+ --theme-primary-hover: 190 18 60;
+ --theme-primary-soft: 76 16 34;
+ --theme-accent: 245 158 11;
}
html,
@@ -26,22 +50,32 @@ body {
}
body {
- color: rgb(var(--foreground-rgb));
+ color: rgb(var(--theme-foreground));
+ background: rgb(var(--theme-background));
+ transition: color 200ms ease, background-color 200ms ease;
}
html:not(.dark) body {
background: linear-gradient(
180deg,
- #e6f3fb 0%,
- #eaf3f7 18%,
- #f7f7f3 38%,
- #e9ecef 60%,
- #dbe3ea 80%,
- #d3dde6 100%
+ #edf7f4 0%,
+ #f4f7f6 32%,
+ #eef4f2 68%,
+ #e5eeeb 100%
);
background-attachment: fixed;
}
+html.dark body {
+ background: radial-gradient(
+ circle at 82% 4%,
+ rgb(76 16 34 / 0.34),
+ transparent 30rem
+ ),
+ linear-gradient(180deg, #0b0b12 0%, #100d15 55%, #0b0b12 100%);
+ background-attachment: fixed;
+}
+
/* 自定义滚动条样式 */
::-webkit-scrollbar {
width: 8px;
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 51ad3a93c9..df5b34c100 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -18,7 +18,7 @@ export const dynamic = 'force-dynamic';
export async function generateMetadata(): Promise
{
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
const config = await getConfig();
- let siteName = process.env.NEXT_PUBLIC_SITE_NAME || 'MoonTV';
+ let siteName = process.env.NEXT_PUBLIC_SITE_NAME || 'CraterTV';
if (storageType !== 'localstorage') {
siteName = config.SiteConfig.SiteName;
}
@@ -41,18 +41,17 @@ export default async function RootLayout({
}) {
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
- let siteName = process.env.NEXT_PUBLIC_SITE_NAME || 'MoonTV';
+ let siteName = process.env.NEXT_PUBLIC_SITE_NAME || 'CraterTV';
let announcement =
process.env.ANNOUNCEMENT ||
'本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。';
- let doubanProxyType = process.env.NEXT_PUBLIC_DOUBAN_PROXY_TYPE || 'cmliussss-cdn-tencent';
+ let doubanProxyType =
+ process.env.NEXT_PUBLIC_DOUBAN_PROXY_TYPE || 'cmliussss-cdn-tencent';
let doubanProxy = process.env.NEXT_PUBLIC_DOUBAN_PROXY || '';
let doubanImageProxyType =
process.env.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE || 'cmliussss-cdn-tencent';
let doubanImageProxy = process.env.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY || '';
- let disableYellowFilter =
- process.env.NEXT_PUBLIC_DISABLE_YELLOW_FILTER === 'true';
let fluidSearch = process.env.NEXT_PUBLIC_FLUID_SEARCH !== 'false';
let enableWebLive = false;
let customCategories = [] as {
@@ -69,7 +68,6 @@ export default async function RootLayout({
doubanProxy = config.SiteConfig.DoubanProxy;
doubanImageProxyType = config.SiteConfig.DoubanImageProxyType;
doubanImageProxy = config.SiteConfig.DoubanImageProxy;
- disableYellowFilter = config.SiteConfig.DisableYellowFilter;
customCategories = config.CustomCategories.filter(
(category) => !category.disabled
).map((category) => ({
@@ -88,7 +86,6 @@ export default async function RootLayout({
DOUBAN_PROXY: doubanProxy,
DOUBAN_IMAGE_PROXY_TYPE: doubanImageProxyType,
DOUBAN_IMAGE_PROXY: doubanImageProxy,
- DISABLE_YELLOW_FILTER: disableYellowFilter,
CUSTOM_CATEGORIES: customCategories,
FLUID_SEARCH: fluidSearch,
ENABLE_WEB_LIVE: enableWebLive,
@@ -111,7 +108,7 @@ export default async function RootLayout({
/>
([]);
- const [currentChannel, setCurrentChannel] = useState(null);
+ const [currentChannel, setCurrentChannel] = useState(
+ null
+ );
useEffect(() => {
currentChannelRef.current = currentChannel;
}, [currentChannel]);
@@ -90,11 +92,15 @@ function LivePageClient() {
const [isSwitchingSource, setIsSwitchingSource] = useState(false);
// 分组相关
- const [groupedChannels, setGroupedChannels] = useState<{ [key: string]: LiveChannel[] }>({});
+ const [groupedChannels, setGroupedChannels] = useState<{
+ [key: string]: LiveChannel[];
+ }>({});
const [selectedGroup, setSelectedGroup] = useState('');
// Tab 切换
- const [activeTab, setActiveTab] = useState<'channels' | 'sources'>('channels');
+ const [activeTab, setActiveTab] = useState<'channels' | 'sources'>(
+ 'channels'
+ );
// 频道列表收起状态
const [isChannelListCollapsed, setIsChannelListCollapsed] = useState(false);
@@ -123,22 +129,40 @@ function LivePageClient() {
const currentChannelRef = useRef(null);
// EPG数据清洗函数 - 去除重叠的节目,保留时间较短的,只显示今日节目
- const cleanEpgData = (programs: Array<{ start: string; end: string; title: string }>) => {
+ const cleanEpgData = (
+ programs: Array<{ start: string; end: string; title: string }>
+ ) => {
if (!programs || programs.length === 0) return programs;
// 获取今日日期(只考虑年月日,忽略时间)
const today = new Date();
- const todayStart = new Date(today.getFullYear(), today.getMonth(), today.getDate());
- const todayEnd = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 1);
+ const todayStart = new Date(
+ today.getFullYear(),
+ today.getMonth(),
+ today.getDate()
+ );
+ const todayEnd = new Date(
+ today.getFullYear(),
+ today.getMonth(),
+ today.getDate() + 1
+ );
// 首先过滤出今日的节目(包括跨天节目)
- const todayPrograms = programs.filter(program => {
+ const todayPrograms = programs.filter((program) => {
const programStart = parseCustomTimeFormat(program.start);
const programEnd = parseCustomTimeFormat(program.end);
// 获取节目的日期范围
- const programStartDate = new Date(programStart.getFullYear(), programStart.getMonth(), programStart.getDate());
- const programEndDate = new Date(programEnd.getFullYear(), programEnd.getMonth(), programEnd.getDate());
+ const programStartDate = new Date(
+ programStart.getFullYear(),
+ programStart.getMonth(),
+ programStart.getDate()
+ );
+ const programEndDate = new Date(
+ programEnd.getFullYear(),
+ programEnd.getMonth(),
+ programEnd.getDate()
+ );
// 如果节目的开始时间或结束时间在今天,或者节目跨越今天,都算作今天的节目
return (
@@ -155,7 +179,11 @@ function LivePageClient() {
return startA - startB;
});
- const cleanedPrograms: Array<{ start: string; end: string; title: string }> = [];
+ const cleanedPrograms: Array<{
+ start: string;
+ end: string;
+ title: string;
+ }> = [];
for (let i = 0; i < sortedPrograms.length; i++) {
const currentProgram = sortedPrograms[i];
@@ -197,8 +225,10 @@ function LivePageClient() {
(currentStart <= existingStart && currentEnd >= existingEnd)
) {
// 计算节目时长
- const currentDuration = currentEnd.getTime() - currentStart.getTime();
- const existingDuration = existingEnd.getTime() - existingStart.getTime();
+ const currentDuration =
+ currentEnd.getTime() - currentStart.getTime();
+ const existingDuration =
+ existingEnd.getTime() - existingStart.getTime();
// 如果当前节目时间更短,则替换已存在的节目
if (currentDuration < existingDuration) {
@@ -250,7 +280,9 @@ function LivePageClient() {
// 默认选中第一个源
const firstSource = sources[0];
if (needLoadSource) {
- const foundSource = sources.find((s: LiveSource) => s.key === needLoadSource);
+ const foundSource = sources.find(
+ (s: LiveSource) => s.key === needLoadSource
+ );
if (foundSource) {
setCurrentSource(foundSource);
await fetchChannels(foundSource);
@@ -313,8 +345,8 @@ function LivePageClient() {
setFilteredChannels([]);
// 更新直播源的频道数为 0
- setLiveSources(prevSources =>
- prevSources.map(s =>
+ setLiveSources((prevSources) =>
+ prevSources.map((s) =>
s.key === source.key ? { ...s, channelNumber: 0 } : s
)
);
@@ -330,14 +362,14 @@ function LivePageClient() {
name: channel.name,
logo: channel.logo,
group: channel.group || '其他',
- url: channel.url
+ url: channel.url,
}));
setCurrentChannels(channels);
// 更新直播源的频道数
- setLiveSources(prevSources =>
- prevSources.map(s =>
+ setLiveSources((prevSources) =>
+ prevSources.map((s) =>
s.key === source.key ? { ...s, channelNumber: channels.length } : s
)
);
@@ -345,7 +377,9 @@ function LivePageClient() {
// 默认选中第一个频道
if (channels.length > 0) {
if (needLoadChannel) {
- const foundChannel = channels.find((c: LiveChannel) => c.id === needLoadChannel);
+ const foundChannel = channels.find(
+ (c: LiveChannel) => c.id === needLoadChannel
+ );
if (foundChannel) {
setCurrentChannel(foundChannel);
setVideoUrl(foundChannel.url);
@@ -378,7 +412,9 @@ function LivePageClient() {
// 默认选中当前加载的channel所在的分组,如果没有则选中第一个分组
let targetGroup = '';
if (needLoadChannel) {
- const foundChannel = channels.find((c: LiveChannel) => c.id === needLoadChannel);
+ const foundChannel = channels.find(
+ (c: LiveChannel) => c.id === needLoadChannel
+ );
if (foundChannel) {
targetGroup = foundChannel.group || '其他';
}
@@ -412,8 +448,8 @@ function LivePageClient() {
setFilteredChannels([]);
// 更新直播源的频道数为 0
- setLiveSources(prevSources =>
- prevSources.map(s =>
+ setLiveSources((prevSources) =>
+ prevSources.map((s) =>
s.key === source.key ? { ...s, channelNumber: 0 } : s
)
);
@@ -473,14 +509,16 @@ function LivePageClient() {
if (channel.tvgId && currentSource) {
try {
setIsEpgLoading(true); // 开始加载 EPG 数据
- const response = await fetch(`/api/live/epg?source=${currentSource.key}&tvgId=${channel.tvgId}`);
+ const response = await fetch(
+ `/api/live/epg?source=${currentSource.key}&tvgId=${channel.tvgId}`
+ );
if (response.ok) {
const result = await response.json();
if (result.success) {
// 清洗EPG数据,去除重叠的节目
const cleanedData = {
...result.data,
- programs: cleanEpgData(result.data.programs)
+ programs: cleanEpgData(result.data.programs),
};
setEpgData(cleanedData);
}
@@ -502,7 +540,9 @@ function LivePageClient() {
if (!channelListRef.current) return;
// 使用 data 属性来查找频道元素
- const targetElement = channelListRef.current.querySelector(`[data-channel-id="${channel.id}"]`) as HTMLButtonElement;
+ const targetElement = channelListRef.current.querySelector(
+ `[data-channel-id="${channel.id}"]`
+ ) as HTMLButtonElement;
if (targetElement) {
// 计算滚动位置,使频道居中显示
@@ -511,12 +551,16 @@ function LivePageClient() {
const elementRect = targetElement.getBoundingClientRect();
// 计算目标滚动位置
- const scrollTop = container.scrollTop + (elementRect.top - containerRect.top) - (containerRect.height / 2) + (elementRect.height / 2);
+ const scrollTop =
+ container.scrollTop +
+ (elementRect.top - containerRect.top) -
+ containerRect.height / 2 +
+ elementRect.height / 2;
// 平滑滚动到目标位置
container.scrollTo({
top: Math.max(0, scrollTop),
- behavior: 'smooth'
+ behavior: 'smooth',
});
}
};
@@ -535,7 +579,9 @@ function LivePageClient() {
}
// 直接通过 data-group 属性查找目标按钮
- const targetButton = groupContainerRef.current.querySelector(`[data-group="${group}"]`) as HTMLButtonElement;
+ const targetButton = groupContainerRef.current.querySelector(
+ `[data-group="${group}"]`
+ ) as HTMLButtonElement;
if (targetButton) {
// 手动设置分组状态,确保状态一致性
@@ -629,11 +675,16 @@ function LivePageClient() {
if (isSwitchingSource) return;
setSelectedGroup(group);
- const filtered = currentChannels.filter(channel => channel.group === group);
+ const filtered = currentChannels.filter(
+ (channel) => channel.group === group
+ );
setFilteredChannels(filtered);
// 如果当前选中的频道在新的分组中,自动滚动到该频道位置
- if (currentChannel && filtered.some(channel => channel.id === currentChannel.id)) {
+ if (
+ currentChannel &&
+ filtered.some((channel) => channel.id === currentChannel.id)
+ ) {
setTimeout(() => {
scrollToChannel(currentChannel);
}, 100);
@@ -642,7 +693,7 @@ function LivePageClient() {
if (channelListRef.current) {
channelListRef.current.scrollTo({
top: 0,
- behavior: 'smooth'
+ behavior: 'smooth',
});
}
}
@@ -664,19 +715,28 @@ function LivePageClient() {
try {
if (newFavorited) {
// 如果未收藏,添加收藏
- await saveFavorite(`live_${currentSourceRef.current.key}`, `live_${currentChannelRef.current.id}`, {
- title: currentChannelRef.current.name,
- source_name: currentSourceRef.current.name,
- year: '',
- cover: `/api/proxy/logo?url=${encodeURIComponent(currentChannelRef.current.logo)}&source=${currentSourceRef.current.key}`,
- total_episodes: 1,
- save_time: Date.now(),
- search_title: '',
- origin: 'live',
- });
+ await saveFavorite(
+ `live_${currentSourceRef.current.key}`,
+ `live_${currentChannelRef.current.id}`,
+ {
+ title: currentChannelRef.current.name,
+ source_name: currentSourceRef.current.name,
+ year: '',
+ cover: `/api/proxy/logo?url=${encodeURIComponent(
+ currentChannelRef.current.logo
+ )}&source=${currentSourceRef.current.key}`,
+ total_episodes: 1,
+ save_time: Date.now(),
+ search_title: '',
+ origin: 'live',
+ }
+ );
} else {
// 如果已收藏,删除收藏
- await deleteFavorite(`live_${currentSourceRef.current.key}`, `live_${currentChannelRef.current.id}`);
+ await deleteFavorite(
+ `live_${currentSourceRef.current.key}`,
+ `live_${currentChannelRef.current.id}`
+ );
}
} catch (err) {
console.error('收藏操作失败:', err);
@@ -699,7 +759,10 @@ function LivePageClient() {
if (!currentSource || !currentChannel) return;
(async () => {
try {
- const fav = await checkIsFavorited(`live_${currentSource.key}`, `live_${currentChannel.id}`);
+ const fav = await checkIsFavorited(
+ `live_${currentSource.key}`,
+ `live_${currentChannel.id}`
+ );
setFavorited(fav);
favoritedRef.current = fav;
} catch (err) {
@@ -715,7 +778,10 @@ function LivePageClient() {
const unsubscribe = subscribeToDataUpdates(
'favoritesUpdated',
(favorites: Record) => {
- const key = generateStorageKey(`live_${currentSource.key}`, `live_${currentChannel.id}`);
+ const key = generateStorageKey(
+ `live_${currentSource.key}`,
+ `live_${currentChannel.id}`
+ );
const isFav = !!favorites[key];
setFavorited(isFav);
favoritedRef.current = isFav;
@@ -765,7 +831,10 @@ function LivePageClient() {
// 所有的请求都带一个 source 参数
try {
const url = new URL(context.url);
- url.searchParams.set('moontv-source', currentSourceRef.current?.key || '');
+ url.searchParams.set(
+ 'moontv-source',
+ currentSourceRef.current?.key || ''
+ );
context.url = url.toString();
} catch (error) {
// ignore
@@ -776,7 +845,8 @@ function LivePageClient() {
(context as any).type === 'level'
) {
// 判断是否浏览器直连
- const isLiveDirectConnectStr = localStorage.getItem('liveDirectConnect');
+ const isLiveDirectConnectStr =
+ localStorage.getItem('liveDirectConnect');
const isLiveDirectConnect = isLiveDirectConnectStr === 'true';
if (isLiveDirectConnect) {
// 浏览器直连,使用 URL 对象处理参数
@@ -867,7 +937,9 @@ function LivePageClient() {
// precheck type
let type = 'm3u8';
- const precheckUrl = `/api/live/precheck?url=${encodeURIComponent(videoUrl)}&moontv-source=${currentSourceRef.current?.key || ''}`;
+ const precheckUrl = `/api/live/precheck?url=${encodeURIComponent(
+ videoUrl
+ )}&moontv-source=${currentSourceRef.current?.key || ''}`;
const precheckResponse = await fetch(precheckUrl);
if (!precheckResponse.ok) {
console.error('预检查失败:', precheckResponse.statusText);
@@ -889,7 +961,9 @@ function LivePageClient() {
setUnsupportedType(null);
const customType = { m3u8: m3u8Loader };
- const targetUrl = `/api/proxy/m3u8?url=${encodeURIComponent(videoUrl)}&moontv-source=${currentSourceRef.current?.key || ''}`;
+ const targetUrl = `/api/proxy/m3u8?url=${encodeURIComponent(
+ videoUrl
+ )}&moontv-source=${currentSourceRef.current?.key || ''}`;
try {
// 创建新的播放器实例
Artplayer.USE_RAF = false;
@@ -942,7 +1016,6 @@ function LivePageClient() {
artPlayerRef.current.on('ready', () => {
setError(null);
setIsVideoLoading(false);
-
});
artPlayerRef.current.on('loadstart', () => {
@@ -971,12 +1044,11 @@ function LivePageClient() {
targetUrl
);
}
-
} catch (err) {
console.error('创建播放器失败:', err);
// 不设置错误,只记录日志
}
- }
+ };
preload();
}, [Artplayer, Hls, videoUrl, currentChannel, loading]);
@@ -1065,15 +1137,15 @@ function LivePageClient() {
{/* 动画直播图标 */}
-
+
{/* 浮动粒子效果 */}
-
+
{/* 进度条 */}
@@ -1159,7 +1244,7 @@ function LivePageClient() {
window.location.reload()}
- className='w-full px-6 py-3 bg-gradient-to-r from-blue-500 to-cyan-600 text-white rounded-xl font-medium hover:from-blue-600 hover:to-cyan-700 transform hover:scale-105 transition-all duration-200 shadow-lg hover:shadow-xl'
+ className='w-full px-6 py-3 bg-theme-primary hover:bg-theme-primary-hover text-white rounded-xl font-medium transform hover:scale-105 transition-all duration-200 shadow-lg hover:shadow-xl'
>
🔄 重新尝试
@@ -1176,7 +1261,7 @@ function LivePageClient() {
{/* 第一行:页面标题 */}
-
+
{currentSource?.name}
@@ -1200,17 +1285,14 @@ function LivePageClient() {
{/* 折叠控制 - 仅在 lg 及以上屏幕显示 */}
- setIsChannelListCollapsed(!isChannelListCollapsed)
- }
+ onClick={() => setIsChannelListCollapsed(!isChannelListCollapsed)}
className='group relative flex items-center space-x-1.5 px-3 py-1.5 rounded-full bg-white/80 hover:bg-white dark:bg-gray-800/80 dark:hover:bg-gray-800 backdrop-blur-sm border border-gray-200/50 dark:border-gray-700/50 shadow-sm hover:shadow-md transition-all duration-200'
- title={
- isChannelListCollapsed ? '显示频道列表' : '隐藏频道列表'
- }
+ title={isChannelListCollapsed ? '显示频道列表' : '隐藏频道列表'}
>
-
+
{/* 播放器 */}
-
+
- 当前频道直播流类型:{unsupportedType.toUpperCase()}
+ 当前频道直播流类型:
+
+ {unsupportedType.toUpperCase()}
+
目前仅支持 M3U8 格式的直播流
-
- 请尝试其他频道
-
+
请尝试其他频道
@@ -1283,9 +1374,9 @@ function LivePageClient() {
-
@@ -1300,19 +1391,23 @@ function LivePageClient() {
{/* 频道列表 */}
-
+
{/* 主要的 Tab 切换 */}
setActiveTab('channels')}
className={`flex-1 py-3 px-6 text-center cursor-pointer transition-all duration-200 font-medium
- ${activeTab === 'channels'
- ? 'text-green-600 dark:text-green-400'
- : 'text-gray-700 hover:text-green-600 bg-black/5 dark:bg-white/5 dark:text-gray-300 dark:hover:text-green-400 hover:bg-black/3 dark:hover:bg-white/3'
+ ${
+ activeTab === 'channels'
+ ? 'text-theme-primary'
+ : 'text-gray-700 hover:text-theme-primary bg-black/5 dark:bg-white/5 dark:text-gray-300 hover:bg-black/3 dark:hover:bg-white/3'
}
`.trim()}
>
@@ -1321,9 +1416,10 @@ function LivePageClient() {
setActiveTab('sources')}
className={`flex-1 py-3 px-6 text-center cursor-pointer transition-all duration-200 font-medium
- ${activeTab === 'sources'
- ? 'text-green-600 dark:text-green-400'
- : 'text-gray-700 hover:text-green-600 bg-black/5 dark:bg-white/5 dark:text-gray-300 dark:hover:text-green-400 hover:bg-black/3 dark:hover:bg-white/3'
+ ${
+ activeTab === 'sources'
+ ? 'text-theme-primary'
+ : 'text-gray-700 hover:text-theme-primary bg-black/5 dark:bg-white/5 dark:text-gray-300 hover:bg-black/3 dark:hover:bg-white/3'
}
`.trim()}
>
@@ -1352,12 +1448,16 @@ function LivePageClient() {
const container = groupContainerRef.current;
if (container) {
const handleWheel = (e: WheelEvent) => {
- if (container.scrollWidth > container.clientWidth) {
+ if (
+ container.scrollWidth > container.clientWidth
+ ) {
e.preventDefault();
container.scrollLeft += e.deltaY;
}
};
- container.addEventListener('wheel', handleWheel, { passive: false });
+ container.addEventListener('wheel', handleWheel, {
+ passive: false,
+ });
// 将事件处理器存储在容器上,以便后续移除
(container as any)._wheelHandler = handleWheel;
}
@@ -1366,7 +1466,10 @@ function LivePageClient() {
// 鼠标离开分组标签区域时,移除滚轮事件监听
const container = groupContainerRef.current;
if (container && (container as any)._wheelHandler) {
- container.removeEventListener('wheel', (container as any)._wheelHandler);
+ container.removeEventListener(
+ 'wheel',
+ (container as any)._wheelHandler
+ );
delete (container as any)._wheelHandler;
}
}}
@@ -1382,20 +1485,25 @@ function LivePageClient() {
onClick={() => handleGroupChange(group)}
disabled={isSwitchingSource}
className={`w-20 relative py-2 text-sm font-medium transition-colors flex-shrink-0 text-center overflow-hidden
- ${isSwitchingSource
- ? 'text-gray-400 dark:text-gray-600 cursor-not-allowed opacity-50'
- : selectedGroup === group
- ? 'text-green-500 dark:text-green-400'
- : 'text-gray-700 hover:text-green-600 dark:text-gray-300 dark:hover:text-green-400'
- }
+ ${
+ isSwitchingSource
+ ? 'text-gray-400 dark:text-gray-600 cursor-not-allowed opacity-50'
+ : selectedGroup === group
+ ? 'text-theme-primary'
+ : 'text-gray-700 hover:text-theme-primary dark:text-gray-300'
+ }
`.trim()}
>
-
+
{group}
- {selectedGroup === group && !isSwitchingSource && (
-
- )}
+ {selectedGroup === group &&
+ !isSwitchingSource && (
+
+ )}
))}
@@ -1403,9 +1511,12 @@ function LivePageClient() {
{/* 频道列表 */}
-
+
{filteredChannels.length > 0 ? (
- filteredChannels.map(channel => {
+ filteredChannels.map((channel) => {
const isActive = channel.id === currentChannel?.id;
return (
handleChannelChange(channel)}
disabled={isSwitchingSource}
- className={`w-full p-3 rounded-lg text-left transition-all duration-200 ${isSwitchingSource
- ? 'opacity-50 cursor-not-allowed'
- : isActive
- ? 'bg-green-100 dark:bg-green-900/30 border border-green-300 dark:border-green-700'
+ className={`w-full p-3 rounded-lg text-left transition-all duration-200 ${
+ isSwitchingSource
+ ? 'opacity-50 cursor-not-allowed'
+ : isActive
+ ? 'bg-theme-primary-soft border border-theme-primary/30'
: 'hover:bg-gray-100 dark:hover:bg-gray-700'
- }`}
+ }`}
>
{channel.logo ? (
) : (
)}
-
+
{channel.name}
-
@@ -1468,15 +1588,19 @@ function LivePageClient() {
{liveSources.length > 0 ? (
liveSources.map((source) => {
- const isCurrentSource = source.key === currentSource?.key;
+ const isCurrentSource =
+ source.key === currentSource?.key;
return (
!isCurrentSource && handleSourceChange(source)}
+ onClick={() =>
+ !isCurrentSource && handleSourceChange(source)
+ }
className={`flex items-start gap-3 px-2 py-3 rounded-lg transition-all select-none duration-200 relative
- ${isCurrentSource
- ? 'bg-green-500/10 dark:bg-green-500/20 border-green-500/30 border'
- : 'hover:bg-gray-200/50 dark:hover:bg-white/10 hover:scale-[1.02] cursor-pointer'
+ ${
+ isCurrentSource
+ ? 'bg-theme-primary-soft/70 border-theme-primary/30 border'
+ : 'hover:bg-gray-200/50 dark:hover:bg-white/10 hover:scale-[1.02] cursor-pointer'
}`.trim()}
>
{/* 图标 */}
@@ -1490,13 +1614,16 @@ function LivePageClient() {
{source.name}
- {!source.channelNumber || source.channelNumber === 0 ? '-' : `${source.channelNumber} 个频道`}
+ {!source.channelNumber ||
+ source.channelNumber === 0
+ ? '-'
+ : `${source.channelNumber} 个频道`}
{/* 当前标识 */}
{isCurrentSource && (
-
+
)}
);
@@ -1532,10 +1659,12 @@ function LivePageClient() {
{currentChannel.logo ? (
) : (
diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx
index a0f2e72991..955ecb29f4 100644
--- a/src/app/login/page.tsx
+++ b/src/app/login/page.tsx
@@ -34,20 +34,19 @@ function VersionDisplay() {
return (
- window.open('https://github.com/MoonTechLab/LunaTV', '_blank')
- }
+ onClick={() => window.open('https://github.com/liiider/LunaTV', '_blank')}
className='absolute bottom-4 left-1/2 transform -translate-x-1/2 flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400 transition-colors cursor-pointer'
>
v{CURRENT_VERSION}
{!isChecking && updateStatus !== UpdateStatus.FETCH_FAILED && (
{updateStatus === UpdateStatus.HAS_UPDATE && (
<>
@@ -119,15 +118,13 @@ function LoginPageClient() {
}
};
-
-
return (