OrangeTV/src/pages/admin.tsx

4980 lines
162 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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 { 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;
}) => (
<Table variant='secondary' className='overflow-visible'>
<Table.ScrollContainer>
<Table.Content aria-label={ariaLabel} className={minWidth}>
{children}
</Table.Content>
</Table.ScrollContainer>
</Table>
);
const AdminCheckbox = ({
ariaLabel,
isSelected,
onChange,
isDisabled,
}: {
ariaLabel: string;
isSelected: boolean;
onChange: (isSelected: boolean) => void;
isDisabled?: boolean;
}) => (
<Checkbox
aria-label={ariaLabel}
isSelected={isSelected}
isDisabled={isDisabled}
slot='selection'
variant='secondary'
onChange={onChange}
>
<Checkbox.Control>
<Checkbox.Indicator />
</Checkbox.Control>
</Checkbox>
);
const AdminChip = ({
children,
color = 'default',
}: {
children: React.ReactNode;
color?: 'default' | 'accent' | 'success' | 'warning' | 'danger';
}) => (
<Chip size='sm' color={color} variant='soft'>
<Chip.Label>{children}</Chip.Label>
</Chip>
);
const AdminSettingSwitch = ({
label,
description,
isSelected,
onChange,
}: {
label: string;
description: string;
isSelected: boolean;
onChange: (isSelected: boolean) => void;
}) => (
<div className='flex items-start justify-between gap-4'>
<div className='space-y-1'>
<Label className='text-sm'>{label}</Label>
<p className='text-xs text-muted'>{description}</p>
</div>
<Switch aria-label={label} isSelected={isSelected} onChange={onChange}>
<Switch.Control>
<Switch.Thumb />
</Switch.Control>
</Switch>
</div>
);
// 获取用户头像的函数
const getUserAvatar = async (username: string): Promise<string | null> => {
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<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchAvatar = async () => {
setLoading(true);
const avatar = await getUserAvatar(username);
setAvatarUrl(avatar);
setLoading(false);
};
fetchAvatar();
}, [username]);
return (
<Avatar size={size}>
{loading ? (
<Avatar.Fallback>
<Skeleton className='h-full w-full' />
</Avatar.Fallback>
) : avatarUrl ? (
<Avatar.Image src={avatarUrl} alt={`${username} 的头像`} />
) : (
<Avatar.Fallback>
<User className='h-4 w-4' />
</Avatar.Fallback>
)}
</Avatar>
);
};
// 机器码单元格组件
interface MachineCodeCellProps {
username: string;
canManage: boolean;
machineCodeData: Record<string, { machineCode: string; deviceInfo?: string; bindTime: number }>;
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<HTMLDivElement>(null);
const codeRef = useRef<HTMLElement>(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 (
<div className="flex items-center space-x-2">
<span className="text-sm text-muted"></span>
</div>
);
}
return (
<div className="flex flex-col space-y-1">
<div className="flex items-center space-x-2">
<div className="group relative" onMouseEnter={handleMouseEnter}>
<code
ref={codeRef}
className="text-xs font-mono text-foreground cursor-help"
>
{formatMachineCode(machineCodeInfo.machineCode).substring(0, 12)}...
</code>
{/* 悬停显示完整机器码 - 智能定位 */}
<div
ref={tooltipRef}
className={`absolute left-0 px-3 py-2 bg-gray-800 text-white text-xs rounded-md shadow-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 whitespace-nowrap pointer-events-none z-50 ${tooltipPosition === 'bottom'
? 'top-full mt-2'
: 'bottom-full mb-2'
}`}
>
<div className="font-mono">
{formatMachineCode(machineCodeInfo.machineCode)}
</div>
{machineCodeInfo.deviceInfo && (
<div className="mt-1 text-gray-300">
{machineCodeInfo.deviceInfo}
</div>
)}
<div className="mt-1 text-gray-400">
: {formatDate(machineCodeInfo.bindTime)}
</div>
{/* 箭头 - 根据位置动态调整 */}
<div className={`absolute left-4 w-0 h-0 border-l-4 border-r-4 border-transparent ${tooltipPosition === 'bottom'
? 'bottom-full border-b-4 border-b-gray-800'
: 'top-full border-t-4 border-t-gray-800'
}`}></div>
</div>
</div>
{canManage && (
<Button
size='sm'
variant='danger-soft'
onPress={handleUnbind}
isDisabled={unbinding}
>
{unbinding ? '解绑中...' : '解绑'}
</Button>
)}
</div>
<div className="flex items-center space-x-1">
<AdminChip color='success'></AdminChip>
</div>
</div>
);
};
// 通用弹窗组件
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 <CheckCircle className="w-5 h-5" />;
case 'error':
return <AlertCircle className="w-5 h-5" />;
case 'warning':
return <AlertTriangle className="w-5 h-5" />;
default:
return null;
}
};
const getStatus = () => {
switch (type) {
case 'success':
return 'success';
case 'error':
return 'danger';
case 'warning':
return 'warning';
default:
return 'accent';
}
};
return (
<AppDialog
isOpen={isOpen}
onOpenChange={(open) => {
if (!open) onClose();
}}
title={title}
icon={getIcon()}
footer={
showConfirm ? (
<Button variant='primary' onPress={onClose}>
</Button>
) : null
}
>
{message ? <Alert status={getStatus()}>{message}</Alert> : null}
</AppDialog>
);
};
// 弹窗状态管理
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;
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 (
<div className='rounded-lg border border-border bg-surface/70 p-3'>
<div className='space-y-3'>
<div className='flex flex-wrap items-center gap-2'>
<span className='text-sm font-medium text-foreground'></span>
<Chip size='sm' color={statusMeta.chipColor} variant='soft'>
<Chip.Label className='inline-flex items-center gap-1.5'>
<StatusIcon
className={`size-3.5 ${result.status === 'validating' ? 'animate-spin' : ''}`}
/>
{statusMeta.label}
</Chip.Label>
</Chip>
<span className='text-sm text-muted'>{result.message}</span>
</div>
{result.details && (
<div className='grid gap-1 text-xs text-muted sm:grid-cols-2'>
{result.details.searchKeyword && (
<div>: {result.details.searchKeyword}</div>
)}
{result.details.responseTime && (
<div>: {result.details.responseTime}ms</div>
)}
{result.details.resultCount !== undefined && (
<div>: {result.details.resultCount}</div>
)}
{result.details.error && (
<div className='text-danger sm:col-span-2'>
: {result.details.error}
</div>
)}
</div>
)}
</div>
</div>
);
};
// 自定义分类数据类型
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 (
<Card variant='default' className='mb-4 overflow-hidden'>
<Button
fullWidth
variant='tertiary'
className='h-auto justify-between px-6 py-4'
onPress={onToggle}
>
<div className='flex items-center gap-3'>
{icon}
<h3 className='text-lg font-semibold text-foreground'>
{title}
</h3>
</div>
<div className='text-muted'>
{isExpanded ? <ChevronUp size={20} /> : <ChevronDown size={20} />}
</div>
</Button>
{isExpanded && <div className='px-6 py-4'>{children}</div>}
</Card>
);
};
// 用户配置组件
interface UserConfigProps {
config: AdminConfig | null;
role: 'owner' | 'admin' | null;
refreshConfig: () => Promise<void>;
machineCodeUsers: Record<string, { machineCode: string; deviceInfo?: string; bindTime: number }>;
fetchMachineCodeUsers: () => Promise<void>;
}
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<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-accent/10 rounded-lg border border-accent/20'>
<div className='text-2xl font-bold text-accent'>
{config.UserConfig.Users.length}
</div>
<div className='text-sm text-accent'>
</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
size='sm'
variant={showAddUserGroupForm ? 'secondary' : 'primary'}
onPress={() => {
setShowAddUserGroupForm(!showAddUserGroupForm);
if (showEditUserGroupForm) {
setShowEditUserGroupForm(false);
setEditingUserGroup(null);
}
}}
>
{showAddUserGroupForm ? '取消' : '添加用户组'}
</Button>
</div>
{/* 用户组列表 */}
<AdminTable ariaLabel='用户组列表'>
<Table.Header>
<Table.Column isRowHeader></Table.Column>
<Table.Column></Table.Column>
<Table.Column className='text-right'></Table.Column>
</Table.Header>
<Table.Body>
{userGroups.map((group) => (
<Table.Row key={group.name} id={group.name}>
<Table.Cell className='font-medium'>
{group.name}
</Table.Cell>
<Table.Cell>
{group.enabledApis && group.enabledApis.length > 0
? `${group.enabledApis.length} 个源`
: '无限制'}
</Table.Cell>
<Table.Cell>
<div className='flex justify-end gap-2'>
<Button
size='sm'
variant='secondary'
onPress={() => handleStartEditUserGroup(group)}
isDisabled={isLoading(`userGroup_edit_${group.name}`)}
>
</Button>
<Button
size='sm'
variant='danger-soft'
onPress={() => handleDeleteUserGroup(group.name)}
>
</Button>
</div>
</Table.Cell>
</Table.Row>
))}
{userGroups.length === 0 && (
<Table.Row id='empty-user-groups'>
<Table.Cell colSpan={3} className='py-8 text-center text-muted'>
</Table.Cell>
</Table.Row>
)}
</Table.Body>
</AdminTable>
</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
size='sm'
variant='primary'
onPress={() => setShowBatchUserGroupModal(true)}
>
</Button>
</div>
<div className='w-px h-6 bg-gray-300 dark:bg-gray-600'></div>
</>
)}
<Button
size='sm'
variant={showAddUserForm ? 'secondary' : 'primary'}
onPress={() => {
setShowAddUserForm(!showAddUserForm);
if (showChangePasswordForm) {
setShowChangePasswordForm(false);
setChangePasswordUser({ username: '', password: '' });
}
}}
>
{showAddUserForm ? '取消' : '添加用户'}
</Button>
</div>
</div>
{/* 添加用户表单 */}
{showAddUserForm && (
<Card variant='secondary' className='mb-4 p-4'>
<div className='space-y-4'>
<div className='grid grid-cols-1 sm:grid-cols-2 gap-4'>
<TextField fullWidth>
<Label></Label>
<Input
type='text'
placeholder='用户名'
value={newUser.username}
onChange={(e) =>
setNewUser((prev) => ({ ...prev, username: e.target.value }))
}
/>
</TextField>
<TextField fullWidth>
<Label></Label>
<Input
type='password'
placeholder='密码'
value={newUser.password}
onChange={(e) =>
setNewUser((prev) => ({ ...prev, password: e.target.value }))
}
/>
</TextField>
</div>
<AppFilterSelect
label='用户组(可选)'
value={newUser.userGroup}
placeholder='无用户组(无限制)'
options={[
{ label: '无用户组(无限制)', value: '' },
...userGroups.map((group) => ({
label: `${group.name} (${group.enabledApis && group.enabledApis.length > 0 ? `${group.enabledApis.length} 个源` : '无限制'})`,
value: group.name,
})),
]}
onChange={(value) =>
setNewUser((prev) => ({ ...prev, userGroup: value }))
}
/>
<div className='flex justify-end'>
<Button
variant='primary'
onPress={handleAddUser}
isDisabled={!newUser.username || !newUser.password || isLoading('addUser')}
>
{isLoading('addUser') ? '添加中...' : '添加'}
</Button>
</div>
</div>
</Card>
)}
{/* 修改密码表单 */}
{showChangePasswordForm && (
<div className='mb-4 p-4 bg-accent/10 rounded-lg border border-accent/20'>
<h5 className='text-sm font-medium text-accent mb-3'>
</h5>
<div className='flex flex-col sm:flex-row gap-4 sm:gap-3'>
<TextField className='flex-1'>
<Label></Label>
<Input
type='text'
placeholder='用户名'
value={changePasswordUser.username}
disabled
/>
</TextField>
<TextField className='flex-1'>
<Label></Label>
<Input
type='password'
placeholder='新密码'
value={changePasswordUser.password}
onChange={(e) =>
setChangePasswordUser((prev) => ({
...prev,
password: e.target.value,
}))
}
/>
</TextField>
<Button
className='w-full sm:w-auto'
variant='primary'
onPress={handleChangePassword}
isDisabled={!changePasswordUser.password || isLoading(`changePassword_${changePasswordUser.username}`)}
>
{isLoading(`changePassword_${changePasswordUser.username}`) ? '修改中...' : '修改密码'}
</Button>
<Button
className='w-full sm:w-auto'
variant='secondary'
onPress={() => {
setShowChangePasswordForm(false);
setChangePasswordUser({ username: '', password: '' });
}}
>
</Button>
</div>
</div>
)}
{/* 用户列表 */}
<AdminTable ariaLabel='用户列表' minWidth='min-w-[1200px]'>
<Table.Header>
<Table.Column className='w-4' />
<Table.Column className='w-10 text-center'>
{(() => {
// 检查是否有权限操作任何用户
const hasAnyPermission = config?.UserConfig?.Users?.some(user =>
(role === 'owner' ||
(role === 'admin' &&
(user.role === 'user' ||
user.username === currentUsername)))
);
return hasAnyPermission ? (
<AdminCheckbox
ariaLabel='选择全部用户'
isSelected={selectAllUsers}
onChange={handleSelectAllUsers}
/>
) : (
<div className='w-4 h-4' />
);
})()}
</Table.Column>
<Table.Column isRowHeader>
</Table.Column>
<Table.Column>
</Table.Column>
<Table.Column>
</Table.Column>
<Table.Column>
</Table.Column>
<Table.Column>
</Table.Column>
<Table.Column>
</Table.Column>
<Table.Column className='text-right'>
</Table.Column>
</Table.Header>
{/* 按规则排序用户:自己 -> 站长(若非自己) -> 管理员 -> 其他 */}
{(() => {
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 (
<Table.Body>
{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 (
<Table.Row
key={user.username}
id={user.username}
>
<Table.Cell className='w-4' />
<Table.Cell className='w-10 text-center'>
{(role === 'owner' ||
(role === 'admin' &&
(user.role === 'user' ||
user.username === currentUsername))) ? (
<AdminCheckbox
ariaLabel={`选择用户 ${user.username}`}
isSelected={selectedUsers.has(user.username)}
onChange={(selected) => handleSelectUser(user.username, selected)}
/>
) : (
<div className='w-4 h-4' />
)}
</Table.Cell>
<Table.Cell className='font-medium'>
<div className='flex items-center gap-3'>
<UserAvatar username={user.username} size="sm" />
<span>{user.username}</span>
</div>
</Table.Cell>
<Table.Cell>
<AdminChip color={user.role === 'owner' ? 'warning' : user.role === 'admin' ? 'accent' : 'default'}>
{user.role === 'owner'
? '站长'
: user.role === 'admin'
? '管理员'
: '普通用户'}
</AdminChip>
</Table.Cell>
<Table.Cell>
<AdminChip color={!user.banned ? 'success' : 'danger'}>
{!user.banned ? '正常' : '已封禁'}
</AdminChip>
</Table.Cell>
<Table.Cell>
<div className='flex items-center space-x-2'>
<span className='text-sm'>
{user.tags && user.tags.length > 0
? user.tags.join(', ')
: '无用户组'}
</span>
{/* 配置用户组按钮 */}
{(role === 'owner' ||
(role === 'admin' &&
(user.role === 'user' ||
user.username === currentUsername))) && (
<Button
size='sm'
variant='secondary'
onPress={() => handleConfigureUserGroup(user)}
>
</Button>
)}
</div>
</Table.Cell>
<Table.Cell>
<div className='flex items-center space-x-2'>
<span className='text-sm'>
{user.enabledApis && user.enabledApis.length > 0
? `${user.enabledApis.length} 个源`
: '无限制'}
</span>
{/* 配置采集源权限按钮 */}
{(role === 'owner' ||
(role === 'admin' &&
(user.role === 'user' ||
user.username === currentUsername))) && (
<Button
size='sm'
variant='secondary'
onPress={() => handleConfigureUserApis(user)}
>
</Button>
)}
</div>
</Table.Cell>
<Table.Cell>
<MachineCodeCell
username={user.username}
canManage={canOperate}
machineCodeData={machineCodeUsers}
onRefresh={fetchMachineCodeUsers}
showAlert={showAlert}
/>
</Table.Cell>
<Table.Cell>
<div className='flex flex-wrap justify-end gap-2'>
{/* 修改密码按钮 */}
{canChangePassword && (
<Button
size='sm'
variant='secondary'
onPress={() =>
handleShowChangePasswordForm(user.username)
}
>
</Button>
)}
{canOperate && (
<>
{/* 其他操作按钮 */}
{user.role === 'user' && (
<Button
size='sm'
variant='secondary'
onPress={() => handleSetAdmin(user.username)}
isDisabled={isLoading(`setAdmin_${user.username}`)}
>
</Button>
)}
{user.role === 'admin' && (
<Button
size='sm'
variant='secondary'
onPress={() =>
handleRemoveAdmin(user.username)
}
isDisabled={isLoading(`removeAdmin_${user.username}`)}
>
</Button>
)}
{user.role !== 'owner' &&
(!user.banned ? (
<Button
size='sm'
variant='danger-soft'
onPress={() => handleBanUser(user.username)}
isDisabled={isLoading(`banUser_${user.username}`)}
>
</Button>
) : (
<Button
size='sm'
variant='secondary'
onPress={() =>
handleUnbanUser(user.username)
}
isDisabled={isLoading(`unbanUser_${user.username}`)}
>
</Button>
))}
</>
)}
{/* 删除用户按钮 - 放在最后,使用更明显的红色样式 */}
{canDeleteUser && (
<Button
size='sm'
variant='danger-soft'
onPress={() => handleDeleteUser(user.username)}
>
</Button>
)}
</div>
</Table.Cell>
</Table.Row>
);
})}
</Table.Body>
);
})()}
</AdminTable>
</div>
{/* 配置用户采集源权限弹窗 */}
<AppDialog
isOpen={showConfigureApisModal && Boolean(selectedUser)}
onOpenChange={(open) => {
if (!open) {
setShowConfigureApisModal(false);
setSelectedUser(null);
setSelectedApis([]);
}
}}
title={`配置用户采集源权限${selectedUser ? ` - ${selectedUser.username}` : ''}`}
size='lg'
footer={
selectedUser ? (
<>
<Button
variant='secondary'
onPress={() => {
setShowConfigureApisModal(false);
setSelectedUser(null);
setSelectedApis([]);
}}
>
</Button>
<Button
variant='primary'
onPress={handleSaveUserApis}
isDisabled={isLoading(`saveUserApis_${selectedUser.username}`)}
>
{isLoading(`saveUserApis_${selectedUser.username}`) ? '配置中...' : '确认配置'}
</Button>
</>
) : null
}
>
<div className='space-y-6'>
<Alert status='accent'>
<Alert.Content>
<Alert.Title></Alert.Title>
<Alert.Description>
访
</Alert.Description>
</Alert.Content>
</Alert>
<section>
<h4 className='mb-4 text-sm font-medium'></h4>
<div className='grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3'>
{config?.SourceConfig?.map((source) => (
<Checkbox
key={source.key}
isSelected={selectedApis.includes(source.key)}
onChange={(isSelected) => {
if (isSelected) {
setSelectedApis([...selectedApis, source.key]);
} else {
setSelectedApis(selectedApis.filter(api => api !== source.key));
}
}}
>
<Checkbox.Control>
<Checkbox.Indicator />
</Checkbox.Control>
<Checkbox.Content>
<div className='min-w-0'>
<div className='truncate text-sm font-medium'>{source.name}</div>
{source.api ? (
<div className='truncate text-xs text-muted'>
{extractDomain(source.api)}
</div>
) : null}
</div>
</Checkbox.Content>
</Checkbox>
))}
</div>
</section>
<Card variant='secondary' className='p-4'>
<div className='flex flex-wrap items-center justify-between gap-3'>
<div className='flex gap-2'>
<Button size='sm' variant='secondary' onPress={() => setSelectedApis([])}>
</Button>
<Button
size='sm'
variant='secondary'
onPress={() => {
const allApis = config?.SourceConfig?.filter(source => !source.disabled).map(s => s.key) || [];
setSelectedApis(allApis);
}}
>
</Button>
</div>
<div className='text-sm text-muted'>
<span className='font-medium text-accent'>
{selectedApis.length > 0 ? `${selectedApis.length} 个源` : '无限制'}
</span>
</div>
</div>
</Card>
</div>
</AppDialog>
{/* 添加用户组弹窗 */}
<AppDialog
isOpen={showAddUserGroupForm}
onOpenChange={(open) => {
if (!open) {
setShowAddUserGroupForm(false);
setNewUserGroup({ name: '', enabledApis: [] });
}
}}
title='添加新用户组'
size='lg'
footer={
<>
<Button
variant='secondary'
onPress={() => {
setShowAddUserGroupForm(false);
setNewUserGroup({ name: '', enabledApis: [] });
}}
>
</Button>
<Button
variant='primary'
onPress={handleAddUserGroup}
isDisabled={!newUserGroup.name.trim() || isLoading('userGroup_add_new')}
>
{isLoading('userGroup_add_new') ? '添加中...' : '添加用户组'}
</Button>
</>
}
>
<div className='space-y-6'>
<TextField>
<Label></Label>
<Input
type='text'
placeholder='请输入用户组名称'
value={newUserGroup.name}
onChange={(e) =>
setNewUserGroup((prev) => ({ ...prev, name: e.target.value }))
}
/>
</TextField>
<section>
<Label></Label>
<div className='mt-4 grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3'>
{config?.SourceConfig?.map((source) => (
<Checkbox
key={source.key}
isSelected={newUserGroup.enabledApis.includes(source.key)}
onChange={(isSelected) => {
if (isSelected) {
setNewUserGroup(prev => ({
...prev,
enabledApis: [...prev.enabledApis, source.key]
}));
} else {
setNewUserGroup(prev => ({
...prev,
enabledApis: prev.enabledApis.filter(api => api !== source.key)
}));
}
}}
>
<Checkbox.Control>
<Checkbox.Indicator />
</Checkbox.Control>
<Checkbox.Content>
<div className='min-w-0'>
<div className='truncate text-sm font-medium'>{source.name}</div>
{source.api ? (
<div className='truncate text-xs text-muted'>
{extractDomain(source.api)}
</div>
) : null}
</div>
</Checkbox.Content>
</Checkbox>
))}
</div>
<div className='mt-4 flex gap-2'>
<Button
size='sm'
variant='secondary'
onPress={() => setNewUserGroup(prev => ({ ...prev, enabledApis: [] }))}
>
</Button>
<Button
size='sm'
variant='secondary'
onPress={() => {
const allApis = config?.SourceConfig?.filter(source => !source.disabled).map(s => s.key) || [];
setNewUserGroup(prev => ({ ...prev, enabledApis: allApis }));
}}
>
</Button>
</div>
</section>
</div>
</AppDialog>
{/* 编辑用户组弹窗 */}
<AppDialog
isOpen={showEditUserGroupForm && Boolean(editingUserGroup)}
onOpenChange={(open) => {
if (!open) {
setShowEditUserGroupForm(false);
setEditingUserGroup(null);
}
}}
title={`编辑用户组${editingUserGroup ? ` - ${editingUserGroup.name}` : ''}`}
size='lg'
footer={
editingUserGroup ? (
<>
<Button
variant='secondary'
onPress={() => {
setShowEditUserGroupForm(false);
setEditingUserGroup(null);
}}
>
</Button>
<Button
variant='primary'
onPress={handleEditUserGroup}
isDisabled={isLoading(`userGroup_edit_${editingUserGroup.name}`)}
>
{isLoading(`userGroup_edit_${editingUserGroup.name}`) ? '保存中...' : '保存修改'}
</Button>
</>
) : null
}
>
{editingUserGroup ? (
<section>
<Label></Label>
<div className='mt-4 grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3'>
{config?.SourceConfig?.map((source) => (
<Checkbox
key={source.key}
isSelected={editingUserGroup.enabledApis.includes(source.key)}
onChange={(isSelected) => {
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);
}
}}
>
<Checkbox.Control>
<Checkbox.Indicator />
</Checkbox.Control>
<Checkbox.Content>
<div className='min-w-0'>
<div className='truncate text-sm font-medium'>{source.name}</div>
{source.api ? (
<div className='truncate text-xs text-muted'>
{extractDomain(source.api)}
</div>
) : null}
</div>
</Checkbox.Content>
</Checkbox>
))}
</div>
<div className='mt-4 flex gap-2'>
<Button
size='sm'
variant='secondary'
onPress={() => setEditingUserGroup(prev => prev ? { ...prev, enabledApis: [] } : null)}
>
</Button>
<Button
size='sm'
variant='secondary'
onPress={() => {
const allApis = config?.SourceConfig?.filter(source => !source.disabled).map(s => s.key) || [];
setEditingUserGroup(prev => prev ? { ...prev, enabledApis: allApis } : null);
}}
>
</Button>
</div>
</section>
) : null}
</AppDialog>
{/* 配置用户组弹窗 */}
<AppDialog
isOpen={showConfigureUserGroupModal && Boolean(selectedUserForGroup)}
onOpenChange={(open) => {
if (!open) {
setShowConfigureUserGroupModal(false);
setSelectedUserForGroup(null);
setSelectedUserGroups([]);
}
}}
title={`配置用户组${selectedUserForGroup ? ` - ${selectedUserForGroup.username}` : ''}`}
size='lg'
footer={
selectedUserForGroup ? (
<>
<Button
variant='secondary'
onPress={() => {
setShowConfigureUserGroupModal(false);
setSelectedUserForGroup(null);
setSelectedUserGroups([]);
}}
>
</Button>
<Button
variant='primary'
onPress={handleSaveUserGroups}
isDisabled={isLoading(`saveUserGroups_${selectedUserForGroup.username}`)}
>
{isLoading(`saveUserGroups_${selectedUserForGroup.username}`) ? '配置中...' : '确认配置'}
</Button>
</>
) : null
}
>
<div className='space-y-6'>
<Alert status='accent'>
<Alert.Content>
<Alert.Title></Alert.Title>
<Alert.Description>
"无用户组"访
</Alert.Description>
</Alert.Content>
</Alert>
<AppFilterSelect
label='选择用户组'
value={selectedUserGroups.length > 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] : [])}
/>
<p className='text-xs text-muted'>
"无用户组"访
</p>
</div>
</AppDialog>
{/* 删除用户组确认弹窗 */}
<AppDialog
isOpen={showDeleteUserGroupModal && Boolean(deletingUserGroup)}
onOpenChange={(open) => {
if (!open) {
setShowDeleteUserGroupModal(false);
setDeletingUserGroup(null);
}
}}
title='确认删除用户组'
footer={
deletingUserGroup ? (
<>
<Button
variant='secondary'
onPress={() => {
setShowDeleteUserGroupModal(false);
setDeletingUserGroup(null);
}}
>
</Button>
<Button
variant='primary'
onPress={handleConfirmDeleteUserGroup}
isDisabled={isLoading(`userGroup_delete_${deletingUserGroup.name}`)}
>
{isLoading(`userGroup_delete_${deletingUserGroup.name}`) ? '删除中...' : '确认删除'}
</Button>
</>
) : null
}
>
{deletingUserGroup ? (
<div className='space-y-4'>
<Alert status='danger'>
<Alert.Content>
<Alert.Title></Alert.Title>
<Alert.Description>
<strong>{deletingUserGroup.name}</strong> 使
</Alert.Description>
</Alert.Content>
</Alert>
{deletingUserGroup.affectedUsers.length > 0 ? (
<Alert status='warning'>
<Alert.Content>
<Alert.Title>
{deletingUserGroup.affectedUsers.length}
</Alert.Title>
<Alert.Description>
<div className='space-y-1'>
{deletingUserGroup.affectedUsers.map((user, index) => (
<div key={index}>
{user.username} ({user.role})
</div>
))}
</div>
<p className='mt-2 text-xs'></p>
</Alert.Description>
</Alert.Content>
</Alert>
) : (
<Alert status='success'>
使
</Alert>
)}
</div>
) : null}
</AppDialog>
{/* 删除用户确认弹窗 */}
<AppDialog
isOpen={showDeleteUserModal && Boolean(deletingUser)}
onOpenChange={(open) => {
if (!open) {
setShowDeleteUserModal(false);
setDeletingUser(null);
}
}}
title='确认删除用户'
footer={
<>
<Button
variant='secondary'
onPress={() => {
setShowDeleteUserModal(false);
setDeletingUser(null);
}}
>
</Button>
<Button variant='primary' onPress={handleConfirmDeleteUser}>
</Button>
</>
}
>
<Alert status='danger'>
<Alert.Content>
<Alert.Title></Alert.Title>
<Alert.Description>
<strong>{deletingUser}</strong>
</Alert.Description>
</Alert.Content>
</Alert>
</AppDialog>
{/* 批量设置用户组弹窗 */}
<AppDialog
isOpen={showBatchUserGroupModal}
onOpenChange={(open) => {
if (!open) {
setShowBatchUserGroupModal(false);
setSelectedUserGroup('');
}
}}
title='批量设置用户组'
footer={
<>
<Button
variant='secondary'
onPress={() => {
setShowBatchUserGroupModal(false);
setSelectedUserGroup('');
}}
>
</Button>
<Button
variant='primary'
onPress={() => handleBatchSetUserGroup(selectedUserGroup)}
isDisabled={isLoading('batchSetUserGroup')}
>
{isLoading('batchSetUserGroup') ? '设置中...' : '确认设置'}
</Button>
</>
}
>
<div className='space-y-6'>
<Alert status='accent'>
<Alert.Content>
<Alert.Title></Alert.Title>
<Alert.Description>
<strong>{selectedUsers.size} </strong> "无用户组"
</Alert.Description>
</Alert.Content>
</Alert>
<AppFilterSelect
label='选择用户组'
value={selectedUserGroup}
options={[
{ value: '', label: '无用户组(无限制)' },
...userGroups.map((group) => ({
value: group.name,
label: `${group.name} ${group.enabledApis && group.enabledApis.length > 0 ? `(${group.enabledApis.length} 个源)` : ''}`,
})),
]}
onChange={setSelectedUserGroup}
/>
<p className='text-xs text-muted'>
"无用户组"访
</p>
</div>
</AppDialog>
{/* 通用弹窗组件 */}
<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 [editingSource, setEditingSource] = useState<DataSource | null>(null);
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;
}>>([]);
// 单个视频源验证状态
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<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);
// 清除检测结果
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 (
<Table.Row
ref={setNodeRef}
style={style}
id={source.key}
className='select-none'
>
<Table.Cell
className='cursor-grab text-muted'
style={{ touchAction: 'none' }}
{...attributes}
{...listeners}
>
<GripVertical size={16} />
</Table.Cell>
<Table.Cell className='text-center'>
<AdminCheckbox
ariaLabel={`选择视频源 ${source.name}`}
isSelected={selectedSources.has(source.key)}
onChange={(selected) => handleSelectSource(source.key, selected)}
/>
</Table.Cell>
<Table.Cell>
{source.name}
</Table.Cell>
<Table.Cell>
{source.key}
</Table.Cell>
<Table.Cell className='max-w-[12rem]'>
<span className='block truncate' title={source.api}>
{source.api}
</span>
</Table.Cell>
<Table.Cell className='max-w-[8rem]'>
<span className='block truncate' title={source.detail || '-'}>
{source.detail || '-'}
</span>
</Table.Cell>
<Table.Cell>
<AdminChip color={!source.disabled ? 'success' : 'danger'}>
{!source.disabled ? '启用中' : '已禁用'}
</AdminChip>
</Table.Cell>
<Table.Cell>
{(() => {
const status = getValidationStatus(source.key);
if (!status) {
return (
<AdminChip></AdminChip>
);
}
return (
<AdminChip color={status.text === '无效' ? 'danger' : status.text === '无法搜索' ? 'warning' : 'success'}>
{status.icon} {status.text}
</AdminChip>
);
})()}
</Table.Cell>
<Table.Cell>
<div className='flex justify-end gap-2'>
<Button
size='sm'
variant={!source.disabled ? 'danger-soft' : 'secondary'}
onPress={() => handleToggleEnable(source.key)}
isDisabled={isLoading(`toggleSource_${source.key}`)}
>
{!source.disabled ? '禁用' : '启用'}
</Button>
<Button
size='sm'
variant='secondary'
onPress={() => {
setEditingSource(source);
// 清除之前的检测结果
setSingleValidationResult({ status: null, message: '' });
setIsSingleValidating(false);
}}
isDisabled={isLoading(`editSource_${source.key}`)}
>
</Button>
<Button
size='sm'
variant='danger-soft'
onPress={() => handleDelete(source.key)}
isDisabled={isLoading(`deleteSource_${source.key}`)}
>
</Button>
</div>
</Table.Cell>
</Table.Row>
);
};
// 全选/取消全选
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 (
<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-foreground'>
</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-muted'>
<span className='sm:hidden'> {selectedSources.size}</span>
<span className='hidden sm:inline'> {selectedSources.size} </span>
</span>
<Button
size='sm'
variant='primary'
onPress={() => handleBatchOperation('batch_enable')}
isDisabled={isLoading('batchSource_batch_enable')}
>
{isLoading('batchSource_batch_enable') ? '启用中...' : '批量启用'}
</Button>
<Button
size='sm'
variant='secondary'
onPress={() => handleBatchOperation('batch_disable')}
isDisabled={isLoading('batchSource_batch_disable')}
>
{isLoading('batchSource_batch_disable') ? '禁用中...' : '批量禁用'}
</Button>
<Button
size='sm'
variant='danger-soft'
onPress={() => handleBatchOperation('batch_delete')}
isDisabled={isLoading('batchSource_batch_delete')}
>
{isLoading('batchSource_batch_delete') ? '删除中...' : '批量删除'}
</Button>
</div>
<div className='hidden sm:block w-px h-6 bg-border order-2'></div>
</>
)}
<div className='flex items-center gap-2 order-1 sm:order-2'>
<Button
size='sm'
variant='primary'
onPress={() => setShowValidationModal(true)}
isDisabled={isValidating}
>
{isValidating ? (
<>
<span>...</span>
</>
) : (
'有效性检测'
)}
</Button>
<Button
size='sm'
variant={showAddForm ? 'secondary' : 'primary'}
onPress={() => {
setShowAddForm(!showAddForm);
// 切换表单时清除检测结果
if (!showAddForm) {
clearNewSourceValidation();
}
}}
>
{showAddForm ? '取消' : '添加视频源'}
</Button>
</div>
</div>
</div>
{showAddForm && (
<Card variant='secondary'>
<Card.Header className='flex flex-col gap-1 sm:flex-row sm:items-start sm:justify-between'>
<div>
<Card.Title></Card.Title>
<Card.Description>
Key API
</Card.Description>
</div>
</Card.Header>
<Card.Content className='space-y-4'>
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
<TextField fullWidth>
<Label></Label>
<Input
type='text'
placeholder='例如:量子资源'
value={newSource.name}
onChange={(e) =>
setNewSource((prev) => ({ ...prev, name: e.target.value }))
}
variant='secondary'
/>
</TextField>
<TextField fullWidth>
<Label>Key</Label>
<Input
type='text'
placeholder='例如lzizy'
value={newSource.key}
onChange={(e) =>
setNewSource((prev) => ({ ...prev, key: e.target.value }))
}
variant='secondary'
/>
</TextField>
<TextField fullWidth>
<Label>API </Label>
<Input
type='text'
placeholder='https://example.com/api.php/provide/vod'
value={newSource.api}
onChange={(e) =>
setNewSource((prev) => ({ ...prev, api: e.target.value }))
}
variant='secondary'
/>
</TextField>
<TextField fullWidth>
<Label>Detail </Label>
<Input
type='text'
placeholder='选填,用于详情页跳转'
value={newSource.detail}
onChange={(e) =>
setNewSource((prev) => ({ ...prev, detail: e.target.value }))
}
variant='secondary'
/>
</TextField>
</div>
<SourceValidationSummary result={newSourceValidationResult} />
</Card.Content>
<Card.Footer className='flex flex-col-reverse gap-2 sm:flex-row sm:justify-end'>
<Button
className='w-full sm:w-auto'
variant='secondary'
onPress={handleValidateNewSource}
isDisabled={!newSource.api || isNewSourceValidating || isLoading('validateNewSource')}
isPending={isNewSourceValidating || isLoading('validateNewSource')}
>
{isNewSourceValidating || isLoading('validateNewSource') ? '检测中...' : '有效性检测'}
</Button>
<Button
className='w-full sm:w-auto'
variant='primary'
onPress={handleAddSource}
isDisabled={!newSource.name || !newSource.key || !newSource.api || isLoading('addSource')}
isPending={isLoading('addSource')}
>
{isLoading('addSource') ? '添加中...' : '添加'}
</Button>
</Card.Footer>
</Card>
)}
{/* 编辑视频源表单 */}
{editingSource && (
<Card variant='secondary'>
<Card.Header className='flex flex-row items-start justify-between gap-4'>
<div>
<Card.Title></Card.Title>
<Card.Description>
{editingSource.name} · {editingSource.key}
</Card.Description>
</div>
<Button
isIconOnly
size='sm'
variant='tertiary'
aria-label='关闭编辑视频源'
onPress={handleCancelEdit}
>
<X className='size-4' />
</Button>
</Card.Header>
<Card.Content className='space-y-4'>
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
<TextField fullWidth>
<Label></Label>
<Input
type='text'
value={editingSource.name}
onChange={(e) =>
setEditingSource((prev) => prev ? ({ ...prev, name: e.target.value }) : null)
}
variant='secondary'
/>
</TextField>
<TextField fullWidth>
<Label>Key</Label>
<Input
type='text'
value={editingSource.key}
disabled
variant='secondary'
/>
<p className='text-xs text-muted'>Key </p>
</TextField>
<TextField fullWidth>
<Label>API </Label>
<Input
type='text'
value={editingSource.api}
onChange={(e) =>
setEditingSource((prev) => prev ? ({ ...prev, api: e.target.value }) : null)
}
variant='secondary'
/>
</TextField>
<TextField fullWidth>
<Label>Detail </Label>
<Input
type='text'
value={editingSource.detail || ''}
onChange={(e) =>
setEditingSource((prev) => prev ? ({ ...prev, detail: e.target.value }) : null)
}
variant='secondary'
/>
</TextField>
</div>
<SourceValidationSummary result={singleValidationResult} />
</Card.Content>
<Card.Footer className='flex flex-col-reverse gap-2 sm:flex-row sm:justify-end'>
<Button
className='w-full sm:w-auto'
variant='secondary'
onPress={handleCancelEdit}
>
</Button>
<Button
className='w-full sm:w-auto'
variant='secondary'
onPress={handleValidateSingleSource}
isDisabled={!editingSource.api || isSingleValidating || isLoading('validateSingleSource')}
isPending={isSingleValidating || isLoading('validateSingleSource')}
>
{isSingleValidating || isLoading('validateSingleSource') ? '检测中...' : '有效性检测'}
</Button>
<Button
className='w-full sm:w-auto'
variant='primary'
onPress={handleEditSource}
isDisabled={!editingSource.name || !editingSource.api || isLoading('editSource')}
isPending={isLoading('editSource')}
>
{isLoading('editSource') ? '保存中...' : '保存'}
</Button>
</Card.Footer>
</Card>
)}
{/* 视频源表格 */}
<AdminTable ariaLabel='视频源列表' minWidth='min-w-[1100px]'>
<Table.Header>
<Table.Column className='w-8' />
<Table.Column className='w-12 text-center'>
<AdminCheckbox
ariaLabel='选择全部视频源'
isSelected={selectAll}
onChange={handleSelectAll}
/>
</Table.Column>
<Table.Column isRowHeader>
</Table.Column>
<Table.Column>
Key
</Table.Column>
<Table.Column>
API
</Table.Column>
<Table.Column>
Detail
</Table.Column>
<Table.Column>
</Table.Column>
<Table.Column>
</Table.Column>
<Table.Column className='text-right'>
</Table.Column>
</Table.Header>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
autoScroll={false}
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
>
<SortableContext
items={sources.map((s) => s.key)}
strategy={verticalListSortingStrategy}
>
<Table.Body>
{sources.map((source) => (
<DraggableRow key={source.key} source={source} />
))}
</Table.Body>
</SortableContext>
</DndContext>
</AdminTable>
{/* 保存排序按钮 */}
{orderChanged && (
<div className='flex justify-end'>
<Button
size='sm'
variant='primary'
onPress={handleSaveOrder}
isDisabled={isLoading('saveSourceOrder')}
>
{isLoading('saveSourceOrder') ? '保存中...' : '保存排序'}
</Button>
</div>
)}
{/* 有效性检测弹窗 */}
<AppDialog
isOpen={showValidationModal}
onOpenChange={setShowValidationModal}
title='视频源有效性检测'
description='请输入检测用的搜索关键词'
footer={
<>
<Button variant='secondary' onPress={() => setShowValidationModal(false)}>
</Button>
<Button
variant='primary'
onPress={handleValidateSources}
isDisabled={!searchKeyword.trim()}
>
</Button>
</>
}
>
<TextField>
<Label></Label>
<Input
type='text'
placeholder='请输入搜索关键词'
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleValidateSources()}
/>
</TextField>
</AppDialog>
{/* 通用弹窗组件 */}
<AlertModal
isOpen={alertModal.isOpen}
onClose={hideAlert}
type={alertModal.type}
title={alertModal.title}
message={alertModal.message}
timer={alertModal.timer}
showConfirm={alertModal.showConfirm}
/>
{/* 批量操作确认弹窗 */}
<AppDialog
isOpen={confirmModal.isOpen}
onOpenChange={(open) => {
if (!open) confirmModal.onCancel();
}}
title={confirmModal.title}
description={confirmModal.message}
footer={
<>
<Button variant='secondary' onPress={confirmModal.onCancel}>
</Button>
<Button
variant='primary'
onPress={confirmModal.onConfirm}
isDisabled={isLoading('batchSource_batch_enable') || isLoading('batchSource_batch_disable') || isLoading('batchSource_batch_delete')}
>
{isLoading('batchSource_batch_enable') || isLoading('batchSource_batch_disable') || isLoading('batchSource_batch_delete') ? '操作中...' : '确认'}
</Button>
</>
}
/>
</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 (
<Table.Row
ref={setNodeRef}
style={style}
id={`${category.query}:${category.type}`}
className='select-none'
>
<Table.Cell
className="cursor-grab text-muted"
style={{ touchAction: 'none' }}
{...{ ...attributes, ...listeners }}
>
<GripVertical size={16} />
</Table.Cell>
<Table.Cell>
{category.name || '-'}
</Table.Cell>
<Table.Cell>
<AdminChip color={category.type === 'movie' ? 'accent' : 'default'}>
{category.type === 'movie' ? '电影' : '电视剧'}
</AdminChip>
</Table.Cell>
<Table.Cell className='max-w-[12rem]'>
<span className='block truncate' title={category.query}>
{category.query}
</span>
</Table.Cell>
<Table.Cell>
<AdminChip color={!category.disabled ? 'success' : 'danger'}>
{!category.disabled ? '启用中' : '已禁用'}
</AdminChip>
</Table.Cell>
<Table.Cell>
<div className='flex justify-end gap-2'>
<Button
size='sm'
variant={!category.disabled ? 'danger-soft' : 'secondary'}
onPress={() =>
handleToggleEnable(category.query, category.type)
}
isDisabled={isLoading(`toggleCategory_${category.query}_${category.type}`)}
>
{!category.disabled ? '禁用' : '启用'}
</Button>
{category.from !== 'config' && (
<Button
size='sm'
variant='danger-soft'
onPress={() => handleDelete(category.query, category.type)}
isDisabled={isLoading(`deleteCategory_${category.query}_${category.type}`)}
>
</Button>
)}
</div>
</Table.Cell>
</Table.Row>
);
};
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
size='sm'
variant={showAddForm ? 'secondary' : 'primary'}
onPress={() => setShowAddForm(!showAddForm)}
>
{showAddForm ? '取消' : '添加分类'}
</Button>
</div>
{showAddForm && (
<Card variant='secondary' className='p-4'>
<div className='space-y-4'>
<div className='grid grid-cols-1 sm:grid-cols-2 gap-4'>
<TextField fullWidth>
<Label></Label>
<Input
type='text'
placeholder='分类名称'
value={newCategory.name}
onChange={(e) =>
setNewCategory((prev) => ({ ...prev, name: e.target.value }))
}
/>
</TextField>
<AppFilterSelect
label='类型'
value={newCategory.type}
options={[
{ label: '电影', value: 'movie' },
{ label: '电视剧', value: 'tv' },
]}
onChange={(value) =>
setNewCategory((prev) => ({
...prev,
type: value as 'movie' | 'tv',
}))
}
/>
<TextField fullWidth>
<Label></Label>
<Input
type='text'
placeholder='搜索关键词'
value={newCategory.query}
onChange={(e) =>
setNewCategory((prev) => ({ ...prev, query: e.target.value }))
}
/>
</TextField>
</div>
<div className='flex justify-end'>
<Button
className='w-full sm:w-auto'
variant='primary'
onPress={handleAddCategory}
isDisabled={!newCategory.name || !newCategory.query || isLoading('addCategory')}
>
{isLoading('addCategory') ? '添加中...' : '添加'}
</Button>
</div>
</div>
</Card>
)}
{/* 分类表格 */}
<AdminTable ariaLabel='分类列表'>
<Table.Header>
<Table.Column className='w-8' />
<Table.Column isRowHeader>
</Table.Column>
<Table.Column>
</Table.Column>
<Table.Column>
</Table.Column>
<Table.Column>
</Table.Column>
<Table.Column className='text-right'>
</Table.Column>
</Table.Header>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
autoScroll={false}
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
>
<SortableContext
items={categories.map((c) => `${c.query}:${c.type}`)}
strategy={verticalListSortingStrategy}
>
<Table.Body>
{categories.map((category) => (
<DraggableRow
key={`${category.query}:${category.type}`}
category={category}
/>
))}
</Table.Body>
</SortableContext>
</DndContext>
</AdminTable>
{/* 保存排序按钮 */}
{orderChanged && (
<div className='flex justify-end'>
<Button
size='sm'
variant='primary'
onPress={handleSaveOrder}
isDisabled={isLoading('saveCategoryOrder')}
>
{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'>
{/* 配置订阅区域 */}
<Card variant='default'>
<Card.Header className='flex items-center justify-between gap-4'>
<Card.Title></Card.Title>
<Card.Description className='text-right'>
: {lastCheckTime ? new Date(lastCheckTime).toLocaleString('zh-CN') : '从未更新'}
</Card.Description>
</Card.Header>
<Card.Content className='space-y-6'>
<TextField fullWidth type='url'>
<Label>URL</Label>
<Input
value={subscriptionUrl}
onChange={(e) => setSubscriptionUrl(e.target.value)}
placeholder='https://example.com/config.json'
variant='secondary'
/>
<p className='text-xs text-muted'>
JSON 使 Base58
</p>
</TextField>
<Button
fullWidth
isDisabled={isLoading('fetchConfig') || !subscriptionUrl.trim()}
isPending={isLoading('fetchConfig')}
onPress={() => void handleFetchConfig()}
>
{isLoading('fetchConfig') ? '拉取中…' : '拉取配置'}
</Button>
<AdminSettingSwitch
label='自动更新'
description='启用后系统将定期自动拉取最新配置'
isSelected={autoUpdate}
onChange={setAutoUpdate}
/>
</Card.Content>
</Card>
{/* 配置文件编辑区域 */}
<Card variant='default'>
<Card.Content className='space-y-4'>
<div className='space-y-2'>
<Label htmlFor='admin-config-file-content'></Label>
<TextArea
id='admin-config-file-content'
aria-label='配置文件内容'
value={configContent}
onChange={(e) => setConfigContent(e.target.value)}
rows={20}
placeholder='请输入配置文件内容JSON 格式)...'
className='font-mono text-sm leading-relaxed'
fullWidth
variant='secondary'
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 gap-4'>
<Card.Description>
JSON
</Card.Description>
<Button
isPending={isLoading('saveConfig')}
onPress={() => void handleSave()}
>
{isLoading('saveConfig') ? '保存中…' : '保存'}
</Button>
</div>
</Card.Content>
</Card>
{/* 通用弹窗组件 */}
<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 [siteSettings, setSiteSettings] = useState<SiteConfig>({
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 (
<div className='text-center text-gray-500 dark:text-gray-400'>
...
</div>
);
}
return (
<div className='space-y-6'>
<TextField fullWidth>
<Label></Label>
<Input
value={siteSettings.SiteName}
onChange={(e) =>
setSiteSettings((prev) => ({ ...prev, SiteName: e.target.value }))
}
variant='secondary'
/>
</TextField>
<div className='space-y-2'>
<Label htmlFor='site-announcement'></Label>
<TextArea
id='site-announcement'
aria-label='站点公告'
value={siteSettings.Announcement}
onChange={(e) =>
setSiteSettings((prev) => ({
...prev,
Announcement: e.target.value,
}))
}
rows={3}
fullWidth
variant='secondary'
/>
</div>
<div className='space-y-3'>
<AppFilterSelect
label='豆瓣数据代理'
value={siteSettings.DoubanProxyType}
options={doubanDataSourceOptions}
onChange={handleDoubanDataSourceChange}
/>
<p className='text-xs text-muted'></p>
{getThanksInfo(siteSettings.DoubanProxyType) && (
<Button
fullWidth
variant='ghost'
onPress={() =>
window.open(
getThanksInfo(siteSettings.DoubanProxyType)!.url,
'_blank'
)
}
>
{getThanksInfo(siteSettings.DoubanProxyType)!.text}
<ExternalLink className='w-3.5' />
</Button>
)}
{siteSettings.DoubanProxyType === 'custom' && (
<TextField fullWidth>
<Label></Label>
<Input
placeholder='例如: https://proxy.example.com/fetch?url='
value={siteSettings.DoubanProxy}
onChange={(e) =>
setSiteSettings((prev) => ({
...prev,
DoubanProxy: e.target.value,
}))
}
variant='secondary'
/>
<p className='text-xs text-muted'></p>
</TextField>
)}
</div>
<div className='space-y-3'>
<AppFilterSelect
label='豆瓣图片代理'
value={siteSettings.DoubanImageProxyType}
options={doubanImageProxyTypeOptions}
onChange={handleDoubanImageProxyChange}
/>
<p className='text-xs text-muted'></p>
{getThanksInfo(siteSettings.DoubanImageProxyType) && (
<Button
fullWidth
variant='ghost'
onPress={() =>
window.open(
getThanksInfo(siteSettings.DoubanImageProxyType)!.url,
'_blank'
)
}
>
{getThanksInfo(siteSettings.DoubanImageProxyType)!.text}
<ExternalLink className='w-3.5' />
</Button>
)}
{siteSettings.DoubanImageProxyType === 'custom' && (
<TextField fullWidth>
<Label></Label>
<Input
placeholder='例如: https://proxy.example.com/fetch?url='
value={siteSettings.DoubanImageProxy}
onChange={(e) =>
setSiteSettings((prev) => ({
...prev,
DoubanImageProxy: e.target.value,
}))
}
variant='secondary'
/>
<p className='text-xs text-muted'></p>
</TextField>
)}
</div>
<TextField fullWidth type='number'>
<Label></Label>
<Input
min={1}
value={siteSettings.SearchDownstreamMaxPage}
onChange={(e) =>
setSiteSettings((prev) => ({
...prev,
SearchDownstreamMaxPage: Number(e.target.value),
}))
}
variant='secondary'
/>
</TextField>
<TextField fullWidth type='number'>
<Label></Label>
<Input
min={1}
value={siteSettings.SiteInterfaceCacheTime}
onChange={(e) =>
setSiteSettings((prev) => ({
...prev,
SiteInterfaceCacheTime: Number(e.target.value),
}))
}
variant='secondary'
/>
</TextField>
<AdminSettingSwitch
label='启用设备码验证'
description='启用后用户登录时需要绑定设备码,提升账户安全性。禁用后用户可以直接登录而无需绑定设备码。'
isSelected={siteSettings.RequireDeviceCode}
onChange={(isSelected) =>
setSiteSettings((prev) => ({
...prev,
RequireDeviceCode: isSelected,
}))
}
/>
<AdminSettingSwitch
label='禁用黄色过滤器'
description='禁用黄色内容的过滤功能,允许显示所有内容。'
isSelected={siteSettings.DisableYellowFilter}
onChange={(isSelected) =>
setSiteSettings((prev) => ({
...prev,
DisableYellowFilter: isSelected,
}))
}
/>
<AdminSettingSwitch
label='启用流式搜索'
description='启用后搜索结果将实时流式返回,提升用户体验。'
isSelected={siteSettings.FluidSearch}
onChange={(isSelected) =>
setSiteSettings((prev) => ({
...prev,
FluidSearch: isSelected,
}))
}
/>
<div className='flex justify-end'>
<Button
isPending={isLoading('saveSiteConfig')}
onPress={() => void handleSave()}
>
{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}
/>
</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 (
<Table.Row
ref={setNodeRef}
style={style}
id={liveSource.key}
className='select-none'
>
<Table.Cell
className='cursor-grab text-muted'
style={{ touchAction: 'none' }}
{...attributes}
{...listeners}
>
<GripVertical size={16} />
</Table.Cell>
<Table.Cell>
{liveSource.name}
</Table.Cell>
<Table.Cell>
{liveSource.key}
</Table.Cell>
<Table.Cell className='max-w-[12rem]'>
<span className='block truncate' title={liveSource.url}>
{liveSource.url}
</span>
</Table.Cell>
<Table.Cell className='max-w-[8rem]'>
<span className='block truncate' title={liveSource.epg || '-'}>
{liveSource.epg || '-'}
</span>
</Table.Cell>
<Table.Cell className='max-w-[8rem]'>
<span className='block truncate' title={liveSource.ua || '-'}>
{liveSource.ua || '-'}
</span>
</Table.Cell>
<Table.Cell className='text-center'>
{liveSource.channelNumber && liveSource.channelNumber > 0 ? liveSource.channelNumber : '-'}
</Table.Cell>
<Table.Cell>
<AdminChip color={!liveSource.disabled ? 'success' : 'danger'}>
{!liveSource.disabled ? '启用中' : '已禁用'}
</AdminChip>
</Table.Cell>
<Table.Cell>
<div className='flex justify-end gap-2'>
<Button
size='sm'
variant={!liveSource.disabled ? 'danger-soft' : 'secondary'}
onPress={() => handleToggleEnable(liveSource.key)}
isDisabled={isLoading(`toggleLiveSource_${liveSource.key}`)}
>
{!liveSource.disabled ? '禁用' : '启用'}
</Button>
{liveSource.from !== 'config' && (
<>
<Button
size='sm'
variant='secondary'
onPress={() => setEditingLiveSource(liveSource)}
isDisabled={isLoading(`editLiveSource_${liveSource.key}`)}
>
</Button>
<Button
size='sm'
variant='danger-soft'
onPress={() => handleDelete(liveSource.key)}
isDisabled={isLoading(`deleteLiveSource_${liveSource.key}`)}
>
</Button>
</>
)}
</div>
</Table.Cell>
</Table.Row>
);
};
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-foreground'>
</h4>
<div className='flex items-center space-x-2'>
<Button
size='sm'
variant='primary'
onPress={handleRefreshLiveSources}
isDisabled={isRefreshing || isLoading('refreshLiveSources')}
>
<span>{isRefreshing || isLoading('refreshLiveSources') ? '刷新中...' : '刷新直播源'}</span>
</Button>
<Button
size='sm'
variant={showAddForm ? 'secondary' : 'primary'}
onPress={() => setShowAddForm(!showAddForm)}
>
{showAddForm ? '取消' : '添加直播源'}
</Button>
</div>
</div>
{showAddForm && (
<Card variant='secondary'>
<Card.Header>
<div>
<Card.Title></Card.Title>
<Card.Description>
M3U UA
</Card.Description>
</div>
</Card.Header>
<Card.Content className='space-y-4'>
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
<TextField fullWidth>
<Label></Label>
<Input
type='text'
placeholder='例如:央视频道'
value={newLiveSource.name}
onChange={(e) =>
setNewLiveSource((prev) => ({ ...prev, name: e.target.value }))
}
variant='secondary'
/>
</TextField>
<TextField fullWidth>
<Label>Key</Label>
<Input
type='text'
placeholder='例如cctv'
value={newLiveSource.key}
onChange={(e) =>
setNewLiveSource((prev) => ({ ...prev, key: e.target.value }))
}
variant='secondary'
/>
</TextField>
<TextField fullWidth>
<Label>M3U </Label>
<Input
type='text'
placeholder='https://example.com/live.m3u'
value={newLiveSource.url}
onChange={(e) =>
setNewLiveSource((prev) => ({ ...prev, url: e.target.value }))
}
variant='secondary'
/>
</TextField>
<TextField fullWidth>
<Label></Label>
<Input
type='text'
placeholder='选填XMLTV EPG 地址'
value={newLiveSource.epg}
onChange={(e) =>
setNewLiveSource((prev) => ({ ...prev, epg: e.target.value }))
}
variant='secondary'
/>
</TextField>
<TextField className='sm:col-span-2' fullWidth>
<Label> UA</Label>
<Input
type='text'
placeholder='选填,例如 Mozilla/5.0'
value={newLiveSource.ua}
onChange={(e) =>
setNewLiveSource((prev) => ({ ...prev, ua: e.target.value }))
}
variant='secondary'
/>
</TextField>
</div>
</Card.Content>
<Card.Footer className='flex justify-end'>
<Button
className='w-full sm:w-auto'
variant='primary'
onPress={handleAddLiveSource}
isDisabled={!newLiveSource.name || !newLiveSource.key || !newLiveSource.url || isLoading('addLiveSource')}
isPending={isLoading('addLiveSource')}
>
{isLoading('addLiveSource') ? '添加中...' : '添加'}
</Button>
</Card.Footer>
</Card>
)}
{/* 编辑直播源表单 */}
{editingLiveSource && (
<Card variant='secondary'>
<Card.Header className='flex flex-row items-start justify-between gap-4'>
<div>
<Card.Title></Card.Title>
<Card.Description>
{editingLiveSource.name} · {editingLiveSource.key}
</Card.Description>
</div>
<Button
isIconOnly
size='sm'
variant='tertiary'
aria-label='关闭编辑直播源'
onPress={handleCancelEdit}
>
<X className='size-4' />
</Button>
</Card.Header>
<Card.Content className='space-y-4'>
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
<TextField fullWidth>
<Label></Label>
<Input
type='text'
value={editingLiveSource.name}
onChange={(e) =>
setEditingLiveSource((prev) => prev ? ({ ...prev, name: e.target.value }) : null)
}
variant='secondary'
/>
</TextField>
<TextField fullWidth>
<Label>Key</Label>
<Input
type='text'
value={editingLiveSource.key}
disabled
variant='secondary'
/>
<p className='text-xs text-muted'>Key </p>
</TextField>
<TextField fullWidth>
<Label>M3U </Label>
<Input
type='text'
value={editingLiveSource.url}
onChange={(e) =>
setEditingLiveSource((prev) => prev ? ({ ...prev, url: e.target.value }) : null)
}
variant='secondary'
/>
</TextField>
<TextField fullWidth>
<Label></Label>
<Input
type='text'
value={editingLiveSource.epg}
onChange={(e) =>
setEditingLiveSource((prev) => prev ? ({ ...prev, epg: e.target.value }) : null)
}
variant='secondary'
/>
</TextField>
<TextField className='sm:col-span-2' fullWidth>
<Label> UA</Label>
<Input
type='text'
value={editingLiveSource.ua}
onChange={(e) =>
setEditingLiveSource((prev) => prev ? ({ ...prev, ua: e.target.value }) : null)
}
variant='secondary'
/>
</TextField>
</div>
</Card.Content>
<Card.Footer className='flex flex-col-reverse gap-2 sm:flex-row sm:justify-end'>
<Button
className='w-full sm:w-auto'
variant='secondary'
onPress={handleCancelEdit}
>
</Button>
<Button
className='w-full sm:w-auto'
variant='primary'
onPress={handleEditLiveSource}
isDisabled={!editingLiveSource.name || !editingLiveSource.url || isLoading('editLiveSource')}
isPending={isLoading('editLiveSource')}
>
{isLoading('editLiveSource') ? '保存中...' : '保存'}
</Button>
</Card.Footer>
</Card>
)}
{/* 直播源表格 */}
<AdminTable ariaLabel='直播源列表' minWidth='min-w-[1100px]'>
<Table.Header>
<Table.Column className='w-8' />
<Table.Column isRowHeader>
</Table.Column>
<Table.Column>
Key
</Table.Column>
<Table.Column>
M3U
</Table.Column>
<Table.Column>
</Table.Column>
<Table.Column>
UA
</Table.Column>
<Table.Column>
</Table.Column>
<Table.Column>
</Table.Column>
<Table.Column className='text-right'>
</Table.Column>
</Table.Header>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
autoScroll={false}
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
>
<SortableContext
items={liveSources.map((s) => s.key)}
strategy={verticalListSortingStrategy}
>
<Table.Body>
{liveSources.map((liveSource) => (
<DraggableRow key={liveSource.key} liveSource={liveSource} />
))}
</Table.Body>
</SortableContext>
</DndContext>
</AdminTable>
{/* 保存排序按钮 */}
{orderChanged && (
<div className='flex justify-end'>
<Button
size='sm'
variant='primary'
onPress={handleSaveOrder}
isDisabled={isLoading('saveLiveSourceOrder')}
>
{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,
themeManager: false,
});
// 机器码管理状态
const [machineCodeUsers, setMachineCodeUsers] = useState<Record<string, { machineCode: string; deviceInfo?: string; bindTime: number }>>({});
// 获取机器码用户列表
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 (
<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
size='sm'
variant='danger-soft'
onPress={handleResetConfig}
>
</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}
machineCodeUsers={machineCodeUsers}
fetchMachineCodeUsers={fetchMachineCodeUsers}
/>
</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>
)}
{/* 主题定制标签 */}
<CollapsibleTab
title='主题定制'
icon={
<Palette
size={20}
className='text-gray-600 dark:text-gray-400'
/>
}
isExpanded={expandedTabs.themeManager}
onToggle={() => toggleTab('themeManager')}
>
<ThemeManager showAlert={showAlert} role={role} />
</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}
/>
{/* 重置配置确认弹窗 */}
<AppDialog
isOpen={showResetConfigModal}
onOpenChange={setShowResetConfigModal}
title='确认重置配置'
footer={
<>
<Button variant='secondary' onPress={() => setShowResetConfigModal(false)}>
</Button>
<Button
variant='primary'
onPress={handleConfirmResetConfig}
isDisabled={isLoading('resetConfig')}
>
{isLoading('resetConfig') ? '重置中...' : '确认重置'}
</Button>
</>
}
>
<Alert status='warning'>
<Alert.Content>
<Alert.Title></Alert.Title>
<Alert.Description>
</Alert.Description>
</Alert.Content>
</Alert>
</AppDialog>
</PageLayout>
);
}
export default function AdminPage() {
return (
<Suspense>
<AdminPageClient />
</Suspense>
);
}