mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-10-14 21:20:48 +08:00
* refactor(ipc): generate bindings with Specta * fix(profile): project IPC inputs to consumed fields * fix(events): remove unused refresh payloads * fix(webdav): return only consumed backup metadata * fix(network): trim unused interface metadata * fix(sysinfo): return only displayed system fields * chore(knip): check generated runtime exports * fix(proxy): confirm runtime chain updates before UI changes * fix(clipboard): propagate proxy environment copy failures * refactor(dns): remove ignored runtime patch * refactor(core): trim unused upgrade response fields * refactor(ipc): avoid redundant response copies * refactor(proxy): clarify automatic proxy query naming * chore(tests): remove added IPC regression tests * refactor(ipc): enforce typed command boundaries * chore(contract): check generated bindings before push * refactor(ipc): remove unused proxy liveness payloads
384 lines
10 KiB
TypeScript
384 lines
10 KiB
TypeScript
import {
|
|
CloudUploadOutlined,
|
|
DnsOutlined,
|
|
EventOutlined,
|
|
LaunchOutlined,
|
|
SpeedOutlined,
|
|
StorageOutlined,
|
|
UpdateOutlined,
|
|
} from '@mui/icons-material'
|
|
import {
|
|
Box,
|
|
Button,
|
|
LinearProgress,
|
|
Link,
|
|
Stack,
|
|
Typography,
|
|
alpha,
|
|
keyframes,
|
|
useTheme,
|
|
} from '@mui/material'
|
|
import { useLockFn } from 'ahooks'
|
|
import dayjs from 'dayjs'
|
|
import { useCallback, useMemo, useState } from 'react'
|
|
import { useTranslation } from 'react-i18next'
|
|
import { useNavigate } from 'react-router'
|
|
|
|
import { useAppRefreshers } from '@/providers/app-data-context'
|
|
import { updateProfile } from '@/services/cmds'
|
|
import { mutate } from '@/services/mutate'
|
|
import { showNotice } from '@/services/notice-service'
|
|
import { isValidUrl } from '@/utils/network'
|
|
import { openExternalUrl } from '@/utils/open-external-url'
|
|
import parseTraffic from '@/utils/parse-traffic'
|
|
|
|
import { EnhancedCard } from './enhanced-card'
|
|
|
|
// 定义旋转动画
|
|
const round = keyframes`
|
|
from { transform: rotate(0deg); }
|
|
to { transform: rotate(360deg); }
|
|
`
|
|
|
|
// 辅助函数解析URL和过期时间
|
|
const parseUrl = (url?: string) => {
|
|
if (!url) return '-'
|
|
if (isValidUrl(url)) return new URL(url).host
|
|
return 'local'
|
|
}
|
|
|
|
const parseExpire = (expire?: number) => {
|
|
if (!expire) return '-'
|
|
return dayjs(expire * 1000).format('YYYY-MM-DD')
|
|
}
|
|
|
|
const openProfileHome = (url: string) => {
|
|
void openExternalUrl(url).catch(showNotice.error)
|
|
}
|
|
|
|
type ProfileItem = IProfileItem
|
|
|
|
interface HomeProfileCardProps {
|
|
current: ProfileItem | null | undefined
|
|
onProfileUpdated?: () => void
|
|
}
|
|
|
|
// 提取独立组件减少主组件复杂度
|
|
const ProfileDetails = ({
|
|
current,
|
|
onUpdateProfile,
|
|
updating,
|
|
}: {
|
|
current: ProfileItem
|
|
onUpdateProfile: () => void
|
|
updating: boolean
|
|
}) => {
|
|
const { t } = useTranslation()
|
|
const theme = useTheme()
|
|
|
|
const usedTraffic = useMemo(() => {
|
|
if (!current.extra) return 0
|
|
return current.extra.upload + current.extra.download
|
|
}, [current.extra])
|
|
|
|
const trafficPercentage = useMemo(() => {
|
|
if (!current.extra || !current.extra.total || current.extra.total <= 0)
|
|
return 0
|
|
return Math.min(Math.round((usedTraffic / current.extra.total) * 100), 100)
|
|
}, [current.extra, usedTraffic])
|
|
|
|
return (
|
|
<Box>
|
|
<Stack spacing={2}>
|
|
{current.url && (
|
|
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
|
|
<DnsOutlined fontSize="small" color="action" />
|
|
<Typography
|
|
variant="body2"
|
|
color="text.secondary"
|
|
noWrap
|
|
sx={{ display: 'flex', alignItems: 'center' }}
|
|
>
|
|
<span style={{ flexShrink: 0 }}>{t('shared.labels.from')}: </span>
|
|
{current.home ? (
|
|
<Link
|
|
component="button"
|
|
onClick={() => current.home && openProfileHome(current.home)}
|
|
sx={{
|
|
display: 'inline-flex',
|
|
alignItems: 'center',
|
|
minWidth: 0,
|
|
maxWidth: 'calc(100% - 40px)',
|
|
ml: 0.5,
|
|
fontWeight: 'medium',
|
|
}}
|
|
title={parseUrl(current.url)}
|
|
>
|
|
<Typography
|
|
component="span"
|
|
sx={{
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
whiteSpace: 'nowrap',
|
|
minWidth: 0,
|
|
flex: 1,
|
|
}}
|
|
>
|
|
{parseUrl(current.url)}
|
|
</Typography>
|
|
<LaunchOutlined
|
|
fontSize="inherit"
|
|
sx={{
|
|
ml: 0.5,
|
|
fontSize: '0.8rem',
|
|
opacity: 0.7,
|
|
flexShrink: 0,
|
|
}}
|
|
/>
|
|
</Link>
|
|
) : (
|
|
<Typography
|
|
component="span"
|
|
sx={{
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
whiteSpace: 'nowrap',
|
|
minWidth: 0,
|
|
flex: 1,
|
|
ml: 0.5,
|
|
fontWeight: 'medium',
|
|
}}
|
|
title={parseUrl(current.url)}
|
|
>
|
|
{parseUrl(current.url)}
|
|
</Typography>
|
|
)}
|
|
</Typography>
|
|
</Stack>
|
|
)}
|
|
|
|
{current.updated && (
|
|
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
|
|
<UpdateOutlined
|
|
fontSize="small"
|
|
color="action"
|
|
sx={{
|
|
cursor: 'pointer',
|
|
animation: updating ? `${round} 1.5s linear infinite` : 'none',
|
|
}}
|
|
onClick={onUpdateProfile}
|
|
/>
|
|
<Typography
|
|
variant="body2"
|
|
color="text.secondary"
|
|
sx={{ cursor: 'pointer' }}
|
|
onClick={onUpdateProfile}
|
|
>
|
|
{t('shared.labels.updateTime')}:{' '}
|
|
<Box component="span" sx={{ fontWeight: 'medium' }}>
|
|
{dayjs(current.updated * 1000).format('YYYY-MM-DD HH:mm')}
|
|
</Box>
|
|
</Typography>
|
|
</Stack>
|
|
)}
|
|
|
|
{current.extra && (
|
|
<>
|
|
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
|
|
<SpeedOutlined fontSize="small" color="action" />
|
|
<Typography variant="body2" color="text.secondary">
|
|
{t('shared.labels.usedTotal')}:{' '}
|
|
<Box component="span" sx={{ fontWeight: 'medium' }}>
|
|
{parseTraffic(usedTraffic)} /{' '}
|
|
{parseTraffic(current.extra.total)}
|
|
</Box>
|
|
</Typography>
|
|
</Stack>
|
|
|
|
{current.extra.expire > 0 && (
|
|
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
|
|
<EventOutlined fontSize="small" color="action" />
|
|
<Typography variant="body2" color="text.secondary">
|
|
{t('shared.labels.expireTime')}:{' '}
|
|
<Box component="span" sx={{ fontWeight: 'medium' }}>
|
|
{parseExpire(current.extra.expire)}
|
|
</Box>
|
|
</Typography>
|
|
</Stack>
|
|
)}
|
|
|
|
<Box sx={{ mt: 1 }}>
|
|
<Typography
|
|
variant="caption"
|
|
color="text.secondary"
|
|
sx={{ mb: 0.5, display: 'block' }}
|
|
>
|
|
{trafficPercentage}%
|
|
</Typography>
|
|
<LinearProgress
|
|
variant="determinate"
|
|
value={trafficPercentage}
|
|
sx={{
|
|
height: 8,
|
|
borderRadius: 4,
|
|
backgroundColor: alpha(theme.palette.primary.main, 0.12),
|
|
}}
|
|
/>
|
|
</Box>
|
|
</>
|
|
)}
|
|
</Stack>
|
|
</Box>
|
|
)
|
|
}
|
|
|
|
// 提取空配置组件
|
|
const EmptyProfile = ({ onClick }: { onClick: () => void }) => {
|
|
const { t } = useTranslation()
|
|
|
|
return (
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
py: 2.4,
|
|
cursor: 'pointer',
|
|
'&:hover': { bgcolor: 'action.hover' },
|
|
borderRadius: 2,
|
|
}}
|
|
onClick={onClick}
|
|
>
|
|
<CloudUploadOutlined
|
|
sx={{ fontSize: 60, color: 'primary.main', mb: 2 }}
|
|
/>
|
|
<Typography variant="h6" gutterBottom>
|
|
{t('profiles.page.actions.import')} {t('profiles.page.title')}
|
|
</Typography>
|
|
<Typography variant="body2" color="text.secondary">
|
|
{t('profiles.components.card.labels.clickToImport')}
|
|
</Typography>
|
|
</Box>
|
|
)
|
|
}
|
|
|
|
export const HomeProfileCard = ({
|
|
current,
|
|
onProfileUpdated,
|
|
}: HomeProfileCardProps) => {
|
|
const { t } = useTranslation()
|
|
const navigate = useNavigate()
|
|
const { refreshAll } = useAppRefreshers()
|
|
|
|
// 更新当前订阅
|
|
const [updating, setUpdating] = useState(false)
|
|
|
|
const onUpdateProfile = useLockFn(async () => {
|
|
if (!current?.uid) return
|
|
|
|
setUpdating(true)
|
|
try {
|
|
await mutate(
|
|
() => updateProfile(current.uid!, current.option ?? undefined),
|
|
{
|
|
id: `update-profile:${current.uid}`,
|
|
errorNotice: false,
|
|
},
|
|
)
|
|
onProfileUpdated?.()
|
|
|
|
// 刷新首页数据
|
|
refreshAll()
|
|
} catch (err) {
|
|
showNotice.error(err, 3000)
|
|
} finally {
|
|
setUpdating(false)
|
|
}
|
|
})
|
|
|
|
// 导航到订阅页面
|
|
const goToProfiles = useCallback(() => {
|
|
navigate('/profile')
|
|
}, [navigate])
|
|
|
|
// 卡片标题
|
|
const cardTitle = useMemo(() => {
|
|
if (!current) return t('profiles.page.title')
|
|
|
|
if (!current.home) return current.name
|
|
|
|
return (
|
|
<Link
|
|
component="button"
|
|
variant="h6"
|
|
onClick={() => current.home && openProfileHome(current.home)}
|
|
sx={{
|
|
color: 'inherit',
|
|
textDecoration: 'none',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
minWidth: 0,
|
|
maxWidth: '100%',
|
|
fontWeight: 'medium',
|
|
fontSize: 18,
|
|
'& > span': {
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
whiteSpace: 'nowrap',
|
|
flex: 1,
|
|
},
|
|
}}
|
|
title={current.name!}
|
|
>
|
|
<span>{current.name}</span>
|
|
<LaunchOutlined
|
|
fontSize="inherit"
|
|
sx={{
|
|
ml: 0.5,
|
|
fontSize: '0.8rem',
|
|
opacity: 0.7,
|
|
flexShrink: 0,
|
|
}}
|
|
/>
|
|
</Link>
|
|
)
|
|
}, [current, t])
|
|
|
|
// 卡片操作按钮
|
|
const cardAction = useMemo(() => {
|
|
if (!current) return null
|
|
|
|
return (
|
|
<Button
|
|
variant="outlined"
|
|
size="small"
|
|
onClick={goToProfiles}
|
|
endIcon={<StorageOutlined fontSize="small" />}
|
|
sx={{ borderRadius: 1.5 }}
|
|
>
|
|
{t('layout.components.navigation.tabs.profiles')}
|
|
</Button>
|
|
)
|
|
}, [current, goToProfiles, t])
|
|
|
|
return (
|
|
<EnhancedCard
|
|
title={cardTitle}
|
|
icon={<CloudUploadOutlined />}
|
|
iconColor="info"
|
|
action={cardAction}
|
|
>
|
|
{current ? (
|
|
<ProfileDetails
|
|
current={current}
|
|
onUpdateProfile={onUpdateProfile}
|
|
updating={updating}
|
|
/>
|
|
) : (
|
|
<EmptyProfile onClick={goToProfiles} />
|
|
)}
|
|
</EnhancedCard>
|
|
)
|
|
}
|