Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"@radix-ui/react-dropdown-menu": "^2.1.2",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-popover": "^1.1.2",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-scroll-area": "^1.2.0",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slot": "^1.1.0",
Expand Down
26 changes: 26 additions & 0 deletions frontend/src/components/ui/progress.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"

import { cn } from "@/lib/utils"

const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName

export { Progress }
8 changes: 8 additions & 0 deletions frontend/src/i18n/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,14 @@ const en = {
cacheTTLPlaceholder: 'Cache TTL in seconds, 0 = no cache',
emptyTitle: 'Add your first upstream provider',
emptyDescription: 'Providers supply the real nodes. Later, subscriptions can enable these sources and merge them into the final config.',
traffic: 'Traffic',
trafficUpload: 'Upload',
trafficDownload: 'Download',
trafficTotal: 'Total',
trafficExpires: 'Expires',
trafficExpired: 'Expired',
trafficExpiresToday: 'Expires today',
trafficDaysLeft: '{{count}} days left',
},

userAgents: {
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/i18n/locales/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,14 @@ const zh = {
cacheTTLPlaceholder: '缓存有效期(秒),0 表示不缓存',
emptyTitle: '先添加一个上游订阅源',
emptyDescription: '订阅源负责提供真实节点。后续创建订阅时,可以勾选这些来源并统一生成最终配置。',
traffic: '流量',
trafficUpload: '上传',
trafficDownload: '下载',
trafficTotal: '总量',
trafficExpires: '到期',
trafficExpired: '已过期',
trafficExpiresToday: '今日到期',
trafficDaysLeft: '{{count}} 天后到期',
},

// UA 库
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,8 @@ import {
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'

interface ProviderFormData {
name: string
url: string
user_agent_id: string
cache_ttl: string
}
import type { ProviderFormData } from './shared/types'
import { TrafficCell } from './traffic/TrafficCell'

export function Providers() {
const { t } = useTranslation()
Expand Down Expand Up @@ -177,21 +172,22 @@ export function Providers() {
<TableHead>{t('providers.providerUrl')}</TableHead>
<TableHead>{t('providers.userAgent')}</TableHead>
<TableHead>{t('providers.lastFetched')}</TableHead>
<TableHead>{t('providers.traffic')}</TableHead>
<TableHead className="w-[130px]">{t('common.actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
Array.from({ length: 3 }).map((_, i) => (
<TableRow key={i}>
{Array.from({ length: 5 }).map((_, j) => (
{Array.from({ length: 6 }).map((_, j) => (
<TableCell key={j}><Skeleton className="h-5 w-full" /></TableCell>
))}
</TableRow>
))
) : providers.length === 0 ? (
<TableRow>
<TableCell colSpan={5}>
<TableCell colSpan={6}>
<EmptyState
title={t('providers.emptyTitle')}
description={t('providers.emptyDescription')}
Expand Down Expand Up @@ -254,6 +250,14 @@ export function Providers() {
</span>
)}
</TableCell>
<TableCell>
<TrafficCell
upload={p.traffic_upload}
download={p.traffic_download}
total={p.traffic_total}
expireAt={p.traffic_expire_at}
/>
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Button
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/pages/Providers/shared/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export interface ProviderFormData {
name: string
url: string
user_agent_id: string
cache_ttl: string
}
120 changes: 120 additions & 0 deletions frontend/src/pages/Providers/traffic/TrafficCell.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { useTranslation } from 'react-i18next'
import { CalendarDays } from 'lucide-react'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { Badge } from '@/components/ui/badge'
import { cn } from '@/lib/utils'
import { formatBytes, calcUsagePercent, formatExpireDate, daysRemaining } from './traffic-utils'

interface TrafficCellProps {
upload?: number | null
download?: number | null
total?: number | null
expireAt?: string | null
}

function percentBadgeVariant(p: number) {
if (p >= 90) return 'destructive' as const
if (p >= 75) return 'secondary' as const
return 'outline' as const
}

function progressColor(p: number) {
if (p >= 90) return 'bg-destructive'
if (p >= 75) return 'bg-amber-500'
return 'bg-emerald-500'
}

export function TrafficCell({ upload, download, total, expireAt }: TrafficCellProps) {
const { t } = useTranslation()
const percent = calcUsagePercent(upload, download, total)
const used = (upload ?? 0) + (download ?? 0)
const expireStr = formatExpireDate(expireAt)
const days = daysRemaining(expireAt)

// 无任何流量数据
if (percent === null && !expireStr) {
return <span className="text-muted-foreground text-sm">-</span>
}

return (
<div className="min-w-[180px] space-y-2">
{/* 流量使用量 + 百分比 */}
{percent !== null && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="space-y-1.5">
{/* 第一行:已用 | 百分比 badge */}
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-semibold tabular-nums truncate">
{formatBytes(used)}
</span>
<Badge
variant={percentBadgeVariant(percent)}
className={cn(
'shrink-0 text-[11px] font-bold tabular-nums px-1.5 py-0',
percent < 75 && 'border-emerald-500/50 text-emerald-600'
)}
>
{percent}%
</Badge>
</div>

{/* 进度条 */}
<div className="h-2.5 w-full overflow-hidden rounded-full bg-secondary">
<div
className={cn('h-full rounded-full transition-all', progressColor(percent))}
style={{ width: `${percent}%` }}
/>
</div>

{/* 第三行:总量 */}
<p className="text-[11px] text-muted-foreground">
{t('providers.trafficTotal')}: {formatBytes(total)}
</p>
</div>
</TooltipTrigger>
<TooltipContent>
<div className="space-y-1 text-xs">
<p>{t('providers.trafficUpload')}: {formatBytes(upload ?? 0)}</p>
<p>{t('providers.trafficDownload')}: {formatBytes(download ?? 0)}</p>
<p>{t('providers.trafficTotal')}: {formatBytes(total)}</p>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}

{/* 到期时间 */}
{expireStr && (
<div
className={cn(
'flex items-center gap-1.5 text-[11px]',
days !== null && days < 0
? 'text-destructive font-medium'
: days !== null && days <= 7
? 'text-amber-600 font-medium'
: 'text-muted-foreground'
)}
>
<CalendarDays className="h-3 w-3 shrink-0" />
<span>{expireStr}</span>
{days !== null && (
<span className="ml-auto">
{days < 0
? t('providers.trafficExpired')
: days === 0
? t('providers.trafficExpiresToday')
: t('providers.trafficDaysLeft', { count: days })}
</span>
)}
</div>
)}
</div>
)
}
62 changes: 62 additions & 0 deletions frontend/src/pages/Providers/traffic/traffic-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/**
* 将字节数格式化为可读字符串(KB/MB/GB/TB)。
* 使用二进制单位(1 GB = 1024^3),网络流量通用标准。
*/
export function formatBytes(bytes: number | null | undefined): string {
if (bytes == null || bytes <= 0) return '-'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let i = 0
let value = bytes
while (value >= 1024 && i < units.length - 1) {
value /= 1024
i++
}
return `${value.toFixed(i === 0 ? 0 : 2)} ${units[i]}`
}

/**
* 计算流量使用百分比: (upload + download) / total * 100
* total 不可用或为 0 时返回 null。
*/
export function calcUsagePercent(
upload: number | null | undefined,
download: number | null | undefined,
total: number | null | undefined
): number | null {
if (total == null || total <= 0) return null
const used = (upload ?? 0) + (download ?? 0)
return Math.min(Math.round((used / total) * 100), 100)
}

/**
* 将 ISO 日期字符串格式化为 YYYY-MM-DD 格式。
* 输入为 null/undefined 或 Unix epoch 0 时返回 null。
*/
export function formatExpireDate(isoDate: string | null | undefined): string | null {
if (!isoDate) return null
const d = new Date(isoDate)
if (isNaN(d.getTime())) return null
if (d.getTime() <= 0) return null
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}

/**
* 计算剩余天数。返回 null 表示无到期信息;负数表示已过期。
*/
export function daysRemaining(isoDate: string | null | undefined): number | null {
if (!isoDate) return null
const d = new Date(isoDate)
if (isNaN(d.getTime()) || d.getTime() <= 0) return null
return Math.ceil((d.getTime() - Date.now()) / (1000 * 60 * 60 * 24))
}

/**
* 判断订阅是否已过期。
*/
export function isExpired(isoDate: string | null | undefined): boolean {
const days = daysRemaining(isoDate)
return days !== null && days < 0
}
5 changes: 5 additions & 0 deletions frontend/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ export interface Provider {
last_fetched_at?: string
fetch_error?: string
user_agent?: UserAgent
// 订阅流量信息(从 subscription-userinfo 响应头解析)
traffic_upload?: number | null
traffic_download?: number | null
traffic_total?: number | null
traffic_expire_at?: string | null
}

// 代理节点(结构化)
Expand Down
6 changes: 6 additions & 0 deletions internal/model/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ type Provider struct {
CacheTTL int `gorm:"default:60" json:"cache_ttl"` // 缓存有效期(分钟)
FetchError string `json:"fetch_error,omitempty"` // 最近一次拉取错误信息

// ── 订阅流量信息(从 subscription-userinfo 响应头解析) ──
TrafficUpload *int64 `json:"traffic_upload"` // 已上传(字节)
TrafficDownload *int64 `json:"traffic_download"` // 已下载(字节)
TrafficTotal *int64 `json:"traffic_total"` // 总流量(字节)
TrafficExpireAt *time.Time `json:"traffic_expire_at"` // 订阅到期时间

User User `gorm:"foreignKey:UserID" json:"-"`
UserAgent *UserAgent `gorm:"foreignKey:UserAgentID" json:"user_agent,omitempty"`
}
Loading