/* 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 { Alert, Avatar, Button, Card, Checkbox, Chip, Input, Label, Skeleton, Switch, Table, TextArea, TextField } from '@heroui/react';
import {
AlertCircle,
AlertTriangle,
CheckCircle,
ChevronDown,
ChevronUp,
Database,
ExternalLink,
FileText,
FolderOpen,
Settings,
Tv,
User,
Users,
Video,
X,
} from 'lucide-react';
import { GripVertical, Palette } from 'lucide-react';
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { AdminConfig, AdminConfigResult } from '@/lib/admin.types';
import { getAuthInfoFromBrowserCookie } from '@/lib/auth';
import DataMigration from '@/components/DataMigration';
import PageLayout from '@/components/PageLayout';
import ThemeManager from '@/components/ThemeManager';
import { AppDialog, AppFilterSelect } from '@/components/ui/HeroPrimitives';
const AdminTable = ({
ariaLabel,
minWidth = 'min-w-[900px]',
children,
}: {
ariaLabel: string;
minWidth?: string;
children: React.ReactNode;
}) => (
);
const AdminCheckbox = ({
ariaLabel,
isSelected,
onChange,
isDisabled,
}: {
ariaLabel: string;
isSelected: boolean;
onChange: (isSelected: boolean) => void;
isDisabled?: boolean;
}) => (
);
const AdminChip = ({
children,
color = 'default',
}: {
children: React.ReactNode;
color?: 'default' | 'accent' | 'success' | 'warning' | 'danger';
}) => (
{children}
);
const AdminSettingSwitch = ({
label,
description,
isSelected,
onChange,
}: {
label: string;
description: string;
isSelected: boolean;
onChange: (isSelected: boolean) => void;
}) => (
);
// 获取用户头像的函数
const getUserAvatar = async (username: string): Promise => {
try {
const response = await fetch(`/api/avatar?user=${encodeURIComponent(username)}`);
if (response.ok) {
const data = await response.json();
return data.avatar || null;
}
} catch (error) {
console.error('获取头像失败:', error);
}
return null;
};
// 用户头像组件
interface UserAvatarProps {
username: string;
size?: 'sm' | 'md' | 'lg';
}
const UserAvatar = ({ username, size = 'sm' }: UserAvatarProps) => {
const [avatarUrl, setAvatarUrl] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchAvatar = async () => {
setLoading(true);
const avatar = await getUserAvatar(username);
setAvatarUrl(avatar);
setLoading(false);
};
fetchAvatar();
}, [username]);
return (
{loading ? (
) : avatarUrl ? (
) : (
)}
);
};
// 机器码单元格组件
interface MachineCodeCellProps {
username: string;
canManage: boolean;
machineCodeData: Record;
onRefresh: () => void;
showAlert: (config: any) => void;
}
const MachineCodeCell = ({ username, canManage, machineCodeData, onRefresh, showAlert }: MachineCodeCellProps) => {
const [unbinding, setUnbinding] = useState(false);
const [tooltipPosition, setTooltipPosition] = useState<'top' | 'bottom'>('bottom');
const tooltipRef = useRef(null);
const codeRef = useRef(null);
const machineCodeInfo = machineCodeData[username] || null;
// 智能定位逻辑
const handleMouseEnter = useCallback(() => {
if (!codeRef.current) return;
const element = codeRef.current;
const rect = element.getBoundingClientRect();
const viewportHeight = window.innerHeight;
if (rect.top < viewportHeight / 2) {
setTooltipPosition('bottom');
} else {
setTooltipPosition('top');
}
}, []);
// 解绑机器码
const handleUnbind = async () => {
if (!machineCodeInfo || !canManage) return;
try {
setUnbinding(true);
const response = await fetch('/api/machine-code', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
action: 'unbind',
targetUser: username,
}),
});
if (response.ok) {
showSuccess('机器码解绑成功', showAlert);
onRefresh(); // 刷新数据
} else {
const error = await response.json();
showError(`解绑失败: ${error.error || '未知错误'}`, showAlert);
}
} catch (error) {
console.error('解绑机器码失败:', error);
showError('解绑失败,请重试', showAlert);
} finally {
setUnbinding(false);
}
};
const formatMachineCode = (code: string) => {
if (code.length !== 32) return code;
return code.match(/.{1,4}/g)?.join('-') || code;
};
const formatDate = (timestamp: number) => {
return new Date(timestamp).toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
};
if (!machineCodeInfo) {
return (
未绑定
);
}
return (
{formatMachineCode(machineCodeInfo.machineCode).substring(0, 12)}...
{/* 悬停显示完整机器码 - 智能定位 */}
{formatMachineCode(machineCodeInfo.machineCode)}
{machineCodeInfo.deviceInfo && (
{machineCodeInfo.deviceInfo}
)}
绑定时间: {formatDate(machineCodeInfo.bindTime)}
{/* 箭头 - 根据位置动态调整 */}
{canManage && (
)}
);
};
// 通用弹窗组件
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) => {
useEffect(() => {
if (isOpen && timer) {
const timeout = setTimeout(onClose, timer);
return () => clearTimeout(timeout);
}
}, [isOpen, timer, onClose]);
const getIcon = () => {
switch (type) {
case 'success':
return ;
case 'error':
return ;
case 'warning':
return ;
default:
return null;
}
};
const getStatus = () => {
switch (type) {
case 'success':
return 'success';
case 'error':
return 'danger';
case 'warning':
return 'warning';
default:
return 'accent';
}
};
return (
{
if (!open) onClose();
}}
title={title}
icon={getIcon()}
footer={
showConfirm ? (
) : null
}
>
{message ? {message} : null}
);
};
// 弹窗状态管理
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) => {
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({});
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): Promise => {
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;
RequireDeviceCode: 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';
}
type ValidationResult = {
status: 'valid' | 'invalid' | 'no_results' | 'validating' | null;
message: string;
details?: {
responseTime?: number;
resultCount?: number;
error?: string;
searchKeyword?: string;
};
};
const SourceValidationSummary = ({ result }: { result: ValidationResult }) => {
if (!result.status) return null;
const statusMeta = {
valid: {
chipColor: 'success' as const,
icon: CheckCircle,
label: '检测通过',
},
validating: {
chipColor: 'accent' as const,
icon: Settings,
label: '检测中',
},
no_results: {
chipColor: 'warning' as const,
icon: AlertTriangle,
label: '无搜索结果',
},
invalid: {
chipColor: 'danger' as const,
icon: AlertCircle,
label: '检测失败',
},
}[result.status];
const StatusIcon = statusMeta.icon;
return (
检测结果
{statusMeta.label}
{result.message}
{result.details && (
{result.details.searchKeyword && (
测试关键词: {result.details.searchKeyword}
)}
{result.details.responseTime && (
响应时间: {result.details.responseTime}ms
)}
{result.details.resultCount !== undefined && (
搜索结果数: {result.details.resultCount}
)}
{result.details.error && (
错误信息: {result.details.error}
)}
)}
);
};
// 自定义分类数据类型
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 (
{isExpanded && {children}
}
);
};
// 用户配置组件
interface UserConfigProps {
config: AdminConfig | null;
role: 'owner' | 'admin' | null;
refreshConfig: () => Promise;
machineCodeUsers: Record;
fetchMachineCodeUsers: () => Promise;
}
const UserConfig = ({ config, role, refreshConfig, machineCodeUsers, fetchMachineCodeUsers }: 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([]);
const [showConfigureUserGroupModal, setShowConfigureUserGroupModal] = useState(false);
const [selectedUserForGroup, setSelectedUserForGroup] = useState<{
username: string;
role: 'user' | 'admin' | 'owner';
tags?: string[];
} | null>(null);
const [selectedUserGroups, setSelectedUserGroups] = useState([]);
const [selectedUsers, setSelectedUsers] = useState>(new Set());
const [showBatchUserGroupModal, setShowBatchUserGroupModal] = useState(false);
const [selectedUserGroup, setSelectedUserGroup] = useState('');
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(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 (
加载中...
);
}
return (
{/* 用户统计 */}
用户统计
{config.UserConfig.Users.length}
总用户数
{/* 用户组管理 */}
用户组管理
{/* 用户组列表 */}
用户组名称
可用视频源
操作
{userGroups.map((group) => (
{group.name}
{group.enabledApis && group.enabledApis.length > 0
? `${group.enabledApis.length} 个源`
: '无限制'}
))}
{userGroups.length === 0 && (
暂无用户组,请添加用户组来管理用户权限
)}
{/* 用户列表 */}
用户列表
{/* 批量操作按钮 */}
{selectedUsers.size > 0 && (
<>
已选择 {selectedUsers.size} 个用户
>
)}
{/* 添加用户表单 */}
{showAddUserForm && (
)}
{/* 修改密码表单 */}
{showChangePasswordForm && (
)}
{/* 用户列表 */}
{(() => {
// 检查是否有权限操作任何用户
const hasAnyPermission = config?.UserConfig?.Users?.some(user =>
(role === 'owner' ||
(role === 'admin' &&
(user.role === 'user' ||
user.username === currentUsername)))
);
return hasAnyPermission ? (
) : (
);
})()}
用户名
角色
状态
用户组
采集源权限
机器码
操作
{/* 按规则排序用户:自己 -> 站长(若非自己) -> 管理员 -> 其他 */}
{(() => {
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 (
{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 (
{(role === 'owner' ||
(role === 'admin' &&
(user.role === 'user' ||
user.username === currentUsername))) ? (
handleSelectUser(user.username, selected)}
/>
) : (
)}
{user.username}
{user.role === 'owner'
? '站长'
: user.role === 'admin'
? '管理员'
: '普通用户'}
{!user.banned ? '正常' : '已封禁'}
{user.tags && user.tags.length > 0
? user.tags.join(', ')
: '无用户组'}
{/* 配置用户组按钮 */}
{(role === 'owner' ||
(role === 'admin' &&
(user.role === 'user' ||
user.username === currentUsername))) && (
)}
{user.enabledApis && user.enabledApis.length > 0
? `${user.enabledApis.length} 个源`
: '无限制'}
{/* 配置采集源权限按钮 */}
{(role === 'owner' ||
(role === 'admin' &&
(user.role === 'user' ||
user.username === currentUsername))) && (
)}
{/* 修改密码按钮 */}
{canChangePassword && (
)}
{canOperate && (
<>
{/* 其他操作按钮 */}
{user.role === 'user' && (
)}
{user.role === 'admin' && (
)}
{user.role !== 'owner' &&
(!user.banned ? (
) : (
))}
>
)}
{/* 删除用户按钮 - 放在最后,使用更明显的红色样式 */}
{canDeleteUser && (
)}
);
})}
);
})()}
{/* 配置用户采集源权限弹窗 */}
{
if (!open) {
setShowConfigureApisModal(false);
setSelectedUser(null);
setSelectedApis([]);
}
}}
title={`配置用户采集源权限${selectedUser ? ` - ${selectedUser.username}` : ''}`}
size='lg'
footer={
selectedUser ? (
<>
>
) : null
}
>
配置说明
提示:全不选为无限制,选中的采集源将限制用户只能访问这些源
选择可用的采集源:
{config?.SourceConfig?.map((source) => (
{
if (isSelected) {
setSelectedApis([...selectedApis, source.key]);
} else {
setSelectedApis(selectedApis.filter(api => api !== source.key));
}
}}
>
{source.name}
{source.api ? (
{extractDomain(source.api)}
) : null}
))}
已选择:
{selectedApis.length > 0 ? `${selectedApis.length} 个源` : '无限制'}
{/* 添加用户组弹窗 */}
{
if (!open) {
setShowAddUserGroupForm(false);
setNewUserGroup({ name: '', enabledApis: [] });
}
}}
title='添加新用户组'
size='lg'
footer={
<>
>
}
>
setNewUserGroup((prev) => ({ ...prev, name: e.target.value }))
}
/>
{config?.SourceConfig?.map((source) => (
{
if (isSelected) {
setNewUserGroup(prev => ({
...prev,
enabledApis: [...prev.enabledApis, source.key]
}));
} else {
setNewUserGroup(prev => ({
...prev,
enabledApis: prev.enabledApis.filter(api => api !== source.key)
}));
}
}}
>
{source.name}
{source.api ? (
{extractDomain(source.api)}
) : null}
))}
{/* 编辑用户组弹窗 */}
{
if (!open) {
setShowEditUserGroupForm(false);
setEditingUserGroup(null);
}
}}
title={`编辑用户组${editingUserGroup ? ` - ${editingUserGroup.name}` : ''}`}
size='lg'
footer={
editingUserGroup ? (
<>
>
) : null
}
>
{editingUserGroup ? (
{config?.SourceConfig?.map((source) => (
{
if (isSelected) {
setEditingUserGroup(prev => prev ? {
...prev,
enabledApis: [...prev.enabledApis, source.key]
} : null);
} else {
setEditingUserGroup(prev => prev ? {
...prev,
enabledApis: prev.enabledApis.filter(api => api !== source.key)
} : null);
}
}}
>
{source.name}
{source.api ? (
{extractDomain(source.api)}
) : null}
))}
) : null}
{/* 配置用户组弹窗 */}
{
if (!open) {
setShowConfigureUserGroupModal(false);
setSelectedUserForGroup(null);
setSelectedUserGroups([]);
}
}}
title={`配置用户组${selectedUserForGroup ? ` - ${selectedUserForGroup.username}` : ''}`}
size='lg'
footer={
selectedUserForGroup ? (
<>
>
) : null
}
>
配置说明
提示:选择"无用户组"为无限制,选择特定用户组将限制用户只能访问该用户组允许的采集源
0 ? selectedUserGroups[0] : ''}
options={[
{ value: '', label: '无用户组(无限制)' },
...userGroups.map((group) => ({
value: group.name,
label: `${group.name} ${group.enabledApis && group.enabledApis.length > 0 ? `(${group.enabledApis.length} 个源)` : ''}`,
})),
]}
onChange={(value) => setSelectedUserGroups(value ? [value] : [])}
/>
选择"无用户组"为无限制,选择特定用户组将限制用户只能访问该用户组允许的采集源
{/* 删除用户组确认弹窗 */}
{
if (!open) {
setShowDeleteUserGroupModal(false);
setDeletingUserGroup(null);
}
}}
title='确认删除用户组'
footer={
deletingUserGroup ? (
<>
>
) : null
}
>
{deletingUserGroup ? (
危险操作警告
删除用户组 {deletingUserGroup.name} 将影响所有使用该组的用户,此操作不可恢复!
{deletingUserGroup.affectedUsers.length > 0 ? (
将影响 {deletingUserGroup.affectedUsers.length} 个用户
{deletingUserGroup.affectedUsers.map((user, index) => (
• {user.username} ({user.role})
))}
这些用户的用户组将被自动移除
) : (
当前没有用户使用此用户组
)}
) : null}
{/* 删除用户确认弹窗 */}
{
if (!open) {
setShowDeleteUserModal(false);
setDeletingUser(null);
}
}}
title='确认删除用户'
footer={
<>
>
}
>
危险操作警告
删除用户 {deletingUser} 将同时删除其搜索历史、播放记录和收藏夹,此操作不可恢复!
{/* 批量设置用户组弹窗 */}
{
if (!open) {
setShowBatchUserGroupModal(false);
setSelectedUserGroup('');
}
}}
title='批量设置用户组'
footer={
<>
>
}
>
批量操作说明
将为选中的 {selectedUsers.size} 个用户 设置用户组,选择"无用户组"为无限制
({
value: group.name,
label: `${group.name} ${group.enabledApis && group.enabledApis.length > 0 ? `(${group.enabledApis.length} 个源)` : ''}`,
})),
]}
onChange={setSelectedUserGroup}
/>
选择"无用户组"为无限制,选择特定用户组将限制用户只能访问该用户组允许的采集源
{/* 通用弹窗组件 */}
);
}
// 视频源配置组件
const VideoSourceConfig = ({
config,
refreshConfig,
}: {
config: AdminConfig | null;
refreshConfig: () => Promise;
}) => {
const { alertModal, showAlert, hideAlert } = useAlertModal();
const { isLoading, withLoading } = useLoadingState();
const [sources, setSources] = useState([]);
const [showAddForm, setShowAddForm] = useState(false);
const [editingSource, setEditingSource] = useState(null);
const [orderChanged, setOrderChanged] = useState(false);
const [newSource, setNewSource] = useState({
name: '',
key: '',
api: '',
detail: '',
disabled: false,
from: 'config',
});
// 批量操作相关状态
const [selectedSources, setSelectedSources] = useState>(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>([]);
// 单个视频源验证状态
const [singleValidationResult, setSingleValidationResult] = useState<{
status: 'valid' | 'invalid' | 'no_results' | 'validating' | null;
message: string;
details?: {
responseTime?: number;
resultCount?: number;
error?: string;
searchKeyword?: string;
};
}>({ status: null, message: '' });
const [isSingleValidating, setIsSingleValidating] = useState(false);
// 新增视频源验证状态
const [newSourceValidationResult, setNewSourceValidationResult] = useState<{
status: 'valid' | 'invalid' | 'no_results' | 'validating' | null;
message: string;
details?: {
responseTime?: number;
resultCount?: number;
error?: string;
searchKeyword?: string;
};
}>({ status: null, message: '' });
const [isNewSourceValidating, setIsNewSourceValidating] = useState(false);
// 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) => {
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);
// 清除检测结果
clearNewSourceValidation();
}).catch(() => {
console.error('操作失败', 'add', newSource);
});
};
const handleEditSource = () => {
if (!editingSource || !editingSource.name || !editingSource.api) return;
withLoading('editSource', async () => {
await callSourceApi({
action: 'edit',
key: editingSource.key,
name: editingSource.name,
api: editingSource.api,
detail: editingSource.detail,
});
setEditingSource(null);
}).catch(() => {
console.error('操作失败', 'edit', editingSource);
});
};
const handleCancelEdit = () => {
setEditingSource(null);
// 清除单个源的检测结果
setSingleValidationResult({ status: null, message: '' });
setIsSingleValidating(false);
};
// 清除新增视频源检测结果
const clearNewSourceValidation = () => {
setNewSourceValidationResult({ status: null, message: '' });
setIsNewSourceValidating(false);
};
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: '搜索关键词不能为空', showConfirm: true });
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: '连接错误,请重试', showConfirm: true });
};
// 设置超时,防止长时间等待
setTimeout(() => {
if (eventSource.readyState === EventSource.OPEN) {
eventSource.close();
setIsValidating(false);
showAlert({ type: 'warning', title: '验证超时', message: '检测超时,请重试', showConfirm: true });
}
}, 60000); // 60秒超时
} catch (error) {
setIsValidating(false);
showAlert({ type: 'error', title: '验证失败', message: error instanceof Error ? error.message : '未知错误', showConfirm: true });
throw error;
}
});
};
// 通用视频源有效性检测函数
const handleValidateSource = async (
api: string,
name: string,
isNewSource: boolean = false
) => {
if (!api.trim()) {
showAlert({ type: 'warning', title: 'API地址不能为空', message: '请输入有效的API地址', showConfirm: true });
return;
}
const validationKey = isNewSource ? 'validateNewSource' : 'validateSingleSource';
const setValidating = isNewSource ? setIsNewSourceValidating : setIsSingleValidating;
const setResult = isNewSource ? setNewSourceValidationResult : setSingleValidationResult;
await withLoading(validationKey, async () => {
setValidating(true);
setResult({ status: 'validating', message: '检测中...' });
const startTime = Date.now();
const testKeyword = '灵笼';
try {
// 构建检测 URL,使用临时 API 地址
const eventSource = new EventSource(`/api/admin/source/validate?q=${encodeURIComponent(testKeyword)}&tempApi=${encodeURIComponent(api.trim())}&tempName=${encodeURIComponent(name)}`);
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
const responseTime = Date.now() - startTime;
switch (data.type) {
case 'start':
console.log(`开始检测视频源: ${name}`);
break;
case 'source_result':
case 'source_error':
if (data.source === 'temp') {
let message = '';
let details: any = {
responseTime,
searchKeyword: testKeyword
};
if (data.status === 'valid') {
message = '搜索正常';
details.resultCount = data.resultCount || 0;
} else if (data.status === 'no_results') {
message = '无法搜索到结果';
details.resultCount = 0;
} else {
message = '连接失败';
details.error = data.error || '未知错误';
}
setResult({
status: data.status,
message,
details
});
}
break;
case 'complete':
console.log(`检测完成: ${name}`);
eventSource.close();
setValidating(false);
break;
}
} catch (error) {
console.error('解析EventSource数据失败:', error);
}
};
eventSource.onerror = (error) => {
console.error('EventSource错误:', error);
eventSource.close();
setValidating(false);
const responseTime = Date.now() - startTime;
setResult({
status: 'invalid',
message: '连接错误,请重试',
details: {
responseTime,
error: '网络连接失败',
searchKeyword: testKeyword
}
});
};
// 设置超时,防止长时间等待
setTimeout(() => {
if (eventSource.readyState === EventSource.OPEN) {
eventSource.close();
setValidating(false);
const responseTime = Date.now() - startTime;
setResult({
status: 'invalid',
message: '检测超时,请重试',
details: {
responseTime,
error: '请求超时(30秒)',
searchKeyword: testKeyword
}
});
}
}, 30000); // 30秒超时
} catch (error) {
setValidating(false);
const responseTime = Date.now() - startTime;
setResult({
status: 'invalid',
message: error instanceof Error ? error.message : '未知错误',
details: {
responseTime,
error: error instanceof Error ? error.message : '未知错误',
searchKeyword: testKeyword
}
});
}
});
};
// 单个视频源有效性检测函数
const handleValidateSingleSource = async () => {
if (!editingSource) {
showAlert({ type: 'warning', title: '没有可检测的视频源', message: '请确保正在编辑视频源', showConfirm: true });
return;
}
await handleValidateSource(editingSource.api, editingSource.name, false);
};
// 新增视频源有效性检测函数
const handleValidateNewSource = async () => {
if (!newSource.name.trim()) {
showAlert({ type: 'warning', title: '视频源名称不能为空', message: '请输入视频源名称', showConfirm: true });
return;
}
await handleValidateSource(newSource.api, newSource.name, true);
};
// 获取有效性状态显示
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-accent/10 text-accent',
icon: '⟳',
message: result.message
};
case 'valid':
return {
text: '有效',
className: 'bg-accent/10 text-accent',
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 (
handleSelectSource(source.key, selected)}
/>
{source.name}
{source.key}
{source.api}
{source.detail || '-'}
{!source.disabled ? '启用中' : '已禁用'}
{(() => {
const status = getValidationStatus(source.key);
if (!status) {
return (
未检测
);
}
return (
{status.icon} {status.text}
);
})()}
);
};
// 全选/取消全选
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: '请选择至少一个视频源', showConfirm: true });
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 : '操作失败', showConfirm: true });
}
setConfirmModal({ isOpen: false, title: '', message: '', onConfirm: () => { }, onCancel: () => { } });
},
onCancel: () => {
setConfirmModal({ isOpen: false, title: '', message: '', onConfirm: () => { }, onCancel: () => { } });
}
});
};
if (!config) {
return (
加载中...
);
}
return (
{/* 添加视频源表单 */}
视频源列表
{/* 批量操作按钮 - 移动端显示在下一行,PC端显示在左侧 */}
{selectedSources.size > 0 && (
<>
已选 {selectedSources.size}
已选择 {selectedSources.size} 个视频源
>
)}
{showAddForm && (
添加视频源
填写资源站名称、唯一 Key 和采集 API 地址。
setNewSource((prev) => ({ ...prev, name: e.target.value }))
}
variant='secondary'
/>
setNewSource((prev) => ({ ...prev, key: e.target.value }))
}
variant='secondary'
/>
setNewSource((prev) => ({ ...prev, api: e.target.value }))
}
variant='secondary'
/>
setNewSource((prev) => ({ ...prev, detail: e.target.value }))
}
variant='secondary'
/>
)}
{/* 编辑视频源表单 */}
{editingSource && (
编辑视频源
{editingSource.name} · {editingSource.key}
)}
{/* 视频源表格 */}
名称
Key
API 地址
Detail 地址
状态
有效性
操作
s.key)}
strategy={verticalListSortingStrategy}
>
{sources.map((source) => (
))}
{/* 保存排序按钮 */}
{orderChanged && (
)}
{/* 有效性检测弹窗 */}
>
}
>
setSearchKeyword(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleValidateSources()}
/>
{/* 通用弹窗组件 */}
{/* 批量操作确认弹窗 */}
{
if (!open) confirmModal.onCancel();
}}
title={confirmModal.title}
description={confirmModal.message}
footer={
<>
>
}
/>
);
};
// 分类配置组件
const CategoryConfig = ({
config,
refreshConfig,
}: {
config: AdminConfig | null;
refreshConfig: () => Promise;
}) => {
const { alertModal, showAlert, hideAlert } = useAlertModal();
const { isLoading, withLoading } = useLoadingState();
const [categories, setCategories] = useState([]);
const [showAddForm, setShowAddForm] = useState(false);
const [orderChanged, setOrderChanged] = useState(false);
const [newCategory, setNewCategory] = useState({
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) => {
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 (
{category.name || '-'}
{category.type === 'movie' ? '电影' : '电视剧'}
{category.query}
{!category.disabled ? '启用中' : '已禁用'}
{category.from !== 'config' && (
)}
);
};
if (!config) {
return (
加载中...
);
}
return (
{/* 添加分类表单 */}
自定义分类列表
{showAddForm && (
)}
{/* 分类表格 */}
分类名称
类型
搜索关键词
状态
操作
`${c.query}:${c.type}`)}
strategy={verticalListSortingStrategy}
>
{categories.map((category) => (
))}
{/* 保存排序按钮 */}
{orderChanged && (
)}
{/* 通用弹窗组件 */}
);
};
// 新增配置文件组件
const ConfigFileComponent = ({ config, refreshConfig }: { config: AdminConfig | null; refreshConfig: () => Promise }) => {
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('');
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 (
加载中...
);
}
return (
{/* 配置订阅区域 */}
配置订阅
最后更新: {lastCheckTime ? new Date(lastCheckTime).toLocaleString('zh-CN') : '从未更新'}
setSubscriptionUrl(e.target.value)}
placeholder='https://example.com/config.json'
variant='secondary'
/>
输入配置文件的订阅地址,要求 JSON 格式,且使用 Base58 编码
{/* 配置文件编辑区域 */}
支持 JSON 格式,用于配置视频源和自定义分类
{/* 通用弹窗组件 */}
);
};
// 新增站点配置组件
const SiteConfigComponent = ({ config, refreshConfig }: { config: AdminConfig | null; refreshConfig: () => Promise }) => {
const { alertModal, showAlert, hideAlert } = useAlertModal();
const { isLoading, withLoading } = useLoadingState();
const [siteSettings, setSiteSettings] = useState({
SiteName: '',
Announcement: '',
SearchDownstreamMaxPage: 1,
SiteInterfaceCacheTime: 7200,
DoubanProxyType: 'cmliussss-cdn-tencent',
DoubanProxy: '',
DoubanImageProxyType: 'cmliussss-cdn-tencent',
DoubanImageProxy: '',
DisableYellowFilter: false,
FluidSearch: true,
RequireDeviceCode: true,
});
// 豆瓣数据源选项
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,
RequireDeviceCode: config.SiteConfig.RequireDeviceCode !== undefined ? config.SiteConfig.RequireDeviceCode : true,
});
}
}, [config]);
// 处理豆瓣数据源变化
const handleDoubanDataSourceChange = (value: string) => {
setSiteSettings((prev) => ({
...prev,
DoubanProxyType: value,
}));
};
// 处理豆瓣图片代理变化
const handleDoubanImageProxyChange = (value: string) => {
setSiteSettings((prev) => ({
...prev,
DoubanImageProxyType: value,
}));
};
// 保存站点配置
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 (
加载中...
);
}
return (
setSiteSettings((prev) => ({ ...prev, SiteName: e.target.value }))
}
variant='secondary'
/>
选择获取豆瓣数据的方式
{getThanksInfo(siteSettings.DoubanProxyType) && (
)}
{siteSettings.DoubanProxyType === 'custom' && (
setSiteSettings((prev) => ({
...prev,
DoubanProxy: e.target.value,
}))
}
variant='secondary'
/>
自定义代理服务器地址
)}
选择获取豆瓣图片的方式
{getThanksInfo(siteSettings.DoubanImageProxyType) && (
)}
{siteSettings.DoubanImageProxyType === 'custom' && (
setSiteSettings((prev) => ({
...prev,
DoubanImageProxy: e.target.value,
}))
}
variant='secondary'
/>
自定义图片代理服务器地址
)}
setSiteSettings((prev) => ({
...prev,
SearchDownstreamMaxPage: Number(e.target.value),
}))
}
variant='secondary'
/>
setSiteSettings((prev) => ({
...prev,
SiteInterfaceCacheTime: Number(e.target.value),
}))
}
variant='secondary'
/>
setSiteSettings((prev) => ({
...prev,
RequireDeviceCode: isSelected,
}))
}
/>
setSiteSettings((prev) => ({
...prev,
DisableYellowFilter: isSelected,
}))
}
/>
setSiteSettings((prev) => ({
...prev,
FluidSearch: isSelected,
}))
}
/>
{/* 通用弹窗组件 */}
);
};
// 直播源配置组件
const LiveSourceConfig = ({
config,
refreshConfig,
}: {
config: AdminConfig | null;
refreshConfig: () => Promise;
}) => {
const { alertModal, showAlert, hideAlert } = useAlertModal();
const { isLoading, withLoading } = useLoadingState();
const [liveSources, setLiveSources] = useState([]);
const [showAddForm, setShowAddForm] = useState(false);
const [editingLiveSource, setEditingLiveSource] = useState(null);
const [orderChanged, setOrderChanged] = useState(false);
const [isRefreshing, setIsRefreshing] = useState(false);
const [newLiveSource, setNewLiveSource] = useState({
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) => {
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 (
{liveSource.name}
{liveSource.key}
{liveSource.url}
{liveSource.epg || '-'}
{liveSource.ua || '-'}
{liveSource.channelNumber && liveSource.channelNumber > 0 ? liveSource.channelNumber : '-'}
{!liveSource.disabled ? '启用中' : '已禁用'}
{liveSource.from !== 'config' && (
<>
>
)}
);
};
if (!config) {
return (
加载中...
);
}
return (
);
};
function AdminPageClient() {
const { alertModal, showAlert, hideAlert } = useAlertModal();
const { isLoading, withLoading } = useLoadingState();
const [config, setConfig] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(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,
themeManager: false,
});
// 机器码管理状态
const [machineCodeUsers, setMachineCodeUsers] = useState>({});
// 获取机器码用户列表
const fetchMachineCodeUsers = useCallback(async () => {
try {
const response = await fetch('/api/machine-code?action=list');
if (response.ok) {
const data = await response.json();
setMachineCodeUsers(data.users || {});
}
} catch (error) {
console.error('获取机器码用户列表失败:', error);
}
}, []);
// 获取管理员配置
// 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);
// 获取机器码用户列表
fetchMachineCodeUsers();
}, [fetchConfig, fetchMachineCodeUsers]);
// 切换标签展开状态
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();
await fetchMachineCodeUsers(); // 重新获取机器码数据
setShowResetConfigModal(false);
} catch (err) {
showError(err instanceof Error ? err.message : '重置失败', showAlert);
throw err;
}
});
};
if (loading) {
return (
管理员设置
{Array.from({ length: 3 }).map((_, index) => (
))}
);
}
if (error) {
// 错误已通过弹窗展示,此处直接返回空
return null;
}
return (
{/* 标题 + 重置配置按钮 */}
管理员设置
{config && role === 'owner' && (
)}
{/* 配置文件标签 - 仅站长可见 */}
{role === 'owner' && (
}
isExpanded={expandedTabs.configFile}
onToggle={() => toggleTab('configFile')}
>
)}
{/* 站点配置标签 */}
}
isExpanded={expandedTabs.siteConfig}
onToggle={() => toggleTab('siteConfig')}
>
{/* 用户配置标签 */}
}
isExpanded={expandedTabs.userConfig}
onToggle={() => toggleTab('userConfig')}
>
{/* 视频源配置标签 */}
}
isExpanded={expandedTabs.videoSource}
onToggle={() => toggleTab('videoSource')}
>
{/* 直播源配置标签 */}
}
isExpanded={expandedTabs.liveSource}
onToggle={() => toggleTab('liveSource')}
>
{/* 分类配置标签 */}
}
isExpanded={expandedTabs.categoryConfig}
onToggle={() => toggleTab('categoryConfig')}
>
{/* 数据迁移标签 - 仅站长可见 */}
{role === 'owner' && (
}
isExpanded={expandedTabs.dataMigration}
onToggle={() => toggleTab('dataMigration')}
>
)}
{/* 主题定制标签 */}
}
isExpanded={expandedTabs.themeManager}
onToggle={() => toggleTab('themeManager')}
>
{/* 通用弹窗组件 */}
{/* 重置配置确认弹窗 */}
>
}
>
危险操作警告
此操作将重置用户封禁和管理员设置、自定义视频源,站点配置将重置为默认值,是否继续?
);
}
export default function AdminPage() {
return (
);
}