style: refresh admin source forms

This commit is contained in:
leowang 2026-06-23 22:25:22 +08:00
parent 7dc3db8baa
commit 898a9874d0
1 changed files with 420 additions and 408 deletions

View File

@ -21,10 +21,10 @@ import {
verticalListSortingStrategy, verticalListSortingStrategy,
} from '@dnd-kit/sortable'; } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities'; import { CSS } from '@dnd-kit/utilities';
import { Alert, Avatar, Button, Card, Checkbox, Chip, Input, Label, Skeleton, Switch, Table, TextArea, TextField } from '@heroui/react';
import { import {
AlertCircle, AlertCircle,
AlertTriangle, AlertTriangle,
Check,
CheckCircle, CheckCircle,
ChevronDown, ChevronDown,
ChevronUp, ChevronUp,
@ -37,61 +37,19 @@ import {
User, User,
Users, Users,
Video, Video,
X,
} from 'lucide-react'; } from 'lucide-react';
import { GripVertical, Palette } from 'lucide-react'; import { GripVertical, Palette } from 'lucide-react';
import { Alert, Avatar, Button, Card, Checkbox, Chip, Input, Label, Skeleton, Switch, Table, TextArea, TextField } from '@heroui/react';
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { AdminConfig, AdminConfigResult } from '@/lib/admin.types'; import { AdminConfig, AdminConfigResult } from '@/lib/admin.types';
import { getAuthInfoFromBrowserCookie } from '@/lib/auth'; import { getAuthInfoFromBrowserCookie } from '@/lib/auth';
import DataMigration from '@/components/DataMigration'; import DataMigration from '@/components/DataMigration';
import ThemeManager from '@/components/ThemeManager';
import PageLayout from '@/components/PageLayout'; import PageLayout from '@/components/PageLayout';
import ThemeManager from '@/components/ThemeManager';
import { AppDialog, AppFilterSelect } from '@/components/ui/HeroPrimitives'; import { AppDialog, AppFilterSelect } from '@/components/ui/HeroPrimitives';
// 统一按钮样式系统
const buttonStyles = {
// 主要操作按钮(蓝色)- 用于配置、设置、确认等
primary: 'px-3 py-1.5 text-sm font-medium bg-accent hover:bg-accent-strong text-accent-foreground rounded-xl transition-colors',
// 成功操作按钮(绿色)- 用于添加、启用、保存等
success: 'px-3 py-1.5 text-sm font-medium bg-accent hover:bg-accent-strong text-accent-foreground rounded-xl transition-colors',
// 危险操作按钮(红色)- 用于删除、禁用、重置等
danger: 'px-3 py-1.5 text-sm font-medium bg-danger hover:bg-danger/90 text-white rounded-xl transition-colors',
// 次要操作按钮(灰色)- 用于取消、关闭等
secondary: 'px-3 py-1.5 text-sm font-medium bg-surface-secondary hover:bg-surface-tertiary text-foreground border border-border rounded-xl transition-colors',
// 警告操作按钮(黄色)- 用于批量禁用等
warning: 'px-3 py-1.5 text-sm font-medium bg-yellow-600 hover:bg-yellow-700 dark:bg-yellow-600 dark:hover:bg-yellow-700 text-white rounded-lg transition-colors',
// 小尺寸主要按钮
primarySmall: 'px-2 py-1 text-xs font-medium bg-accent hover:bg-accent-strong text-accent-foreground rounded-lg transition-colors',
// 小尺寸成功按钮
successSmall: 'px-2 py-1 text-xs font-medium bg-accent hover:bg-accent-strong text-accent-foreground rounded-lg transition-colors',
// 小尺寸危险按钮
dangerSmall: 'px-2 py-1 text-xs font-medium bg-red-600 hover:bg-red-700 dark:bg-red-600 dark:hover:bg-red-700 text-white rounded-md transition-colors',
// 小尺寸次要按钮
secondarySmall: 'px-2 py-1 text-xs font-medium bg-surface-secondary hover:bg-surface-tertiary text-foreground border border-border rounded-lg transition-colors',
// 小尺寸警告按钮
warningSmall: 'px-2 py-1 text-xs font-medium bg-yellow-600 hover:bg-yellow-700 dark:bg-yellow-600 dark:hover:bg-yellow-700 text-white rounded-md transition-colors',
// 圆角小按钮(用于表格操作)
roundedPrimary: 'inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium bg-accent/10 text-accent hover:bg-accent/15 transition-colors',
roundedSuccess: 'inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium bg-accent/10 text-accent hover:bg-accent/15 transition-colors',
roundedDanger: 'inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium bg-red-100 text-red-800 hover:bg-red-200 dark:bg-red-900/40 dark:hover:bg-red-900/60 dark:text-red-200 transition-colors',
roundedSecondary: 'inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium bg-surface-secondary text-foreground hover:bg-surface-tertiary transition-colors',
roundedWarning: 'inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 hover:bg-yellow-200 dark:bg-yellow-900/40 dark:hover:bg-yellow-900/60 dark:text-yellow-200 transition-colors',
roundedPurple: 'inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium bg-accent/10 text-accent hover:bg-accent/15 transition-colors',
// 禁用状态
disabled: 'px-3 py-1.5 text-sm font-medium bg-gray-400 dark:bg-gray-600 cursor-not-allowed text-white rounded-lg transition-colors',
disabledSmall: 'px-2 py-1 text-xs font-medium bg-gray-400 dark:bg-gray-600 cursor-not-allowed text-white rounded-md transition-colors',
// 开关按钮样式
toggleOn: 'bg-accent',
toggleOff: 'bg-surface-tertiary',
toggleThumb: 'bg-surface',
toggleThumbOn: 'translate-x-6',
toggleThumbOff: 'translate-x-1',
// 快速操作按钮样式
quickAction: 'px-3 py-1.5 text-xs font-medium text-muted bg-surface border border-border hover:bg-surface-secondary rounded-lg transition-colors',
};
const AdminTable = ({ const AdminTable = ({
ariaLabel, ariaLabel,
minWidth = 'min-w-[900px]', minWidth = 'min-w-[900px]',
@ -545,6 +503,83 @@ interface LiveDataSource {
from: 'config' | 'custom'; 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 { interface CustomCategory {
name?: string; name?: string;
@ -2772,7 +2807,7 @@ const VideoSourceConfig = ({
<div className='space-y-6'> <div className='space-y-6'>
{/* 添加视频源表单 */} {/* 添加视频源表单 */}
<div className='flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4'> <div className='flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4'>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'> <h4 className='text-sm font-medium text-foreground'>
</h4> </h4>
<div className='flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-2'> <div className='flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-2'>
@ -2780,7 +2815,7 @@ const VideoSourceConfig = ({
{selectedSources.size > 0 && ( {selectedSources.size > 0 && (
<> <>
<div className='flex flex-wrap items-center gap-3 order-2 sm:order-1'> <div className='flex flex-wrap items-center gap-3 order-2 sm:order-1'>
<span className='text-sm text-gray-600 dark:text-gray-400'> <span className='text-sm text-muted'>
<span className='sm:hidden'> {selectedSources.size}</span> <span className='sm:hidden'> {selectedSources.size}</span>
<span className='hidden sm:inline'> {selectedSources.size} </span> <span className='hidden sm:inline'> {selectedSources.size} </span>
</span> </span>
@ -2809,7 +2844,7 @@ const VideoSourceConfig = ({
{isLoading('batchSource_batch_delete') ? '删除中...' : '批量删除'} {isLoading('batchSource_batch_delete') ? '删除中...' : '批量删除'}
</Button> </Button>
</div> </div>
<div className='hidden sm:block w-px h-6 bg-gray-300 dark:bg-gray-600 order-2'></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'> <div className='flex items-center gap-2 order-1 sm:order-2'>
@ -2845,240 +2880,189 @@ const VideoSourceConfig = ({
</div> </div>
{showAddForm && ( {showAddForm && (
<div className='p-4 bg-gray-50 dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700 space-y-4'> <Card variant='secondary'>
<div className='grid grid-cols-1 sm:grid-cols-2 gap-4'> <Card.Header className='flex flex-col gap-1 sm:flex-row sm:items-start sm:justify-between'>
<input <div>
type='text' <Card.Title></Card.Title>
placeholder='名称' <Card.Description>
value={newSource.name} Key API
onChange={(e) => </Card.Description>
setNewSource((prev) => ({ ...prev, name: e.target.value })) </div>
} </Card.Header>
className='px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100' <Card.Content className='space-y-4'>
/> <div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
<input <TextField fullWidth>
type='text' <Label></Label>
placeholder='Key' <Input
value={newSource.key} type='text'
onChange={(e) => placeholder='例如:量子资源'
setNewSource((prev) => ({ ...prev, key: e.target.value })) value={newSource.name}
} onChange={(e) =>
className='px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100' setNewSource((prev) => ({ ...prev, name: e.target.value }))
/> }
<input variant='secondary'
type='text' />
placeholder='API 地址' </TextField>
value={newSource.api} <TextField fullWidth>
onChange={(e) => <Label>Key</Label>
setNewSource((prev) => ({ ...prev, api: e.target.value })) <Input
} type='text'
className='px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100' placeholder='例如lzizy'
/> value={newSource.key}
<input onChange={(e) =>
type='text' setNewSource((prev) => ({ ...prev, key: e.target.value }))
placeholder='Detail 地址(选填)' }
value={newSource.detail} variant='secondary'
onChange={(e) => />
setNewSource((prev) => ({ ...prev, detail: e.target.value })) </TextField>
} <TextField fullWidth>
className='px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100' <Label>API </Label>
/> <Input
</div> type='text'
placeholder='https://example.com/api.php/provide/vod'
{/* 新增视频源有效性检测结果显示 */} value={newSource.api}
{newSourceValidationResult.status && ( onChange={(e) =>
<div className='p-3 rounded-lg border'> setNewSource((prev) => ({ ...prev, api: e.target.value }))
<div className='space-y-2'> }
<div className='flex items-center space-x-2'> variant='secondary'
<span className='text-sm font-medium text-gray-700 dark:text-gray-300'>:</span> />
<span </TextField>
className={`px-2 py-1 text-xs rounded-full ${newSourceValidationResult.status === 'valid' <TextField fullWidth>
? 'bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300' <Label>Detail </Label>
: newSourceValidationResult.status === 'validating' <Input
? 'bg-accent/10 text-accent' type='text'
: newSourceValidationResult.status === 'no_results' placeholder='选填,用于详情页跳转'
? 'bg-yellow-100 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-300' value={newSource.detail}
: 'bg-red-100 dark:bg-red-900/20 text-red-800 dark:text-red-300' onChange={(e) =>
}`} setNewSource((prev) => ({ ...prev, detail: e.target.value }))
> }
{newSourceValidationResult.status === 'valid' && '✓ '} variant='secondary'
{newSourceValidationResult.status === 'validating' && '⏳ '} />
{newSourceValidationResult.status === 'no_results' && '⚠️ '} </TextField>
{newSourceValidationResult.status === 'invalid' && '✗ '}
{newSourceValidationResult.message}
</span>
</div>
{newSourceValidationResult.details && (
<div className='text-xs text-gray-600 dark:text-gray-400 space-y-1'>
{newSourceValidationResult.details.searchKeyword && (
<div>: {newSourceValidationResult.details.searchKeyword}</div>
)}
{newSourceValidationResult.details.responseTime && (
<div>: {newSourceValidationResult.details.responseTime}ms</div>
)}
{newSourceValidationResult.details.resultCount !== undefined && (
<div>: {newSourceValidationResult.details.resultCount}</div>
)}
{newSourceValidationResult.details.error && (
<div className='text-red-600 dark:text-red-400'>: {newSourceValidationResult.details.error}</div>
)}
</div>
)}
</div>
</div> </div>
)}
<div className='flex justify-end space-x-2'> <SourceValidationSummary result={newSourceValidationResult} />
<button </Card.Content>
onClick={handleValidateNewSource} <Card.Footer className='flex flex-col-reverse gap-2 sm:flex-row sm:justify-end'>
disabled={!newSource.api || isNewSourceValidating || isLoading('validateNewSource')} <Button
className={`px-4 py-2 ${!newSource.api || isNewSourceValidating || isLoading('validateNewSource') ? buttonStyles.disabled : buttonStyles.primary}`} className='w-full sm:w-auto'
variant='secondary'
onPress={handleValidateNewSource}
isDisabled={!newSource.api || isNewSourceValidating || isLoading('validateNewSource')}
isPending={isNewSourceValidating || isLoading('validateNewSource')}
> >
{isNewSourceValidating || isLoading('validateNewSource') ? '检测中...' : '有效性检测'} {isNewSourceValidating || isLoading('validateNewSource') ? '检测中...' : '有效性检测'}
</button> </Button>
<button <Button
onClick={handleAddSource} className='w-full sm:w-auto'
disabled={!newSource.name || !newSource.key || !newSource.api || isLoading('addSource')} variant='primary'
className={`px-4 py-2 ${!newSource.name || !newSource.key || !newSource.api || isLoading('addSource') ? buttonStyles.disabled : buttonStyles.success}`} onPress={handleAddSource}
isDisabled={!newSource.name || !newSource.key || !newSource.api || isLoading('addSource')}
isPending={isLoading('addSource')}
> >
{isLoading('addSource') ? '添加中...' : '添加'} {isLoading('addSource') ? '添加中...' : '添加'}
</button> </Button>
</div> </Card.Footer>
</div> </Card>
)} )}
{/* 编辑视频源表单 */} {/* 编辑视频源表单 */}
{editingSource && ( {editingSource && (
<div className='p-4 bg-gray-50 dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700 space-y-4'> <Card variant='secondary'>
<div className='flex items-center justify-between'> <Card.Header className='flex flex-row items-start justify-between gap-4'>
<h5 className='text-sm font-medium text-gray-700 dark:text-gray-300'> <div>
: {editingSource.name} <Card.Title></Card.Title>
</h5> <Card.Description>
<button {editingSource.name} · {editingSource.key}
onClick={handleCancelEdit} </Card.Description>
className='text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200' </div>
<Button
isIconOnly
size='sm'
variant='tertiary'
aria-label='关闭编辑视频源'
onPress={handleCancelEdit}
> >
<X className='size-4' />
</button> </Button>
</div> </Card.Header>
<div className='grid grid-cols-1 sm:grid-cols-2 gap-4'> <Card.Content className='space-y-4'>
<div> <div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
<label className='block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1'> <TextField fullWidth>
<Label></Label>
</label> <Input
<input type='text'
type='text' value={editingSource.name}
value={editingSource.name} onChange={(e) =>
onChange={(e) => setEditingSource((prev) => prev ? ({ ...prev, name: e.target.value }) : null)
setEditingSource((prev) => prev ? ({ ...prev, name: e.target.value }) : null) }
} variant='secondary'
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100' />
/> </TextField>
</div> <TextField fullWidth>
<div> <Label>Key</Label>
<label className='block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1'> <Input
Key () type='text'
</label> value={editingSource.key}
<input disabled
type='text' variant='secondary'
value={editingSource.key} />
disabled <p className='text-xs text-muted'>Key </p>
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 cursor-not-allowed' </TextField>
/> <TextField fullWidth>
</div> <Label>API </Label>
<div> <Input
<label className='block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1'> type='text'
API value={editingSource.api}
</label> onChange={(e) =>
<input setEditingSource((prev) => prev ? ({ ...prev, api: e.target.value }) : null)
type='text' }
value={editingSource.api} variant='secondary'
onChange={(e) => />
setEditingSource((prev) => prev ? ({ ...prev, api: e.target.value }) : null) </TextField>
} <TextField fullWidth>
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100' <Label>Detail </Label>
/> <Input
</div> type='text'
<div> value={editingSource.detail || ''}
<label className='block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1'> onChange={(e) =>
Detail setEditingSource((prev) => prev ? ({ ...prev, detail: e.target.value }) : null)
</label> }
<input variant='secondary'
type='text' />
value={editingSource.detail || ''} </TextField>
onChange={(e) =>
setEditingSource((prev) => prev ? ({ ...prev, detail: e.target.value }) : null)
}
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100'
/>
</div> </div>
{/* 有效性检测结果显示 */} <SourceValidationSummary result={singleValidationResult} />
{singleValidationResult.status && ( </Card.Content>
<div className='col-span-full mt-4 p-3 rounded-lg border'> <Card.Footer className='flex flex-col-reverse gap-2 sm:flex-row sm:justify-end'>
<div className='space-y-2'> <Button
<div className='flex items-center space-x-2'> className='w-full sm:w-auto'
<span className='text-sm font-medium text-gray-700 dark:text-gray-300'>:</span> variant='secondary'
<span onPress={handleCancelEdit}
className={`px-2 py-1 text-xs rounded-full ${singleValidationResult.status === 'valid'
? 'bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300'
: singleValidationResult.status === 'validating'
? 'bg-accent/10 text-accent'
: singleValidationResult.status === 'no_results'
? 'bg-yellow-100 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-300'
: 'bg-red-100 dark:bg-red-900/20 text-red-800 dark:text-red-300'
}`}
>
{singleValidationResult.status === 'valid' && '✓ '}
{singleValidationResult.status === 'validating' && '⏳ '}
{singleValidationResult.status === 'no_results' && '⚠️ '}
{singleValidationResult.status === 'invalid' && '✗ '}
{singleValidationResult.message}
</span>
</div>
{singleValidationResult.details && (
<div className='text-xs text-gray-600 dark:text-gray-400 space-y-1'>
{singleValidationResult.details.searchKeyword && (
<div>: {singleValidationResult.details.searchKeyword}</div>
)}
{singleValidationResult.details.responseTime && (
<div>: {singleValidationResult.details.responseTime}ms</div>
)}
{singleValidationResult.details.resultCount !== undefined && (
<div>: {singleValidationResult.details.resultCount}</div>
)}
{singleValidationResult.details.error && (
<div className='text-red-600 dark:text-red-400'>: {singleValidationResult.details.error}</div>
)}
</div>
)}
</div>
</div>
)}
</div>
<div className='flex justify-end space-x-2'>
<button
onClick={handleCancelEdit}
className={buttonStyles.secondary}
> >
</button> </Button>
<button <Button
onClick={handleValidateSingleSource} className='w-full sm:w-auto'
disabled={!editingSource.api || isSingleValidating || isLoading('validateSingleSource')} variant='secondary'
className={`${!editingSource.api || isSingleValidating || isLoading('validateSingleSource') ? buttonStyles.disabled : buttonStyles.primary}`} onPress={handleValidateSingleSource}
isDisabled={!editingSource.api || isSingleValidating || isLoading('validateSingleSource')}
isPending={isSingleValidating || isLoading('validateSingleSource')}
> >
{isSingleValidating || isLoading('validateSingleSource') ? '检测中...' : '有效性检测'} {isSingleValidating || isLoading('validateSingleSource') ? '检测中...' : '有效性检测'}
</button> </Button>
<button <Button
onClick={handleEditSource} className='w-full sm:w-auto'
disabled={!editingSource.name || !editingSource.api || isLoading('editSource')} variant='primary'
className={`${!editingSource.name || !editingSource.api || isLoading('editSource') ? buttonStyles.disabled : buttonStyles.success}`} onPress={handleEditSource}
isDisabled={!editingSource.name || !editingSource.api || isLoading('editSource')}
isPending={isLoading('editSource')}
> >
{isLoading('editSource') ? '保存中...' : '保存'} {isLoading('editSource') ? '保存中...' : '保存'}
</button> </Button>
</div> </Card.Footer>
</div> </Card>
)} )}
@ -4380,7 +4364,7 @@ const LiveSourceConfig = ({
<div className='space-y-6'> <div className='space-y-6'>
{/* 添加直播源表单 */} {/* 添加直播源表单 */}
<div className='flex items-center justify-between'> <div className='flex items-center justify-between'>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'> <h4 className='text-sm font-medium text-foreground'>
</h4> </h4>
<div className='flex items-center space-x-2'> <div className='flex items-center space-x-2'>
@ -4403,162 +4387,190 @@ const LiveSourceConfig = ({
</div> </div>
{showAddForm && ( {showAddForm && (
<div className='p-4 bg-gray-50 dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700 space-y-4'> <Card variant='secondary'>
<div className='grid grid-cols-1 sm:grid-cols-2 gap-4'> <Card.Header>
<input <div>
type='text' <Card.Title></Card.Title>
placeholder='名称' <Card.Description>
value={newLiveSource.name} M3U UA
onChange={(e) => </Card.Description>
setNewLiveSource((prev) => ({ ...prev, name: e.target.value })) </div>
} </Card.Header>
className='px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100' <Card.Content className='space-y-4'>
/> <div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
<input <TextField fullWidth>
type='text' <Label></Label>
placeholder='Key' <Input
value={newLiveSource.key} type='text'
onChange={(e) => placeholder='例如:央视频道'
setNewLiveSource((prev) => ({ ...prev, key: e.target.value })) value={newLiveSource.name}
} onChange={(e) =>
className='px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100' setNewLiveSource((prev) => ({ ...prev, name: e.target.value }))
/> }
<input variant='secondary'
type='text' />
placeholder='M3U 地址' </TextField>
value={newLiveSource.url} <TextField fullWidth>
onChange={(e) => <Label>Key</Label>
setNewLiveSource((prev) => ({ ...prev, url: e.target.value })) <Input
} type='text'
className='px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100' placeholder='例如cctv'
/> value={newLiveSource.key}
<input onChange={(e) =>
type='text' setNewLiveSource((prev) => ({ ...prev, key: e.target.value }))
placeholder='节目单地址(选填)' }
value={newLiveSource.epg} variant='secondary'
onChange={(e) => />
setNewLiveSource((prev) => ({ ...prev, epg: e.target.value })) </TextField>
} <TextField fullWidth>
className='px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100' <Label>M3U </Label>
/> <Input
<input type='text'
type='text' placeholder='https://example.com/live.m3u'
placeholder='自定义 UA选填' value={newLiveSource.url}
value={newLiveSource.ua} onChange={(e) =>
onChange={(e) => setNewLiveSource((prev) => ({ ...prev, url: e.target.value }))
setNewLiveSource((prev) => ({ ...prev, ua: e.target.value })) }
} variant='secondary'
className='px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100' />
/> </TextField>
<TextField fullWidth>
</div> <Label></Label>
<div className='flex justify-end'> <Input
<button type='text'
onClick={handleAddLiveSource} placeholder='选填XMLTV EPG 地址'
disabled={!newLiveSource.name || !newLiveSource.key || !newLiveSource.url || isLoading('addLiveSource')} value={newLiveSource.epg}
className={`w-full sm:w-auto px-4 py-2 ${!newLiveSource.name || !newLiveSource.key || !newLiveSource.url || isLoading('addLiveSource') ? buttonStyles.disabled : buttonStyles.success}`} 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') ? '添加中...' : '添加'} {isLoading('addLiveSource') ? '添加中...' : '添加'}
</button> </Button>
</div> </Card.Footer>
</div> </Card>
)} )}
{/* 编辑直播源表单 */} {/* 编辑直播源表单 */}
{editingLiveSource && ( {editingLiveSource && (
<div className='p-4 bg-gray-50 dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700 space-y-4'> <Card variant='secondary'>
<div className='flex items-center justify-between'> <Card.Header className='flex flex-row items-start justify-between gap-4'>
<h5 className='text-sm font-medium text-gray-700 dark:text-gray-300'> <div>
: {editingLiveSource.name} <Card.Title></Card.Title>
</h5> <Card.Description>
<button {editingLiveSource.name} · {editingLiveSource.key}
onClick={handleCancelEdit} </Card.Description>
className='text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200' </div>
<Button
isIconOnly
size='sm'
variant='tertiary'
aria-label='关闭编辑直播源'
onPress={handleCancelEdit}
> >
<X className='size-4' />
</button> </Button>
</div> </Card.Header>
<div className='grid grid-cols-1 sm:grid-cols-2 gap-4'> <Card.Content className='space-y-4'>
<div> <div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
<label className='block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1'> <TextField fullWidth>
<Label></Label>
</label> <Input
<input type='text'
type='text' value={editingLiveSource.name}
value={editingLiveSource.name} onChange={(e) =>
onChange={(e) => setEditingLiveSource((prev) => prev ? ({ ...prev, name: e.target.value }) : null)
setEditingLiveSource((prev) => prev ? ({ ...prev, name: e.target.value }) : null) }
} variant='secondary'
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100' />
/> </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> </div>
<div> </Card.Content>
<label className='block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1'> <Card.Footer className='flex flex-col-reverse gap-2 sm:flex-row sm:justify-end'>
Key () <Button
</label> className='w-full sm:w-auto'
<input variant='secondary'
type='text' onPress={handleCancelEdit}
value={editingLiveSource.key}
disabled
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 cursor-not-allowed'
/>
</div>
<div>
<label className='block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1'>
M3U
</label>
<input
type='text'
value={editingLiveSource.url}
onChange={(e) =>
setEditingLiveSource((prev) => prev ? ({ ...prev, url: e.target.value }) : null)
}
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100'
/>
</div>
<div>
<label className='block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1'>
</label>
<input
type='text'
value={editingLiveSource.epg}
onChange={(e) =>
setEditingLiveSource((prev) => prev ? ({ ...prev, epg: e.target.value }) : null)
}
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100'
/>
</div>
<div>
<label className='block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1'>
UA
</label>
<input
type='text'
value={editingLiveSource.ua}
onChange={(e) =>
setEditingLiveSource((prev) => prev ? ({ ...prev, ua: e.target.value }) : null)
}
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100'
/>
</div>
</div>
<div className='flex justify-end space-x-2'>
<button
onClick={handleCancelEdit}
className={buttonStyles.secondary}
> >
</button> </Button>
<button <Button
onClick={handleEditLiveSource} className='w-full sm:w-auto'
disabled={!editingLiveSource.name || !editingLiveSource.url || isLoading('editLiveSource')} variant='primary'
className={`${!editingLiveSource.name || !editingLiveSource.url || isLoading('editLiveSource') ? buttonStyles.disabled : buttonStyles.success}`} onPress={handleEditLiveSource}
isDisabled={!editingLiveSource.name || !editingLiveSource.url || isLoading('editLiveSource')}
isPending={isLoading('editLiveSource')}
> >
{isLoading('editLiveSource') ? '保存中...' : '保存'} {isLoading('editLiveSource') ? '保存中...' : '保存'}
</button> </Button>
</div> </Card.Footer>
</div> </Card>
)} )}
{/* 直播源表格 */} {/* 直播源表格 */}