feat: add admin page loading ui

This commit is contained in:
shinya
2025-08-25 02:02:38 +08:00
parent 905544b6fa
commit b8604f3c8d
+516 -432
View File
@@ -228,6 +228,33 @@ const showSuccess = (message: string, showAlert?: (config: any) => void) => {
} }
}; };
// 通用加载状态管理系统
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 { interface SiteConfig {
SiteName: string; SiteName: string;
@@ -320,6 +347,7 @@ interface UserConfigProps {
const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => { const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
const { alertModal, showAlert, hideAlert } = useAlertModal(); const { alertModal, showAlert, hideAlert } = useAlertModal();
const { isLoading, withLoading } = useLoadingState();
const [showAddUserForm, setShowAddUserForm] = useState(false); const [showAddUserForm, setShowAddUserForm] = useState(false);
const [showChangePasswordForm, setShowChangePasswordForm] = useState(false); const [showChangePasswordForm, setShowChangePasswordForm] = useState(false);
const [showAddUserGroupForm, setShowAddUserGroupForm] = useState(false); const [showAddUserGroupForm, setShowAddUserGroupForm] = useState(false);
@@ -390,37 +418,40 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
groupName: string, groupName: string,
enabledApis?: string[] enabledApis?: string[]
) => { ) => {
try { return withLoading(`userGroup_${action}_${groupName}`, async () => {
const res = await fetch('/api/admin/user', { try {
method: 'POST', const res = await fetch('/api/admin/user', {
headers: { 'Content-Type': 'application/json' }, method: 'POST',
body: JSON.stringify({ headers: { 'Content-Type': 'application/json' },
action: 'userGroup', body: JSON.stringify({
groupAction: action, action: 'userGroup',
groupName, groupAction: action,
enabledApis, groupName,
}), enabledApis,
}); }),
});
if (!res.ok) { if (!res.ok) {
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
throw new Error(data.error || `操作失败: ${res.status}`); 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;
} }
});
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);
}
}; };
const handleAddUserGroup = () => { const handleAddUserGroup = () => {
@@ -466,61 +497,68 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
// 为用户分配用户组 // 为用户分配用户组
const handleAssignUserGroup = async (username: string, userGroups: string[]) => { const handleAssignUserGroup = async (username: string, userGroups: string[]) => {
try { return withLoading(`assignUserGroup_${username}`, async () => {
const res = await fetch('/api/admin/user', { try {
method: 'POST', const res = await fetch('/api/admin/user', {
headers: { 'Content-Type': 'application/json' }, method: 'POST',
body: JSON.stringify({ headers: { 'Content-Type': 'application/json' },
targetUsername: username, body: JSON.stringify({
action: 'updateUserGroups', targetUsername: username,
userGroups, action: 'updateUserGroups',
}), userGroups,
}); }),
});
if (!res.ok) { if (!res.ok) {
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
throw new Error(data.error || `操作失败: ${res.status}`); throw new Error(data.error || `操作失败: ${res.status}`);
}
await refreshConfig();
showSuccess('用户组分配成功', showAlert);
} catch (err) {
showError(err instanceof Error ? err.message : '操作失败', showAlert);
throw err;
} }
});
await refreshConfig();
showSuccess('用户组分配成功', showAlert);
} catch (err) {
showError(err instanceof Error ? err.message : '操作失败', showAlert);
}
}; };
const handleBanUser = async (uname: string) => { const handleBanUser = async (uname: string) => {
await handleUserAction('ban', uname); await withLoading(`banUser_${uname}`, () => handleUserAction('ban', uname));
}; };
const handleUnbanUser = async (uname: string) => { const handleUnbanUser = async (uname: string) => {
await handleUserAction('unban', uname); await withLoading(`unbanUser_${uname}`, () => handleUserAction('unban', uname));
}; };
const handleSetAdmin = async (uname: string) => { const handleSetAdmin = async (uname: string) => {
await handleUserAction('setAdmin', uname); await withLoading(`setAdmin_${uname}`, () => handleUserAction('setAdmin', uname));
}; };
const handleRemoveAdmin = async (uname: string) => { const handleRemoveAdmin = async (uname: string) => {
await handleUserAction('cancelAdmin', uname); await withLoading(`removeAdmin_${uname}`, () => handleUserAction('cancelAdmin', uname));
}; };
const handleAddUser = async () => { const handleAddUser = async () => {
if (!newUser.username || !newUser.password) return; if (!newUser.username || !newUser.password) return;
await handleUserAction('add', newUser.username, newUser.password, newUser.userGroup); await withLoading('addUser', async () => {
setNewUser({ username: '', password: '', userGroup: '' }); await handleUserAction('add', newUser.username, newUser.password, newUser.userGroup);
setShowAddUserForm(false); setNewUser({ username: '', password: '', userGroup: '' });
setShowAddUserForm(false);
});
}; };
const handleChangePassword = async () => { const handleChangePassword = async () => {
if (!changePasswordUser.username || !changePasswordUser.password) return; if (!changePasswordUser.username || !changePasswordUser.password) return;
await handleUserAction( await withLoading(`changePassword_${changePasswordUser.username}`, async () => {
'changePassword', await handleUserAction(
changePasswordUser.username, 'changePassword',
changePasswordUser.password changePasswordUser.username,
); changePasswordUser.password
setChangePasswordUser({ username: '', password: '' }); );
setShowChangePasswordForm(false); setChangePasswordUser({ username: '', password: '' });
setShowChangePasswordForm(false);
});
}; };
const handleShowChangePasswordForm = (username: string) => { const handleShowChangePasswordForm = (username: string) => {
@@ -557,14 +595,16 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
const handleSaveUserGroups = async () => { const handleSaveUserGroups = async () => {
if (!selectedUserForGroup) return; if (!selectedUserForGroup) return;
try { await withLoading(`saveUserGroups_${selectedUserForGroup.username}`, async () => {
await handleAssignUserGroup(selectedUserForGroup.username, selectedUserGroups); try {
setShowConfigureUserGroupModal(false); await handleAssignUserGroup(selectedUserForGroup.username, selectedUserGroups);
setSelectedUserForGroup(null); setShowConfigureUserGroupModal(false);
setSelectedUserGroups([]); setSelectedUserForGroup(null);
} catch (err) { setSelectedUserGroups([]);
// 错误处理已在 handleAssignUserGroup 中处理 } catch (err) {
} // 错误处理已在 handleAssignUserGroup 中处理
}
});
}; };
// 处理用户选择 // 处理用户选择
@@ -599,33 +639,36 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
const handleBatchSetUserGroup = async (userGroup: string) => { const handleBatchSetUserGroup = async (userGroup: string) => {
if (selectedUsers.size === 0) return; if (selectedUsers.size === 0) return;
try { await withLoading('batchSetUserGroup', async () => {
const res = await fetch('/api/admin/user', { try {
method: 'POST', const res = await fetch('/api/admin/user', {
headers: { 'Content-Type': 'application/json' }, method: 'POST',
body: JSON.stringify({ headers: { 'Content-Type': 'application/json' },
action: 'batchUpdateUserGroups', body: JSON.stringify({
usernames: Array.from(selectedUsers), action: 'batchUpdateUserGroups',
userGroups: userGroup === '' ? [] : [userGroup], usernames: Array.from(selectedUsers),
}), userGroups: userGroup === '' ? [] : [userGroup],
}); }),
});
if (!res.ok) { if (!res.ok) {
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
throw new Error(data.error || `操作失败: ${res.status}`); 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;
} }
});
const userCount = selectedUsers.size;
setSelectedUsers(new Set());
setShowBatchUserGroupModal(false);
setSelectedUserGroup('');
showSuccess(`已为 ${userCount} 个用户设置用户组: ${userGroup}`, showAlert);
// 刷新配置
await refreshConfig();
} catch (err) {
showError('批量设置用户组失败', showAlert);
}
}; };
@@ -644,30 +687,33 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
const handleSaveUserApis = async () => { const handleSaveUserApis = async () => {
if (!selectedUser) return; if (!selectedUser) return;
try { await withLoading(`saveUserApis_${selectedUser.username}`, async () => {
const res = await fetch('/api/admin/user', { try {
method: 'POST', const res = await fetch('/api/admin/user', {
headers: { 'Content-Type': 'application/json' }, method: 'POST',
body: JSON.stringify({ headers: { 'Content-Type': 'application/json' },
targetUsername: selectedUser.username, body: JSON.stringify({
action: 'updateUserApis', targetUsername: selectedUser.username,
enabledApis: selectedApis, action: 'updateUserApis',
}), enabledApis: selectedApis,
}); }),
});
if (!res.ok) { if (!res.ok) {
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
throw new Error(data.error || `操作失败: ${res.status}`); 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;
} }
});
// 成功后刷新配置
await refreshConfig();
setShowConfigureApisModal(false);
setSelectedUser(null);
setSelectedApis([]);
} catch (err) {
showError(err instanceof Error ? err.message : '操作失败', showAlert);
}
}; };
// 通用请求函数 // 通用请求函数
@@ -711,13 +757,15 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
const handleConfirmDeleteUser = async () => { const handleConfirmDeleteUser = async () => {
if (!deletingUser) return; if (!deletingUser) return;
try { await withLoading(`deleteUser_${deletingUser}`, async () => {
await handleUserAction('deleteUser', deletingUser); try {
setShowDeleteUserModal(false); await handleUserAction('deleteUser', deletingUser);
setDeletingUser(null); setShowDeleteUserModal(false);
} catch (err) { setDeletingUser(null);
// 错误处理已在 handleUserAction 中处理 } catch (err) {
} // 错误处理已在 handleUserAction 中处理
}
});
}; };
if (!config) { if (!config) {
@@ -801,7 +849,8 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
<td className='px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2'> <td className='px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2'>
<button <button
onClick={() => handleStartEditUserGroup(group)} onClick={() => handleStartEditUserGroup(group)}
className={buttonStyles.roundedPrimary} disabled={isLoading(`userGroup_edit_${group.name}`)}
className={`${buttonStyles.roundedPrimary} ${isLoading(`userGroup_edit_${group.name}`) ? 'opacity-50 cursor-not-allowed' : ''}`}
> >
</button> </button>
@@ -911,10 +960,10 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
<div className='flex justify-end'> <div className='flex justify-end'>
<button <button
onClick={handleAddUser} onClick={handleAddUser}
disabled={!newUser.username || !newUser.password} disabled={!newUser.username || !newUser.password || isLoading('addUser')}
className={!newUser.username || !newUser.password ? buttonStyles.disabled : buttonStyles.success} className={!newUser.username || !newUser.password || isLoading('addUser') ? buttonStyles.disabled : buttonStyles.success}
> >
{isLoading('addUser') ? '添加中...' : '添加'}
</button> </button>
</div> </div>
</div> </div>
@@ -949,10 +998,10 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
/> />
<button <button
onClick={handleChangePassword} onClick={handleChangePassword}
disabled={!changePasswordUser.password} disabled={!changePasswordUser.password || isLoading(`changePassword_${changePasswordUser.username}`)}
className={`w-full sm:w-auto ${!changePasswordUser.password ? buttonStyles.disabled : buttonStyles.primary}`} className={`w-full sm:w-auto ${!changePasswordUser.password || isLoading(`changePassword_${changePasswordUser.username}`) ? buttonStyles.disabled : buttonStyles.primary}`}
> >
{isLoading(`changePassword_${changePasswordUser.username}`) ? '修改中...' : '修改密码'}
</button> </button>
<button <button
onClick={() => { onClick={() => {
@@ -1177,7 +1226,8 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
{user.role === 'user' && ( {user.role === 'user' && (
<button <button
onClick={() => handleSetAdmin(user.username)} onClick={() => handleSetAdmin(user.username)}
className={buttonStyles.roundedPurple} disabled={isLoading(`setAdmin_${user.username}`)}
className={`${buttonStyles.roundedPurple} ${isLoading(`setAdmin_${user.username}`) ? 'opacity-50 cursor-not-allowed' : ''}`}
> >
</button> </button>
@@ -1187,7 +1237,8 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
onClick={() => onClick={() =>
handleRemoveAdmin(user.username) handleRemoveAdmin(user.username)
} }
className={buttonStyles.roundedSecondary} disabled={isLoading(`removeAdmin_${user.username}`)}
className={`${buttonStyles.roundedSecondary} ${isLoading(`removeAdmin_${user.username}`) ? 'opacity-50 cursor-not-allowed' : ''}`}
> >
</button> </button>
@@ -1196,7 +1247,8 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
(!user.banned ? ( (!user.banned ? (
<button <button
onClick={() => handleBanUser(user.username)} onClick={() => handleBanUser(user.username)}
className={buttonStyles.roundedDanger} disabled={isLoading(`banUser_${user.username}`)}
className={`${buttonStyles.roundedDanger} ${isLoading(`banUser_${user.username}`) ? 'opacity-50 cursor-not-allowed' : ''}`}
> >
</button> </button>
@@ -1205,7 +1257,8 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
onClick={() => onClick={() =>
handleUnbanUser(user.username) handleUnbanUser(user.username)
} }
className={buttonStyles.roundedSuccess} disabled={isLoading(`unbanUser_${user.username}`)}
className={`${buttonStyles.roundedSuccess} ${isLoading(`unbanUser_${user.username}`) ? 'opacity-50 cursor-not-allowed' : ''}`}
> >
</button> </button>
@@ -1350,9 +1403,10 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
</button> </button>
<button <button
onClick={handleSaveUserApis} onClick={handleSaveUserApis}
className={`px-6 py-2.5 text-sm font-medium ${buttonStyles.primary}`} disabled={isLoading(`saveUserApis_${selectedUser?.username}`)}
className={`px-6 py-2.5 text-sm font-medium ${isLoading(`saveUserApis_${selectedUser?.username}`) ? buttonStyles.disabled : buttonStyles.primary}`}
> >
{isLoading(`saveUserApis_${selectedUser?.username}`) ? '配置中...' : '确认配置'}
</button> </button>
</div> </div>
</div> </div>
@@ -1476,10 +1530,10 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
</button> </button>
<button <button
onClick={handleAddUserGroup} onClick={handleAddUserGroup}
disabled={!newUserGroup.name.trim()} disabled={!newUserGroup.name.trim() || isLoading('userGroup_add_new')}
className={`px-6 py-2.5 text-sm font-medium ${!newUserGroup.name.trim() ? buttonStyles.disabled : buttonStyles.primary}`} className={`px-6 py-2.5 text-sm font-medium ${!newUserGroup.name.trim() || isLoading('userGroup_add_new') ? buttonStyles.disabled : buttonStyles.primary}`}
> >
{isLoading('userGroup_add_new') ? '添加中...' : '添加用户组'}
</button> </button>
</div> </div>
</div> </div>
@@ -1588,9 +1642,10 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
</button> </button>
<button <button
onClick={handleEditUserGroup} onClick={handleEditUserGroup}
className={`px-6 py-2.5 text-sm font-medium ${buttonStyles.primary}`} disabled={isLoading(`userGroup_edit_${editingUserGroup?.name}`)}
className={`px-6 py-2.5 text-sm font-medium ${isLoading(`userGroup_edit_${editingUserGroup?.name}`) ? buttonStyles.disabled : buttonStyles.primary}`}
> >
{isLoading(`userGroup_edit_${editingUserGroup?.name}`) ? '保存中...' : '保存修改'}
</button> </button>
</div> </div>
</div> </div>
@@ -1684,9 +1739,10 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
</button> </button>
<button <button
onClick={handleSaveUserGroups} onClick={handleSaveUserGroups}
className={`px-6 py-2.5 text-sm font-medium ${buttonStyles.primary}`} disabled={isLoading(`saveUserGroups_${selectedUserForGroup?.username}`)}
className={`px-6 py-2.5 text-sm font-medium ${isLoading(`saveUserGroups_${selectedUserForGroup?.username}`) ? buttonStyles.disabled : buttonStyles.primary}`}
> >
{isLoading(`saveUserGroups_${selectedUserForGroup?.username}`) ? '配置中...' : '确认配置'}
</button> </button>
</div> </div>
</div> </div>
@@ -1783,9 +1839,10 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
</button> </button>
<button <button
onClick={handleConfirmDeleteUserGroup} onClick={handleConfirmDeleteUserGroup}
className={`px-6 py-2.5 text-sm font-medium ${buttonStyles.danger}`} disabled={isLoading(`userGroup_delete_${deletingUserGroup?.name}`)}
className={`px-6 py-2.5 text-sm font-medium ${isLoading(`userGroup_delete_${deletingUserGroup?.name}`) ? buttonStyles.disabled : buttonStyles.danger}`}
> >
{isLoading(`userGroup_delete_${deletingUserGroup?.name}`) ? '删除中...' : '确认删除'}
</button> </button>
</div> </div>
</div> </div>
@@ -1934,9 +1991,10 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
</button> </button>
<button <button
onClick={() => handleBatchSetUserGroup(selectedUserGroup)} onClick={() => handleBatchSetUserGroup(selectedUserGroup)}
className={`px-6 py-2.5 text-sm font-medium ${buttonStyles.primary}`} disabled={isLoading('batchSetUserGroup')}
className={`px-6 py-2.5 text-sm font-medium ${isLoading('batchSetUserGroup') ? buttonStyles.disabled : buttonStyles.primary}`}
> >
{isLoading('batchSetUserGroup') ? '设置中...' : '确认设置'}
</button> </button>
</div> </div>
</div> </div>
@@ -1970,6 +2028,7 @@ const VideoSourceConfig = ({
refreshConfig: () => Promise<void>; refreshConfig: () => Promise<void>;
}) => { }) => {
const { alertModal, showAlert, hideAlert } = useAlertModal(); const { alertModal, showAlert, hideAlert } = useAlertModal();
const { isLoading, withLoading } = useLoadingState();
const [sources, setSources] = useState<DataSource[]>([]); const [sources, setSources] = useState<DataSource[]>([]);
const [showAddForm, setShowAddForm] = useState(false); const [showAddForm, setShowAddForm] = useState(false);
const [orderChanged, setOrderChanged] = useState(false); const [orderChanged, setOrderChanged] = useState(false);
@@ -2069,40 +2128,39 @@ const VideoSourceConfig = ({
const target = sources.find((s) => s.key === key); const target = sources.find((s) => s.key === key);
if (!target) return; if (!target) return;
const action = target.disabled ? 'enable' : 'disable'; const action = target.disabled ? 'enable' : 'disable';
callSourceApi({ action, key }).catch(() => { withLoading(`toggleSource_${key}`, () => callSourceApi({ action, key })).catch(() => {
console.error('操作失败', action, key); console.error('操作失败', action, key);
}); });
}; };
const handleDelete = (key: string) => { const handleDelete = (key: string) => {
callSourceApi({ action: 'delete', key }).catch(() => { withLoading(`deleteSource_${key}`, () => callSourceApi({ action: 'delete', key })).catch(() => {
console.error('操作失败', 'delete', key); console.error('操作失败', 'delete', key);
}); });
}; };
const handleAddSource = () => { const handleAddSource = () => {
if (!newSource.name || !newSource.key || !newSource.api) return; if (!newSource.name || !newSource.key || !newSource.api) return;
callSourceApi({ withLoading('addSource', async () => {
action: 'add', await callSourceApi({
key: newSource.key, action: 'add',
name: newSource.name, key: newSource.key,
api: newSource.api, name: newSource.name,
detail: newSource.detail, api: newSource.api,
}) detail: newSource.detail,
.then(() => {
setNewSource({
name: '',
key: '',
api: '',
detail: '',
disabled: false,
from: 'custom',
});
setShowAddForm(false);
})
.catch(() => {
console.error('操作失败', 'add', newSource);
}); });
setNewSource({
name: '',
key: '',
api: '',
detail: '',
disabled: false,
from: 'custom',
});
setShowAddForm(false);
}).catch(() => {
console.error('操作失败', 'add', newSource);
});
}; };
const handleDragEnd = (event: any) => { const handleDragEnd = (event: any) => {
@@ -2116,7 +2174,7 @@ const VideoSourceConfig = ({
const handleSaveOrder = () => { const handleSaveOrder = () => {
const order = sources.map((s) => s.key); const order = sources.map((s) => s.key);
callSourceApi({ action: 'sort', order }) withLoading('saveSourceOrder', () => callSourceApi({ action: 'sort', order }))
.then(() => { .then(() => {
setOrderChanged(false); setOrderChanged(false);
}) })
@@ -2132,91 +2190,94 @@ const VideoSourceConfig = ({
return; return;
} }
setIsValidating(true); await withLoading('validateSources', async () => {
setValidationResults([]); // 清空之前的结果 setIsValidating(true);
setShowValidationModal(false); // 立即关闭弹窗 setValidationResults([]); // 清空之前的结果
setShowValidationModal(false); // 立即关闭弹窗
// 初始化所有视频源为检测中状态 // 初始化所有视频源为检测中状态
const initialResults = sources.map(source => ({ const initialResults = sources.map(source => ({
key: source.key, key: source.key,
name: source.name, name: source.name,
status: 'validating' as const, status: 'validating' as const,
message: '检测中...', message: '检测中...',
resultCount: 0 resultCount: 0
})); }));
setValidationResults(initialResults); setValidationResults(initialResults);
try { try {
// 使用EventSource接收流式数据 // 使用EventSource接收流式数据
const eventSource = new EventSource(`/api/admin/source/validate?q=${encodeURIComponent(searchKeyword.trim())}`); const eventSource = new EventSource(`/api/admin/source/validate?q=${encodeURIComponent(searchKeyword.trim())}`);
eventSource.onmessage = (event) => { eventSource.onmessage = (event) => {
try { try {
const data = JSON.parse(event.data); const data = JSON.parse(event.data);
switch (data.type) { switch (data.type) {
case 'start': case 'start':
console.log(`开始检测 ${data.totalSources} 个视频源`); console.log(`开始检测 ${data.totalSources} 个视频源`);
break; break;
case 'source_result': case 'source_result':
case 'source_error': case 'source_error':
// 更新验证结果 // 更新验证结果
setValidationResults(prev => { setValidationResults(prev => {
const existing = prev.find(r => r.key === data.source); const existing = prev.find(r => r.key === data.source);
if (existing) { if (existing) {
return prev.map(r => r.key === data.source ? { return prev.map(r => r.key === data.source ? {
key: data.source, key: data.source,
name: sources.find(s => s.key === data.source)?.name || data.source, name: sources.find(s => s.key === data.source)?.name || data.source,
status: data.status, status: data.status,
message: data.status === 'valid' ? '搜索正常' : message: data.status === 'valid' ? '搜索正常' :
data.status === 'no_results' ? '无法搜索到结果' : '连接失败', data.status === 'no_results' ? '无法搜索到结果' : '连接失败',
resultCount: data.status === 'valid' ? 1 : 0 resultCount: data.status === 'valid' ? 1 : 0
} : r); } : r);
} else { } else {
return [...prev, { return [...prev, {
key: data.source, key: data.source,
name: sources.find(s => s.key === data.source)?.name || data.source, name: sources.find(s => s.key === data.source)?.name || data.source,
status: data.status, status: data.status,
message: data.status === 'valid' ? '搜索正常' : message: data.status === 'valid' ? '搜索正常' :
data.status === 'no_results' ? '无法搜索到结果' : '连接失败', data.status === 'no_results' ? '无法搜索到结果' : '连接失败',
resultCount: data.status === 'valid' ? 1 : 0 resultCount: data.status === 'valid' ? 1 : 0
}]; }];
} }
}); });
break; break;
case 'complete': case 'complete':
console.log(`检测完成,共检测 ${data.completedSources} 个视频源`); console.log(`检测完成,共检测 ${data.completedSources} 个视频源`);
eventSource.close(); eventSource.close();
setIsValidating(false); setIsValidating(false);
break; break;
}
} catch (error) {
console.error('解析EventSource数据失败:', error);
} }
} catch (error) { };
console.error('解析EventSource数据失败:', error);
}
};
eventSource.onerror = (error) => { eventSource.onerror = (error) => {
console.error('EventSource错误:', error); console.error('EventSource错误:', error);
eventSource.close();
setIsValidating(false);
showAlert({ type: 'error', title: '验证失败', message: '连接错误,请重试' });
};
// 设置超时,防止长时间等待
setTimeout(() => {
if (eventSource.readyState === EventSource.OPEN) {
eventSource.close(); eventSource.close();
setIsValidating(false); setIsValidating(false);
showAlert({ type: 'warning', title: '验证超时', message: '检测超时,请重试' }); showAlert({ type: 'error', title: '验证失败', message: '连接错误,请重试' });
} };
}, 60000); // 60秒超时
} catch (error) { // 设置超时,防止长时间等待
setIsValidating(false); setTimeout(() => {
showAlert({ type: 'error', title: '验证失败', message: error instanceof Error ? error.message : '未知错误' }); if (eventSource.readyState === EventSource.OPEN) {
} eventSource.close();
setIsValidating(false);
showAlert({ type: 'warning', title: '验证超时', message: '检测超时,请重试' });
}
}, 60000); // 60秒超时
} catch (error) {
setIsValidating(false);
showAlert({ type: 'error', title: '验证失败', message: error instanceof Error ? error.message : '未知错误' });
throw error;
}
});
}; };
// 获取有效性状态显示 // 获取有效性状态显示
@@ -2338,17 +2399,19 @@ const VideoSourceConfig = ({
<td className='px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2'> <td className='px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2'>
<button <button
onClick={() => handleToggleEnable(source.key)} onClick={() => handleToggleEnable(source.key)}
disabled={isLoading(`toggleSource_${source.key}`)}
className={`inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium ${!source.disabled className={`inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium ${!source.disabled
? buttonStyles.roundedDanger ? buttonStyles.roundedDanger
: buttonStyles.roundedSuccess : buttonStyles.roundedSuccess
} transition-colors`} } transition-colors ${isLoading(`toggleSource_${source.key}`) ? 'opacity-50 cursor-not-allowed' : ''}`}
> >
{!source.disabled ? '禁用' : '启用'} {!source.disabled ? '禁用' : '启用'}
</button> </button>
{source.from !== 'config' && ( {source.from !== 'config' && (
<button <button
onClick={() => handleDelete(source.key)} onClick={() => handleDelete(source.key)}
className={buttonStyles.roundedSecondary} disabled={isLoading(`deleteSource_${source.key}`)}
className={`${buttonStyles.roundedSecondary} ${isLoading(`deleteSource_${source.key}`) ? 'opacity-50 cursor-not-allowed' : ''}`}
> >
</button> </button>
@@ -2414,7 +2477,7 @@ const VideoSourceConfig = ({
message: confirmMessage, message: confirmMessage,
onConfirm: async () => { onConfirm: async () => {
try { try {
await callSourceApi({ action, keys }); await withLoading(`batchSource_${action}`, () => callSourceApi({ action, keys }));
showAlert({ type: 'success', title: `${actionName}成功`, message: `${actionName}${keys.length} 个视频源`, timer: 2000 }); showAlert({ type: 'success', title: `${actionName}成功`, message: `${actionName}${keys.length} 个视频源`, timer: 2000 });
// 重置选择状态 // 重置选择状态
setSelectedSources(new Set()); setSelectedSources(new Set());
@@ -2454,21 +2517,24 @@ const VideoSourceConfig = ({
</span> </span>
<button <button
onClick={() => handleBatchOperation('batch_enable')} onClick={() => handleBatchOperation('batch_enable')}
className={`px-3 py-1 text-sm ${buttonStyles.success}`} disabled={isLoading('batchSource_batch_enable')}
className={`px-3 py-1 text-sm ${isLoading('batchSource_batch_enable') ? buttonStyles.disabled : buttonStyles.success}`}
> >
{isLoading('batchSource_batch_enable') ? '启用中...' : '批量启用'}
</button> </button>
<button <button
onClick={() => handleBatchOperation('batch_disable')} onClick={() => handleBatchOperation('batch_disable')}
className={`px-3 py-1 text-sm ${buttonStyles.warning}`} disabled={isLoading('batchSource_batch_disable')}
className={`px-3 py-1 text-sm ${isLoading('batchSource_batch_disable') ? buttonStyles.disabled : buttonStyles.warning}`}
> >
{isLoading('batchSource_batch_disable') ? '禁用中...' : '批量禁用'}
</button> </button>
<button <button
onClick={() => handleBatchOperation('batch_delete')} onClick={() => handleBatchOperation('batch_delete')}
className={`px-3 py-1 text-sm ${buttonStyles.danger}`} disabled={isLoading('batchSource_batch_delete')}
className={`px-3 py-1 text-sm ${isLoading('batchSource_batch_delete') ? buttonStyles.disabled : buttonStyles.danger}`}
> >
{isLoading('batchSource_batch_delete') ? '删除中...' : '批量删除'}
</button> </button>
</div> </div>
<div className='w-px h-6 bg-gray-300 dark:bg-gray-600'></div> <div className='w-px h-6 bg-gray-300 dark:bg-gray-600'></div>
@@ -2543,10 +2609,10 @@ const VideoSourceConfig = ({
<div className='flex justify-end'> <div className='flex justify-end'>
<button <button
onClick={handleAddSource} onClick={handleAddSource}
disabled={!newSource.name || !newSource.key || !newSource.api} disabled={!newSource.name || !newSource.key || !newSource.api || isLoading('addSource')}
className={`w-full sm:w-auto px-4 py-2 ${!newSource.name || !newSource.key || !newSource.api ? buttonStyles.disabled : buttonStyles.success}`} className={`w-full sm:w-auto px-4 py-2 ${!newSource.name || !newSource.key || !newSource.api || isLoading('addSource') ? buttonStyles.disabled : buttonStyles.success}`}
> >
{isLoading('addSource') ? '添加中...' : '添加'}
</button> </button>
</div> </div>
</div> </div>
@@ -2617,9 +2683,10 @@ const VideoSourceConfig = ({
<div className='flex justify-end'> <div className='flex justify-end'>
<button <button
onClick={handleSaveOrder} onClick={handleSaveOrder}
className={`px-3 py-1.5 text-sm ${buttonStyles.primary}`} disabled={isLoading('saveSourceOrder')}
className={`px-3 py-1.5 text-sm ${isLoading('saveSourceOrder') ? buttonStyles.disabled : buttonStyles.primary}`}
> >
{isLoading('saveSourceOrder') ? '保存中...' : '保存排序'}
</button> </button>
</div> </div>
)} )}
@@ -2652,10 +2719,10 @@ const VideoSourceConfig = ({
</button> </button>
<button <button
onClick={handleValidateSources} onClick={handleValidateSources}
disabled={isValidating || !searchKeyword.trim()} disabled={!searchKeyword.trim()}
className={`px-4 py-2 ${isValidating || !searchKeyword.trim() ? buttonStyles.disabled : buttonStyles.primary}`} className={`px-4 py-2 ${!searchKeyword.trim() ? buttonStyles.disabled : buttonStyles.primary}`}
> >
{isValidating ? `检测中... (${validationResults.length}/${sources.length})` : '开始检测'}
</button> </button>
</div> </div>
</div> </div>
@@ -2710,9 +2777,10 @@ const VideoSourceConfig = ({
</button> </button>
<button <button
onClick={confirmModal.onConfirm} onClick={confirmModal.onConfirm}
className={`px-4 py-2 text-sm font-medium ${buttonStyles.primary}`} disabled={isLoading('batchSource_batch_enable') || isLoading('batchSource_batch_disable') || isLoading('batchSource_batch_delete')}
className={`px-4 py-2 text-sm font-medium ${isLoading('batchSource_batch_enable') || isLoading('batchSource_batch_disable') || isLoading('batchSource_batch_delete') ? buttonStyles.disabled : buttonStyles.primary}`}
> >
{isLoading('batchSource_batch_enable') || isLoading('batchSource_batch_disable') || isLoading('batchSource_batch_delete') ? '操作中...' : '确认'}
</button> </button>
</div> </div>
</div> </div>
@@ -2733,6 +2801,7 @@ const CategoryConfig = ({
refreshConfig: () => Promise<void>; refreshConfig: () => Promise<void>;
}) => { }) => {
const { alertModal, showAlert, hideAlert } = useAlertModal(); const { alertModal, showAlert, hideAlert } = useAlertModal();
const { isLoading, withLoading } = useLoadingState();
const [categories, setCategories] = useState<CustomCategory[]>([]); const [categories, setCategories] = useState<CustomCategory[]>([]);
const [showAddForm, setShowAddForm] = useState(false); const [showAddForm, setShowAddForm] = useState(false);
const [orderChanged, setOrderChanged] = useState(false); const [orderChanged, setOrderChanged] = useState(false);
@@ -2794,38 +2863,37 @@ const CategoryConfig = ({
const target = categories.find((c) => c.query === query && c.type === type); const target = categories.find((c) => c.query === query && c.type === type);
if (!target) return; if (!target) return;
const action = target.disabled ? 'enable' : 'disable'; const action = target.disabled ? 'enable' : 'disable';
callCategoryApi({ action, query, type }).catch(() => { withLoading(`toggleCategory_${query}_${type}`, () => callCategoryApi({ action, query, type })).catch(() => {
console.error('操作失败', action, query, type); console.error('操作失败', action, query, type);
}); });
}; };
const handleDelete = (query: string, type: 'movie' | 'tv') => { const handleDelete = (query: string, type: 'movie' | 'tv') => {
callCategoryApi({ action: 'delete', query, type }).catch(() => { withLoading(`deleteCategory_${query}_${type}`, () => callCategoryApi({ action: 'delete', query, type })).catch(() => {
console.error('操作失败', 'delete', query, type); console.error('操作失败', 'delete', query, type);
}); });
}; };
const handleAddCategory = () => { const handleAddCategory = () => {
if (!newCategory.name || !newCategory.query) return; if (!newCategory.name || !newCategory.query) return;
callCategoryApi({ withLoading('addCategory', async () => {
action: 'add', await callCategoryApi({
name: newCategory.name, action: 'add',
type: newCategory.type, name: newCategory.name,
query: newCategory.query, type: newCategory.type,
}) query: newCategory.query,
.then(() => {
setNewCategory({
name: '',
type: 'movie',
query: '',
disabled: false,
from: 'custom',
});
setShowAddForm(false);
})
.catch(() => {
console.error('操作失败', 'add', newCategory);
}); });
setNewCategory({
name: '',
type: 'movie',
query: '',
disabled: false,
from: 'custom',
});
setShowAddForm(false);
}).catch(() => {
console.error('操作失败', 'add', newCategory);
});
}; };
const handleDragEnd = (event: any) => { const handleDragEnd = (event: any) => {
@@ -2843,7 +2911,7 @@ const CategoryConfig = ({
const handleSaveOrder = () => { const handleSaveOrder = () => {
const order = categories.map((c) => `${c.query}:${c.type}`); const order = categories.map((c) => `${c.query}:${c.type}`);
callCategoryApi({ action: 'sort', order }) withLoading('saveCategoryOrder', () => callCategoryApi({ action: 'sort', order }))
.then(() => { .then(() => {
setOrderChanged(false); setOrderChanged(false);
}) })
@@ -2909,17 +2977,19 @@ const CategoryConfig = ({
onClick={() => onClick={() =>
handleToggleEnable(category.query, category.type) handleToggleEnable(category.query, category.type)
} }
disabled={isLoading(`toggleCategory_${category.query}_${category.type}`)}
className={`inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium ${!category.disabled className={`inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium ${!category.disabled
? buttonStyles.roundedDanger ? buttonStyles.roundedDanger
: buttonStyles.roundedSuccess : buttonStyles.roundedSuccess
} transition-colors`} } transition-colors ${isLoading(`toggleCategory_${category.query}_${category.type}`) ? 'opacity-50 cursor-not-allowed' : ''}`}
> >
{!category.disabled ? '禁用' : '启用'} {!category.disabled ? '禁用' : '启用'}
</button> </button>
{category.from !== 'config' && ( {category.from !== 'config' && (
<button <button
onClick={() => handleDelete(category.query, category.type)} onClick={() => handleDelete(category.query, category.type)}
className={buttonStyles.roundedSecondary} disabled={isLoading(`deleteCategory_${category.query}_${category.type}`)}
className={`${buttonStyles.roundedSecondary} ${isLoading(`deleteCategory_${category.query}_${category.type}`) ? 'opacity-50 cursor-not-allowed' : ''}`}
> >
</button> </button>
@@ -2990,10 +3060,10 @@ const CategoryConfig = ({
<div className='flex justify-end'> <div className='flex justify-end'>
<button <button
onClick={handleAddCategory} onClick={handleAddCategory}
disabled={!newCategory.name || !newCategory.query} disabled={!newCategory.name || !newCategory.query || isLoading('addCategory')}
className={`w-full sm:w-auto px-4 py-2 ${!newCategory.name || !newCategory.query ? buttonStyles.disabled : buttonStyles.success}`} className={`w-full sm:w-auto px-4 py-2 ${!newCategory.name || !newCategory.query || isLoading('addCategory') ? buttonStyles.disabled : buttonStyles.success}`}
> >
{isLoading('addCategory') ? '添加中...' : '添加'}
</button> </button>
</div> </div>
</div> </div>
@@ -3051,9 +3121,10 @@ const CategoryConfig = ({
<div className='flex justify-end'> <div className='flex justify-end'>
<button <button
onClick={handleSaveOrder} onClick={handleSaveOrder}
className={`px-3 py-1.5 text-sm ${buttonStyles.primary}`} disabled={isLoading('saveCategoryOrder')}
className={`px-3 py-1.5 text-sm ${isLoading('saveCategoryOrder') ? buttonStyles.disabled : buttonStyles.primary}`}
> >
{isLoading('saveCategoryOrder') ? '保存中...' : '保存排序'}
</button> </button>
</div> </div>
)} )}
@@ -3075,6 +3146,7 @@ const CategoryConfig = ({
// 新增配置文件组件 // 新增配置文件组件
const ConfigFileComponent = ({ config, refreshConfig }: { config: AdminConfig | null; refreshConfig: () => Promise<void> }) => { const ConfigFileComponent = ({ config, refreshConfig }: { config: AdminConfig | null; refreshConfig: () => Promise<void> }) => {
const { alertModal, showAlert, hideAlert } = useAlertModal(); const { alertModal, showAlert, hideAlert } = useAlertModal();
const { isLoading, withLoading } = useLoadingState();
const [configContent, setConfigContent] = useState(''); const [configContent, setConfigContent] = useState('');
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [subscriptionUrl, setSubscriptionUrl] = useState(''); const [subscriptionUrl, setSubscriptionUrl] = useState('');
@@ -3104,63 +3176,63 @@ const ConfigFileComponent = ({ config, refreshConfig }: { config: AdminConfig |
return; return;
} }
try { await withLoading('fetchConfig', async () => {
setFetching(true); try {
const resp = await fetch('/api/admin/config_subscription/fetch', { const resp = await fetch('/api/admin/config_subscription/fetch', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: subscriptionUrl }), body: JSON.stringify({ url: subscriptionUrl }),
}); });
if (!resp.ok) { if (!resp.ok) {
const data = await resp.json().catch(() => ({})); const data = await resp.json().catch(() => ({}));
throw new Error(data.error || `拉取失败: ${resp.status}`); throw new Error(data.error || `拉取失败: ${resp.status}`);
} }
const data = await resp.json(); const data = await resp.json();
if (data.configContent) { if (data.configContent) {
setConfigContent(data.configContent); setConfigContent(data.configContent);
// 更新本地配置的最后检查时间 // 更新本地配置的最后检查时间
const currentTime = new Date().toISOString(); const currentTime = new Date().toISOString();
setLastCheckTime(currentTime); setLastCheckTime(currentTime);
showSuccess('配置拉取成功', showAlert); showSuccess('配置拉取成功', showAlert);
} else { } else {
showError('拉取失败:未获取到配置内容', showAlert); showError('拉取失败:未获取到配置内容', showAlert);
}
} catch (err) {
showError(err instanceof Error ? err.message : '拉取失败', showAlert);
throw err;
} }
} catch (err) { });
showError(err instanceof Error ? err.message : '拉取失败', showAlert);
} finally {
setFetching(false);
}
}; };
// 保存配置文件 // 保存配置文件
const handleSave = async () => { const handleSave = async () => {
try { await withLoading('saveConfig', async () => {
setSaving(true); try {
const resp = await fetch('/api/admin/config_file', { const resp = await fetch('/api/admin/config_file', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
configFile: configContent, configFile: configContent,
subscriptionUrl, subscriptionUrl,
autoUpdate, autoUpdate,
lastCheckTime: lastCheckTime || new Date().toISOString() lastCheckTime: lastCheckTime || new Date().toISOString()
}), }),
}); });
if (!resp.ok) { if (!resp.ok) {
const data = await resp.json().catch(() => ({})); const data = await resp.json().catch(() => ({}));
throw new Error(data.error || `保存失败: ${resp.status}`); throw new Error(data.error || `保存失败: ${resp.status}`);
}
showSuccess('配置文件保存成功', showAlert);
await refreshConfig();
} catch (err) {
showError(err instanceof Error ? err.message : '保存失败', showAlert);
throw err;
} }
});
showSuccess('配置文件保存成功', showAlert);
await refreshConfig();
} catch (err) {
showError(err instanceof Error ? err.message : '保存失败', showAlert);
} finally {
setSaving(false);
}
}; };
@@ -3209,13 +3281,13 @@ const ConfigFileComponent = ({ config, refreshConfig }: { config: AdminConfig |
<div className='pt-2'> <div className='pt-2'>
<button <button
onClick={handleFetchConfig} onClick={handleFetchConfig}
disabled={fetching || !subscriptionUrl.trim()} disabled={isLoading('fetchConfig') || !subscriptionUrl.trim()}
className={`w-full px-6 py-3 rounded-lg font-medium transition-all duration-200 ${fetching || !subscriptionUrl.trim() className={`w-full px-6 py-3 rounded-lg font-medium transition-all duration-200 ${isLoading('fetchConfig') || !subscriptionUrl.trim()
? buttonStyles.disabled ? buttonStyles.disabled
: buttonStyles.success : buttonStyles.success
}`} }`}
> >
{fetching ? ( {isLoading('fetchConfig') ? (
<div className='flex items-center justify-center gap-2'> <div className='flex items-center justify-center gap-2'>
<div className='w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin'></div> <div className='w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin'></div>
@@ -3280,13 +3352,13 @@ const ConfigFileComponent = ({ config, refreshConfig }: { config: AdminConfig |
</div> </div>
<button <button
onClick={handleSave} onClick={handleSave}
disabled={saving} disabled={isLoading('saveConfig')}
className={`px-4 py-2 rounded-lg transition-colors ${saving className={`px-4 py-2 rounded-lg transition-colors ${isLoading('saveConfig')
? buttonStyles.disabled ? buttonStyles.disabled
: buttonStyles.success : buttonStyles.success
}`} }`}
> >
{saving ? '保存中…' : '保存'} {isLoading('saveConfig') ? '保存中…' : '保存'}
</button> </button>
</div> </div>
</div> </div>
@@ -3308,6 +3380,7 @@ const ConfigFileComponent = ({ config, refreshConfig }: { config: AdminConfig |
// 新增站点配置组件 // 新增站点配置组件
const SiteConfigComponent = ({ config, refreshConfig }: { config: AdminConfig | null; refreshConfig: () => Promise<void> }) => { const SiteConfigComponent = ({ config, refreshConfig }: { config: AdminConfig | null; refreshConfig: () => Promise<void> }) => {
const { alertModal, showAlert, hideAlert } = useAlertModal(); const { alertModal, showAlert, hideAlert } = useAlertModal();
const { isLoading, withLoading } = useLoadingState();
const [siteSettings, setSiteSettings] = useState<SiteConfig>({ const [siteSettings, setSiteSettings] = useState<SiteConfig>({
SiteName: '', SiteName: '',
Announcement: '', Announcement: '',
@@ -3447,26 +3520,26 @@ const SiteConfigComponent = ({ config, refreshConfig }: { config: AdminConfig |
// 保存站点配置 // 保存站点配置
const handleSave = async () => { const handleSave = async () => {
try { await withLoading('saveSiteConfig', async () => {
setSaving(true); try {
const resp = await fetch('/api/admin/site', { const resp = await fetch('/api/admin/site', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...siteSettings }), body: JSON.stringify({ ...siteSettings }),
}); });
if (!resp.ok) { if (!resp.ok) {
const data = await resp.json().catch(() => ({})); const data = await resp.json().catch(() => ({}));
throw new Error(data.error || `保存失败: ${resp.status}`); throw new Error(data.error || `保存失败: ${resp.status}`);
}
showSuccess('保存成功, 请刷新页面', showAlert);
await refreshConfig();
} catch (err) {
showError(err instanceof Error ? err.message : '保存失败', showAlert);
throw err;
} }
});
showSuccess('保存成功, 请刷新页面', showAlert);
await refreshConfig();
} catch (err) {
showError(err instanceof Error ? err.message : '保存失败', showAlert);
} finally {
setSaving(false);
}
}; };
if (!config) { if (!config) {
@@ -3847,13 +3920,13 @@ const SiteConfigComponent = ({ config, refreshConfig }: { config: AdminConfig |
<div className='flex justify-end'> <div className='flex justify-end'>
<button <button
onClick={handleSave} onClick={handleSave}
disabled={saving} disabled={isLoading('saveSiteConfig')}
className={`px-4 py-2 ${saving className={`px-4 py-2 ${isLoading('saveSiteConfig')
? buttonStyles.disabled ? buttonStyles.disabled
: buttonStyles.success : buttonStyles.success
} rounded-lg transition-colors`} } rounded-lg transition-colors`}
> >
{saving ? '保存中…' : '保存'} {isLoading('saveSiteConfig') ? '保存中…' : '保存'}
</button> </button>
</div> </div>
@@ -3880,6 +3953,7 @@ const LiveSourceConfig = ({
refreshConfig: () => Promise<void>; refreshConfig: () => Promise<void>;
}) => { }) => {
const { alertModal, showAlert, hideAlert } = useAlertModal(); const { alertModal, showAlert, hideAlert } = useAlertModal();
const { isLoading, withLoading } = useLoadingState();
const [liveSources, setLiveSources] = useState<LiveDataSource[]>([]); const [liveSources, setLiveSources] = useState<LiveDataSource[]>([]);
const [showAddForm, setShowAddForm] = useState(false); const [showAddForm, setShowAddForm] = useState(false);
const [orderChanged, setOrderChanged] = useState(false); const [orderChanged, setOrderChanged] = useState(false);
@@ -3944,13 +4018,13 @@ const LiveSourceConfig = ({
const target = liveSources.find((s) => s.key === key); const target = liveSources.find((s) => s.key === key);
if (!target) return; if (!target) return;
const action = target.disabled ? 'enable' : 'disable'; const action = target.disabled ? 'enable' : 'disable';
callLiveSourceApi({ action, key }).catch(() => { withLoading(`toggleLiveSource_${key}`, () => callLiveSourceApi({ action, key })).catch(() => {
console.error('操作失败', action, key); console.error('操作失败', action, key);
}); });
}; };
const handleDelete = (key: string) => { const handleDelete = (key: string) => {
callLiveSourceApi({ action: 'delete', key }).catch(() => { withLoading(`deleteLiveSource_${key}`, () => callLiveSourceApi({ action: 'delete', key })).catch(() => {
console.error('操作失败', 'delete', key); console.error('操作失败', 'delete', key);
}); });
}; };
@@ -3959,53 +4033,55 @@ const LiveSourceConfig = ({
const handleRefreshLiveSources = async () => { const handleRefreshLiveSources = async () => {
if (isRefreshing) return; if (isRefreshing) return;
setIsRefreshing(true); await withLoading('refreshLiveSources', async () => {
try { setIsRefreshing(true);
const response = await fetch('/api/admin/live/refresh', { try {
method: 'POST', const response = await fetch('/api/admin/live/refresh', {
headers: { 'Content-Type': 'application/json' }, method: 'POST',
}); headers: { 'Content-Type': 'application/json' },
});
if (!response.ok) { if (!response.ok) {
const data = await response.json().catch(() => ({})); const data = await response.json().catch(() => ({}));
throw new Error(data.error || `刷新失败: ${response.status}`); 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);
} }
});
// 刷新成功后重新获取配置
await refreshConfig();
showAlert({ type: 'success', title: '刷新成功', message: '直播源已刷新', timer: 2000 });
} catch (err) {
showError(err instanceof Error ? err.message : '刷新失败', showAlert);
} finally {
setIsRefreshing(false);
}
}; };
const handleAddLiveSource = () => { const handleAddLiveSource = () => {
if (!newLiveSource.name || !newLiveSource.key || !newLiveSource.url) return; if (!newLiveSource.name || !newLiveSource.key || !newLiveSource.url) return;
callLiveSourceApi({ withLoading('addLiveSource', async () => {
action: 'add', await callLiveSourceApi({
key: newLiveSource.key, action: 'add',
name: newLiveSource.name, key: newLiveSource.key,
url: newLiveSource.url, name: newLiveSource.name,
ua: newLiveSource.ua, url: newLiveSource.url,
epg: newLiveSource.epg, ua: newLiveSource.ua,
}) epg: newLiveSource.epg,
.then(() => {
setNewLiveSource({
name: '',
key: '',
url: '',
epg: '',
ua: '',
disabled: false,
from: 'custom',
});
setShowAddForm(false);
})
.catch(() => {
console.error('操作失败', 'add', newLiveSource);
}); });
setNewLiveSource({
name: '',
key: '',
url: '',
epg: '',
ua: '',
disabled: false,
from: 'custom',
});
setShowAddForm(false);
}).catch(() => {
console.error('操作失败', 'add', newLiveSource);
});
}; };
const handleDragEnd = (event: any) => { const handleDragEnd = (event: any) => {
@@ -4019,7 +4095,7 @@ const LiveSourceConfig = ({
const handleSaveOrder = () => { const handleSaveOrder = () => {
const order = liveSources.map((s) => s.key); const order = liveSources.map((s) => s.key);
callLiveSourceApi({ action: 'sort', order }) withLoading('saveLiveSourceOrder', () => callLiveSourceApi({ action: 'sort', order }))
.then(() => { .then(() => {
setOrderChanged(false); setOrderChanged(false);
}) })
@@ -4092,17 +4168,19 @@ const LiveSourceConfig = ({
<td className='px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2'> <td className='px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2'>
<button <button
onClick={() => handleToggleEnable(liveSource.key)} onClick={() => handleToggleEnable(liveSource.key)}
disabled={isLoading(`toggleLiveSource_${liveSource.key}`)}
className={`inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium ${!liveSource.disabled className={`inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium ${!liveSource.disabled
? buttonStyles.roundedDanger ? buttonStyles.roundedDanger
: buttonStyles.roundedSuccess : buttonStyles.roundedSuccess
} transition-colors`} } transition-colors ${isLoading(`toggleLiveSource_${liveSource.key}`) ? 'opacity-50 cursor-not-allowed' : ''}`}
> >
{!liveSource.disabled ? '禁用' : '启用'} {!liveSource.disabled ? '禁用' : '启用'}
</button> </button>
{liveSource.from !== 'config' && ( {liveSource.from !== 'config' && (
<button <button
onClick={() => handleDelete(liveSource.key)} onClick={() => handleDelete(liveSource.key)}
className={buttonStyles.roundedSecondary} disabled={isLoading(`deleteLiveSource_${liveSource.key}`)}
className={`${buttonStyles.roundedSecondary} ${isLoading(`deleteLiveSource_${liveSource.key}`) ? 'opacity-50 cursor-not-allowed' : ''}`}
> >
</button> </button>
@@ -4130,13 +4208,13 @@ const LiveSourceConfig = ({
<div className='flex items-center space-x-2'> <div className='flex items-center space-x-2'>
<button <button
onClick={handleRefreshLiveSources} onClick={handleRefreshLiveSources}
disabled={isRefreshing} disabled={isRefreshing || isLoading('refreshLiveSources')}
className={`px-3 py-1.5 text-sm font-medium flex items-center space-x-2 ${isRefreshing className={`px-3 py-1.5 text-sm font-medium flex items-center space-x-2 ${isRefreshing || isLoading('refreshLiveSources')
? 'bg-gray-400 dark:bg-gray-600 cursor-not-allowed text-white rounded-lg' ? 'bg-gray-400 dark:bg-gray-600 cursor-not-allowed text-white rounded-lg'
: 'bg-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-700 text-white rounded-lg transition-colors' : 'bg-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-700 text-white rounded-lg transition-colors'
}`} }`}
> >
<span>{isRefreshing ? '刷新中...' : '刷新直播源'}</span> <span>{isRefreshing || isLoading('refreshLiveSources') ? '刷新中...' : '刷新直播源'}</span>
</button> </button>
<button <button
onClick={() => setShowAddForm(!showAddForm)} onClick={() => setShowAddForm(!showAddForm)}
@@ -4200,10 +4278,10 @@ const LiveSourceConfig = ({
<div className='flex justify-end'> <div className='flex justify-end'>
<button <button
onClick={handleAddLiveSource} onClick={handleAddLiveSource}
disabled={!newLiveSource.name || !newLiveSource.key || !newLiveSource.url} disabled={!newLiveSource.name || !newLiveSource.key || !newLiveSource.url || isLoading('addLiveSource')}
className={`w-full sm:w-auto px-4 py-2 ${!newLiveSource.name || !newLiveSource.key || !newLiveSource.url ? buttonStyles.disabled : buttonStyles.success}`} className={`w-full sm:w-auto px-4 py-2 ${!newLiveSource.name || !newLiveSource.key || !newLiveSource.url || isLoading('addLiveSource') ? buttonStyles.disabled : buttonStyles.success}`}
> >
{isLoading('addLiveSource') ? '添加中...' : '添加'}
</button> </button>
</div> </div>
</div> </div>
@@ -4267,9 +4345,10 @@ const LiveSourceConfig = ({
<div className='flex justify-end'> <div className='flex justify-end'>
<button <button
onClick={handleSaveOrder} onClick={handleSaveOrder}
className={`px-3 py-1.5 text-sm ${buttonStyles.primary}`} disabled={isLoading('saveLiveSourceOrder')}
className={`px-3 py-1.5 text-sm ${isLoading('saveLiveSourceOrder') ? buttonStyles.disabled : buttonStyles.primary}`}
> >
{isLoading('saveLiveSourceOrder') ? '保存中...' : '保存排序'}
</button> </button>
</div> </div>
)} )}
@@ -4292,6 +4371,7 @@ const LiveSourceConfig = ({
function AdminPageClient() { function AdminPageClient() {
const { alertModal, showAlert, hideAlert } = useAlertModal(); const { alertModal, showAlert, hideAlert } = useAlertModal();
const { isLoading, withLoading } = useLoadingState();
const [config, setConfig] = useState<AdminConfig | null>(null); const [config, setConfig] = useState<AdminConfig | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -4355,17 +4435,20 @@ function AdminPageClient() {
}; };
const handleConfirmResetConfig = async () => { const handleConfirmResetConfig = async () => {
try { await withLoading('resetConfig', async () => {
const response = await fetch(`/api/admin/reset`); try {
if (!response.ok) { const response = await fetch(`/api/admin/reset`);
throw new Error(`重置失败: ${response.status}`); if (!response.ok) {
throw new Error(`重置失败: ${response.status}`);
}
showSuccess('重置成功,请刷新页面!', showAlert);
await fetchConfig();
setShowResetConfigModal(false);
} catch (err) {
showError(err instanceof Error ? err.message : '重置失败', showAlert);
throw err;
} }
showSuccess('重置成功,请刷新页面!', showAlert); });
await fetchConfig();
setShowResetConfigModal(false);
} catch (err) {
showError(err instanceof Error ? err.message : '重置失败', showAlert);
}
}; };
if (loading) { if (loading) {
@@ -4578,9 +4661,10 @@ function AdminPageClient() {
</button> </button>
<button <button
onClick={handleConfirmResetConfig} onClick={handleConfirmResetConfig}
className={`px-6 py-2.5 text-sm font-medium ${buttonStyles.danger}`} disabled={isLoading('resetConfig')}
className={`px-6 py-2.5 text-sm font-medium ${isLoading('resetConfig') ? buttonStyles.disabled : buttonStyles.danger}`}
> >
{isLoading('resetConfig') ? '重置中...' : '确认重置'}
</button> </button>
</div> </div>
</div> </div>