mirror of https://github.com/djteang/OrangeTV.git
4980 lines
162 KiB
TypeScript
4980 lines
162 KiB
TypeScript
/* 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>
|
||
);
|
||
}
|