Files
clash-verge-rev/src/components/home/home-profile-card.tsx
T
Tunglies 6e08a77a84 refactor(backend): specta ipc bindings (#8195)
* 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
2026-10-12 06:16:05 +00:00

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>
)
}