Files
MoonTVPlus/src/app/admin/page.tsx
2025-12-03 20:21:44 +08:00

4977 lines
197 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* eslint-disable @typescript-eslint/no-explicit-any, no-console, @typescript-eslint/no-non-null-assertion,react-hooks/exhaustive-deps,@typescript-eslint/no-empty-function */
'use client';
import {
closestCenter,
DndContext,
PointerSensor,
TouchSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
restrictToParentElement,
restrictToVerticalAxis,
} from '@dnd-kit/modifiers';
import {
arrayMove,
SortableContext,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import {
AlertCircle,
AlertTriangle,
Check,
CheckCircle,
ChevronDown,
ChevronUp,
Database,
ExternalLink,
FileText,
FolderOpen,
Settings,
Tv,
Users,
Video,
} from 'lucide-react';
import { GripVertical } from 'lucide-react';
import { Suspense, useCallback, useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import { AdminConfig, AdminConfigResult } from '@/lib/admin.types';
import { getAuthInfoFromBrowserCookie } from '@/lib/auth';
import DataMigration from '@/components/DataMigration';
import PageLayout from '@/components/PageLayout';
// 统一按钮样式系统
const buttonStyles = {
// 主要操作按钮(蓝色)- 用于配置、设置、确认等
primary: 'px-3 py-1.5 text-sm font-medium bg-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-700 text-white rounded-lg transition-colors',
// 成功操作按钮(绿色)- 用于添加、启用、保存等
success: 'px-3 py-1.5 text-sm font-medium bg-green-600 hover:bg-green-700 dark:bg-green-600 dark:hover:bg-green-700 text-white rounded-lg transition-colors',
// 危险操作按钮(红色)- 用于删除、禁用、重置等
danger: 'px-3 py-1.5 text-sm font-medium bg-red-600 hover:bg-red-700 dark:bg-red-600 dark:hover:bg-red-700 text-white rounded-lg transition-colors',
// 次要操作按钮(灰色)- 用于取消、关闭等
secondary: 'px-3 py-1.5 text-sm font-medium bg-gray-600 hover:bg-gray-700 dark:bg-gray-600 dark:hover:bg-gray-700 text-white rounded-lg transition-colors',
// 警告操作按钮(黄色)- 用于批量禁用等
warning: 'px-3 py-1.5 text-sm font-medium bg-yellow-600 hover:bg-yellow-700 dark:bg-yellow-600 dark:hover:bg-yellow-700 text-white rounded-lg transition-colors',
// 小尺寸主要按钮
primarySmall: 'px-2 py-1 text-xs font-medium bg-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-700 text-white rounded-md transition-colors',
// 小尺寸成功按钮
successSmall: 'px-2 py-1 text-xs font-medium bg-green-600 hover:bg-green-700 dark:bg-green-600 dark:hover:bg-green-700 text-white rounded-md transition-colors',
// 小尺寸危险按钮
dangerSmall: 'px-2 py-1 text-xs font-medium bg-red-600 hover:bg-red-700 dark:bg-red-600 dark:hover:bg-red-700 text-white rounded-md transition-colors',
// 小尺寸次要按钮
secondarySmall: 'px-2 py-1 text-xs font-medium bg-gray-600 hover:bg-gray-700 dark:bg-gray-600 dark:hover:bg-gray-700 text-white rounded-md transition-colors',
// 小尺寸警告按钮
warningSmall: 'px-2 py-1 text-xs font-medium bg-yellow-600 hover:bg-yellow-700 dark:bg-yellow-600 dark:hover:bg-yellow-700 text-white rounded-md transition-colors',
// 圆角小按钮(用于表格操作)
roundedPrimary: 'inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 hover:bg-blue-200 dark:bg-blue-900/40 dark:hover:bg-blue-900/60 dark:text-blue-200 transition-colors',
roundedSuccess: 'inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium bg-green-100 text-green-800 hover:bg-green-200 dark:bg-green-900/40 dark:hover:bg-green-900/60 dark:text-green-200 transition-colors',
roundedDanger: 'inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium bg-red-100 text-red-800 hover:bg-red-200 dark:bg-red-900/40 dark:hover:bg-red-900/60 dark:text-red-200 transition-colors',
roundedSecondary: 'inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 hover:bg-gray-200 dark:bg-gray-700/40 dark:hover:bg-gray-700/60 dark:text-gray-200 transition-colors',
roundedWarning: 'inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 hover:bg-yellow-200 dark:bg-yellow-900/40 dark:hover:bg-yellow-900/60 dark:text-yellow-200 transition-colors',
roundedPurple: 'inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800 hover:bg-purple-200 dark:bg-purple-900/40 dark:hover:bg-purple-900/60 dark:text-purple-200 transition-colors',
// 禁用状态
disabled: 'px-3 py-1.5 text-sm font-medium bg-gray-400 dark:bg-gray-600 cursor-not-allowed text-white rounded-lg transition-colors',
disabledSmall: 'px-2 py-1 text-xs font-medium bg-gray-400 dark:bg-gray-600 cursor-not-allowed text-white rounded-md transition-colors',
// 开关按钮样式
toggleOn: 'bg-green-600 dark:bg-green-600',
toggleOff: 'bg-gray-200 dark:bg-gray-700',
toggleThumb: 'bg-white',
toggleThumbOn: 'translate-x-6',
toggleThumbOff: 'translate-x-1',
// 快速操作按钮样式
quickAction: 'px-3 py-1.5 text-xs font-medium text-gray-600 dark:text-gray-400 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 rounded-md transition-colors',
};
// 通用弹窗组件
interface AlertModalProps {
isOpen: boolean;
onClose: () => void;
type: 'success' | 'error' | 'warning';
title: string;
message?: string;
timer?: number;
showConfirm?: boolean;
}
const AlertModal = ({
isOpen,
onClose,
type,
title,
message,
timer,
showConfirm = false
}: AlertModalProps) => {
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
if (isOpen) {
setIsVisible(true);
if (timer) {
setTimeout(() => {
onClose();
}, timer);
}
} else {
setIsVisible(false);
}
}, [isOpen, timer, onClose]);
if (!isOpen) return null;
const getIcon = () => {
switch (type) {
case 'success':
return <CheckCircle className="w-8 h-8 text-green-500" />;
case 'error':
return <AlertCircle className="w-8 h-8 text-red-500" />;
case 'warning':
return <AlertTriangle className="w-8 h-8 text-yellow-500" />;
default:
return null;
}
};
const getBgColor = () => {
switch (type) {
case 'success':
return 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800';
case 'error':
return 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800';
case 'warning':
return 'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800';
default:
return 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800';
}
};
return createPortal(
<div className={`fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4 transition-opacity duration-200 ${isVisible ? 'opacity-100' : 'opacity-0'}`}>
<div className={`bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-sm w-full border ${getBgColor()} transition-all duration-200 ${isVisible ? 'scale-100' : 'scale-95'}`}>
<div className="p-6 text-center">
<div className="flex justify-center mb-4">
{getIcon()}
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
{title}
</h3>
{message && (
<p className="text-gray-600 dark:text-gray-400 mb-4">
{message}
</p>
)}
{showConfirm && (
<button
onClick={onClose}
className={`px-4 py-2 text-sm font-medium ${buttonStyles.primary}`}
>
</button>
)}
</div>
</div>
</div>,
document.body
);
};
// 弹窗状态管理
const useAlertModal = () => {
const [alertModal, setAlertModal] = useState<{
isOpen: boolean;
type: 'success' | 'error' | 'warning';
title: string;
message?: string;
timer?: number;
showConfirm?: boolean;
}>({
isOpen: false,
type: 'success',
title: '',
});
const showAlert = (config: Omit<typeof alertModal, 'isOpen'>) => {
setAlertModal({ ...config, isOpen: true });
};
const hideAlert = () => {
setAlertModal(prev => ({ ...prev, isOpen: false }));
};
return { alertModal, showAlert, hideAlert };
};
// 统一弹窗方法(必须在首次使用前定义)
const showError = (message: string, showAlert?: (config: any) => void) => {
if (showAlert) {
showAlert({ type: 'error', title: '错误', message, showConfirm: true });
} else {
console.error(message);
}
};
const showSuccess = (message: string, showAlert?: (config: any) => void) => {
if (showAlert) {
showAlert({ type: 'success', title: '成功', message, timer: 2000 });
} else {
console.log(message);
}
};
// 通用加载状态管理系统
interface LoadingState {
[key: string]: boolean;
}
const useLoadingState = () => {
const [loadingStates, setLoadingStates] = useState<LoadingState>({});
const setLoading = (key: string, loading: boolean) => {
setLoadingStates(prev => ({ ...prev, [key]: loading }));
};
const isLoading = (key: string) => loadingStates[key] || false;
const withLoading = async (key: string, operation: () => Promise<any>): Promise<any> => {
setLoading(key, true);
try {
const result = await operation();
return result;
} finally {
setLoading(key, false);
}
};
return { loadingStates, setLoading, isLoading, withLoading };
};
// 新增站点配置类型
interface SiteConfig {
SiteName: string;
Announcement: string;
SearchDownstreamMaxPage: number;
SiteInterfaceCacheTime: number;
DoubanProxyType: string;
DoubanProxy: string;
DoubanImageProxyType: string;
DoubanImageProxy: string;
DisableYellowFilter: boolean;
FluidSearch: boolean;
DanmakuApiBase: string;
DanmakuApiToken: string;
EnableComments: boolean;
}
// 视频源数据类型
interface DataSource {
name: string;
key: string;
api: string;
detail?: string;
disabled?: boolean;
from: 'config' | 'custom';
}
// 直播源数据类型
interface LiveDataSource {
name: string;
key: string;
url: string;
ua?: string;
epg?: string;
channelNumber?: number;
disabled?: boolean;
from: 'config' | 'custom';
}
// 自定义分类数据类型
interface CustomCategory {
name?: string;
type: 'movie' | 'tv';
query: string;
disabled?: boolean;
from: 'config' | 'custom';
}
// 可折叠标签组件
interface CollapsibleTabProps {
title: string;
icon?: React.ReactNode;
isExpanded: boolean;
onToggle: () => void;
children: React.ReactNode;
}
const CollapsibleTab = ({
title,
icon,
isExpanded,
onToggle,
children,
}: CollapsibleTabProps) => {
return (
<div className='rounded-xl shadow-sm mb-4 overflow-hidden bg-white/80 backdrop-blur-md dark:bg-gray-800/50 dark:ring-1 dark:ring-gray-700'>
<button
onClick={onToggle}
className='w-full px-6 py-4 flex items-center justify-between bg-gray-50/70 dark:bg-gray-800/60 hover:bg-gray-100/80 dark:hover:bg-gray-700/60 transition-colors'
>
<div className='flex items-center gap-3'>
{icon}
<h3 className='text-lg font-medium text-gray-900 dark:text-gray-100'>
{title}
</h3>
</div>
<div className='text-gray-500 dark:text-gray-400'>
{isExpanded ? <ChevronUp size={20} /> : <ChevronDown size={20} />}
</div>
</button>
{isExpanded && <div className='px-6 py-4'>{children}</div>}
</div>
);
};
// 用户配置组件
interface UserConfigProps {
config: AdminConfig | null;
role: 'owner' | 'admin' | null;
refreshConfig: () => Promise<void>;
}
const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
const { alertModal, showAlert, hideAlert } = useAlertModal();
const { isLoading, withLoading } = useLoadingState();
const [showAddUserForm, setShowAddUserForm] = useState(false);
const [showChangePasswordForm, setShowChangePasswordForm] = useState(false);
const [showAddUserGroupForm, setShowAddUserGroupForm] = useState(false);
const [showEditUserGroupForm, setShowEditUserGroupForm] = useState(false);
const [newUser, setNewUser] = useState({
username: '',
password: '',
userGroup: '', // 新增用户组字段
});
const [changePasswordUser, setChangePasswordUser] = useState({
username: '',
password: '',
});
const [newUserGroup, setNewUserGroup] = useState({
name: '',
enabledApis: [] as string[],
});
const [editingUserGroup, setEditingUserGroup] = useState<{
name: string;
enabledApis: string[];
} | null>(null);
const [showConfigureApisModal, setShowConfigureApisModal] = useState(false);
const [selectedUser, setSelectedUser] = useState<{
username: string;
role: 'user' | 'admin' | 'owner';
enabledApis?: string[];
tags?: string[];
} | null>(null);
const [selectedApis, setSelectedApis] = useState<string[]>([]);
const [showConfigureUserGroupModal, setShowConfigureUserGroupModal] = useState(false);
const [selectedUserForGroup, setSelectedUserForGroup] = useState<{
username: string;
role: 'user' | 'admin' | 'owner';
tags?: string[];
} | null>(null);
const [selectedUserGroups, setSelectedUserGroups] = useState<string[]>([]);
const [selectedUsers, setSelectedUsers] = useState<Set<string>>(new Set());
const [showBatchUserGroupModal, setShowBatchUserGroupModal] = useState(false);
const [selectedUserGroup, setSelectedUserGroup] = useState<string>('');
const [showDeleteUserGroupModal, setShowDeleteUserGroupModal] = useState(false);
const [deletingUserGroup, setDeletingUserGroup] = useState<{
name: string;
affectedUsers: Array<{ username: string; role: 'user' | 'admin' | 'owner' }>;
} | null>(null);
const [showDeleteUserModal, setShowDeleteUserModal] = useState(false);
const [deletingUser, setDeletingUser] = useState<string | null>(null);
// 当前登录用户名
const currentUsername = getAuthInfoFromBrowserCookie()?.username || null;
// 使用 useMemo 计算全选状态,避免每次渲染都重新计算
const selectAllUsers = useMemo(() => {
const selectableUserCount = config?.UserConfig?.Users?.filter(user =>
(role === 'owner' ||
(role === 'admin' &&
(user.role === 'user' ||
user.username === currentUsername)))
).length || 0;
return selectedUsers.size === selectableUserCount && selectedUsers.size > 0;
}, [selectedUsers.size, config?.UserConfig?.Users, role, currentUsername]);
// 获取用户组列表
const userGroups = config?.UserConfig?.Tags || [];
// 处理用户组相关操作
const handleUserGroupAction = async (
action: 'add' | 'edit' | 'delete',
groupName: string,
enabledApis?: string[]
) => {
return withLoading(`userGroup_${action}_${groupName}`, async () => {
try {
const res = await fetch('/api/admin/user', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'userGroup',
groupAction: action,
groupName,
enabledApis,
}),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || `操作失败: ${res.status}`);
}
await refreshConfig();
if (action === 'add') {
setNewUserGroup({ name: '', enabledApis: [] });
setShowAddUserGroupForm(false);
} else if (action === 'edit') {
setEditingUserGroup(null);
setShowEditUserGroupForm(false);
}
showSuccess(action === 'add' ? '用户组添加成功' : action === 'edit' ? '用户组更新成功' : '用户组删除成功', showAlert);
} catch (err) {
showError(err instanceof Error ? err.message : '操作失败', showAlert);
throw err;
}
});
};
const handleAddUserGroup = () => {
if (!newUserGroup.name.trim()) return;
handleUserGroupAction('add', newUserGroup.name, newUserGroup.enabledApis);
};
const handleEditUserGroup = () => {
if (!editingUserGroup?.name.trim()) return;
handleUserGroupAction('edit', editingUserGroup.name, editingUserGroup.enabledApis);
};
const handleDeleteUserGroup = (groupName: string) => {
// 计算会受影响的用户数量
const affectedUsers = config?.UserConfig?.Users?.filter(user =>
user.tags && user.tags.includes(groupName)
) || [];
setDeletingUserGroup({
name: groupName,
affectedUsers: affectedUsers.map(u => ({ username: u.username, role: u.role }))
});
setShowDeleteUserGroupModal(true);
};
const handleConfirmDeleteUserGroup = async () => {
if (!deletingUserGroup) return;
try {
await handleUserGroupAction('delete', deletingUserGroup.name);
setShowDeleteUserGroupModal(false);
setDeletingUserGroup(null);
} catch (err) {
// 错误处理已在 handleUserGroupAction 中处理
}
};
const handleStartEditUserGroup = (group: { name: string; enabledApis: string[] }) => {
setEditingUserGroup({ ...group });
setShowEditUserGroupForm(true);
setShowAddUserGroupForm(false);
};
// 为用户分配用户组
const handleAssignUserGroup = async (username: string, userGroups: string[]) => {
return withLoading(`assignUserGroup_${username}`, async () => {
try {
const res = await fetch('/api/admin/user', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
targetUsername: username,
action: 'updateUserGroups',
userGroups,
}),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || `操作失败: ${res.status}`);
}
await refreshConfig();
showSuccess('用户组分配成功', showAlert);
} catch (err) {
showError(err instanceof Error ? err.message : '操作失败', showAlert);
throw err;
}
});
};
const handleBanUser = async (uname: string) => {
await withLoading(`banUser_${uname}`, () => handleUserAction('ban', uname));
};
const handleUnbanUser = async (uname: string) => {
await withLoading(`unbanUser_${uname}`, () => handleUserAction('unban', uname));
};
const handleSetAdmin = async (uname: string) => {
await withLoading(`setAdmin_${uname}`, () => handleUserAction('setAdmin', uname));
};
const handleRemoveAdmin = async (uname: string) => {
await withLoading(`removeAdmin_${uname}`, () => handleUserAction('cancelAdmin', uname));
};
const handleAddUser = async () => {
if (!newUser.username || !newUser.password) return;
await withLoading('addUser', async () => {
await handleUserAction('add', newUser.username, newUser.password, newUser.userGroup);
setNewUser({ username: '', password: '', userGroup: '' });
setShowAddUserForm(false);
});
};
const handleChangePassword = async () => {
if (!changePasswordUser.username || !changePasswordUser.password) return;
await withLoading(`changePassword_${changePasswordUser.username}`, async () => {
await handleUserAction(
'changePassword',
changePasswordUser.username,
changePasswordUser.password
);
setChangePasswordUser({ username: '', password: '' });
setShowChangePasswordForm(false);
});
};
const handleShowChangePasswordForm = (username: string) => {
setChangePasswordUser({ username, password: '' });
setShowChangePasswordForm(true);
setShowAddUserForm(false); // 关闭添加用户表单
};
const handleDeleteUser = (username: string) => {
setDeletingUser(username);
setShowDeleteUserModal(true);
};
const handleConfigureUserApis = (user: {
username: string;
role: 'user' | 'admin' | 'owner';
enabledApis?: string[];
}) => {
setSelectedUser(user);
setSelectedApis(user.enabledApis || []);
setShowConfigureApisModal(true);
};
const handleConfigureUserGroup = (user: {
username: string;
role: 'user' | 'admin' | 'owner';
tags?: string[];
}) => {
setSelectedUserForGroup(user);
setSelectedUserGroups(user.tags || []);
setShowConfigureUserGroupModal(true);
};
const handleSaveUserGroups = async () => {
if (!selectedUserForGroup) return;
await withLoading(`saveUserGroups_${selectedUserForGroup.username}`, async () => {
try {
await handleAssignUserGroup(selectedUserForGroup.username, selectedUserGroups);
setShowConfigureUserGroupModal(false);
setSelectedUserForGroup(null);
setSelectedUserGroups([]);
} catch (err) {
// 错误处理已在 handleAssignUserGroup 中处理
}
});
};
// 处理用户选择
const handleSelectUser = useCallback((username: string, checked: boolean) => {
setSelectedUsers(prev => {
const newSelectedUsers = new Set(prev);
if (checked) {
newSelectedUsers.add(username);
} else {
newSelectedUsers.delete(username);
}
return newSelectedUsers;
});
}, []);
const handleSelectAllUsers = useCallback((checked: boolean) => {
if (checked) {
// 只选择自己有权限操作的用户
const selectableUsernames = config?.UserConfig?.Users?.filter(user =>
(role === 'owner' ||
(role === 'admin' &&
(user.role === 'user' ||
user.username === currentUsername)))
).map(u => u.username) || [];
setSelectedUsers(new Set(selectableUsernames));
} else {
setSelectedUsers(new Set());
}
}, [config?.UserConfig?.Users, role, currentUsername]);
// 批量设置用户组
const handleBatchSetUserGroup = async (userGroup: string) => {
if (selectedUsers.size === 0) return;
await withLoading('batchSetUserGroup', async () => {
try {
const res = await fetch('/api/admin/user', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'batchUpdateUserGroups',
usernames: Array.from(selectedUsers),
userGroups: userGroup === '' ? [] : [userGroup],
}),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || `操作失败: ${res.status}`);
}
const userCount = selectedUsers.size;
setSelectedUsers(new Set());
setShowBatchUserGroupModal(false);
setSelectedUserGroup('');
showSuccess(`已为 ${userCount} 个用户设置用户组: ${userGroup}`, showAlert);
// 刷新配置
await refreshConfig();
} catch (err) {
showError('批量设置用户组失败', showAlert);
throw err;
}
});
};
// 提取URL域名的辅助函数
const extractDomain = (url: string): string => {
try {
const urlObj = new URL(url);
return urlObj.hostname;
} catch {
// 如果URL格式不正确返回原字符串
return url;
}
};
const handleSaveUserApis = async () => {
if (!selectedUser) return;
await withLoading(`saveUserApis_${selectedUser.username}`, async () => {
try {
const res = await fetch('/api/admin/user', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
targetUsername: selectedUser.username,
action: 'updateUserApis',
enabledApis: selectedApis,
}),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || `操作失败: ${res.status}`);
}
// 成功后刷新配置
await refreshConfig();
setShowConfigureApisModal(false);
setSelectedUser(null);
setSelectedApis([]);
} catch (err) {
showError(err instanceof Error ? err.message : '操作失败', showAlert);
throw err;
}
});
};
// 通用请求函数
const handleUserAction = async (
action:
| 'add'
| 'ban'
| 'unban'
| 'setAdmin'
| 'cancelAdmin'
| 'changePassword'
| 'deleteUser',
targetUsername: string,
targetPassword?: string,
userGroup?: string
) => {
try {
const res = await fetch('/api/admin/user', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
targetUsername,
...(targetPassword ? { targetPassword } : {}),
...(userGroup ? { userGroup } : {}),
action,
}),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || `操作失败: ${res.status}`);
}
// 成功后刷新配置(无需整页刷新)
await refreshConfig();
} catch (err) {
showError(err instanceof Error ? err.message : '操作失败', showAlert);
}
};
const handleConfirmDeleteUser = async () => {
if (!deletingUser) return;
await withLoading(`deleteUser_${deletingUser}`, async () => {
try {
await handleUserAction('deleteUser', deletingUser);
setShowDeleteUserModal(false);
setDeletingUser(null);
} catch (err) {
// 错误处理已在 handleUserAction 中处理
}
});
};
if (!config) {
return (
<div className='text-center text-gray-500 dark:text-gray-400'>
...
</div>
);
}
return (
<div className='space-y-6'>
{/* 用户统计 */}
<div>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300 mb-3'>
</h4>
<div className='p-4 bg-green-50 dark:bg-green-900/20 rounded-lg border border-green-200 dark:border-green-800'>
<div className='text-2xl font-bold text-green-800 dark:text-green-300'>
{config.UserConfig.Users.length}
</div>
<div className='text-sm text-green-600 dark:text-green-400'>
</div>
</div>
</div>
{/* 用户组管理 */}
<div>
<div className='flex items-center justify-between mb-3'>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
</h4>
<button
onClick={() => {
setShowAddUserGroupForm(!showAddUserGroupForm);
if (showEditUserGroupForm) {
setShowEditUserGroupForm(false);
setEditingUserGroup(null);
}
}}
className={showAddUserGroupForm ? buttonStyles.secondary : buttonStyles.primary}
>
{showAddUserGroupForm ? '取消' : '添加用户组'}
</button>
</div>
{/* 用户组列表 */}
<div className='border border-gray-200 dark:border-gray-700 rounded-lg max-h-[20rem] overflow-y-auto overflow-x-auto relative'>
<table className='min-w-full divide-y divide-gray-200 dark:divide-gray-700'>
<thead className='bg-gray-50 dark:bg-gray-900 sticky top-0 z-10'>
<tr>
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
</th>
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
</th>
<th className='px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
</th>
</tr>
</thead>
<tbody className='divide-y divide-gray-200 dark:divide-gray-700'>
{userGroups.map((group) => (
<tr key={group.name} className='hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors'>
<td className='px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-gray-100'>
{group.name}
</td>
<td className='px-6 py-4 whitespace-nowrap'>
<div className='flex items-center space-x-2'>
<span className='text-sm text-gray-900 dark:text-gray-100'>
{group.enabledApis && group.enabledApis.length > 0
? `${group.enabledApis.length} 个源`
: '无限制'}
</span>
</div>
</td>
<td className='px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2'>
<button
onClick={() => handleStartEditUserGroup(group)}
disabled={isLoading(`userGroup_edit_${group.name}`)}
className={`${buttonStyles.roundedPrimary} ${isLoading(`userGroup_edit_${group.name}`) ? 'opacity-50 cursor-not-allowed' : ''}`}
>
</button>
<button
onClick={() => handleDeleteUserGroup(group.name)}
className={buttonStyles.roundedDanger}
>
</button>
</td>
</tr>
))}
{userGroups.length === 0 && (
<tr>
<td colSpan={3} className='px-6 py-8 text-center text-sm text-gray-500 dark:text-gray-400'>
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
{/* 用户列表 */}
<div>
<div className='flex items-center justify-between mb-3'>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
</h4>
<div className='flex items-center space-x-2'>
{/* 批量操作按钮 */}
{selectedUsers.size > 0 && (
<>
<div className='flex items-center space-x-3'>
<span className='text-sm text-gray-600 dark:text-gray-400'>
{selectedUsers.size}
</span>
<button
onClick={() => setShowBatchUserGroupModal(true)}
className={buttonStyles.primary}
>
</button>
</div>
<div className='w-px h-6 bg-gray-300 dark:bg-gray-600'></div>
</>
)}
<button
onClick={() => {
setShowAddUserForm(!showAddUserForm);
if (showChangePasswordForm) {
setShowChangePasswordForm(false);
setChangePasswordUser({ username: '', password: '' });
}
}}
className={showAddUserForm ? buttonStyles.secondary : buttonStyles.success}
>
{showAddUserForm ? '取消' : '添加用户'}
</button>
</div>
</div>
{/* 添加用户表单 */}
{showAddUserForm && (
<div className='mb-4 p-4 bg-gray-50 dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700'>
<div className='space-y-4'>
<div className='grid grid-cols-1 sm:grid-cols-2 gap-4'>
<input
type='text'
placeholder='用户名'
value={newUser.username}
onChange={(e) =>
setNewUser((prev) => ({ ...prev, username: e.target.value }))
}
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 focus:ring-2 focus:ring-green-500 focus:border-transparent'
/>
<input
type='password'
placeholder='密码'
value={newUser.password}
onChange={(e) =>
setNewUser((prev) => ({ ...prev, password: e.target.value }))
}
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 focus:ring-2 focus:ring-green-500 focus:border-transparent'
/>
</div>
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
</label>
<select
value={newUser.userGroup}
onChange={(e) =>
setNewUser((prev) => ({ ...prev, userGroup: 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'
>
<option value=''></option>
{userGroups.map((group) => (
<option key={group.name} value={group.name}>
{group.name} ({group.enabledApis && group.enabledApis.length > 0 ? `${group.enabledApis.length} 个源` : '无限制'})
</option>
))}
</select>
</div>
<div className='flex justify-end'>
<button
onClick={handleAddUser}
disabled={!newUser.username || !newUser.password || isLoading('addUser')}
className={!newUser.username || !newUser.password || isLoading('addUser') ? buttonStyles.disabled : buttonStyles.success}
>
{isLoading('addUser') ? '添加中...' : '添加'}
</button>
</div>
</div>
</div>
)}
{/* 修改密码表单 */}
{showChangePasswordForm && (
<div className='mb-4 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-700'>
<h5 className='text-sm font-medium text-blue-800 dark:text-blue-300 mb-3'>
</h5>
<div className='flex flex-col sm:flex-row gap-4 sm:gap-3'>
<input
type='text'
placeholder='用户名'
value={changePasswordUser.username}
disabled
className='flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-gray-100 cursor-not-allowed'
/>
<input
type='password'
placeholder='新密码'
value={changePasswordUser.password}
onChange={(e) =>
setChangePasswordUser((prev) => ({
...prev,
password: e.target.value,
}))
}
className='flex-1 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'
/>
<button
onClick={handleChangePassword}
disabled={!changePasswordUser.password || isLoading(`changePassword_${changePasswordUser.username}`)}
className={`w-full sm:w-auto ${!changePasswordUser.password || isLoading(`changePassword_${changePasswordUser.username}`) ? buttonStyles.disabled : buttonStyles.primary}`}
>
{isLoading(`changePassword_${changePasswordUser.username}`) ? '修改中...' : '修改密码'}
</button>
<button
onClick={() => {
setShowChangePasswordForm(false);
setChangePasswordUser({ username: '', password: '' });
}}
className={`w-full sm:w-auto ${buttonStyles.secondary}`}
>
</button>
</div>
</div>
)}
{/* 用户列表 */}
<div className='border border-gray-200 dark:border-gray-700 rounded-lg max-h-[28rem] overflow-y-auto overflow-x-auto relative' data-table="user-list">
<table className='min-w-full divide-y divide-gray-200 dark:divide-gray-700'>
<thead className='bg-gray-50 dark:bg-gray-900 sticky top-0 z-10'>
<tr>
<th className='w-4' />
<th className='w-10 px-1 py-3 text-center'>
{(() => {
// 检查是否有权限操作任何用户
const hasAnyPermission = config?.UserConfig?.Users?.some(user =>
(role === 'owner' ||
(role === 'admin' &&
(user.role === 'user' ||
user.username === currentUsername)))
);
return hasAnyPermission ? (
<input
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'
/>
) : (
<div className='w-4 h-4' />
);
})()}
</th>
<th
scope='col'
className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'
>
</th>
<th
scope='col'
className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'
>
</th>
<th
scope='col'
className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'
>
</th>
<th
scope='col'
className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'
>
</th>
<th
scope='col'
className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'
>
</th>
<th
scope='col'
className='px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'
>
</th>
</tr>
</thead>
{/* 按规则排序用户:自己 -> 站长(若非自己) -> 管理员 -> 其他 */}
{(() => {
const sortedUsers = [...config.UserConfig.Users].sort((a, b) => {
type UserInfo = (typeof config.UserConfig.Users)[number];
const priority = (u: UserInfo) => {
if (u.username === currentUsername) return 0;
if (u.role === 'owner') return 1;
if (u.role === 'admin') return 2;
return 3;
};
return priority(a) - priority(b);
});
return (
<tbody className='divide-y divide-gray-200 dark:divide-gray-700'>
{sortedUsers.map((user) => {
// 修改密码权限:站长可修改管理员和普通用户密码,管理员可修改普通用户和自己的密码,但任何人都不能修改站长密码
const canChangePassword =
user.role !== 'owner' && // 不能修改站长密码
(role === 'owner' || // 站长可以修改管理员和普通用户密码
(role === 'admin' &&
(user.role === 'user' ||
user.username === currentUsername))); // 管理员可以修改普通用户和自己的密码
// 删除用户权限:站长可删除除自己外的所有用户,管理员仅可删除普通用户
const canDeleteUser =
user.username !== currentUsername &&
(role === 'owner' || // 站长可以删除除自己外的所有用户
(role === 'admin' && user.role === 'user')); // 管理员仅可删除普通用户
// 其他操作权限:不能操作自己,站长可操作所有用户,管理员可操作普通用户
const canOperate =
user.username !== currentUsername &&
(role === 'owner' ||
(role === 'admin' && user.role === 'user'));
return (
<tr
key={user.username}
className='hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors'
>
<td className='w-4' />
<td className='w-10 px-1 py-3 text-center'>
{(role === 'owner' ||
(role === 'admin' &&
(user.role === 'user' ||
user.username === currentUsername))) ? (
<input
type='checkbox'
checked={selectedUsers.has(user.username)}
onChange={(e) => 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'
/>
) : (
<div className='w-4 h-4' />
)}
</td>
<td className='px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-gray-100'>
{user.username}
</td>
<td className='px-6 py-4 whitespace-nowrap'>
<span
className={`px-2 py-1 text-xs rounded-full ${user.role === 'owner'
? 'bg-yellow-100 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-300'
: user.role === 'admin'
? 'bg-purple-100 dark:bg-purple-900/20 text-purple-800 dark:text-purple-300'
: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
}`}
>
{user.role === 'owner'
? '站长'
: user.role === 'admin'
? '管理员'
: '普通用户'}
</span>
</td>
<td className='px-6 py-4 whitespace-nowrap'>
<span
className={`px-2 py-1 text-xs rounded-full ${!user.banned
? 'bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300'
: 'bg-red-100 dark:bg-red-900/20 text-red-800 dark:text-red-300'
}`}
>
{!user.banned ? '正常' : '已封禁'}
</span>
</td>
<td className='px-6 py-4 whitespace-nowrap'>
<div className='flex items-center space-x-2'>
<span className='text-sm text-gray-900 dark:text-gray-100'>
{user.tags && user.tags.length > 0
? user.tags.join(', ')
: '无用户组'}
</span>
{/* 配置用户组按钮 */}
{(role === 'owner' ||
(role === 'admin' &&
(user.role === 'user' ||
user.username === currentUsername))) && (
<button
onClick={() => handleConfigureUserGroup(user)}
className={buttonStyles.roundedPrimary}
>
</button>
)}
</div>
</td>
<td className='px-6 py-4 whitespace-nowrap'>
<div className='flex items-center space-x-2'>
<span className='text-sm text-gray-900 dark:text-gray-100'>
{user.enabledApis && user.enabledApis.length > 0
? `${user.enabledApis.length} 个源`
: '无限制'}
</span>
{/* 配置采集源权限按钮 */}
{(role === 'owner' ||
(role === 'admin' &&
(user.role === 'user' ||
user.username === currentUsername))) && (
<button
onClick={() => handleConfigureUserApis(user)}
className={buttonStyles.roundedPrimary}
>
</button>
)}
</div>
</td>
<td className='px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2'>
{/* 修改密码按钮 */}
{canChangePassword && (
<button
onClick={() =>
handleShowChangePasswordForm(user.username)
}
className={buttonStyles.roundedPrimary}
>
</button>
)}
{canOperate && (
<>
{/* 其他操作按钮 */}
{user.role === 'user' && (
<button
onClick={() => handleSetAdmin(user.username)}
disabled={isLoading(`setAdmin_${user.username}`)}
className={`${buttonStyles.roundedPurple} ${isLoading(`setAdmin_${user.username}`) ? 'opacity-50 cursor-not-allowed' : ''}`}
>
</button>
)}
{user.role === 'admin' && (
<button
onClick={() =>
handleRemoveAdmin(user.username)
}
disabled={isLoading(`removeAdmin_${user.username}`)}
className={`${buttonStyles.roundedSecondary} ${isLoading(`removeAdmin_${user.username}`) ? 'opacity-50 cursor-not-allowed' : ''}`}
>
</button>
)}
{user.role !== 'owner' &&
(!user.banned ? (
<button
onClick={() => handleBanUser(user.username)}
disabled={isLoading(`banUser_${user.username}`)}
className={`${buttonStyles.roundedDanger} ${isLoading(`banUser_${user.username}`) ? 'opacity-50 cursor-not-allowed' : ''}`}
>
</button>
) : (
<button
onClick={() =>
handleUnbanUser(user.username)
}
disabled={isLoading(`unbanUser_${user.username}`)}
className={`${buttonStyles.roundedSuccess} ${isLoading(`unbanUser_${user.username}`) ? 'opacity-50 cursor-not-allowed' : ''}`}
>
</button>
))}
</>
)}
{/* 删除用户按钮 - 放在最后,使用更明显的红色样式 */}
{canDeleteUser && (
<button
onClick={() => handleDeleteUser(user.username)}
className={buttonStyles.roundedDanger}
>
</button>
)}
</td>
</tr>
);
})}
</tbody>
);
})()}
</table>
</div>
</div>
{/* 配置用户采集源权限弹窗 */}
{showConfigureApisModal && selectedUser && createPortal(
<div className='fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4' onClick={() => {
setShowConfigureApisModal(false);
setSelectedUser(null);
setSelectedApis([]);
}}>
<div className='bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-4xl w-full max-h-[80vh] overflow-y-auto' onClick={(e) => e.stopPropagation()}>
<div className='p-6'>
<div className='flex items-center justify-between mb-6'>
<h3 className='text-xl font-semibold text-gray-900 dark:text-gray-100'>
- {selectedUser.username}
</h3>
<button
onClick={() => {
setShowConfigureApisModal(false);
setSelectedUser(null);
setSelectedApis([]);
}}
className='text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors'
>
<svg className='w-6 h-6' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M6 18L18 6M6 6l12 12' />
</svg>
</button>
</div>
<div className='mb-6'>
<div className='bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4'>
<div className='flex items-center space-x-2 mb-2'>
<svg className='w-5 h-5 text-blue-600 dark:text-blue-400' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z' />
</svg>
<span className='text-sm font-medium text-blue-800 dark:text-blue-300'>
</span>
</div>
<p className='text-sm text-blue-700 dark:text-blue-400 mt-1'>
访
</p>
</div>
</div>
{/* 采集源选择 - 多列布局 */}
<div className='mb-6'>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300 mb-4'>
</h4>
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4'>
{config?.SourceConfig?.map((source) => (
<label key={source.key} className='flex items-center space-x-3 p-3 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 cursor-pointer transition-colors'>
<input
type='checkbox'
checked={selectedApis.includes(source.key)}
onChange={(e) => {
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'
/>
<div className='flex-1 min-w-0'>
<div className='text-sm font-medium text-gray-900 dark:text-gray-100 truncate'>
{source.name}
</div>
{source.api && (
<div className='text-xs text-gray-500 dark:text-gray-400 truncate'>
{extractDomain(source.api)}
</div>
)}
</div>
</label>
))}
</div>
</div>
{/* 快速操作按钮 */}
<div className='flex flex-wrap items-center justify-between mb-6 p-4 bg-gray-50 dark:bg-gray-900 rounded-lg'>
<div className='flex space-x-2'>
<button
onClick={() => setSelectedApis([])}
className={buttonStyles.quickAction}
>
</button>
<button
onClick={() => {
const allApis = config?.SourceConfig?.filter(source => !source.disabled).map(s => s.key) || [];
setSelectedApis(allApis);
}}
className={buttonStyles.quickAction}
>
</button>
</div>
<div className='text-sm text-gray-600 dark:text-gray-400'>
<span className='font-medium text-blue-600 dark:text-blue-400'>
{selectedApis.length > 0 ? `${selectedApis.length} 个源` : '无限制'}
</span>
</div>
</div>
{/* 操作按钮 */}
<div className='flex justify-end space-x-3'>
<button
onClick={() => {
setShowConfigureApisModal(false);
setSelectedUser(null);
setSelectedApis([]);
}}
className={`px-6 py-2.5 text-sm font-medium ${buttonStyles.secondary}`}
>
</button>
<button
onClick={handleSaveUserApis}
disabled={isLoading(`saveUserApis_${selectedUser?.username}`)}
className={`px-6 py-2.5 text-sm font-medium ${isLoading(`saveUserApis_${selectedUser?.username}`) ? buttonStyles.disabled : buttonStyles.primary}`}
>
{isLoading(`saveUserApis_${selectedUser?.username}`) ? '配置中...' : '确认配置'}
</button>
</div>
</div>
</div>
</div>,
document.body
)}
{/* 添加用户组弹窗 */}
{showAddUserGroupForm && createPortal(
<div className='fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4' onClick={() => {
setShowAddUserGroupForm(false);
setNewUserGroup({ name: '', enabledApis: [] });
}}>
<div className='bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-4xl w-full max-h-[80vh] overflow-y-auto' onClick={(e) => e.stopPropagation()}>
<div className='p-6'>
<div className='flex items-center justify-between mb-6'>
<h3 className='text-xl font-semibold text-gray-900 dark:text-gray-100'>
</h3>
<button
onClick={() => {
setShowAddUserGroupForm(false);
setNewUserGroup({ name: '', enabledApis: [] });
}}
className='text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors'
>
<svg className='w-6 h-6' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M6 18L18 6M6 6l12 12' />
</svg>
</button>
</div>
<div className='space-y-6'>
{/* 用户组名称 */}
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
</label>
<input
type='text'
placeholder='请输入用户组名称'
value={newUserGroup.name}
onChange={(e) =>
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'
/>
</div>
{/* 可用视频源 */}
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-4'>
</label>
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3'>
{config?.SourceConfig?.map((source) => (
<label key={source.key} className='flex items-center space-x-3 p-3 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 cursor-pointer transition-colors'>
<input
type='checkbox'
checked={newUserGroup.enabledApis.includes(source.key)}
onChange={(e) => {
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-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700'
/>
<div className='flex-1 min-w-0'>
<div className='text-sm font-medium text-gray-900 dark:text-gray-100 truncate'>
{source.name}
</div>
{source.api && (
<div className='text-xs text-gray-500 dark:text-gray-400 truncate'>
{extractDomain(source.api)}
</div>
)}
</div>
</label>
))}
</div>
{/* 快速操作按钮 */}
<div className='mt-4 flex space-x-2'>
<button
onClick={() => setNewUserGroup(prev => ({ ...prev, enabledApis: [] }))}
className={buttonStyles.quickAction}
>
</button>
<button
onClick={() => {
const allApis = config?.SourceConfig?.filter(source => !source.disabled).map(s => s.key) || [];
setNewUserGroup(prev => ({ ...prev, enabledApis: allApis }));
}}
className={buttonStyles.quickAction}
>
</button>
</div>
</div>
{/* 操作按钮 */}
<div className='flex justify-end space-x-3 pt-4 border-t border-gray-200 dark:border-gray-700'>
<button
onClick={() => {
setShowAddUserGroupForm(false);
setNewUserGroup({ name: '', enabledApis: [] });
}}
className={`px-6 py-2.5 text-sm font-medium ${buttonStyles.secondary}`}
>
</button>
<button
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') ? '添加中...' : '添加用户组'}
</button>
</div>
</div>
</div>
</div>
</div>,
document.body
)}
{/* 编辑用户组弹窗 */}
{showEditUserGroupForm && editingUserGroup && createPortal(
<div className='fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4' onClick={() => {
setShowEditUserGroupForm(false);
setEditingUserGroup(null);
}}>
<div className='bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-4xl w-full max-h-[80vh] overflow-y-auto' onClick={(e) => e.stopPropagation()}>
<div className='p-6'>
<div className='flex items-center justify-between mb-6'>
<h3 className='text-xl font-semibold text-gray-900 dark:text-gray-100'>
- {editingUserGroup.name}
</h3>
<button
onClick={() => {
setShowEditUserGroupForm(false);
setEditingUserGroup(null);
}}
className='text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors'
>
<svg className='w-6 h-6' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M6 18L18 6M6 6l12 12' />
</svg>
</button>
</div>
<div className='space-y-6'>
{/* 可用视频源 */}
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-4'>
</label>
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3'>
{config?.SourceConfig?.map((source) => (
<label key={source.key} className='flex items-center space-x-3 p-3 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 cursor-pointer transition-colors'>
<input
type='checkbox'
checked={editingUserGroup.enabledApis.includes(source.key)}
onChange={(e) => {
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'
/>
<div className='flex-1 min-w-0'>
<div className='text-sm font-medium text-gray-900 dark:text-gray-100 truncate'>
{source.name}
</div>
{source.api && (
<div className='text-xs text-gray-500 dark:text-gray-400 truncate'>
{extractDomain(source.api)}
</div>
)}
</div>
</label>
))}
</div>
{/* 快速操作按钮 */}
<div className='mt-4 flex space-x-2'>
<button
onClick={() => setEditingUserGroup(prev => prev ? { ...prev, enabledApis: [] } : null)}
className={buttonStyles.quickAction}
>
</button>
<button
onClick={() => {
const allApis = config?.SourceConfig?.filter(source => !source.disabled).map(s => s.key) || [];
setEditingUserGroup(prev => prev ? { ...prev, enabledApis: allApis } : null);
}}
className={buttonStyles.quickAction}
>
</button>
</div>
</div>
{/* 操作按钮 */}
<div className='flex justify-end space-x-3 pt-4 border-t border-gray-200 dark:border-gray-700'>
<button
onClick={() => {
setShowEditUserGroupForm(false);
setEditingUserGroup(null);
}}
className={`px-6 py-2.5 text-sm font-medium ${buttonStyles.secondary}`}
>
</button>
<button
onClick={handleEditUserGroup}
disabled={isLoading(`userGroup_edit_${editingUserGroup?.name}`)}
className={`px-6 py-2.5 text-sm font-medium ${isLoading(`userGroup_edit_${editingUserGroup?.name}`) ? buttonStyles.disabled : buttonStyles.primary}`}
>
{isLoading(`userGroup_edit_${editingUserGroup?.name}`) ? '保存中...' : '保存修改'}
</button>
</div>
</div>
</div>
</div>
</div>,
document.body
)}
{/* 配置用户组弹窗 */}
{showConfigureUserGroupModal && selectedUserForGroup && createPortal(
<div className='fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4' onClick={() => {
setShowConfigureUserGroupModal(false);
setSelectedUserForGroup(null);
setSelectedUserGroups([]);
}}>
<div className='bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-4xl w-full max-h-[80vh] overflow-y-auto' onClick={(e) => e.stopPropagation()}>
<div className='p-6'>
<div className='flex items-center justify-between mb-6'>
<h3 className='text-xl font-semibold text-gray-900 dark:text-gray-100'>
- {selectedUserForGroup.username}
</h3>
<button
onClick={() => {
setShowConfigureUserGroupModal(false);
setSelectedUserForGroup(null);
setSelectedUserGroups([]);
}}
className='text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors'
>
<svg className='w-6 h-6' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M6 18L18 6M6 6l12 12' />
</svg>
</button>
</div>
<div className='mb-6'>
<div className='bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4'>
<div className='flex items-center space-x-2 mb-2'>
<svg className='w-5 h-5 text-blue-600 dark:text-blue-400' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z' />
</svg>
<span className='text-sm font-medium text-blue-800 dark:text-blue-300'>
</span>
</div>
<p className='text-sm text-blue-700 dark:text-blue-400 mt-1'>
"无用户组"访
</p>
</div>
</div>
{/* 用户组选择 - 下拉选择器 */}
<div className='mb-6'>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
</label>
<select
value={selectedUserGroups.length > 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'
>
<option value=''></option>
{userGroups.map((group) => (
<option key={group.name} value={group.name}>
{group.name} {group.enabledApis && group.enabledApis.length > 0 ? `(${group.enabledApis.length} 个源)` : ''}
</option>
))}
</select>
<p className='mt-2 text-xs text-gray-500 dark:text-gray-400'>
"无用户组"访
</p>
</div>
{/* 操作按钮 */}
<div className='flex justify-end space-x-3'>
<button
onClick={() => {
setShowConfigureUserGroupModal(false);
setSelectedUserForGroup(null);
setSelectedUserGroups([]);
}}
className={`px-6 py-2.5 text-sm font-medium ${buttonStyles.secondary}`}
>
</button>
<button
onClick={handleSaveUserGroups}
disabled={isLoading(`saveUserGroups_${selectedUserForGroup?.username}`)}
className={`px-6 py-2.5 text-sm font-medium ${isLoading(`saveUserGroups_${selectedUserForGroup?.username}`) ? buttonStyles.disabled : buttonStyles.primary}`}
>
{isLoading(`saveUserGroups_${selectedUserForGroup?.username}`) ? '配置中...' : '确认配置'}
</button>
</div>
</div>
</div>
</div>,
document.body
)}
{/* 删除用户组确认弹窗 */}
{showDeleteUserGroupModal && deletingUserGroup && createPortal(
<div className='fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4' onClick={() => {
setShowDeleteUserGroupModal(false);
setDeletingUserGroup(null);
}}>
<div className='bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-2xl w-full' onClick={(e) => e.stopPropagation()}>
<div className='p-6'>
<div className='flex items-center justify-between mb-6'>
<h3 className='text-xl font-semibold text-gray-900 dark:text-gray-100'>
</h3>
<button
onClick={() => {
setShowDeleteUserGroupModal(false);
setDeletingUserGroup(null);
}}
className='text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors'
>
<svg className='w-6 h-6' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M6 18L18 6M6 6l12 12' />
</svg>
</button>
</div>
<div className='mb-6'>
<div className='bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 mb-4'>
<div className='flex items-center space-x-2 mb-2'>
<svg className='w-5 h-5 text-red-600 dark:text-red-400' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z' />
</svg>
<span className='text-sm font-medium text-red-800 dark:text-red-300'>
</span>
</div>
<p className='text-sm text-red-700 dark:text-red-400'>
<strong>{deletingUserGroup.name}</strong> 使
</p>
</div>
{deletingUserGroup.affectedUsers.length > 0 ? (
<div className='bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4'>
<div className='flex items-center space-x-2 mb-2'>
<svg className='w-5 h-5 text-yellow-600 dark:text-yellow-400' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z' />
</svg>
<span className='text-sm font-medium text-yellow-800 dark:text-yellow-300'>
{deletingUserGroup.affectedUsers.length}
</span>
</div>
<div className='space-y-1'>
{deletingUserGroup.affectedUsers.map((user, index) => (
<div key={index} className='text-sm text-yellow-700 dark:text-yellow-300'>
{user.username} ({user.role})
</div>
))}
</div>
<p className='text-xs text-yellow-600 dark:text-yellow-400 mt-2'>
</p>
</div>
) : (
<div className='bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4'>
<div className='flex items-center space-x-2'>
<svg className='w-5 h-5 text-green-600 dark:text-green-400' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M5 13l4 4L19 7' />
</svg>
<span className='text-sm font-medium text-green-800 dark:text-green-300'>
使
</span>
</div>
</div>
)}
</div>
{/* 操作按钮 */}
<div className='flex justify-end space-x-3'>
<button
onClick={() => {
setShowDeleteUserGroupModal(false);
setDeletingUserGroup(null);
}}
className={`px-6 py-2.5 text-sm font-medium ${buttonStyles.secondary}`}
>
</button>
<button
onClick={handleConfirmDeleteUserGroup}
disabled={isLoading(`userGroup_delete_${deletingUserGroup?.name}`)}
className={`px-6 py-2.5 text-sm font-medium ${isLoading(`userGroup_delete_${deletingUserGroup?.name}`) ? buttonStyles.disabled : buttonStyles.danger}`}
>
{isLoading(`userGroup_delete_${deletingUserGroup?.name}`) ? '删除中...' : '确认删除'}
</button>
</div>
</div>
</div>
</div>,
document.body
)}
{/* 删除用户确认弹窗 */}
{showDeleteUserModal && deletingUser && createPortal(
<div className='fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4' onClick={() => {
setShowDeleteUserModal(false);
setDeletingUser(null);
}}>
<div className='bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-2xl w-full' onClick={(e) => e.stopPropagation()}>
<div className='p-6'>
<div className='flex items-center justify-between mb-6'>
<h3 className='text-xl font-semibold text-gray-900 dark:text-gray-100'>
</h3>
<button
onClick={() => {
setShowDeleteUserModal(false);
setDeletingUser(null);
}}
className='text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors'
>
<svg className='w-6 h-6' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M6 18L18 6M6 6l12 12' />
</svg>
</button>
</div>
<div className='mb-6'>
<div className='bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 mb-4'>
<div className='flex items-center space-x-2 mb-2'>
<svg className='w-5 h-5 text-red-600 dark:text-red-400' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z' />
</svg>
<span className='text-sm font-medium text-red-800 dark:text-red-300'>
</span>
</div>
<p className='text-sm text-red-700 dark:text-red-400'>
<strong>{deletingUser}</strong>
</p>
</div>
{/* 操作按钮 */}
<div className='flex justify-end space-x-3'>
<button
onClick={() => {
setShowDeleteUserModal(false);
setDeletingUser(null);
}}
className={`px-6 py-2.5 text-sm font-medium ${buttonStyles.secondary}`}
>
</button>
<button
onClick={handleConfirmDeleteUser}
className={`px-6 py-2.5 text-sm font-medium ${buttonStyles.danger}`}
>
</button>
</div>
</div>
</div>
</div>
</div>,
document.body
)}
{/* 批量设置用户组弹窗 */}
{showBatchUserGroupModal && createPortal(
<div className='fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4' onClick={() => {
setShowBatchUserGroupModal(false);
setSelectedUserGroup('');
}}>
<div className='bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-2xl w-full' onClick={(e) => e.stopPropagation()}>
<div className='p-6'>
<div className='flex items-center justify-between mb-6'>
<h3 className='text-xl font-semibold text-gray-900 dark:text-gray-100'>
</h3>
<button
onClick={() => {
setShowBatchUserGroupModal(false);
setSelectedUserGroup('');
}}
className='text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors'
>
<svg className='w-6 h-6' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M6 18L18 6M6 6l12 12' />
</svg>
</button>
</div>
<div className='mb-6'>
<div className='bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4 mb-4'>
<div className='flex items-center space-x-2 mb-2'>
<svg className='w-5 h-5 text-blue-600 dark:text-blue-400' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z' />
</svg>
<span className='text-sm font-medium text-blue-800 dark:text-blue-300'>
</span>
</div>
<p className='text-sm text-blue-700 dark:text-blue-400'>
<strong>{selectedUsers.size} </strong> "无用户组"
</p>
</div>
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
</label>
<select
onChange={(e) => 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}
>
<option value=''></option>
{userGroups.map((group) => (
<option key={group.name} value={group.name}>
{group.name} {group.enabledApis && group.enabledApis.length > 0 ? `(${group.enabledApis.length} 个源)` : ''}
</option>
))}
</select>
<p className='mt-2 text-xs text-gray-500 dark:text-gray-400'>
"无用户组"访
</p>
</div>
</div>
{/* 操作按钮 */}
<div className='flex justify-end space-x-3'>
<button
onClick={() => {
setShowBatchUserGroupModal(false);
setSelectedUserGroup('');
}}
className={`px-6 py-2.5 text-sm font-medium ${buttonStyles.secondary}`}
>
</button>
<button
onClick={() => handleBatchSetUserGroup(selectedUserGroup)}
disabled={isLoading('batchSetUserGroup')}
className={`px-6 py-2.5 text-sm font-medium ${isLoading('batchSetUserGroup') ? buttonStyles.disabled : buttonStyles.primary}`}
>
{isLoading('batchSetUserGroup') ? '设置中...' : '确认设置'}
</button>
</div>
</div>
</div>
</div>,
document.body
)}
{/* 通用弹窗组件 */}
<AlertModal
isOpen={alertModal.isOpen}
onClose={hideAlert}
type={alertModal.type}
title={alertModal.title}
message={alertModal.message}
timer={alertModal.timer}
showConfirm={alertModal.showConfirm}
/>
</div>
);
}
// 视频源配置组件
const VideoSourceConfig = ({
config,
refreshConfig,
}: {
config: AdminConfig | null;
refreshConfig: () => Promise<void>;
}) => {
const { alertModal, showAlert, hideAlert } = useAlertModal();
const { isLoading, withLoading } = useLoadingState();
const [sources, setSources] = useState<DataSource[]>([]);
const [showAddForm, setShowAddForm] = useState(false);
const [orderChanged, setOrderChanged] = useState(false);
const [newSource, setNewSource] = useState<DataSource>({
name: '',
key: '',
api: '',
detail: '',
disabled: false,
from: 'config',
});
// 批量操作相关状态
const [selectedSources, setSelectedSources] = useState<Set<string>>(new Set());
// 使用 useMemo 计算全选状态,避免每次渲染都重新计算
const selectAll = useMemo(() => {
return selectedSources.size === sources.length && selectedSources.size > 0;
}, [selectedSources.size, sources.length]);
// 确认弹窗状态
const [confirmModal, setConfirmModal] = useState<{
isOpen: boolean;
title: string;
message: string;
onConfirm: () => void;
onCancel: () => void;
}>({
isOpen: false,
title: '',
message: '',
onConfirm: () => { },
onCancel: () => { }
});
// 有效性检测相关状态
const [showValidationModal, setShowValidationModal] = useState(false);
const [searchKeyword, setSearchKeyword] = useState('');
const [isValidating, setIsValidating] = useState(false);
const [validationResults, setValidationResults] = useState<Array<{
key: string;
name: string;
status: 'valid' | 'no_results' | 'invalid' | 'validating';
message: string;
resultCount: number;
}>>([]);
// dnd-kit 传感器
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 5, // 轻微位移即可触发
},
}),
useSensor(TouchSensor, {
activationConstraint: {
delay: 150, // 长按 150ms 后触发,避免与滚动冲突
tolerance: 5,
},
})
);
// 初始化
useEffect(() => {
if (config?.SourceConfig) {
setSources(config.SourceConfig);
// 进入时重置 orderChanged
setOrderChanged(false);
// 重置选择状态
setSelectedSources(new Set());
}
}, [config]);
// 通用 API 请求
const callSourceApi = async (body: Record<string, any>) => {
try {
const resp = await fetch('/api/admin/source', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...body }),
});
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
throw new Error(data.error || `操作失败: ${resp.status}`);
}
// 成功后刷新配置
await refreshConfig();
} catch (err) {
showError(err instanceof Error ? err.message : '操作失败', showAlert);
throw err; // 向上抛出方便调用处判断
}
};
const handleToggleEnable = (key: string) => {
const target = sources.find((s) => s.key === key);
if (!target) return;
const action = target.disabled ? 'enable' : 'disable';
withLoading(`toggleSource_${key}`, () => callSourceApi({ action, key })).catch(() => {
console.error('操作失败', action, key);
});
};
const handleDelete = (key: string) => {
withLoading(`deleteSource_${key}`, () => callSourceApi({ action: 'delete', key })).catch(() => {
console.error('操作失败', 'delete', key);
});
};
const handleAddSource = () => {
if (!newSource.name || !newSource.key || !newSource.api) return;
withLoading('addSource', async () => {
await callSourceApi({
action: 'add',
key: newSource.key,
name: newSource.name,
api: newSource.api,
detail: newSource.detail,
});
setNewSource({
name: '',
key: '',
api: '',
detail: '',
disabled: false,
from: 'custom',
});
setShowAddForm(false);
}).catch(() => {
console.error('操作失败', 'add', newSource);
});
};
const handleDragEnd = (event: any) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = sources.findIndex((s) => s.key === active.id);
const newIndex = sources.findIndex((s) => s.key === over.id);
setSources((prev) => arrayMove(prev, oldIndex, newIndex));
setOrderChanged(true);
};
const handleSaveOrder = () => {
const order = sources.map((s) => s.key);
withLoading('saveSourceOrder', () => callSourceApi({ action: 'sort', order }))
.then(() => {
setOrderChanged(false);
})
.catch(() => {
console.error('操作失败', 'sort', order);
});
};
// 有效性检测函数
const handleValidateSources = async () => {
if (!searchKeyword.trim()) {
showAlert({ type: 'warning', title: '请输入搜索关键词', message: '搜索关键词不能为空' });
return;
}
await withLoading('validateSources', async () => {
setIsValidating(true);
setValidationResults([]); // 清空之前的结果
setShowValidationModal(false); // 立即关闭弹窗
// 初始化所有视频源为检测中状态
const initialResults = sources.map(source => ({
key: source.key,
name: source.name,
status: 'validating' as const,
message: '检测中...',
resultCount: 0
}));
setValidationResults(initialResults);
try {
// 使用EventSource接收流式数据
const eventSource = new EventSource(`/api/admin/source/validate?q=${encodeURIComponent(searchKeyword.trim())}`);
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
switch (data.type) {
case 'start':
console.log(`开始检测 ${data.totalSources} 个视频源`);
break;
case 'source_result':
case 'source_error':
// 更新验证结果
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);
} 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
}];
}
});
break;
case 'complete':
console.log(`检测完成,共检测 ${data.completedSources} 个视频源`);
eventSource.close();
setIsValidating(false);
break;
}
} catch (error) {
console.error('解析EventSource数据失败:', error);
}
};
eventSource.onerror = (error) => {
console.error('EventSource错误:', error);
eventSource.close();
setIsValidating(false);
showAlert({ type: 'error', title: '验证失败', message: '连接错误,请重试' });
};
// 设置超时,防止长时间等待
setTimeout(() => {
if (eventSource.readyState === EventSource.OPEN) {
eventSource.close();
setIsValidating(false);
showAlert({ type: 'warning', title: '验证超时', message: '检测超时,请重试' });
}
}, 60000); // 60秒超时
} catch (error) {
setIsValidating(false);
showAlert({ type: 'error', title: '验证失败', message: error instanceof Error ? error.message : '未知错误' });
throw error;
}
});
};
// 获取有效性状态显示
const getValidationStatus = (sourceKey: string) => {
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',
icon: '⟳',
message: result.message
};
case 'valid':
return {
text: '有效',
className: 'bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300',
icon: '✓',
message: result.message
};
case 'no_results':
return {
text: '无法搜索',
className: 'bg-yellow-100 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-300',
icon: '⚠',
message: result.message
};
case 'invalid':
return {
text: '无效',
className: 'bg-red-100 dark:bg-red-900/20 text-red-800 dark:text-red-300',
icon: '✗',
message: result.message
};
default:
return null;
}
};
// 可拖拽行封装 (dnd-kit)
const DraggableRow = ({ source }: { source: DataSource }) => {
const { attributes, listeners, setNodeRef, transform, transition } =
useSortable({ id: source.key });
const style = {
transform: CSS.Transform.toString(transform),
transition,
} as React.CSSProperties;
return (
<tr
ref={setNodeRef}
style={style}
className='hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors select-none'
>
<td
className='px-2 py-4 cursor-grab text-gray-400'
style={{ touchAction: 'none' }}
{...attributes}
{...listeners}
>
<GripVertical size={16} />
</td>
<td className='px-2 py-4 text-center'>
<input
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'
/>
</td>
<td className='px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100'>
{source.name}
</td>
<td className='px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100'>
{source.key}
</td>
<td
className='px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100 max-w-[12rem] truncate'
title={source.api}
>
{source.api}
</td>
<td
className='px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100 max-w-[8rem] truncate'
title={source.detail || '-'}
>
{source.detail || '-'}
</td>
<td className='px-6 py-4 whitespace-nowrap max-w-[1rem]'>
<span
className={`px-2 py-1 text-xs rounded-full ${!source.disabled
? 'bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300'
: 'bg-red-100 dark:bg-red-900/20 text-red-800 dark:text-red-300'
}`}
>
{!source.disabled ? '启用中' : '已禁用'}
</span>
</td>
<td className='px-6 py-4 whitespace-nowrap max-w-[1rem]'>
{(() => {
const status = getValidationStatus(source.key);
if (!status) {
return (
<span className='px-2 py-1 text-xs rounded-full bg-gray-100 dark:bg-gray-900/20 text-gray-600 dark:text-gray-400'>
</span>
);
}
return (
<span className={`px-2 py-1 text-xs rounded-full ${status.className}`} title={status.message}>
{status.icon} {status.text}
</span>
);
})()}
</td>
<td className='px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2'>
<button
onClick={() => 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' : ''}`}
>
{!source.disabled ? '禁用' : '启用'}
</button>
{source.from !== 'config' && (
<button
onClick={() => handleDelete(source.key)}
disabled={isLoading(`deleteSource_${source.key}`)}
className={`${buttonStyles.roundedSecondary} ${isLoading(`deleteSource_${source.key}`) ? 'opacity-50 cursor-not-allowed' : ''}`}
>
</button>
)}
</td>
</tr>
);
};
// 全选/取消全选
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 => {
const newSelected = new Set(prev);
if (checked) {
newSelected.add(key);
} else {
newSelected.delete(key);
}
return newSelected;
});
}, []);
// 批量操作
const handleBatchOperation = async (action: 'batch_enable' | 'batch_disable' | 'batch_delete') => {
if (selectedSources.size === 0) {
showAlert({ type: 'warning', title: '请先选择要操作的视频源', message: '请选择至少一个视频源' });
return;
}
const keys = Array.from(selectedSources);
let confirmMessage = '';
let actionName = '';
switch (action) {
case 'batch_enable':
confirmMessage = `确定要启用选中的 ${keys.length} 个视频源吗?`;
actionName = '批量启用';
break;
case 'batch_disable':
confirmMessage = `确定要禁用选中的 ${keys.length} 个视频源吗?`;
actionName = '批量禁用';
break;
case 'batch_delete':
confirmMessage = `确定要删除选中的 ${keys.length} 个视频源吗?此操作不可恢复!`;
actionName = '批量删除';
break;
}
// 显示确认弹窗
setConfirmModal({
isOpen: true,
title: '确认操作',
message: confirmMessage,
onConfirm: async () => {
try {
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 : '操作失败' });
}
setConfirmModal({ isOpen: false, title: '', message: '', onConfirm: () => { }, onCancel: () => { } });
},
onCancel: () => {
setConfirmModal({ isOpen: false, title: '', message: '', onConfirm: () => { }, onCancel: () => { } });
}
});
};
if (!config) {
return (
<div className='text-center text-gray-500 dark:text-gray-400'>
...
</div>
);
}
return (
<div className='space-y-6'>
{/* 添加视频源表单 */}
<div className='flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4'>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
</h4>
<div className='flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-2'>
{/* 批量操作按钮 - 移动端显示在下一行PC端显示在左侧 */}
{selectedSources.size > 0 && (
<>
<div className='flex flex-wrap items-center gap-3 order-2 sm:order-1'>
<span className='text-sm text-gray-600 dark:text-gray-400'>
<span className='sm:hidden'> {selectedSources.size}</span>
<span className='hidden sm:inline'> {selectedSources.size} </span>
</span>
<button
onClick={() => handleBatchOperation('batch_enable')}
disabled={isLoading('batchSource_batch_enable')}
className={`px-3 py-1 text-sm ${isLoading('batchSource_batch_enable') ? buttonStyles.disabled : buttonStyles.success}`}
>
{isLoading('batchSource_batch_enable') ? '启用中...' : '批量启用'}
</button>
<button
onClick={() => handleBatchOperation('batch_disable')}
disabled={isLoading('batchSource_batch_disable')}
className={`px-3 py-1 text-sm ${isLoading('batchSource_batch_disable') ? buttonStyles.disabled : buttonStyles.warning}`}
>
{isLoading('batchSource_batch_disable') ? '禁用中...' : '批量禁用'}
</button>
<button
onClick={() => handleBatchOperation('batch_delete')}
disabled={isLoading('batchSource_batch_delete')}
className={`px-3 py-1 text-sm ${isLoading('batchSource_batch_delete') ? buttonStyles.disabled : buttonStyles.danger}`}
>
{isLoading('batchSource_batch_delete') ? '删除中...' : '批量删除'}
</button>
</div>
<div className='hidden sm:block w-px h-6 bg-gray-300 dark:bg-gray-600 order-2'></div>
</>
)}
<div className='flex items-center gap-2 order-1 sm:order-2'>
<button
onClick={() => 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
}`}
>
{isValidating ? (
<>
<div className='w-3 h-3 border border-white border-t-transparent rounded-full animate-spin'></div>
<span>...</span>
</>
) : (
'有效性检测'
)}
</button>
<button
onClick={() => setShowAddForm(!showAddForm)}
className={showAddForm ? buttonStyles.secondary : buttonStyles.success}
>
{showAddForm ? '取消' : '添加视频源'}
</button>
</div>
</div>
</div>
{showAddForm && (
<div className='p-4 bg-gray-50 dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700 space-y-4'>
<div className='grid grid-cols-1 sm:grid-cols-2 gap-4'>
<input
type='text'
placeholder='名称'
value={newSource.name}
onChange={(e) =>
setNewSource((prev) => ({ ...prev, name: e.target.value }))
}
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'
/>
<input
type='text'
placeholder='Key'
value={newSource.key}
onChange={(e) =>
setNewSource((prev) => ({ ...prev, key: e.target.value }))
}
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'
/>
<input
type='text'
placeholder='API 地址'
value={newSource.api}
onChange={(e) =>
setNewSource((prev) => ({ ...prev, api: e.target.value }))
}
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'
/>
<input
type='text'
placeholder='Detail 地址(选填)'
value={newSource.detail}
onChange={(e) =>
setNewSource((prev) => ({ ...prev, detail: e.target.value }))
}
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'
/>
</div>
<div className='flex justify-end'>
<button
onClick={handleAddSource}
disabled={!newSource.name || !newSource.key || !newSource.api || isLoading('addSource')}
className={`w-full sm:w-auto px-4 py-2 ${!newSource.name || !newSource.key || !newSource.api || isLoading('addSource') ? buttonStyles.disabled : buttonStyles.success}`}
>
{isLoading('addSource') ? '添加中...' : '添加'}
</button>
</div>
</div>
)}
{/* 视频源表格 */}
<div className='border border-gray-200 dark:border-gray-700 rounded-lg max-h-[28rem] overflow-y-auto overflow-x-auto relative' data-table="source-list">
<table className='min-w-full divide-y divide-gray-200 dark:divide-gray-700'>
<thead className='bg-gray-50 dark:bg-gray-900 sticky top-0 z-10'>
<tr>
<th className='w-8' />
<th className='w-12 px-2 py-3 text-center'>
<input
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'
/>
</th>
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
</th>
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
Key
</th>
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
API
</th>
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
Detail
</th>
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
</th>
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
</th>
<th className='px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
</th>
</tr>
</thead>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
autoScroll={false}
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
>
<SortableContext
items={sources.map((s) => s.key)}
strategy={verticalListSortingStrategy}
>
<tbody className='divide-y divide-gray-200 dark:divide-gray-700'>
{sources.map((source) => (
<DraggableRow key={source.key} source={source} />
))}
</tbody>
</SortableContext>
</DndContext>
</table>
</div>
{/* 保存排序按钮 */}
{orderChanged && (
<div className='flex justify-end'>
<button
onClick={handleSaveOrder}
disabled={isLoading('saveSourceOrder')}
className={`px-3 py-1.5 text-sm ${isLoading('saveSourceOrder') ? buttonStyles.disabled : buttonStyles.primary}`}
>
{isLoading('saveSourceOrder') ? '保存中...' : '保存排序'}
</button>
</div>
)}
{/* 有效性检测弹窗 */}
{showValidationModal && createPortal(
<div className='fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50' onClick={() => setShowValidationModal(false)}>
<div className='bg-white dark:bg-gray-800 rounded-lg p-6 w-full max-w-md mx-4' onClick={(e) => e.stopPropagation()}>
<h3 className='text-lg font-medium text-gray-900 dark:text-gray-100 mb-4'>
</h3>
<p className='text-sm text-gray-600 dark:text-gray-400 mb-4'>
</p>
<div className='space-y-4'>
<input
type='text'
placeholder='请输入搜索关键词'
value={searchKeyword}
onChange={(e) => 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()}
/>
<div className='flex justify-end space-x-3'>
<button
onClick={() => 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'
>
</button>
<button
onClick={handleValidateSources}
disabled={!searchKeyword.trim()}
className={`px-4 py-2 ${!searchKeyword.trim() ? buttonStyles.disabled : buttonStyles.primary}`}
>
</button>
</div>
</div>
</div>
</div>,
document.body
)}
{/* 通用弹窗组件 */}
<AlertModal
isOpen={alertModal.isOpen}
onClose={hideAlert}
type={alertModal.type}
title={alertModal.title}
message={alertModal.message}
timer={alertModal.timer}
showConfirm={alertModal.showConfirm}
/>
{/* 批量操作确认弹窗 */}
{confirmModal.isOpen && createPortal(
<div className='fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4' onClick={confirmModal.onCancel}>
<div className='bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full' onClick={(e) => e.stopPropagation()}>
<div className='p-6'>
<div className='flex items-center justify-between mb-4'>
<h3 className='text-lg font-semibold text-gray-900 dark:text-gray-100'>
{confirmModal.title}
</h3>
<button
onClick={confirmModal.onCancel}
className='text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors'
>
<svg className='w-5 h-5' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M6 18L18 6M6 6l12 12' />
</svg>
</button>
</div>
<div className='mb-6'>
<p className='text-sm text-gray-600 dark:text-gray-400'>
{confirmModal.message}
</p>
</div>
{/* 操作按钮 */}
<div className='flex justify-end space-x-3'>
<button
onClick={confirmModal.onCancel}
className={`px-4 py-2 text-sm font-medium ${buttonStyles.secondary}`}
>
</button>
<button
onClick={confirmModal.onConfirm}
disabled={isLoading('batchSource_batch_enable') || isLoading('batchSource_batch_disable') || isLoading('batchSource_batch_delete')}
className={`px-4 py-2 text-sm font-medium ${isLoading('batchSource_batch_enable') || isLoading('batchSource_batch_disable') || isLoading('batchSource_batch_delete') ? buttonStyles.disabled : buttonStyles.primary}`}
>
{isLoading('batchSource_batch_enable') || isLoading('batchSource_batch_disable') || isLoading('batchSource_batch_delete') ? '操作中...' : '确认'}
</button>
</div>
</div>
</div>
</div>,
document.body
)}
</div>
);
};
// 分类配置组件
const CategoryConfig = ({
config,
refreshConfig,
}: {
config: AdminConfig | null;
refreshConfig: () => Promise<void>;
}) => {
const { alertModal, showAlert, hideAlert } = useAlertModal();
const { isLoading, withLoading } = useLoadingState();
const [categories, setCategories] = useState<CustomCategory[]>([]);
const [showAddForm, setShowAddForm] = useState(false);
const [orderChanged, setOrderChanged] = useState(false);
const [newCategory, setNewCategory] = useState<CustomCategory>({
name: '',
type: 'movie',
query: '',
disabled: false,
from: 'config',
});
// dnd-kit 传感器
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 5, // 轻微位移即可触发
},
}),
useSensor(TouchSensor, {
activationConstraint: {
delay: 150, // 长按 150ms 后触发,避免与滚动冲突
tolerance: 5,
},
})
);
// 初始化
useEffect(() => {
if (config?.CustomCategories) {
setCategories(config.CustomCategories);
// 进入时重置 orderChanged
setOrderChanged(false);
}
}, [config]);
// 通用 API 请求
const callCategoryApi = async (body: Record<string, any>) => {
try {
const resp = await fetch('/api/admin/category', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...body }),
});
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
throw new Error(data.error || `操作失败: ${resp.status}`);
}
// 成功后刷新配置
await refreshConfig();
} catch (err) {
showError(err instanceof Error ? err.message : '操作失败', showAlert);
throw err; // 向上抛出方便调用处判断
}
};
const handleToggleEnable = (query: string, type: 'movie' | 'tv') => {
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(() => {
console.error('操作失败', action, query, type);
});
};
const handleDelete = (query: string, type: 'movie' | 'tv') => {
withLoading(`deleteCategory_${query}_${type}`, () => callCategoryApi({ action: 'delete', query, type })).catch(() => {
console.error('操作失败', 'delete', query, type);
});
};
const handleAddCategory = () => {
if (!newCategory.name || !newCategory.query) return;
withLoading('addCategory', async () => {
await callCategoryApi({
action: 'add',
name: newCategory.name,
type: newCategory.type,
query: newCategory.query,
});
setNewCategory({
name: '',
type: 'movie',
query: '',
disabled: false,
from: 'custom',
});
setShowAddForm(false);
}).catch(() => {
console.error('操作失败', 'add', newCategory);
});
};
const handleDragEnd = (event: any) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = categories.findIndex(
(c) => `${c.query}:${c.type}` === active.id
);
const newIndex = categories.findIndex(
(c) => `${c.query}:${c.type}` === over.id
);
setCategories((prev) => arrayMove(prev, oldIndex, newIndex));
setOrderChanged(true);
};
const handleSaveOrder = () => {
const order = categories.map((c) => `${c.query}:${c.type}`);
withLoading('saveCategoryOrder', () => callCategoryApi({ action: 'sort', order }))
.then(() => {
setOrderChanged(false);
})
.catch(() => {
console.error('操作失败', 'sort', order);
});
};
// 可拖拽行封装 (dnd-kit)
const DraggableRow = ({ category }: { category: CustomCategory }) => {
const { attributes, listeners, setNodeRef, transform, transition } =
useSortable({ id: `${category.query}:${category.type}` });
const style = {
transform: CSS.Transform.toString(transform),
transition,
} as React.CSSProperties;
return (
<tr
ref={setNodeRef}
style={style}
className='hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors select-none'
>
<td
className="px-2 py-4 cursor-grab text-gray-400"
style={{ touchAction: 'none' }}
{...{ ...attributes, ...listeners }}
>
<GripVertical size={16} />
</td>
<td className='px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100'>
{category.name || '-'}
</td>
<td className='px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100'>
<span
className={`px-2 py-1 text-xs rounded-full ${category.type === 'movie'
? 'bg-blue-100 dark:bg-blue-900/20 text-blue-800 dark:text-blue-300'
: 'bg-purple-100 dark:bg-purple-900/20 text-purple-800 dark:text-purple-300'
}`}
>
{category.type === 'movie' ? '电影' : '电视剧'}
</span>
</td>
<td
className='px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100 max-w-[12rem] truncate'
title={category.query}
>
{category.query}
</td>
<td className='px-6 py-4 whitespace-nowrap max-w-[1rem]'>
<span
className={`px-2 py-1 text-xs rounded-full ${!category.disabled
? 'bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300'
: 'bg-red-100 dark:bg-red-900/20 text-red-800 dark:text-red-300'
}`}
>
{!category.disabled ? '启用中' : '已禁用'}
</span>
</td>
<td className='px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2'>
<button
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 ? '禁用' : '启用'}
</button>
{category.from !== 'config' && (
<button
onClick={() => 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' : ''}`}
>
</button>
)}
</td>
</tr>
);
};
if (!config) {
return (
<div className='text-center text-gray-500 dark:text-gray-400'>
...
</div>
);
}
return (
<div className='space-y-6'>
{/* 添加分类表单 */}
<div className='flex items-center justify-between'>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
</h4>
<button
onClick={() => setShowAddForm(!showAddForm)}
className={`px-3 py-1 text-sm rounded-lg transition-colors ${showAddForm ? buttonStyles.secondary : buttonStyles.success}`}
>
{showAddForm ? '取消' : '添加分类'}
</button>
</div>
{showAddForm && (
<div className='p-4 bg-gray-50 dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700 space-y-4'>
<div className='grid grid-cols-1 sm:grid-cols-2 gap-4'>
<input
type='text'
placeholder='分类名称'
value={newCategory.name}
onChange={(e) =>
setNewCategory((prev) => ({ ...prev, name: e.target.value }))
}
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'
/>
<select
value={newCategory.type}
onChange={(e) =>
setNewCategory((prev) => ({
...prev,
type: e.target.value as 'movie' | 'tv',
}))
}
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'
>
<option value='movie'></option>
<option value='tv'></option>
</select>
<input
type='text'
placeholder='搜索关键词'
value={newCategory.query}
onChange={(e) =>
setNewCategory((prev) => ({ ...prev, query: e.target.value }))
}
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'
/>
</div>
<div className='flex justify-end'>
<button
onClick={handleAddCategory}
disabled={!newCategory.name || !newCategory.query || isLoading('addCategory')}
className={`w-full sm:w-auto px-4 py-2 ${!newCategory.name || !newCategory.query || isLoading('addCategory') ? buttonStyles.disabled : buttonStyles.success}`}
>
{isLoading('addCategory') ? '添加中...' : '添加'}
</button>
</div>
</div>
)}
{/* 分类表格 */}
<div className='border border-gray-200 dark:border-gray-700 rounded-lg max-h-[28rem] overflow-y-auto overflow-x-auto relative'>
<table className='min-w-full divide-y divide-gray-200 dark:divide-gray-700'>
<thead className='bg-gray-50 dark:bg-gray-900 sticky top-0 z-10'>
<tr>
<th className='w-8' />
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
</th>
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
</th>
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
</th>
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
</th>
<th className='px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
</th>
</tr>
</thead>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
autoScroll={false}
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
>
<SortableContext
items={categories.map((c) => `${c.query}:${c.type}`)}
strategy={verticalListSortingStrategy}
>
<tbody className='divide-y divide-gray-200 dark:divide-gray-700'>
{categories.map((category) => (
<DraggableRow
key={`${category.query}:${category.type}`}
category={category}
/>
))}
</tbody>
</SortableContext>
</DndContext>
</table>
</div>
{/* 保存排序按钮 */}
{orderChanged && (
<div className='flex justify-end'>
<button
onClick={handleSaveOrder}
disabled={isLoading('saveCategoryOrder')}
className={`px-3 py-1.5 text-sm ${isLoading('saveCategoryOrder') ? buttonStyles.disabled : buttonStyles.primary}`}
>
{isLoading('saveCategoryOrder') ? '保存中...' : '保存排序'}
</button>
</div>
)}
{/* 通用弹窗组件 */}
<AlertModal
isOpen={alertModal.isOpen}
onClose={hideAlert}
type={alertModal.type}
title={alertModal.title}
message={alertModal.message}
timer={alertModal.timer}
showConfirm={alertModal.showConfirm}
/>
</div>
);
};
// 新增配置文件组件
const ConfigFileComponent = ({ config, refreshConfig }: { config: AdminConfig | null; refreshConfig: () => Promise<void> }) => {
const { alertModal, showAlert, hideAlert } = useAlertModal();
const { isLoading, withLoading } = useLoadingState();
const [configContent, setConfigContent] = useState('');
const [subscriptionUrl, setSubscriptionUrl] = useState('');
const [autoUpdate, setAutoUpdate] = useState(false);
const [lastCheckTime, setLastCheckTime] = useState<string>('');
useEffect(() => {
if (config?.ConfigFile) {
setConfigContent(config.ConfigFile);
}
if (config?.ConfigSubscribtion) {
setSubscriptionUrl(config.ConfigSubscribtion.URL);
setAutoUpdate(config.ConfigSubscribtion.AutoUpdate);
setLastCheckTime(config.ConfigSubscribtion.LastCheck || '');
}
}, [config]);
// 拉取订阅配置
const handleFetchConfig = async () => {
if (!subscriptionUrl.trim()) {
showError('请输入订阅URL', showAlert);
return;
}
await withLoading('fetchConfig', async () => {
try {
const resp = await fetch('/api/admin/config_subscription/fetch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: subscriptionUrl }),
});
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
throw new Error(data.error || `拉取失败: ${resp.status}`);
}
const data = await resp.json();
if (data.configContent) {
setConfigContent(data.configContent);
// 更新本地配置的最后检查时间
const currentTime = new Date().toISOString();
setLastCheckTime(currentTime);
showSuccess('配置拉取成功', showAlert);
} else {
showError('拉取失败:未获取到配置内容', showAlert);
}
} catch (err) {
showError(err instanceof Error ? err.message : '拉取失败', showAlert);
throw err;
}
});
};
// 保存配置文件
const handleSave = async () => {
await withLoading('saveConfig', async () => {
try {
const resp = await fetch('/api/admin/config_file', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
configFile: configContent,
subscriptionUrl,
autoUpdate,
lastCheckTime: lastCheckTime || new Date().toISOString()
}),
});
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
throw new Error(data.error || `保存失败: ${resp.status}`);
}
showSuccess('配置文件保存成功', showAlert);
await refreshConfig();
} catch (err) {
showError(err instanceof Error ? err.message : '保存失败', showAlert);
throw err;
}
});
};
if (!config) {
return (
<div className='text-center text-gray-500 dark:text-gray-400'>
...
</div>
);
}
return (
<div className='space-y-4'>
{/* 配置订阅区域 */}
<div className='bg-white dark:bg-gray-800 rounded-lg p-6 border border-gray-200 dark:border-gray-700 shadow-sm'>
<div className='flex items-center justify-between mb-6'>
<h3 className='text-xl font-semibold text-gray-900 dark:text-gray-100'>
</h3>
<div className='text-sm text-gray-500 dark:text-gray-400 px-3 py-1.5 rounded-full'>
: {lastCheckTime ? new Date(lastCheckTime).toLocaleString('zh-CN') : '从未更新'}
</div>
</div>
<div className='space-y-6'>
{/* 订阅URL输入 */}
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3'>
URL
</label>
<input
type='url'
value={subscriptionUrl}
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'
/>
<p className='mt-2 text-xs text-gray-500 dark:text-gray-400'>
JSON 使 Base58
</p>
</div>
{/* 拉取配置按钮 */}
<div className='pt-2'>
<button
onClick={handleFetchConfig}
disabled={isLoading('fetchConfig') || !subscriptionUrl.trim()}
className={`w-full px-6 py-3 rounded-lg font-medium transition-all duration-200 ${isLoading('fetchConfig') || !subscriptionUrl.trim()
? buttonStyles.disabled
: buttonStyles.success
}`}
>
{isLoading('fetchConfig') ? (
<div className='flex items-center justify-center gap-2'>
<div className='w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin'></div>
</div>
) : (
'拉取配置'
)}
</button>
</div>
{/* 自动更新开关 */}
<div className='flex items-center justify-between'>
<div>
<label className='text-sm font-medium text-gray-700 dark:text-gray-300'>
</label>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
</p>
</div>
<button
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
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full ${buttonStyles.toggleThumb} transition-transform ${autoUpdate
? buttonStyles.toggleThumbOn
: buttonStyles.toggleThumbOff
}`}
/>
</button>
</div>
</div>
</div>
{/* 配置文件编辑区域 */}
<div className='space-y-4'>
<div className='relative'>
<textarea
value={configContent}
onChange={(e) => setConfigContent(e.target.value)}
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'
style={{
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", Menlo, monospace'
}}
spellCheck={false}
data-gramm={false}
/>
</div>
<div className='flex items-center justify-between'>
<div className='text-xs text-gray-500 dark:text-gray-400'>
JSON
</div>
<button
onClick={handleSave}
disabled={isLoading('saveConfig')}
className={`px-4 py-2 rounded-lg transition-colors ${isLoading('saveConfig')
? buttonStyles.disabled
: buttonStyles.success
}`}
>
{isLoading('saveConfig') ? '保存中…' : '保存'}
</button>
</div>
</div>
{/* 通用弹窗组件 */}
<AlertModal
isOpen={alertModal.isOpen}
onClose={hideAlert}
type={alertModal.type}
title={alertModal.title}
message={alertModal.message}
timer={alertModal.timer}
showConfirm={alertModal.showConfirm}
/>
</div>
);
};
// 新增站点配置组件
const SiteConfigComponent = ({ config, refreshConfig }: { config: AdminConfig | null; refreshConfig: () => Promise<void> }) => {
const { alertModal, showAlert, hideAlert } = useAlertModal();
const { isLoading, withLoading } = useLoadingState();
const [showEnableCommentsModal, setShowEnableCommentsModal] = useState(false);
const [siteSettings, setSiteSettings] = useState<SiteConfig>({
SiteName: '',
Announcement: '',
SearchDownstreamMaxPage: 1,
SiteInterfaceCacheTime: 7200,
DoubanProxyType: 'cmliussss-cdn-tencent',
DoubanProxy: '',
DoubanImageProxyType: 'cmliussss-cdn-tencent',
DoubanImageProxy: '',
DisableYellowFilter: false,
FluidSearch: true,
DanmakuApiBase: 'http://localhost:9321',
DanmakuApiToken: '87654321',
EnableComments: false,
});
// 豆瓣数据源相关状态
const [isDoubanDropdownOpen, setIsDoubanDropdownOpen] = useState(false);
const [isDoubanImageProxyDropdownOpen, setIsDoubanImageProxyDropdownOpen] =
useState(false);
// 豆瓣数据源选项
const doubanDataSourceOptions = [
{ value: 'direct', label: '直连(服务器直接请求豆瓣)' },
{ value: 'cors-proxy-zwei', label: 'Cors Proxy By Zwei' },
{
value: 'cmliussss-cdn-tencent',
label: '豆瓣 CDN By CMLiussss腾讯云',
},
{ value: 'cmliussss-cdn-ali', label: '豆瓣 CDN By CMLiussss阿里云' },
{ value: 'custom', label: '自定义代理' },
];
// 豆瓣图片代理选项
const doubanImageProxyTypeOptions = [
{ value: 'direct', label: '直连(浏览器直接请求豆瓣)' },
{ value: 'server', label: '服务器代理(由服务器代理请求豆瓣)' },
{ value: 'img3', label: '豆瓣官方精品 CDN阿里云' },
{
value: 'cmliussss-cdn-tencent',
label: '豆瓣 CDN By CMLiussss腾讯云',
},
{ value: 'cmliussss-cdn-ali', label: '豆瓣 CDN By CMLiussss阿里云' },
{ value: 'custom', label: '自定义代理' },
];
// 获取感谢信息
const getThanksInfo = (dataSource: string) => {
switch (dataSource) {
case 'cors-proxy-zwei':
return {
text: 'Thanks to @Zwei',
url: 'https://github.com/bestzwei',
};
case 'cmliussss-cdn-tencent':
case 'cmliussss-cdn-ali':
return {
text: 'Thanks to @CMLiussss',
url: 'https://github.com/cmliu',
};
default:
return null;
}
};
useEffect(() => {
if (config?.SiteConfig) {
setSiteSettings({
...config.SiteConfig,
DoubanProxyType: config.SiteConfig.DoubanProxyType || 'cmliussss-cdn-tencent',
DoubanProxy: config.SiteConfig.DoubanProxy || '',
DoubanImageProxyType:
config.SiteConfig.DoubanImageProxyType || 'cmliussss-cdn-tencent',
DoubanImageProxy: config.SiteConfig.DoubanImageProxy || '',
DisableYellowFilter: config.SiteConfig.DisableYellowFilter || false,
FluidSearch: config.SiteConfig.FluidSearch || true,
DanmakuApiBase: config.SiteConfig.DanmakuApiBase || 'http://localhost:9321',
DanmakuApiToken: config.SiteConfig.DanmakuApiToken || '87654321',
EnableComments: config.SiteConfig.EnableComments || false,
});
}
}, [config]);
// 点击外部区域关闭下拉框
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (isDoubanDropdownOpen) {
const target = event.target as Element;
if (!target.closest('[data-dropdown="douban-datasource"]')) {
setIsDoubanDropdownOpen(false);
}
}
};
if (isDoubanDropdownOpen) {
document.addEventListener('mousedown', handleClickOutside);
return () =>
document.removeEventListener('mousedown', handleClickOutside);
}
}, [isDoubanDropdownOpen]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (isDoubanImageProxyDropdownOpen) {
const target = event.target as Element;
if (!target.closest('[data-dropdown="douban-image-proxy"]')) {
setIsDoubanImageProxyDropdownOpen(false);
}
}
};
if (isDoubanImageProxyDropdownOpen) {
document.addEventListener('mousedown', handleClickOutside);
return () =>
document.removeEventListener('mousedown', handleClickOutside);
}
}, [isDoubanImageProxyDropdownOpen]);
// 处理豆瓣数据源变化
const handleDoubanDataSourceChange = (value: string) => {
setSiteSettings((prev) => ({
...prev,
DoubanProxyType: value,
}));
};
// 处理豆瓣图片代理变化
const handleDoubanImageProxyChange = (value: string) => {
setSiteSettings((prev) => ({
...prev,
DoubanImageProxyType: value,
}));
};
// 处理评论开关变化
const handleCommentsToggle = (checked: boolean) => {
if (checked) {
// 如果要开启评论,弹出确认框
setShowEnableCommentsModal(true);
} else {
// 直接关闭评论
setSiteSettings((prev) => ({
...prev,
EnableComments: false,
}));
}
};
// 确认开启评论
const handleConfirmEnableComments = () => {
setSiteSettings((prev) => ({
...prev,
EnableComments: true,
}));
setShowEnableCommentsModal(false);
};
// 保存站点配置
const handleSave = async () => {
await withLoading('saveSiteConfig', async () => {
try {
const resp = await fetch('/api/admin/site', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...siteSettings }),
});
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
throw new Error(data.error || `保存失败: ${resp.status}`);
}
showSuccess('保存成功, 请刷新页面', showAlert);
await refreshConfig();
} catch (err) {
showError(err instanceof Error ? err.message : '保存失败', showAlert);
throw err;
}
});
};
if (!config) {
return (
<div className='text-center text-gray-500 dark:text-gray-400'>
...
</div>
);
}
return (
<div className='space-y-6'>
{/* 站点名称 */}
<div>
<label
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
</label>
<input
type='text'
value={siteSettings.SiteName}
onChange={(e) =>
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"
/>
</div>
{/* 站点公告 */}
<div>
<label
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
</label>
<textarea
value={siteSettings.Announcement}
onChange={(e) =>
setSiteSettings((prev) => ({
...prev,
Announcement: e.target.value,
}))
}
rows={3}
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"
/>
</div>
{/* 豆瓣数据源设置 */}
<div className='space-y-3'>
<div>
<label
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
</label>
<div className='relative' data-dropdown='douban-datasource'>
{/* 自定义下拉选择框 */}
<button
type='button'
onClick={() => 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"
>
{
doubanDataSourceOptions.find(
(option) => option.value === siteSettings.DoubanProxyType
)?.label
}
</button>
{/* 下拉箭头 */}
<div className='absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none'>
<ChevronDown
className={`w-4 h-4 text-gray-400 dark:text-gray-500 transition-transform duration-200 ${isDoubanDropdownOpen ? 'rotate-180' : ''
}`}
/>
</div>
{/* 下拉选项列表 */}
{isDoubanDropdownOpen && (
<div className='absolute z-50 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg max-h-60 overflow-auto'>
{doubanDataSourceOptions.map((option) => (
<button
key={option.value}
type='button'
onClick={() => {
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'
}`}
>
<span className='truncate'>{option.label}</span>
{siteSettings.DoubanProxyType === option.value && (
<Check className='w-4 h-4 text-green-600 dark:text-green-400 flex-shrink-0 ml-2' />
)}
</button>
))}
</div>
)}
</div>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
</p>
{/* 感谢信息 */}
{getThanksInfo(siteSettings.DoubanProxyType) && (
<div className='mt-3'>
<button
type='button'
onClick={() =>
window.open(
getThanksInfo(siteSettings.DoubanProxyType)!.url,
'_blank'
)
}
className='flex items-center justify-center gap-1.5 w-full px-3 text-xs text-gray-500 dark:text-gray-400 cursor-pointer'
>
<span className='font-medium'>
{getThanksInfo(siteSettings.DoubanProxyType)!.text}
</span>
<ExternalLink className='w-3.5 opacity-70' />
</button>
</div>
)}
</div>
{/* 豆瓣代理地址设置 - 仅在选择自定义代理时显示 */}
{siteSettings.DoubanProxyType === 'custom' && (
<div>
<label
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
</label>
<input
type='text'
placeholder='例如: https://proxy.example.com/fetch?url='
value={siteSettings.DoubanProxy}
onChange={(e) =>
setSiteSettings((prev) => ({
...prev,
DoubanProxy: e.target.value,
}))
}
className="w-full px-3 py-2.5 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 placeholder-gray-500 dark:placeholder-gray-400 shadow-sm hover:border-gray-400 dark:hover:border-gray-500"
/>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
</p>
</div>
)}
</div>
{/* 豆瓣图片代理设置 */}
<div className='space-y-3'>
<div>
<label
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
</label>
<div className='relative' data-dropdown='douban-image-proxy'>
{/* 自定义下拉选择框 */}
<button
type='button'
onClick={() =>
setIsDoubanImageProxyDropdownOpen(
!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"
>
{
doubanImageProxyTypeOptions.find(
(option) => option.value === siteSettings.DoubanImageProxyType
)?.label
}
</button>
{/* 下拉箭头 */}
<div className='absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none'>
<ChevronDown
className={`w-4 h-4 text-gray-400 dark:text-gray-500 transition-transform duration-200 ${isDoubanImageProxyDropdownOpen ? 'rotate-180' : ''
}`}
/>
</div>
{/* 下拉选项列表 */}
{isDoubanImageProxyDropdownOpen && (
<div className='absolute z-50 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg max-h-60 overflow-auto'>
{doubanImageProxyTypeOptions.map((option) => (
<button
key={option.value}
type='button'
onClick={() => {
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'
}`}
>
<span className='truncate'>{option.label}</span>
{siteSettings.DoubanImageProxyType === option.value && (
<Check className='w-4 h-4 text-green-600 dark:text-green-400 flex-shrink-0 ml-2' />
)}
</button>
))}
</div>
)}
</div>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
</p>
{/* 感谢信息 */}
{getThanksInfo(siteSettings.DoubanImageProxyType) && (
<div className='mt-3'>
<button
type='button'
onClick={() =>
window.open(
getThanksInfo(siteSettings.DoubanImageProxyType)!.url,
'_blank'
)
}
className='flex items-center justify-center gap-1.5 w-full px-3 text-xs text-gray-500 dark:text-gray-400 cursor-pointer'
>
<span className='font-medium'>
{getThanksInfo(siteSettings.DoubanImageProxyType)!.text}
</span>
<ExternalLink className='w-3.5 opacity-70' />
</button>
</div>
)}
</div>
{/* 豆瓣代理地址设置 - 仅在选择自定义代理时显示 */}
{siteSettings.DoubanImageProxyType === 'custom' && (
<div>
<label
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
</label>
<input
type='text'
placeholder='例如: https://proxy.example.com/fetch?url='
value={siteSettings.DoubanImageProxy}
onChange={(e) =>
setSiteSettings((prev) => ({
...prev,
DoubanImageProxy: e.target.value,
}))
}
className="w-full px-3 py-2.5 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 placeholder-gray-500 dark:placeholder-gray-400 shadow-sm hover:border-gray-400 dark:hover:border-gray-500"
/>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
</p>
</div>
)}
</div>
{/* 搜索接口可拉取最大页数 */}
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
</label>
<input
type='number'
min={1}
value={siteSettings.SearchDownstreamMaxPage}
onChange={(e) =>
setSiteSettings((prev) => ({
...prev,
SearchDownstreamMaxPage: 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'
/>
</div>
{/* 站点接口缓存时间 */}
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
</label>
<input
type='number'
min={1}
value={siteSettings.SiteInterfaceCacheTime}
onChange={(e) =>
setSiteSettings((prev) => ({
...prev,
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'
/>
</div>
{/* 禁用黄色过滤器 */}
<div>
<div className='flex items-center justify-between'>
<label
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
</label>
<button
type='button'
onClick={() =>
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
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full ${buttonStyles.toggleThumb} transition-transform ${siteSettings.DisableYellowFilter
? buttonStyles.toggleThumbOn
: buttonStyles.toggleThumbOff
}`}
/>
</button>
</div>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
</p>
</div>
{/* 流式搜索 */}
<div>
<div className='flex items-center justify-between'>
<label
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
</label>
<button
type='button'
onClick={() =>
setSiteSettings((prev) => ({
...prev,
FluidSearch: !prev.FluidSearch,
}))
}
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.FluidSearch
? buttonStyles.toggleOn
: buttonStyles.toggleOff
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full ${buttonStyles.toggleThumb} transition-transform ${siteSettings.FluidSearch
? buttonStyles.toggleThumbOn
: buttonStyles.toggleThumbOff
}`}
/>
</button>
</div>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
,
</p>
</div>
{/* 弹幕 API 配置 */}
<div className='space-y-4 pt-4 border-t border-gray-200 dark:border-gray-700'>
<h3 className='text-sm font-semibold text-gray-900 dark:text-gray-100'>
</h3>
{/* 弹幕 API 地址 */}
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
API
</label>
<input
type='text'
placeholder='http://localhost:9321'
value={siteSettings.DanmakuApiBase}
onChange={(e) =>
setSiteSettings((prev) => ({
...prev,
DanmakuApiBase: 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'
/>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
API http://localhost:9321
</p>
</div>
{/* 弹幕 API Token */}
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
API Token
</label>
<input
type='text'
placeholder='87654321'
value={siteSettings.DanmakuApiToken}
onChange={(e) =>
setSiteSettings((prev) => ({
...prev,
DanmakuApiToken: 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'
/>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
访 87654321
</p>
</div>
</div>
{/* 评论功能配置 */}
<div className='space-y-4 pt-4 border-t border-gray-200 dark:border-gray-700'>
<h3 className='text-sm font-semibold text-gray-900 dark:text-gray-100'>
</h3>
{/* 开启评论 */}
<div>
<div className='flex items-center justify-between'>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
</label>
<button
type='button'
onClick={() => handleCommentsToggle(!siteSettings.EnableComments)}
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.EnableComments
? buttonStyles.toggleOn
: buttonStyles.toggleOff
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full ${buttonStyles.toggleThumb} transition-transform ${siteSettings.EnableComments
? buttonStyles.toggleThumbOn
: buttonStyles.toggleThumbOff
}`}
/>
</button>
</div>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
</p>
</div>
</div>
{/* 操作按钮 */}
<div className='flex justify-end'>
<button
onClick={handleSave}
disabled={isLoading('saveSiteConfig')}
className={`px-4 py-2 ${isLoading('saveSiteConfig')
? buttonStyles.disabled
: buttonStyles.success
} rounded-lg transition-colors`}
>
{isLoading('saveSiteConfig') ? '保存中…' : '保存'}
</button>
</div>
{/* 通用弹窗组件 */}
<AlertModal
isOpen={alertModal.isOpen}
onClose={hideAlert}
type={alertModal.type}
title={alertModal.title}
message={alertModal.message}
timer={alertModal.timer}
showConfirm={alertModal.showConfirm}
/>
{/* 开启评论确认弹窗 */}
{showEnableCommentsModal && createPortal(
<div className='fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4' onClick={() => setShowEnableCommentsModal(false)}>
<div className='bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full' onClick={(e) => e.stopPropagation()}>
<div className='p-6'>
<div className='flex items-center justify-between mb-6'>
<h3 className='text-xl font-semibold text-gray-900 dark:text-gray-100'>
</h3>
<button
onClick={() => setShowEnableCommentsModal(false)}
className='text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors'
>
<svg className='w-6 h-6' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M6 18L18 6M6 6l12 12' />
</svg>
</button>
</div>
<div className='mb-6'>
<div className='bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4'>
<div className='flex items-center space-x-2 mb-2'>
<AlertTriangle className='w-5 h-5 text-yellow-600 dark:text-yellow-400' />
<span className='text-sm font-medium text-yellow-800 dark:text-yellow-300'>
</span>
</div>
<p className='text-sm text-yellow-700 dark:text-yellow-400'>
</p>
</div>
</div>
{/* 操作按钮 */}
<div className='flex justify-end space-x-3'>
<button
onClick={() => setShowEnableCommentsModal(false)}
className={`px-6 py-2.5 text-sm font-medium ${buttonStyles.secondary}`}
>
</button>
<button
onClick={handleConfirmEnableComments}
className={`px-6 py-2.5 text-sm font-medium ${buttonStyles.primary}`}
>
</button>
</div>
</div>
</div>
</div>,
document.body
)}
</div>
);
};
// 直播源配置组件
const LiveSourceConfig = ({
config,
refreshConfig,
}: {
config: AdminConfig | null;
refreshConfig: () => Promise<void>;
}) => {
const { alertModal, showAlert, hideAlert } = useAlertModal();
const { isLoading, withLoading } = useLoadingState();
const [liveSources, setLiveSources] = useState<LiveDataSource[]>([]);
const [showAddForm, setShowAddForm] = useState(false);
const [editingLiveSource, setEditingLiveSource] = useState<LiveDataSource | null>(null);
const [orderChanged, setOrderChanged] = useState(false);
const [isRefreshing, setIsRefreshing] = useState(false);
const [newLiveSource, setNewLiveSource] = useState<LiveDataSource>({
name: '',
key: '',
url: '',
ua: '',
epg: '',
disabled: false,
from: 'custom',
});
// dnd-kit 传感器
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 5, // 轻微位移即可触发
},
}),
useSensor(TouchSensor, {
activationConstraint: {
delay: 150, // 长按 150ms 后触发,避免与滚动冲突
tolerance: 5,
},
})
);
// 初始化
useEffect(() => {
if (config?.LiveConfig) {
setLiveSources(config.LiveConfig);
// 进入时重置 orderChanged
setOrderChanged(false);
}
}, [config]);
// 通用 API 请求
const callLiveSourceApi = async (body: Record<string, any>) => {
try {
const resp = await fetch('/api/admin/live', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...body }),
});
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
throw new Error(data.error || `操作失败: ${resp.status}`);
}
// 成功后刷新配置
await refreshConfig();
} catch (err) {
showError(err instanceof Error ? err.message : '操作失败', showAlert);
throw err; // 向上抛出方便调用处判断
}
};
const handleToggleEnable = (key: string) => {
const target = liveSources.find((s) => s.key === key);
if (!target) return;
const action = target.disabled ? 'enable' : 'disable';
withLoading(`toggleLiveSource_${key}`, () => callLiveSourceApi({ action, key })).catch(() => {
console.error('操作失败', action, key);
});
};
const handleDelete = (key: string) => {
withLoading(`deleteLiveSource_${key}`, () => callLiveSourceApi({ action: 'delete', key })).catch(() => {
console.error('操作失败', 'delete', key);
});
};
// 刷新直播源
const handleRefreshLiveSources = async () => {
if (isRefreshing) return;
await withLoading('refreshLiveSources', async () => {
setIsRefreshing(true);
try {
const response = await fetch('/api/admin/live/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.error || `刷新失败: ${response.status}`);
}
// 刷新成功后重新获取配置
await refreshConfig();
showAlert({ type: 'success', title: '刷新成功', message: '直播源已刷新', timer: 2000 });
} catch (err) {
showError(err instanceof Error ? err.message : '刷新失败', showAlert);
throw err;
} finally {
setIsRefreshing(false);
}
});
};
const handleAddLiveSource = () => {
if (!newLiveSource.name || !newLiveSource.key || !newLiveSource.url) return;
withLoading('addLiveSource', async () => {
await callLiveSourceApi({
action: 'add',
key: newLiveSource.key,
name: newLiveSource.name,
url: newLiveSource.url,
ua: newLiveSource.ua,
epg: newLiveSource.epg,
});
setNewLiveSource({
name: '',
key: '',
url: '',
epg: '',
ua: '',
disabled: false,
from: 'custom',
});
setShowAddForm(false);
}).catch(() => {
console.error('操作失败', 'add', newLiveSource);
});
};
const handleEditLiveSource = () => {
if (!editingLiveSource || !editingLiveSource.name || !editingLiveSource.url) return;
withLoading('editLiveSource', async () => {
await callLiveSourceApi({
action: 'edit',
key: editingLiveSource.key,
name: editingLiveSource.name,
url: editingLiveSource.url,
ua: editingLiveSource.ua,
epg: editingLiveSource.epg,
});
setEditingLiveSource(null);
}).catch(() => {
console.error('操作失败', 'edit', editingLiveSource);
});
};
const handleCancelEdit = () => {
setEditingLiveSource(null);
};
const handleDragEnd = (event: any) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = liveSources.findIndex((s) => s.key === active.id);
const newIndex = liveSources.findIndex((s) => s.key === over.id);
setLiveSources((prev) => arrayMove(prev, oldIndex, newIndex));
setOrderChanged(true);
};
const handleSaveOrder = () => {
const order = liveSources.map((s) => s.key);
withLoading('saveLiveSourceOrder', () => callLiveSourceApi({ action: 'sort', order }))
.then(() => {
setOrderChanged(false);
})
.catch(() => {
console.error('操作失败', 'sort', order);
});
};
// 可拖拽行封装 (dnd-kit)
const DraggableRow = ({ liveSource }: { liveSource: LiveDataSource }) => {
const { attributes, listeners, setNodeRef, transform, transition } =
useSortable({ id: liveSource.key });
const style = {
transform: CSS.Transform.toString(transform),
transition,
} as React.CSSProperties;
return (
<tr
ref={setNodeRef}
style={style}
className='hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors select-none'
>
<td
className='px-2 py-4 cursor-grab text-gray-400'
style={{ touchAction: 'none' }}
{...attributes}
{...listeners}
>
<GripVertical size={16} />
</td>
<td className='px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100'>
{liveSource.name}
</td>
<td className='px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100'>
{liveSource.key}
</td>
<td
className='px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100 max-w-[12rem] truncate'
title={liveSource.url}
>
{liveSource.url}
</td>
<td
className='px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100 max-w-[8rem] truncate'
title={liveSource.epg || '-'}
>
{liveSource.epg || '-'}
</td>
<td
className='px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100 max-w-[8rem] truncate'
title={liveSource.ua || '-'}
>
{liveSource.ua || '-'}
</td>
<td className='px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100 text-center'>
{liveSource.channelNumber && liveSource.channelNumber > 0 ? liveSource.channelNumber : '-'}
</td>
<td className='px-6 py-4 whitespace-nowrap max-w-[1rem]'>
<span
className={`px-2 py-1 text-xs rounded-full ${!liveSource.disabled
? 'bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300'
: 'bg-red-100 dark:bg-red-900/20 text-red-800 dark:text-red-300'
}`}
>
{!liveSource.disabled ? '启用中' : '已禁用'}
</span>
</td>
<td className='px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2'>
<button
onClick={() => 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' : ''}`}
>
{!liveSource.disabled ? '禁用' : '启用'}
</button>
{liveSource.from !== 'config' && (
<>
<button
onClick={() => setEditingLiveSource(liveSource)}
disabled={isLoading(`editLiveSource_${liveSource.key}`)}
className={`${buttonStyles.roundedPrimary} ${isLoading(`editLiveSource_${liveSource.key}`) ? 'opacity-50 cursor-not-allowed' : ''}`}
>
</button>
<button
onClick={() => handleDelete(liveSource.key)}
disabled={isLoading(`deleteLiveSource_${liveSource.key}`)}
className={`${buttonStyles.roundedSecondary} ${isLoading(`deleteLiveSource_${liveSource.key}`) ? 'opacity-50 cursor-not-allowed' : ''}`}
>
</button>
</>
)}
</td>
</tr>
);
};
if (!config) {
return (
<div className='text-center text-gray-500 dark:text-gray-400'>
...
</div>
);
}
return (
<div className='space-y-6'>
{/* 添加直播源表单 */}
<div className='flex items-center justify-between'>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
</h4>
<div className='flex items-center space-x-2'>
<button
onClick={handleRefreshLiveSources}
disabled={isRefreshing || isLoading('refreshLiveSources')}
className={`px-3 py-1.5 text-sm font-medium flex items-center space-x-2 ${isRefreshing || isLoading('refreshLiveSources')
? 'bg-gray-400 dark:bg-gray-600 cursor-not-allowed text-white rounded-lg'
: 'bg-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-700 text-white rounded-lg transition-colors'
}`}
>
<span>{isRefreshing || isLoading('refreshLiveSources') ? '刷新中...' : '刷新直播源'}</span>
</button>
<button
onClick={() => setShowAddForm(!showAddForm)}
className={showAddForm ? buttonStyles.secondary : buttonStyles.success}
>
{showAddForm ? '取消' : '添加直播源'}
</button>
</div>
</div>
{showAddForm && (
<div className='p-4 bg-gray-50 dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700 space-y-4'>
<div className='grid grid-cols-1 sm:grid-cols-2 gap-4'>
<input
type='text'
placeholder='名称'
value={newLiveSource.name}
onChange={(e) =>
setNewLiveSource((prev) => ({ ...prev, name: e.target.value }))
}
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'
/>
<input
type='text'
placeholder='Key'
value={newLiveSource.key}
onChange={(e) =>
setNewLiveSource((prev) => ({ ...prev, key: e.target.value }))
}
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'
/>
<input
type='text'
placeholder='M3U 地址'
value={newLiveSource.url}
onChange={(e) =>
setNewLiveSource((prev) => ({ ...prev, url: e.target.value }))
}
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'
/>
<input
type='text'
placeholder='节目单地址(选填)'
value={newLiveSource.epg}
onChange={(e) =>
setNewLiveSource((prev) => ({ ...prev, epg: e.target.value }))
}
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'
/>
<input
type='text'
placeholder='自定义 UA选填'
value={newLiveSource.ua}
onChange={(e) =>
setNewLiveSource((prev) => ({ ...prev, ua: e.target.value }))
}
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'
/>
</div>
<div className='flex justify-end'>
<button
onClick={handleAddLiveSource}
disabled={!newLiveSource.name || !newLiveSource.key || !newLiveSource.url || isLoading('addLiveSource')}
className={`w-full sm:w-auto px-4 py-2 ${!newLiveSource.name || !newLiveSource.key || !newLiveSource.url || isLoading('addLiveSource') ? buttonStyles.disabled : buttonStyles.success}`}
>
{isLoading('addLiveSource') ? '添加中...' : '添加'}
</button>
</div>
</div>
)}
{/* 编辑直播源表单 */}
{editingLiveSource && (
<div className='p-4 bg-gray-50 dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700 space-y-4'>
<div className='flex items-center justify-between'>
<h5 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
: {editingLiveSource.name}
</h5>
<button
onClick={handleCancelEdit}
className='text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200'
>
</button>
</div>
<div className='grid grid-cols-1 sm:grid-cols-2 gap-4'>
<div>
<label className='block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1'>
</label>
<input
type='text'
value={editingLiveSource.name}
onChange={(e) =>
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'
/>
</div>
<div>
<label className='block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1'>
Key ()
</label>
<input
type='text'
value={editingLiveSource.key}
disabled
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 cursor-not-allowed'
/>
</div>
<div>
<label className='block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1'>
M3U
</label>
<input
type='text'
value={editingLiveSource.url}
onChange={(e) =>
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'
/>
</div>
<div>
<label className='block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1'>
</label>
<input
type='text'
value={editingLiveSource.epg}
onChange={(e) =>
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'
/>
</div>
<div>
<label className='block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1'>
UA
</label>
<input
type='text'
value={editingLiveSource.ua}
onChange={(e) =>
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'
/>
</div>
</div>
<div className='flex justify-end space-x-2'>
<button
onClick={handleCancelEdit}
className={buttonStyles.secondary}
>
</button>
<button
onClick={handleEditLiveSource}
disabled={!editingLiveSource.name || !editingLiveSource.url || isLoading('editLiveSource')}
className={`${!editingLiveSource.name || !editingLiveSource.url || isLoading('editLiveSource') ? buttonStyles.disabled : buttonStyles.success}`}
>
{isLoading('editLiveSource') ? '保存中...' : '保存'}
</button>
</div>
</div>
)}
{/* 直播源表格 */}
<div className='border border-gray-200 dark:border-gray-700 rounded-lg max-h-[28rem] overflow-y-auto overflow-x-auto relative' data-table="live-source-list">
<table className='min-w-full divide-y divide-gray-200 dark:divide-gray-700'>
<thead className='bg-gray-50 dark:bg-gray-900 sticky top-0 z-10'>
<tr>
<th className='w-8' />
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
</th>
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
Key
</th>
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
M3U
</th>
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
</th>
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
UA
</th>
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
</th>
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
</th>
<th className='px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
</th>
</tr>
</thead>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
autoScroll={false}
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
>
<SortableContext
items={liveSources.map((s) => s.key)}
strategy={verticalListSortingStrategy}
>
<tbody className='divide-y divide-gray-200 dark:divide-gray-700'>
{liveSources.map((liveSource) => (
<DraggableRow key={liveSource.key} liveSource={liveSource} />
))}
</tbody>
</SortableContext>
</DndContext>
</table>
</div>
{/* 保存排序按钮 */}
{orderChanged && (
<div className='flex justify-end'>
<button
onClick={handleSaveOrder}
disabled={isLoading('saveLiveSourceOrder')}
className={`px-3 py-1.5 text-sm ${isLoading('saveLiveSourceOrder') ? buttonStyles.disabled : buttonStyles.primary}`}
>
{isLoading('saveLiveSourceOrder') ? '保存中...' : '保存排序'}
</button>
</div>
)}
{/* 通用弹窗组件 */}
<AlertModal
isOpen={alertModal.isOpen}
onClose={hideAlert}
type={alertModal.type}
title={alertModal.title}
message={alertModal.message}
timer={alertModal.timer}
showConfirm={alertModal.showConfirm}
/>
</div>
);
};
function AdminPageClient() {
const { alertModal, showAlert, hideAlert } = useAlertModal();
const { isLoading, withLoading } = useLoadingState();
const [config, setConfig] = useState<AdminConfig | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [role, setRole] = useState<'owner' | 'admin' | null>(null);
const [showResetConfigModal, setShowResetConfigModal] = useState(false);
const [expandedTabs, setExpandedTabs] = useState<{ [key: string]: boolean }>({
userConfig: false,
videoSource: false,
liveSource: false,
siteConfig: false,
categoryConfig: false,
configFile: false,
dataMigration: false,
});
// 获取管理员配置
// showLoading 用于控制是否在请求期间显示整体加载骨架。
const fetchConfig = useCallback(async (showLoading = false) => {
try {
if (showLoading) {
setLoading(true);
}
const response = await fetch(`/api/admin/config`);
if (!response.ok) {
const data = (await response.json()) as any;
throw new Error(`获取配置失败: ${data.error}`);
}
const data = (await response.json()) as AdminConfigResult;
setConfig(data.Config);
setRole(data.Role);
} catch (err) {
const msg = err instanceof Error ? err.message : '获取配置失败';
showError(msg, showAlert);
setError(msg);
} finally {
if (showLoading) {
setLoading(false);
}
}
}, []);
useEffect(() => {
// 首次加载时显示骨架
fetchConfig(true);
}, [fetchConfig]);
// 切换标签展开状态
const toggleTab = (tabKey: string) => {
setExpandedTabs((prev) => ({
...prev,
[tabKey]: !prev[tabKey],
}));
};
// 新增: 重置配置处理函数
const handleResetConfig = () => {
setShowResetConfigModal(true);
};
const handleConfirmResetConfig = async () => {
await withLoading('resetConfig', async () => {
try {
const response = await fetch(`/api/admin/reset`);
if (!response.ok) {
throw new Error(`重置失败: ${response.status}`);
}
showSuccess('重置成功,请刷新页面!', showAlert);
await fetchConfig();
setShowResetConfigModal(false);
} catch (err) {
showError(err instanceof Error ? err.message : '重置失败', showAlert);
throw err;
}
});
};
if (loading) {
return (
<PageLayout activePath='/admin'>
<div className='px-2 sm:px-10 py-4 sm:py-8'>
<div className='max-w-[95%] mx-auto'>
<h1 className='text-2xl font-bold text-gray-900 dark:text-gray-100 mb-8'>
</h1>
<div className='space-y-4'>
{Array.from({ length: 3 }).map((_, index) => (
<div
key={index}
className='h-20 bg-gray-200 dark:bg-gray-700 rounded-lg animate-pulse'
/>
))}
</div>
</div>
</div>
</PageLayout>
);
}
if (error) {
// 错误已通过弹窗展示,此处直接返回空
return null;
}
return (
<PageLayout activePath='/admin'>
<div className='px-2 sm:px-10 py-4 sm:py-8'>
<div className='max-w-[95%] mx-auto'>
{/* 标题 + 重置配置按钮 */}
<div className='flex items-center gap-2 mb-8'>
<h1 className='text-2xl font-bold text-gray-900 dark:text-gray-100'>
</h1>
{config && role === 'owner' && (
<button
onClick={handleResetConfig}
className={`px-3 py-1 text-xs rounded-md transition-colors ${buttonStyles.dangerSmall}`}
>
</button>
)}
</div>
{/* 配置文件标签 - 仅站长可见 */}
{role === 'owner' && (
<CollapsibleTab
title='配置文件'
icon={
<FileText
size={20}
className='text-gray-600 dark:text-gray-400'
/>
}
isExpanded={expandedTabs.configFile}
onToggle={() => toggleTab('configFile')}
>
<ConfigFileComponent config={config} refreshConfig={fetchConfig} />
</CollapsibleTab>
)}
{/* 站点配置标签 */}
<CollapsibleTab
title='站点配置'
icon={
<Settings
size={20}
className='text-gray-600 dark:text-gray-400'
/>
}
isExpanded={expandedTabs.siteConfig}
onToggle={() => toggleTab('siteConfig')}
>
<SiteConfigComponent config={config} refreshConfig={fetchConfig} />
</CollapsibleTab>
<div className='space-y-4'>
{/* 用户配置标签 */}
<CollapsibleTab
title='用户配置'
icon={
<Users size={20} className='text-gray-600 dark:text-gray-400' />
}
isExpanded={expandedTabs.userConfig}
onToggle={() => toggleTab('userConfig')}
>
<UserConfig
config={config}
role={role}
refreshConfig={fetchConfig}
/>
</CollapsibleTab>
{/* 视频源配置标签 */}
<CollapsibleTab
title='视频源配置'
icon={
<Video size={20} className='text-gray-600 dark:text-gray-400' />
}
isExpanded={expandedTabs.videoSource}
onToggle={() => toggleTab('videoSource')}
>
<VideoSourceConfig config={config} refreshConfig={fetchConfig} />
</CollapsibleTab>
{/* 直播源配置标签 */}
<CollapsibleTab
title='直播源配置'
icon={
<Tv size={20} className='text-gray-600 dark:text-gray-400' />
}
isExpanded={expandedTabs.liveSource}
onToggle={() => toggleTab('liveSource')}
>
<LiveSourceConfig config={config} refreshConfig={fetchConfig} />
</CollapsibleTab>
{/* 分类配置标签 */}
<CollapsibleTab
title='分类配置'
icon={
<FolderOpen
size={20}
className='text-gray-600 dark:text-gray-400'
/>
}
isExpanded={expandedTabs.categoryConfig}
onToggle={() => toggleTab('categoryConfig')}
>
<CategoryConfig config={config} refreshConfig={fetchConfig} />
</CollapsibleTab>
{/* 数据迁移标签 - 仅站长可见 */}
{role === 'owner' && (
<CollapsibleTab
title='数据迁移'
icon={
<Database
size={20}
className='text-gray-600 dark:text-gray-400'
/>
}
isExpanded={expandedTabs.dataMigration}
onToggle={() => toggleTab('dataMigration')}
>
<DataMigration onRefreshConfig={fetchConfig} />
</CollapsibleTab>
)}
</div>
</div>
</div>
{/* 通用弹窗组件 */}
<AlertModal
isOpen={alertModal.isOpen}
onClose={hideAlert}
type={alertModal.type}
title={alertModal.title}
message={alertModal.message}
timer={alertModal.timer}
showConfirm={alertModal.showConfirm}
/>
{/* 重置配置确认弹窗 */}
{showResetConfigModal && createPortal(
<div className='fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4' onClick={() => setShowResetConfigModal(false)}>
<div className='bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-2xl w-full' onClick={(e) => e.stopPropagation()}>
<div className='p-6'>
<div className='flex items-center justify-between mb-6'>
<h3 className='text-xl font-semibold text-gray-900 dark:text-gray-100'>
</h3>
<button
onClick={() => setShowResetConfigModal(false)}
className='text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors'
>
<svg className='w-6 h-6' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M6 18L18 6M6 6l12 12' />
</svg>
</button>
</div>
<div className='mb-6'>
<div className='bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4 mb-4'>
<div className='flex items-center space-x-2 mb-2'>
<svg className='w-5 h-5 text-yellow-600 dark:text-yellow-400' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z' />
</svg>
<span className='text-sm font-medium text-yellow-800 dark:text-yellow-300'>
</span>
</div>
<p className='text-sm text-yellow-700 dark:text-yellow-400'>
</p>
</div>
</div>
{/* 操作按钮 */}
<div className='flex justify-end space-x-3'>
<button
onClick={() => setShowResetConfigModal(false)}
className={`px-6 py-2.5 text-sm font-medium ${buttonStyles.secondary}`}
>
</button>
<button
onClick={handleConfirmResetConfig}
disabled={isLoading('resetConfig')}
className={`px-6 py-2.5 text-sm font-medium ${isLoading('resetConfig') ? buttonStyles.disabled : buttonStyles.danger}`}
>
{isLoading('resetConfig') ? '重置中...' : '确认重置'}
</button>
</div>
</div>
</div>
</div>,
document.body
)}
</PageLayout>
);
}
export default function AdminPage() {
return (
<Suspense>
<AdminPageClient />
</Suspense>
);
}