Tunglies b8fbabae04
fix: frontend memory leaks — Monaco dispose, TQ cache eviction, useEffect cleanup
- 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
2026-04-05 23:10:45 +08:00

992 lines
28 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 { 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')
}