Files
web-fusion/src/components/HostListDialog.tsx
2026-01-22 15:18:09 +08:00

475 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useCallback, useEffect } from 'react'
import { createPortal } from 'react-dom'
import { isElectron } from '../utils/electronBridge'
interface Host {
anchorId: string
country: string
invitationType: number // 1=普票, 2=金票
state: number
onlineFans?: number
hostsLevel?: string
}
interface HostListDialogProps {
visible: boolean
onClose: () => void
onSave: (hosts: Host[]) => void
}
// 等级数据定义
const LEVEL_OPTIONS = [
{
label: 'A', value: 'A',
children: [
{ label: 'A1', value: 'A1' },
{ label: 'A2', value: 'A2' },
{ label: 'A3', value: 'A3' },
]
},
{
label: 'B', value: 'B',
children: [
{ label: 'B1', value: 'B1' },
{ label: 'B2', value: 'B2' },
{ label: 'B3', value: 'B3' },
{ label: 'B4', value: 'B4' },
{ label: 'B5', value: 'B5' },
]
},
{
label: 'C', value: 'C',
children: [
{ label: 'C1', value: 'C1' },
{ label: 'C2', value: 'C2' },
{ label: 'C3', value: 'C3' },
{ label: 'C4', value: 'C4' },
{ label: 'C5', value: 'C5' },
]
},
{
label: 'D', value: 'D',
children: [
{ label: 'D1', value: 'D1' },
{ label: 'D2', value: 'D2' },
{ label: 'D3', value: 'D3' },
{ label: 'D4', value: 'D4' },
{ label: 'D5', value: 'D5' },
]
}
]
// 获取所有子级等级值
const getAllChildLevels = (parentValue: string): string[] => {
const parent = LEVEL_OPTIONS.find(p => p.value === parentValue)
return parent ? parent.children.map(c => c.value) : []
}
function HostListDialog({ visible, onClose, onSave }: HostListDialogProps) {
const [hosts, setHosts] = useState<Host[]>([])
const [selected, setSelected] = useState<Set<string>>(new Set())
const [filters, setFilters] = useState({
gold: true,
ordinary: true,
minOnlineFans: '',
maxOnlineFans: '',
})
const [maxCount, setMaxCount] = useState<number>(100)
const [selectedLevels, setSelectedLevels] = useState<Set<string>>(new Set()) // 选中的等级
const [showLevelDropdown, setShowLevelDropdown] = useState(false)
// 锁定 Body 滚动
useEffect(() => {
if (visible) {
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = ''
}
return () => {
document.body.style.overflow = ''
}
}, [visible])
// 加载主播数据和配置
useEffect(() => {
if (visible) {
loadHosts()
loadConfig()
}
}, [visible])
const loadHosts = async () => {
if (!isElectron()) return
try {
const data = await window.electronAPI!.loadAnchorData()
setHosts(data as Host[])
setSelected(new Set())
} catch (e) {
console.error('加载主播数据失败:', e)
}
}
// 从后端加载配置(包括 maxAnchorCount 和 hostsLevelList
const loadConfig = async () => {
if (!isElectron()) return
try {
const config = await window.electronAPI!.getAutomationConfig()
if ((config as any)?.maxAnchorCount !== undefined) {
setMaxCount((config as any).maxAnchorCount)
}
// 加载等级过滤配置
if (config?.filters?.hostsLevelList) {
setSelectedLevels(new Set(config.filters.hostsLevelList))
}
} catch (e) {
console.error('加载配置失败:', e)
}
}
// 更新等级过滤配置到后端
const updateLevelFilter = async (levels: Set<string>) => {
setSelectedLevels(levels)
if (!isElectron()) return
try {
await window.electronAPI!.updateAutomationConfig({
filters: { hostsLevelList: Array.from(levels) }
} as any)
console.log('[HostListDialog] 等级过滤已更新:', Array.from(levels))
} catch (e) {
console.error('更新等级配置失败:', e)
}
}
// 切换单个等级选中状态
const toggleLevel = (level: string) => {
const newSet = new Set(selectedLevels)
if (newSet.has(level)) {
newSet.delete(level)
} else {
newSet.add(level)
}
updateLevelFilter(newSet)
}
// 切换整个大类
const toggleParentLevel = (parentValue: string) => {
const childLevels = getAllChildLevels(parentValue)
const allSelected = childLevels.every(l => selectedLevels.has(l))
const newSet = new Set(selectedLevels)
if (allSelected) {
// 全部取消
childLevels.forEach(l => newSet.delete(l))
} else {
// 全部选中
childLevels.forEach(l => newSet.add(l))
}
updateLevelFilter(newSet)
}
// 更新 maxAnchorCount 到后端
const updateMaxCount = async (value: number) => {
setMaxCount(value)
if (!isElectron()) return
try {
await window.electronAPI!.updateAutomationConfig({ maxAnchorCount: value } as any)
console.log('[HostListDialog] 主播数据上限已更新:', value)
} catch (e) {
console.error('更新配置失败:', e)
}
}
// 筛选后的主播列表
const filteredHosts = hosts.filter(h => {
if (!filters.gold && h.invitationType === 2) return false
if (!filters.ordinary && h.invitationType === 1) return false
if (filters.minOnlineFans && h.onlineFans !== undefined) {
if (h.onlineFans < parseInt(filters.minOnlineFans)) return false
}
if (filters.maxOnlineFans && h.onlineFans !== undefined) {
if (h.onlineFans > parseInt(filters.maxOnlineFans)) return false
}
// 等级过滤:如果选择了等级,则只显示选中等级的主播
if (selectedLevels.size > 0 && h.hostsLevel) {
if (!selectedLevels.has(h.hostsLevel)) return false
}
return true
})
const selectedCount = selected.size
const toggleSelect = useCallback((id: string) => {
setSelected(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}, [])
const selectAll = () => {
setSelected(new Set(filteredHosts.map(h => h.anchorId)))
}
const selectNone = () => {
setSelected(new Set())
}
const invertSelect = () => {
setSelected(prev => {
const next = new Set<string>()
filteredHosts.forEach(h => {
if (!prev.has(h.anchorId)) next.add(h.anchorId)
})
return next
})
}
const deleteSelected = () => {
if (!selected.size) return
if (!confirm(`确认删除选中的 ${selected.size} 项吗?`)) return
const remaining = hosts.filter(h => !selected.has(h.anchorId))
setHosts(remaining)
setSelected(new Set())
}
const handleSave = async () => {
if (isElectron()) {
await window.electronAPI!.saveAnchorData(hosts)
}
onSave(hosts)
onClose()
}
if (!visible) return null
return createPortal(
<div className="fixed inset-0 bg-black/50 flex items-center justify-center" style={{ zIndex: 9999 }}>
<div className="bg-white rounded-xl shadow-2xl w-full max-w-4xl max-h-[80vh] flex flex-col mx-4">
{/* 头部 */}
<div className="p-4 border-b border-gray-200 flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-gray-600"></h3>
<span className="text-sm text-gray-700">
{selectedCount} / {filteredHosts.length}
</span>
</div>
<button onClick={onClose} className="text-gray-700 hover:text-gray-700 text-xl"></button>
</div>
{/* 工具栏 */}
<div className="p-4 border-b border-gray-100 space-y-3">
<div className="flex flex-wrap gap-2">
<button onClick={selectAll} className="px-3 py-1.5 text-sm bg-blue-100 text-blue-700 hover:bg-blue-200 rounded border border-blue-300"></button>
<button onClick={selectNone} className="px-3 py-1.5 text-sm bg-slate-100 text-slate-700 hover:bg-slate-200 rounded border border-slate-300"></button>
<button onClick={invertSelect} className="px-3 py-1.5 text-sm bg-slate-100 text-slate-700 hover:bg-slate-200 rounded border border-slate-300"></button>
<button
onClick={deleteSelected}
disabled={!selectedCount}
className="px-3 py-1.5 text-sm bg-red-100 text-red-600 hover:bg-red-200 rounded disabled:opacity-50"
>
</button>
</div>
{/* 筛选 */}
<div className="flex flex-wrap items-center gap-4 text-sm">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={filters.gold}
onChange={(e) => setFilters(f => ({ ...f, gold: e.target.checked }))}
className="w-4 h-4"
/>
<span className="text-yellow-600"></span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={filters.ordinary}
onChange={(e) => setFilters(f => ({ ...f, ordinary: e.target.checked }))}
className="w-4 h-4"
/>
<span></span>
</label>
<span className="text-gray-700">线</span>
<input
type="number"
placeholder="最小"
value={filters.minOnlineFans}
onChange={(e) => setFilters(f => ({ ...f, minOnlineFans: e.target.value }))}
className="w-20 px-2 py-1 border border-gray-300 rounded text-sm"
/>
<span>~</span>
<input
type="number"
placeholder="最大"
value={filters.maxOnlineFans}
onChange={(e) => setFilters(f => ({ ...f, maxOnlineFans: e.target.value }))}
className="w-20 px-2 py-1 border border-gray-300 rounded text-sm"
/>
{/* 等级过滤 */}
<div className="relative border-l border-gray-200 pl-4 ml-2">
<button
onClick={() => setShowLevelDropdown(!showLevelDropdown)}
className="flex items-center gap-2 px-3 py-1 border border-gray-300 rounded text-sm hover:bg-gray-50"
>
<span className="text-gray-700 font-medium"></span>
<span className="text-xs text-blue-600">
{selectedLevels.size > 0 ? `已选 ${selectedLevels.size}` : '全部'}
</span>
<svg className={`w-4 h-4 transition-transform ${showLevelDropdown ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{/* 下拉菜单 */}
{showLevelDropdown && (
<div className="absolute top-full left-4 mt-1 bg-white border border-gray-200 rounded-lg shadow-lg z-50 p-3 w-72">
<div className="text-xs text-gray-500 mb-2"></div>
<div className="space-y-2 max-h-60 overflow-auto">
{LEVEL_OPTIONS.map(parent => {
const childLevels = parent.children.map(c => c.value)
const selectedChildCount = childLevels.filter(l => selectedLevels.has(l)).length
const allSelected = selectedChildCount === childLevels.length
const partialSelected = selectedChildCount > 0 && !allSelected
return (
<div key={parent.value} className="border border-gray-100 rounded p-2">
<label className="flex items-center gap-2 cursor-pointer font-medium text-gray-700">
<input
type="checkbox"
checked={allSelected}
ref={el => { if (el) el.indeterminate = partialSelected }}
onChange={() => toggleParentLevel(parent.value)}
className="w-4 h-4"
/>
{parent.label}
<span className="text-xs text-gray-400">({selectedChildCount}/{childLevels.length})</span>
</label>
<div className="flex flex-wrap gap-2 mt-1 ml-6">
{parent.children.map(child => (
<label key={child.value} className="flex items-center gap-1 cursor-pointer text-gray-600">
<input
type="checkbox"
checked={selectedLevels.has(child.value)}
onChange={() => toggleLevel(child.value)}
className="w-3 h-3"
/>
<span className="text-xs">{child.label}</span>
</label>
))}
</div>
</div>
)
})}
</div>
<div className="mt-2 pt-2 border-t border-gray-100 flex justify-between">
<button
onClick={() => updateLevelFilter(new Set())}
className="text-xs text-gray-500 hover:text-gray-700"
>
</button>
<button
onClick={() => setShowLevelDropdown(false)}
className="text-xs text-blue-600 hover:text-blue-700"
>
</button>
</div>
</div>
)}
</div>
{/* 接收上限 - 紧凑布局 */}
<div className="flex items-center gap-2 border-l border-gray-200 pl-4 ml-2">
<span className="text-gray-700 font-medium whitespace-nowrap"></span>
<input
type="number"
min={0}
placeholder="无限制"
value={maxCount || ''}
onChange={(e) => {
const val = parseInt(e.target.value)
updateMaxCount(isNaN(val) ? 0 : val)
}}
className="w-20 px-2 py-1 border border-gray-300 rounded text-sm focus:border-blue-500 focus:outline-none"
/>
</div>
</div>
</div>
{/* 主播列表 */}
<div className="flex-1 overflow-auto p-4">
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
{filteredHosts.map(host => (
<div
key={host.anchorId}
onClick={() => toggleSelect(host.anchorId)}
className={`p-3 rounded-lg border cursor-pointer transition-all ${selected.has(host.anchorId)
? 'border-blue-500 bg-blue-50 shadow'
: 'border-gray-200 hover:border-gray-300 hover:shadow-sm'
}`}
>
<div className="flex items-center justify-between mb-1">
<span className="font-medium text-sm truncate flex-1" title={host.anchorId}>
{host.anchorId}
</span>
<span className={host.state ? 'text-green-500' : 'text-red-500'}>
{host.state ? '✓' : '✗'}
</span>
</div>
<div className="flex items-center justify-between text-xs text-gray-700">
<span>{host.country || '—'}</span>
<div className="flex items-center gap-1">
{host.hostsLevel && (
<span className="px-1.5 py-0.5 rounded bg-purple-100 text-purple-600 text-xs">
{host.hostsLevel}
</span>
)}
<span className={`px-1.5 py-0.5 rounded border ${host.invitationType === 2
? 'text-yellow-600 border-yellow-400'
: 'border-gray-300'
}`}>
{host.invitationType === 2 ? '金票' : '普票'}
</span>
</div>
</div>
</div>
))}
</div>
{filteredHosts.length === 0 && (
<div className="text-center text-gray-700 py-12">
</div>
)}
</div>
{/* 底部 */}
<div className="p-4 border-t border-gray-200 flex justify-end gap-3">
<button
onClick={onClose}
className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg"
>
</button>
<button
onClick={handleSave}
className="px-4 py-2 text-sm bg-blue-500 text-white rounded-lg hover:bg-blue-600"
>
</button>
</div>
</div>
</div>,
document.body
)
}
export default HostListDialog