mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-04-13 05:20:28 +08:00
- Dispose Monaco editor instances on dialog close to prevent cycle leak - Replace gcTime: Infinity with finite TTLs and evict orphaned subscription queryKeys - Add missing useEffect cleanup for timers, move setTimeout out of useMemo
992 lines
28 KiB
TypeScript
992 lines
28 KiB
TypeScript
import { useSortable } from '@dnd-kit/sortable'
|
||
import { CSS } from '@dnd-kit/utilities'
|
||
import {
|
||
RefreshRounded,
|
||
DragIndicatorRounded,
|
||
CheckBoxRounded,
|
||
CheckBoxOutlineBlankRounded,
|
||
} from '@mui/icons-material'
|
||
import {
|
||
Box,
|
||
Typography,
|
||
LinearProgress,
|
||
IconButton,
|
||
keyframes,
|
||
MenuItem,
|
||
Menu,
|
||
CircularProgress,
|
||
} from '@mui/material'
|
||
import { open } from '@tauri-apps/plugin-shell'
|
||
import { useLockFn } from 'ahooks'
|
||
import dayjs from 'dayjs'
|
||
import { useCallback, useEffect, useReducer, useRef, useState } from 'react'
|
||
import { useTranslation } from 'react-i18next'
|
||
|
||
import { ConfirmViewer } from '@/components/profile/confirm-viewer'
|
||
import { EditorViewer } from '@/components/profile/editor-viewer'
|
||
import { GroupsEditorViewer } from '@/components/profile/groups-editor-viewer'
|
||
import { RulesEditorViewer } from '@/components/profile/rules-editor-viewer'
|
||
import { useEditorDocument } from '@/hooks/use-editor-document'
|
||
import {
|
||
viewProfile,
|
||
readProfileFile,
|
||
updateProfile,
|
||
saveProfileFile,
|
||
getNextUpdateTime,
|
||
} from '@/services/cmds'
|
||
import { showNotice } from '@/services/notice-service'
|
||
import { useLoadingCache, useSetLoadingCache } from '@/services/states'
|
||
import type { TranslationKey } from '@/types/generated/i18n-keys'
|
||
import { debugLog } from '@/utils/debug'
|
||
import parseTraffic from '@/utils/parse-traffic'
|
||
|
||
import { ProfileBox } from './profile-box'
|
||
import { ProxiesEditorViewer } from './proxies-editor-viewer'
|
||
const round = keyframes`
|
||
from { transform: rotate(0deg); }
|
||
to { transform: rotate(360deg); }
|
||
`
|
||
|
||
interface Props {
|
||
id: string
|
||
selected: boolean
|
||
activating: boolean
|
||
itemData: IProfileItem
|
||
mutateProfiles: () => Promise<void>
|
||
onSelect: (force: boolean) => void
|
||
onEdit: () => void
|
||
onSave?: (prev?: string, curr?: string) => void
|
||
onDelete: () => void
|
||
batchMode?: boolean
|
||
isSelected?: boolean
|
||
onSelectionChange?: () => void
|
||
}
|
||
|
||
export const ProfileItem = (props: Props) => {
|
||
const {
|
||
id,
|
||
selected,
|
||
activating,
|
||
itemData,
|
||
mutateProfiles,
|
||
onSelect,
|
||
onEdit,
|
||
onSave,
|
||
onDelete,
|
||
batchMode,
|
||
isSelected,
|
||
onSelectionChange,
|
||
} = props
|
||
const {
|
||
attributes,
|
||
listeners,
|
||
setNodeRef,
|
||
transform,
|
||
transition,
|
||
isDragging,
|
||
} = useSortable({
|
||
id,
|
||
})
|
||
|
||
const { t } = useTranslation()
|
||
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null)
|
||
const [position, setPosition] = useState({ left: 0, top: 0 })
|
||
const loadingCache = useLoadingCache()
|
||
const setLoadingCache = useSetLoadingCache()
|
||
|
||
// 新增状态:是否显示下次更新时间
|
||
const [showNextUpdate, setShowNextUpdate] = useState(false)
|
||
const showNextUpdateRef = useRef(false)
|
||
const [nextUpdateTime, setNextUpdateTime] = useState('')
|
||
const refreshTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(
|
||
undefined,
|
||
)
|
||
|
||
const { uid, name = 'Profile', extra, updated = 0, option } = itemData
|
||
|
||
// 获取下次更新时间的函数
|
||
const fetchNextUpdateTime = useLockFn(async (forceRefresh = false) => {
|
||
if (
|
||
itemData.option?.update_interval &&
|
||
itemData.option.update_interval > 0
|
||
) {
|
||
try {
|
||
debugLog(`尝试获取配置 ${itemData.uid} 的下次更新时间`)
|
||
|
||
// 如果需要强制刷新,先触发Timer.refresh()
|
||
if (forceRefresh) {
|
||
// 这里可以通过一个新的API来触发刷新,但目前我们依赖patch_profile中的刷新
|
||
debugLog(`强制刷新定时器任务`)
|
||
}
|
||
|
||
const nextUpdate = await getNextUpdateTime(itemData.uid)
|
||
debugLog(`获取到下次更新时间结果:`, nextUpdate)
|
||
|
||
if (nextUpdate) {
|
||
const nextUpdateDate = dayjs(nextUpdate * 1000)
|
||
const now = dayjs()
|
||
|
||
// 如果已经过期,显示"更新失败"
|
||
if (nextUpdateDate.isBefore(now)) {
|
||
setNextUpdateTime(
|
||
t('profiles.components.profileItem.status.lastUpdateFailed'),
|
||
)
|
||
} else {
|
||
// 否则显示剩余时间
|
||
const diffMinutes = nextUpdateDate.diff(now, 'minute')
|
||
|
||
if (diffMinutes < 60) {
|
||
if (diffMinutes <= 0) {
|
||
setNextUpdateTime(
|
||
`${t('profiles.components.profileItem.status.nextUp')} <1m`,
|
||
)
|
||
} else {
|
||
setNextUpdateTime(
|
||
`${t('profiles.components.profileItem.status.nextUp')} ${diffMinutes}m`,
|
||
)
|
||
}
|
||
} else {
|
||
const hours = Math.floor(diffMinutes / 60)
|
||
const mins = diffMinutes % 60
|
||
setNextUpdateTime(
|
||
`${t('profiles.components.profileItem.status.nextUp')} ${hours}h ${mins}m`,
|
||
)
|
||
}
|
||
}
|
||
} else {
|
||
debugLog(`返回的下次更新时间为空`)
|
||
setNextUpdateTime(
|
||
t('profiles.components.profileItem.status.noSchedule'),
|
||
)
|
||
}
|
||
} catch (err) {
|
||
console.error(`获取下次更新时间出错:`, err)
|
||
setNextUpdateTime(t('profiles.components.profileItem.status.unknown'))
|
||
}
|
||
} else {
|
||
debugLog(`该配置未设置更新间隔或间隔为0`)
|
||
setNextUpdateTime(
|
||
t('profiles.components.profileItem.status.autoUpdateDisabled'),
|
||
)
|
||
}
|
||
})
|
||
|
||
// 切换显示模式的函数
|
||
const toggleUpdateTimeDisplay = (e: React.MouseEvent) => {
|
||
e.stopPropagation()
|
||
|
||
if (!showNextUpdate) {
|
||
fetchNextUpdateTime()
|
||
}
|
||
|
||
setShowNextUpdate(!showNextUpdate)
|
||
}
|
||
|
||
useEffect(() => {
|
||
showNextUpdateRef.current = showNextUpdate
|
||
}, [showNextUpdate])
|
||
|
||
// 当组件加载或更新间隔变化时更新下次更新时间
|
||
useEffect(() => {
|
||
if (showNextUpdate) {
|
||
fetchNextUpdateTime()
|
||
}
|
||
}, [
|
||
fetchNextUpdateTime,
|
||
showNextUpdate,
|
||
itemData.option?.update_interval,
|
||
updated,
|
||
])
|
||
|
||
// 订阅定时器更新事件
|
||
useEffect(() => {
|
||
// 处理定时器更新事件 - 这个事件专门用于通知定时器变更
|
||
const handleTimerUpdate = (event: Event) => {
|
||
const source = event as CustomEvent<string> & { payload?: string }
|
||
const updatedUid = source.detail ?? source.payload
|
||
|
||
// 只有当更新的是当前配置时才刷新显示
|
||
if (updatedUid === itemData.uid && showNextUpdateRef.current) {
|
||
debugLog(`收到定时器更新事件: uid=${updatedUid}`)
|
||
if (refreshTimeoutRef.current !== undefined) {
|
||
clearTimeout(refreshTimeoutRef.current)
|
||
}
|
||
refreshTimeoutRef.current = window.setTimeout(() => {
|
||
fetchNextUpdateTime(true)
|
||
}, 1000)
|
||
}
|
||
}
|
||
|
||
// 只注册定时器更新事件监听
|
||
window.addEventListener('verge://timer-updated', handleTimerUpdate)
|
||
|
||
return () => {
|
||
if (refreshTimeoutRef.current !== undefined) {
|
||
clearTimeout(refreshTimeoutRef.current)
|
||
}
|
||
// 清理事件监听
|
||
window.removeEventListener('verge://timer-updated', handleTimerUpdate)
|
||
}
|
||
}, [fetchNextUpdateTime, itemData.uid])
|
||
|
||
// local file mode
|
||
// remote file mode
|
||
// remote file mode
|
||
const hasUrl = !!itemData.url
|
||
const hasExtra = !!extra // only subscription url has extra info
|
||
const hasHome = !!itemData.home // only subscription url has home page
|
||
|
||
const { upload = 0, download = 0, total = 0 } = extra ?? {}
|
||
const from = parseUrl(itemData.url)
|
||
const description = itemData.desc
|
||
const expire = parseExpire(extra?.expire)
|
||
const progress = Math.min(
|
||
Math.round(((download + upload) * 100) / (total + 0.01)) + 1,
|
||
100,
|
||
)
|
||
|
||
const loading = loadingCache[itemData.uid] ?? false
|
||
|
||
// interval update fromNow field
|
||
const [, forceRefresh] = useReducer((value: number) => value + 1, 0)
|
||
useEffect(() => {
|
||
if (!hasUrl) return
|
||
|
||
let timer: ReturnType<typeof setTimeout> | undefined
|
||
|
||
const handler = () => {
|
||
const now = Date.now()
|
||
const lastUpdate = updated * 1000
|
||
// 大于一天的不管
|
||
if (now - lastUpdate >= 24 * 36e5) return
|
||
|
||
const wait = now - lastUpdate >= 36e5 ? 30e5 : 5e4
|
||
|
||
timer = setTimeout(() => {
|
||
forceRefresh()
|
||
handler()
|
||
}, wait)
|
||
}
|
||
|
||
handler()
|
||
|
||
return () => {
|
||
if (timer) {
|
||
clearTimeout(timer)
|
||
timer = undefined
|
||
}
|
||
}
|
||
}, [forceRefresh, hasUrl, updated])
|
||
|
||
const [fileOpen, setFileOpen] = useState(false)
|
||
const [rulesOpen, setRulesOpen] = useState(false)
|
||
const [proxiesOpen, setProxiesOpen] = useState(false)
|
||
const [groupsOpen, setGroupsOpen] = useState(false)
|
||
const [mergeOpen, setMergeOpen] = useState(false)
|
||
const [scriptOpen, setScriptOpen] = useState(false)
|
||
const [confirmOpen, setConfirmOpen] = useState(false)
|
||
|
||
const loadProfileDocument = useCallback(() => readProfileFile(uid), [uid])
|
||
const loadMergeDocument = useCallback(
|
||
() => readProfileFile(option?.merge ?? ''),
|
||
[option?.merge],
|
||
)
|
||
const loadScriptDocument = useCallback(
|
||
() => readProfileFile(option?.script ?? ''),
|
||
[option?.script],
|
||
)
|
||
|
||
const profileDocument = useEditorDocument({
|
||
open: fileOpen,
|
||
load: loadProfileDocument,
|
||
})
|
||
const mergeDocument = useEditorDocument({
|
||
open: mergeOpen,
|
||
load: loadMergeDocument,
|
||
})
|
||
const scriptDocument = useEditorDocument({
|
||
open: scriptOpen,
|
||
load: loadScriptDocument,
|
||
})
|
||
|
||
const onOpenHome = () => {
|
||
setAnchorEl(null)
|
||
open(itemData.home ?? '')
|
||
}
|
||
|
||
const onEditInfo = () => {
|
||
setAnchorEl(null)
|
||
onEdit()
|
||
}
|
||
|
||
const onEditFile = () => {
|
||
setAnchorEl(null)
|
||
setFileOpen(true)
|
||
}
|
||
|
||
const onEditRules = () => {
|
||
setAnchorEl(null)
|
||
setRulesOpen(true)
|
||
}
|
||
|
||
const onEditProxies = () => {
|
||
setAnchorEl(null)
|
||
setProxiesOpen(true)
|
||
}
|
||
|
||
const onEditGroups = () => {
|
||
setAnchorEl(null)
|
||
setGroupsOpen(true)
|
||
}
|
||
|
||
const onEditMerge = () => {
|
||
setAnchorEl(null)
|
||
setMergeOpen(true)
|
||
}
|
||
|
||
const onEditScript = () => {
|
||
setAnchorEl(null)
|
||
setScriptOpen(true)
|
||
}
|
||
|
||
const onForceSelect = () => {
|
||
setAnchorEl(null)
|
||
onSelect(true)
|
||
}
|
||
|
||
const onOpenFile = useLockFn(async () => {
|
||
setAnchorEl(null)
|
||
try {
|
||
await viewProfile(itemData.uid)
|
||
} catch (err) {
|
||
showNotice.error(err)
|
||
}
|
||
})
|
||
|
||
/// 0 不使用任何代理
|
||
/// 1 使用订阅好的代理
|
||
/// 2 至少使用一个代理,根据订阅,如果没订阅,默认使用系统代理
|
||
const onUpdate = useLockFn(async (type: 0 | 1 | 2): Promise<void> => {
|
||
setAnchorEl(null)
|
||
setLoadingCache((cache) => ({ ...cache, [itemData.uid]: true }))
|
||
|
||
// 根据类型设置初始更新选项
|
||
const option: Partial<IProfileOption> = {}
|
||
if (type === 0) {
|
||
option.with_proxy = false
|
||
option.self_proxy = false
|
||
} else if (type === 2) {
|
||
if (itemData.option?.self_proxy) {
|
||
option.with_proxy = false
|
||
option.self_proxy = true
|
||
} else {
|
||
option.with_proxy = true
|
||
option.self_proxy = false
|
||
}
|
||
}
|
||
|
||
try {
|
||
// 调用后端更新(后端会自动处理回退逻辑)
|
||
const payload = Object.keys(option).length > 0 ? option : undefined
|
||
await updateProfile(itemData.uid, payload)
|
||
|
||
// 更新成功,刷新列表
|
||
void mutateProfiles()
|
||
} catch {
|
||
// 更新完全失败(包括后端的回退尝试)
|
||
// 不需要做处理,后端会通过事件通知系统发送错误
|
||
} finally {
|
||
setLoadingCache((cache) => ({ ...cache, [itemData.uid]: false }))
|
||
}
|
||
})
|
||
|
||
type ContextMenuItem = {
|
||
label: string
|
||
handler: () => void
|
||
disabled: boolean
|
||
}
|
||
|
||
const menuLabels: Record<string, TranslationKey> = {
|
||
home: 'profiles.components.menu.home',
|
||
select: 'profiles.components.menu.select',
|
||
editInfo: 'profiles.components.menu.editInfo',
|
||
editFile: 'profiles.components.menu.editFile',
|
||
editRules: 'profiles.components.menu.editRules',
|
||
editProxies: 'profiles.components.menu.editProxies',
|
||
editGroups: 'profiles.components.menu.editGroups',
|
||
extendConfig: 'profiles.components.menu.extendConfig',
|
||
extendScript: 'profiles.components.menu.extendScript',
|
||
openFile: 'profiles.components.menu.openFile',
|
||
update: 'profiles.components.menu.update',
|
||
updateViaProxy: 'profiles.components.menu.updateViaProxy',
|
||
delete: 'shared.actions.delete',
|
||
} as const
|
||
|
||
const urlModeMenu: ContextMenuItem[] = [
|
||
...(hasHome
|
||
? [
|
||
{
|
||
label: menuLabels.home,
|
||
handler: onOpenHome,
|
||
disabled: false,
|
||
} satisfies ContextMenuItem,
|
||
]
|
||
: []),
|
||
{
|
||
label: menuLabels.select,
|
||
handler: onForceSelect,
|
||
disabled: false,
|
||
},
|
||
{
|
||
label: menuLabels.editInfo,
|
||
handler: onEditInfo,
|
||
disabled: false,
|
||
},
|
||
{
|
||
label: menuLabels.editFile,
|
||
handler: onEditFile,
|
||
disabled: false,
|
||
},
|
||
{
|
||
label: menuLabels.editRules,
|
||
handler: onEditRules,
|
||
disabled: !option?.rules,
|
||
},
|
||
{
|
||
label: menuLabels.editProxies,
|
||
handler: onEditProxies,
|
||
disabled: !option?.proxies,
|
||
},
|
||
{
|
||
label: menuLabels.editGroups,
|
||
handler: onEditGroups,
|
||
disabled: !option?.groups,
|
||
},
|
||
{
|
||
label: menuLabels.extendConfig,
|
||
handler: onEditMerge,
|
||
disabled: !option?.merge,
|
||
},
|
||
{
|
||
label: menuLabels.extendScript,
|
||
handler: onEditScript,
|
||
disabled: !option?.script,
|
||
},
|
||
{
|
||
label: menuLabels.openFile,
|
||
handler: onOpenFile,
|
||
disabled: false,
|
||
},
|
||
{
|
||
label: menuLabels.update,
|
||
handler: () => onUpdate(0),
|
||
disabled: false,
|
||
},
|
||
{
|
||
label: menuLabels.updateViaProxy,
|
||
handler: () => onUpdate(2),
|
||
disabled: false,
|
||
},
|
||
{
|
||
label: menuLabels.delete,
|
||
handler: () => {
|
||
setAnchorEl(null)
|
||
if (batchMode) {
|
||
// If in batch mode, just toggle selection instead of showing delete confirmation
|
||
if (onSelectionChange) {
|
||
onSelectionChange()
|
||
}
|
||
} else {
|
||
setConfirmOpen(true)
|
||
}
|
||
},
|
||
disabled: false,
|
||
},
|
||
]
|
||
const fileModeMenu: ContextMenuItem[] = [
|
||
{
|
||
label: menuLabels.select,
|
||
handler: onForceSelect,
|
||
disabled: false,
|
||
},
|
||
{
|
||
label: menuLabels.editInfo,
|
||
handler: onEditInfo,
|
||
disabled: false,
|
||
},
|
||
{
|
||
label: menuLabels.editFile,
|
||
handler: onEditFile,
|
||
disabled: false,
|
||
},
|
||
{
|
||
label: menuLabels.editRules,
|
||
handler: onEditRules,
|
||
disabled: !option?.rules,
|
||
},
|
||
{
|
||
label: menuLabels.editProxies,
|
||
handler: onEditProxies,
|
||
disabled: !option?.proxies,
|
||
},
|
||
{
|
||
label: menuLabels.editGroups,
|
||
handler: onEditGroups,
|
||
disabled: !option?.groups,
|
||
},
|
||
{
|
||
label: menuLabels.extendConfig,
|
||
handler: onEditMerge,
|
||
disabled: !option?.merge,
|
||
},
|
||
{
|
||
label: menuLabels.extendScript,
|
||
handler: onEditScript,
|
||
disabled: !option?.script,
|
||
},
|
||
{
|
||
label: menuLabels.openFile,
|
||
handler: onOpenFile,
|
||
disabled: false,
|
||
},
|
||
{
|
||
label: menuLabels.delete,
|
||
handler: () => {
|
||
setAnchorEl(null)
|
||
if (batchMode) {
|
||
// If in batch mode, just toggle selection instead of showing delete confirmation
|
||
if (onSelectionChange) {
|
||
onSelectionChange()
|
||
}
|
||
} else {
|
||
setConfirmOpen(true)
|
||
}
|
||
},
|
||
disabled: false,
|
||
},
|
||
]
|
||
|
||
const boxStyle = {
|
||
height: 26,
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
}
|
||
|
||
// 监听自动更新事件
|
||
useEffect(() => {
|
||
const handleUpdateStarted = (event: Event) => {
|
||
const customEvent = event as CustomEvent<{ uid?: string }>
|
||
if (customEvent.detail?.uid === itemData.uid) {
|
||
setLoadingCache((cache) => ({ ...cache, [itemData.uid]: true }))
|
||
}
|
||
}
|
||
|
||
const handleUpdateCompleted = (event: Event) => {
|
||
const customEvent = event as CustomEvent<{ uid?: string }>
|
||
if (customEvent.detail?.uid === itemData.uid) {
|
||
setLoadingCache((cache) => ({ ...cache, [itemData.uid]: false }))
|
||
// 刷新 profile 数据以获取最新的 updated 时间戳
|
||
void mutateProfiles()
|
||
// 更新完成后刷新显示
|
||
if (showNextUpdate) {
|
||
fetchNextUpdateTime()
|
||
}
|
||
}
|
||
}
|
||
|
||
// 注册事件监听
|
||
window.addEventListener('profile-update-started', handleUpdateStarted)
|
||
window.addEventListener('profile-update-completed', handleUpdateCompleted)
|
||
|
||
return () => {
|
||
// 清理事件监听
|
||
window.removeEventListener('profile-update-started', handleUpdateStarted)
|
||
window.removeEventListener(
|
||
'profile-update-completed',
|
||
handleUpdateCompleted,
|
||
)
|
||
}
|
||
}, [
|
||
fetchNextUpdateTime,
|
||
itemData.uid,
|
||
mutateProfiles,
|
||
setLoadingCache,
|
||
showNextUpdate,
|
||
])
|
||
|
||
const handleSaveProfileDocument = useLockFn(async () => {
|
||
const currentValue = profileDocument.value
|
||
await saveProfileFile(uid, currentValue)
|
||
onSave?.(profileDocument.savedValue, currentValue)
|
||
profileDocument.markSaved(currentValue)
|
||
})
|
||
|
||
const handleSaveMergeDocument = useLockFn(async () => {
|
||
const mergeUid = option?.merge ?? ''
|
||
const currentValue = mergeDocument.value
|
||
await saveProfileFile(mergeUid, currentValue)
|
||
onSave?.(mergeDocument.savedValue, currentValue)
|
||
mergeDocument.markSaved(currentValue)
|
||
})
|
||
|
||
const handleSaveScriptDocument = useLockFn(async () => {
|
||
const scriptUid = option?.script ?? ''
|
||
const currentValue = scriptDocument.value
|
||
await saveProfileFile(scriptUid, currentValue)
|
||
onSave?.(scriptDocument.savedValue, currentValue)
|
||
scriptDocument.markSaved(currentValue)
|
||
})
|
||
|
||
return (
|
||
<Box
|
||
sx={{
|
||
position: 'relative',
|
||
transform: CSS.Transform.toString(transform),
|
||
transition,
|
||
zIndex: isDragging ? 'calc(infinity)' : undefined,
|
||
}}
|
||
>
|
||
<ProfileBox
|
||
aria-selected={selected}
|
||
onClick={(e) => {
|
||
// 如果正在激活中,阻止重复点击
|
||
if (activating) {
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
return
|
||
}
|
||
onSelect(false)
|
||
}}
|
||
onContextMenu={(event) => {
|
||
const { clientX, clientY } = event
|
||
setPosition({ top: clientY, left: clientX })
|
||
setAnchorEl(event.currentTarget as HTMLElement)
|
||
event.preventDefault()
|
||
}}
|
||
>
|
||
{activating && (
|
||
<Box
|
||
sx={{
|
||
position: 'absolute',
|
||
display: 'flex',
|
||
justifyContent: 'center',
|
||
alignItems: 'center',
|
||
top: 10,
|
||
left: 10,
|
||
right: 10,
|
||
bottom: 2,
|
||
zIndex: 10,
|
||
backdropFilter: 'blur(2px)',
|
||
backgroundColor: 'rgba(0, 0, 0, 0.1)',
|
||
}}
|
||
>
|
||
<CircularProgress
|
||
color="inherit"
|
||
size={20}
|
||
sx={{
|
||
animation: 'pulse 1.5s ease-in-out infinite',
|
||
}}
|
||
/>
|
||
</Box>
|
||
)}
|
||
<Box position="relative">
|
||
<Box sx={{ display: 'flex', justifyContent: 'start' }}>
|
||
{batchMode && (
|
||
<IconButton
|
||
size="small"
|
||
sx={{ padding: '2px', marginRight: '4px', marginLeft: '-8px' }}
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
if (onSelectionChange) {
|
||
onSelectionChange()
|
||
}
|
||
}}
|
||
>
|
||
{isSelected ? (
|
||
<CheckBoxRounded color="primary" />
|
||
) : (
|
||
<CheckBoxOutlineBlankRounded />
|
||
)}
|
||
</IconButton>
|
||
)}
|
||
<Box
|
||
ref={setNodeRef}
|
||
sx={{
|
||
display: 'flex',
|
||
margin: 'auto 0',
|
||
...(batchMode && { marginLeft: '-4px' }),
|
||
}}
|
||
{...attributes}
|
||
{...listeners}
|
||
>
|
||
<DragIndicatorRounded
|
||
sx={[
|
||
{ cursor: 'move', marginLeft: '-6px' },
|
||
({ palette: { text } }) => {
|
||
return { color: text.primary }
|
||
},
|
||
]}
|
||
/>
|
||
</Box>
|
||
|
||
<Typography
|
||
width={batchMode ? 'calc(100% - 56px)' : 'calc(100% - 36px)'}
|
||
sx={{ fontSize: '18px', fontWeight: '600', lineHeight: '26px' }}
|
||
variant="h6"
|
||
component="h2"
|
||
noWrap
|
||
title={name}
|
||
>
|
||
{name}
|
||
</Typography>
|
||
</Box>
|
||
|
||
{/* only if has url can it be updated */}
|
||
{hasUrl && (
|
||
<IconButton
|
||
title={t('shared.actions.refresh')}
|
||
sx={{
|
||
position: 'absolute',
|
||
p: '3px',
|
||
top: -1,
|
||
right: -5,
|
||
animation: loading ? `1s linear infinite ${round}` : 'none',
|
||
}}
|
||
size="small"
|
||
color="inherit"
|
||
disabled={loading}
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
// 如果正在激活或加载中,阻止更新操作
|
||
if (activating || loading) {
|
||
return
|
||
}
|
||
onUpdate(1)
|
||
}}
|
||
>
|
||
<RefreshRounded color="inherit" />
|
||
</IconButton>
|
||
)}
|
||
</Box>
|
||
{/* the second line show url's info or description */}
|
||
<Box sx={boxStyle}>
|
||
{
|
||
<>
|
||
{description ? (
|
||
<Typography
|
||
noWrap
|
||
title={description}
|
||
sx={{ fontSize: '14px' }}
|
||
>
|
||
{description}
|
||
</Typography>
|
||
) : (
|
||
hasUrl && (
|
||
<Typography
|
||
noWrap
|
||
title={`${t('shared.labels.from')} ${from}`}
|
||
>
|
||
{from}
|
||
</Typography>
|
||
)
|
||
)}
|
||
{hasUrl && (
|
||
<Box
|
||
sx={{
|
||
display: 'flex',
|
||
justifyContent: 'flex-end',
|
||
ml: 'auto',
|
||
}}
|
||
>
|
||
<Typography
|
||
noWrap
|
||
component="span"
|
||
fontSize={14}
|
||
textAlign="right"
|
||
title={
|
||
showNextUpdate
|
||
? t('profiles.components.profileItem.tooltips.showLast')
|
||
: `${t('shared.labels.updateTime')}: ${parseExpire(updated)}\n${t('profiles.components.profileItem.tooltips.showNext')}`
|
||
}
|
||
sx={{
|
||
cursor: 'pointer',
|
||
display: 'inline-block',
|
||
borderBottom: '1px dashed transparent',
|
||
transition: 'all 0.2s',
|
||
'&:hover': {
|
||
borderBottomColor: 'primary.main',
|
||
color: 'primary.main',
|
||
},
|
||
}}
|
||
onClick={toggleUpdateTimeDisplay}
|
||
>
|
||
{showNextUpdate
|
||
? nextUpdateTime
|
||
: updated > 0
|
||
? dayjs(updated * 1000).fromNow()
|
||
: ''}
|
||
</Typography>
|
||
</Box>
|
||
)}
|
||
</>
|
||
}
|
||
</Box>
|
||
{/* the third line show extra info or last updated time */}
|
||
{hasExtra ? (
|
||
<Box sx={{ ...boxStyle, fontSize: 14 }}>
|
||
<span title={t('shared.labels.usedTotal')}>
|
||
{parseTraffic(upload + download)} / {parseTraffic(total)}
|
||
</span>
|
||
<span title={t('shared.labels.expireTime')}>{expire}</span>
|
||
</Box>
|
||
) : (
|
||
<Box sx={{ ...boxStyle, fontSize: 12, justifyContent: 'flex-end' }}>
|
||
<span title={t('shared.labels.updateTime')}>
|
||
{parseExpire(updated)}
|
||
</span>
|
||
</Box>
|
||
)}
|
||
<LinearProgress
|
||
variant="determinate"
|
||
value={progress}
|
||
style={{ opacity: total > 0 ? 1 : 0 }}
|
||
/>
|
||
</ProfileBox>
|
||
|
||
<Menu
|
||
open={!!anchorEl}
|
||
anchorEl={anchorEl}
|
||
onClose={() => setAnchorEl(null)}
|
||
anchorPosition={position}
|
||
anchorReference="anchorPosition"
|
||
transitionDuration={225}
|
||
MenuListProps={{ sx: { py: 0.5 } }}
|
||
onContextMenu={(e) => {
|
||
setAnchorEl(null)
|
||
e.preventDefault()
|
||
}}
|
||
>
|
||
{(hasUrl ? urlModeMenu : fileModeMenu).map((item) => (
|
||
<MenuItem
|
||
key={item.label}
|
||
onClick={item.handler}
|
||
disabled={item.disabled}
|
||
sx={[
|
||
{
|
||
minWidth: 120,
|
||
},
|
||
(theme) => {
|
||
return {
|
||
color:
|
||
item.label === menuLabels.delete
|
||
? theme.palette.error.main
|
||
: undefined,
|
||
}
|
||
},
|
||
]}
|
||
dense
|
||
>
|
||
{t(item.label)}
|
||
</MenuItem>
|
||
))}
|
||
</Menu>
|
||
{fileOpen && (
|
||
<EditorViewer
|
||
open={true}
|
||
value={profileDocument.value}
|
||
language="yaml"
|
||
path={`profile:${uid}.yaml`}
|
||
loading={profileDocument.loading}
|
||
dirty={profileDocument.dirty}
|
||
onChange={profileDocument.setValue}
|
||
onSave={handleSaveProfileDocument}
|
||
onClose={() => setFileOpen(false)}
|
||
/>
|
||
)}
|
||
{rulesOpen && (
|
||
<RulesEditorViewer
|
||
groupsUid={option?.groups ?? ''}
|
||
mergeUid={option?.merge ?? ''}
|
||
profileUid={uid}
|
||
property={option?.rules ?? ''}
|
||
open={true}
|
||
onSave={onSave}
|
||
onClose={() => setRulesOpen(false)}
|
||
/>
|
||
)}
|
||
{proxiesOpen && (
|
||
<ProxiesEditorViewer
|
||
profileUid={uid}
|
||
property={option?.proxies ?? ''}
|
||
open={true}
|
||
onSave={onSave}
|
||
onClose={() => setProxiesOpen(false)}
|
||
/>
|
||
)}
|
||
{groupsOpen && (
|
||
<GroupsEditorViewer
|
||
mergeUid={option?.merge ?? ''}
|
||
proxiesUid={option?.proxies ?? ''}
|
||
profileUid={uid}
|
||
property={option?.groups ?? ''}
|
||
open={true}
|
||
onSave={onSave}
|
||
onClose={() => {
|
||
setGroupsOpen(false)
|
||
}}
|
||
/>
|
||
)}
|
||
{mergeOpen && (
|
||
<EditorViewer
|
||
open={true}
|
||
value={mergeDocument.value}
|
||
language="yaml"
|
||
path={`merge:${option?.merge ?? ''}.yaml`}
|
||
loading={mergeDocument.loading}
|
||
dirty={mergeDocument.dirty}
|
||
onChange={mergeDocument.setValue}
|
||
onSave={handleSaveMergeDocument}
|
||
onClose={() => setMergeOpen(false)}
|
||
/>
|
||
)}
|
||
{scriptOpen && (
|
||
<EditorViewer
|
||
open={true}
|
||
value={scriptDocument.value}
|
||
language="javascript"
|
||
path={`script:${option?.script ?? ''}.js`}
|
||
loading={scriptDocument.loading}
|
||
dirty={scriptDocument.dirty}
|
||
onChange={scriptDocument.setValue}
|
||
onSave={handleSaveScriptDocument}
|
||
onClose={() => setScriptOpen(false)}
|
||
/>
|
||
)}
|
||
|
||
<ConfirmViewer
|
||
title={t('profiles.modals.confirmDelete.title')}
|
||
message={t('profiles.modals.confirmDelete.message')}
|
||
open={confirmOpen}
|
||
onClose={() => setConfirmOpen(false)}
|
||
onConfirm={() => {
|
||
onDelete()
|
||
setConfirmOpen(false)
|
||
}}
|
||
/>
|
||
</Box>
|
||
)
|
||
}
|
||
|
||
function parseUrl(url?: string) {
|
||
if (!url) return ''
|
||
const regex = /https?:\/\/(.+?)\//
|
||
const result = url.match(regex)
|
||
return result ? result[1] : 'local file'
|
||
}
|
||
|
||
function parseExpire(expire?: number) {
|
||
if (!expire) return '-'
|
||
return dayjs(expire * 1000).format('YYYY-MM-DD')
|
||
}
|