Files
Ant-Browser/frontend/src/modules/browser/pages/proxyPool/ProxyPoolTableCard.tsx
T
2026-05-09 17:30:16 +08:00

364 lines
13 KiB
TypeScript

import { useMemo } from 'react'
import { Button, Card, Input, Switch, Table } from '../../../../shared/components'
import type { SortOrder, TableColumn } from '../../../../shared/components/Table'
import type { ProxyIPHealthResult } from '../../types'
import { BUILTIN_PROXY_IDS, sourceHostLabel, type ProxyDisplayInfo } from './helpers'
interface ProxyPoolTableCardProps {
allFilteredSelected: boolean
checkingIPHealthIds: Set<string>
data: ProxyDisplayInfo[]
filterGroup: string
filterKeyword: string
filterProtocol: string
globalAutoRefreshEnabled: boolean
globalRefreshInterval: number
globalRefreshIntervalM: string
groups: string[]
ipHealthMap: Record<string, ProxyIPHealthResult>
loading: boolean
onCheckOneIPHealth: (record: ProxyDisplayInfo) => void
onClearFilters: () => void
onDelete: (proxyId: string) => void
onEdit: (record: ProxyDisplayInfo) => void
onFilterGroupChange: (nextValue: string) => void
onFilterKeywordChange: (nextValue: string) => void
onFilterProtocolChange: (nextValue: string) => void
onGlobalAutoRefreshEnabledChange: (checked: boolean) => void
onGlobalRefreshIntervalMChange: (nextValue: string) => void
onOpenBatchDelete: () => void
onOpenIPHealthDetail: (proxyId: string) => void
onRefreshSingleSource: (sourceId: string) => void
onSort: (next: { column: string; order: SortOrder }) => void
onTestOne: (record: ProxyDisplayInfo) => void
onToggleAll: () => void
onToggleOne: (proxyId: string) => void
protocolOptions: string[]
refreshingSourceIds: Set<string>
selectedCount: number
selectedIds: Set<string>
someFilteredSelected: boolean
sortColumn: string
sortOrder: SortOrder
latencyMap: Record<string, number>
}
export function ProxyPoolTableCard({
allFilteredSelected,
checkingIPHealthIds,
data,
filterGroup,
filterKeyword,
filterProtocol,
globalAutoRefreshEnabled,
globalRefreshInterval,
globalRefreshIntervalM,
groups,
ipHealthMap,
loading,
onCheckOneIPHealth,
onClearFilters,
onDelete,
onEdit,
onFilterGroupChange,
onFilterKeywordChange,
onFilterProtocolChange,
onGlobalAutoRefreshEnabledChange,
onGlobalRefreshIntervalMChange,
onOpenBatchDelete,
onOpenIPHealthDetail,
onRefreshSingleSource,
onSort,
onTestOne,
onToggleAll,
onToggleOne,
protocolOptions,
refreshingSourceIds,
selectedCount,
selectedIds,
someFilteredSelected,
sortColumn,
sortOrder,
latencyMap,
}: ProxyPoolTableCardProps) {
const hasActiveFilters = filterProtocol !== 'all' || !!filterKeyword || filterGroup !== 'all'
const renderLatency = (record: ProxyDisplayInfo) => {
if (record.proxyConfig === 'direct://') {
return <span className="text-[var(--color-text-muted)] text-xs">不适用</span>
}
const value = latencyMap[record.proxyId]
if (value === undefined) return <span className="text-[var(--color-text-muted)] text-xs">-</span>
if (value === -1) return <span className="text-[var(--color-text-muted)] text-xs animate-pulse">测试中...</span>
if (value === -2) return <span className="text-red-500 text-xs">超时</span>
if (value === -3) return <span className="text-gray-400 text-xs">不支持</span>
if (value === -4) return <span className="text-red-500 text-xs">失败</span>
const color = value < 200 ? 'text-green-500' : value < 500 ? 'text-yellow-500' : 'text-red-500'
return <span className={`text-xs font-medium ${color}`}>{value} ms</span>
}
const renderIPHealth = (record: ProxyDisplayInfo) => {
if (record.proxyConfig === 'direct://') {
return <span className="text-[var(--color-text-muted)] text-xs">不适用</span>
}
if (checkingIPHealthIds.has(record.proxyId)) {
return <span className="text-[var(--color-text-muted)] text-xs animate-pulse">检测中...</span>
}
const result = ipHealthMap[record.proxyId]
if (!result) return <span className="text-[var(--color-text-muted)] text-xs">-</span>
if (!result.ok) {
return (
<div className="flex items-center gap-2">
<span className="text-xs text-red-500 truncate max-w-[120px]" title={result.error || '检测失败'}>失败</span>
<Button size="sm" variant="ghost" onClick={(event) => { event.stopPropagation(); onOpenIPHealthDetail(record.proxyId) }}>原始</Button>
</div>
)
}
const location = [result.country, result.region, result.city].filter(Boolean).join(' / ')
return (
<div className="flex items-center gap-2 min-w-0">
<div className="min-w-0">
<div className="text-xs text-[var(--color-text-primary)] truncate">{result.ip || '-'}</div>
<div className="text-[11px] text-[var(--color-text-muted)] truncate">
{`fraud ${result.fraudScore} | ${result.isResidential ? '住宅' : '机房'}${location ? ` | ${location}` : ''}`}
</div>
</div>
<Button size="sm" variant="ghost" onClick={(event) => { event.stopPropagation(); onOpenIPHealthDetail(record.proxyId) }}>原始</Button>
</div>
)
}
const columns = useMemo<TableColumn<ProxyDisplayInfo>[]>(() => [
{
key: 'checkbox',
title: '',
width: '40px',
render: (_, record) => (
<input
type="checkbox"
checked={selectedIds.has(record.proxyId)}
disabled={BUILTIN_PROXY_IDS.has(record.proxyId)}
onChange={() => onToggleOne(record.proxyId)}
onClick={event => event.stopPropagation()}
className="w-4 h-4 rounded border-[var(--color-border-default)] accent-[var(--color-accent)] cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed"
/>
),
},
{ key: 'proxyName', title: '代理名称', width: '180px', sortable: true },
{
key: 'groupName',
title: '分组',
width: '100px',
sortable: true,
render: (value) => value ? <span className="px-1.5 py-0.5 text-xs rounded bg-[var(--color-accent)]/10 text-[var(--color-accent)]">{value}</span> : '-',
},
{
key: 'source',
title: '来源',
width: '180px',
render: (_, record) => {
if (!record.sourceUrl) return '-'
const host = sourceHostLabel(record.sourceUrl)
return (
<div className="text-xs leading-5">
<div className="text-[var(--color-text-primary)] truncate" title={record.sourceUrl}>{host}</div>
<div className="text-[var(--color-text-muted)]">
{globalAutoRefreshEnabled ? `自动刷新 ${globalRefreshInterval} 分钟(全局)` : '手动刷新'}
</div>
</div>
)
},
},
{ key: 'type', title: '类型', width: '90px', sortable: true },
{ key: 'server', title: '服务器', width: '180px', sortable: true },
{ key: 'port', title: '端口', width: '80px', sortable: true, render: (value) => value || '-' },
{
key: 'latency',
title: '延迟',
width: '90px',
sortable: true,
render: (_, record) => renderLatency(record),
},
{
key: 'ipHealth',
title: (
<div className="leading-tight">
<div>IP健康</div>
<div className="mt-0.5 text-[10px] font-normal text-[var(--color-text-muted)]">
仅供参考
</div>
</div>
),
width: '280px',
render: (_, record) => renderIPHealth(record),
},
{
key: 'actions',
title: '操作',
width: '320px',
render: (_, record) => {
const isBuiltin = BUILTIN_PROXY_IDS.has(record.proxyId)
const sourceId = record.sourceId || ''
const hasSource = !!sourceId && !!record.sourceUrl
return (
<div className="flex gap-2">
{hasSource && (
<Button
size="sm"
variant="secondary"
onClick={(event) => { event.stopPropagation(); onRefreshSingleSource(sourceId) }}
loading={refreshingSourceIds.has(sourceId)}
>
刷新订阅
</Button>
)}
<Button
size="sm"
variant="ghost"
onClick={(event) => { event.stopPropagation(); onTestOne(record) }}
loading={latencyMap[record.proxyId] === -1}
disabled={record.proxyConfig === 'direct://'}
>
测速
</Button>
<Button
size="sm"
variant="ghost"
onClick={(event) => { event.stopPropagation(); onCheckOneIPHealth(record) }}
loading={checkingIPHealthIds.has(record.proxyId)}
disabled={record.proxyConfig === 'direct://'}
>
IP健康
</Button>
<Button
size="sm"
variant="ghost"
disabled={isBuiltin}
title={isBuiltin ? '内置代理不可编辑' : undefined}
onClick={(event) => {
event.stopPropagation()
if (!isBuiltin) onEdit(record)
}}
>
编辑
</Button>
<Button
size="sm"
variant="danger"
disabled={isBuiltin}
title={isBuiltin ? '内置代理不可删除' : undefined}
onClick={(event) => {
event.stopPropagation()
if (!isBuiltin) onDelete(record.proxyId)
}}
>
删除
</Button>
</div>
)
},
},
], [
checkingIPHealthIds,
globalAutoRefreshEnabled,
globalRefreshInterval,
ipHealthMap,
latencyMap,
onCheckOneIPHealth,
onDelete,
onEdit,
onOpenIPHealthDetail,
onRefreshSingleSource,
onTestOne,
onToggleOne,
refreshingSourceIds,
selectedIds,
])
return (
<Card>
<div className="flex items-center gap-3 mb-4">
<Input
value={filterKeyword}
onChange={event => onFilterKeywordChange(event.target.value)}
placeholder="搜索名称或服务器..."
style={{ width: '220px' }}
/>
<select
value={filterProtocol}
onChange={event => onFilterProtocolChange(event.target.value)}
className="h-9 px-3 text-sm rounded-lg border border-[var(--color-border-default)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] focus:outline-none focus:border-[var(--color-border-strong)] focus:ring-1 focus:ring-[var(--color-border-strong)] transition-colors duration-150"
>
{protocolOptions.map(protocol => (
<option key={protocol} value={protocol}>{protocol === 'all' ? '全部协议' : protocol.toUpperCase()}</option>
))}
</select>
<select
value={filterGroup}
onChange={event => onFilterGroupChange(event.target.value)}
className="h-9 px-3 text-sm rounded-lg border border-[var(--color-border-default)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] focus:outline-none focus:border-[var(--color-border-strong)] focus:ring-1 focus:ring-[var(--color-border-strong)] transition-colors duration-150"
>
<option value="all">全部分组</option>
{groups.map(group => <option key={group} value={group}>{group}</option>)}
</select>
{hasActiveFilters && (
<Button size="sm" variant="ghost" onClick={onClearFilters}>清除筛选</Button>
)}
<div className="flex items-center gap-2 rounded-lg border border-[var(--color-border-default)] bg-[var(--color-bg-surface)] px-2 py-1.5">
<span className="text-xs text-[var(--color-text-muted)]">全局自动刷新</span>
<Switch
checked={globalAutoRefreshEnabled}
onChange={onGlobalAutoRefreshEnabledChange}
/>
<Input
type="number"
min={5}
max={1440}
value={globalRefreshIntervalM}
onChange={event => onGlobalRefreshIntervalMChange(event.target.value)}
className="w-24"
disabled={!globalAutoRefreshEnabled}
/>
<span className="text-xs text-[var(--color-text-muted)]">分钟</span>
</div>
<div className="flex-1" />
{data.length > 0 && (
<label className="flex items-center gap-1.5 text-sm text-[var(--color-text-muted)] cursor-pointer select-none">
<input
type="checkbox"
checked={allFilteredSelected}
ref={(element) => {
if (element) {
element.indeterminate = someFilteredSelected && !allFilteredSelected
}
}}
onChange={onToggleAll}
className="w-4 h-4 rounded border-[var(--color-border-default)] accent-[var(--color-accent)] cursor-pointer"
/>
全选
</label>
)}
{selectedCount > 0 && (
<Button size="sm" variant="danger" onClick={onOpenBatchDelete}>
删除所选 ({selectedCount})
</Button>
)}
</div>
<Table
columns={columns}
data={data}
rowKey="proxyId"
loading={loading}
emptyText="暂无代理配置,点击上方按钮添加或导入"
sortColumn={sortColumn}
sortOrder={sortOrder}
onSort={onSort}
/>
</Card>
)
}