mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-06 15:03:54 +08:00
fix(frontend): correct subscription expiry labels
This commit is contained in:
@@ -623,7 +623,9 @@ export default {
|
||||
quotaEndsInMinutes: 'Quota ends in {minutes}m',
|
||||
quotaEndsInHoursMinutes: 'Quota ends in {hours}h {minutes}m',
|
||||
quotaEndsInDaysHours: 'Quota ends in {days}d {hours}h',
|
||||
daysRemaining: 'days remaining',
|
||||
daysRemaining: '{days} days remaining',
|
||||
hoursMinutesRemaining: '{hours}h {minutes}m remaining',
|
||||
minutesRemaining: '{minutes}m remaining',
|
||||
remainingDays: 'Remaining days',
|
||||
noExpiration: 'No expiration',
|
||||
status: {
|
||||
|
||||
@@ -623,7 +623,9 @@ export default {
|
||||
quotaEndsInMinutes: '额度将在 {minutes} 分钟后结束',
|
||||
quotaEndsInHoursMinutes: '额度将在 {hours} 小时 {minutes} 分钟后结束',
|
||||
quotaEndsInDaysHours: '额度将在 {days} 天 {hours} 小时后结束',
|
||||
daysRemaining: '天剩余',
|
||||
daysRemaining: '剩余 {days} 天',
|
||||
hoursMinutesRemaining: '剩余 {hours} 小时 {minutes} 分钟',
|
||||
minutesRemaining: '剩余 {minutes} 分钟',
|
||||
remainingDays: '剩余天数',
|
||||
noExpiration: '无过期时间',
|
||||
status: {
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { getExpirationDateRelation, getRemainingExpiryDuration } from '../subscriptionQuota'
|
||||
|
||||
describe('subscription expiry timing', () => {
|
||||
it('uses local calendar dates for today and tomorrow', () => {
|
||||
const now = new Date(2026, 2, 7, 23, 30)
|
||||
|
||||
expect(getExpirationDateRelation(new Date(2026, 2, 7, 23, 45), now)).toBe('today')
|
||||
expect(getExpirationDateRelation(new Date(2026, 2, 8, 3, 30), now)).toBe('tomorrow')
|
||||
})
|
||||
|
||||
it('treats the exact expiry instant and elapsed expiries as expired', () => {
|
||||
const now = new Date(2026, 6, 30, 9, 0)
|
||||
|
||||
expect(getExpirationDateRelation(now, now)).toBe('expired')
|
||||
expect(getRemainingExpiryDuration(now, now)).toBeNull()
|
||||
expect(getExpirationDateRelation(new Date(2026, 6, 30, 8, 59), now)).toBe('expired')
|
||||
expect(getRemainingExpiryDuration(new Date(2026, 6, 30, 8, 59), now)).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects invalid target and current dates', () => {
|
||||
const invalid = new Date('invalid')
|
||||
const valid = new Date(2026, 6, 30, 9, 0)
|
||||
|
||||
expect(getExpirationDateRelation(invalid, valid)).toBeNull()
|
||||
expect(getExpirationDateRelation(valid, invalid)).toBeNull()
|
||||
expect(getRemainingExpiryDuration(invalid, valid)).toBeNull()
|
||||
expect(getRemainingExpiryDuration(valid, invalid)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns rounded-up hours and minutes for an expiry under 24 hours away', () => {
|
||||
const now = new Date(2026, 6, 30, 9, 0)
|
||||
|
||||
expect(getRemainingExpiryDuration(new Date(2026, 6, 31, 8, 30), now)).toEqual({
|
||||
unit: 'hoursMinutes',
|
||||
hours: 23,
|
||||
minutes: 30
|
||||
})
|
||||
expect(getRemainingExpiryDuration(new Date(now.getTime() + 1), now)).toEqual({
|
||||
unit: 'hoursMinutes',
|
||||
hours: 0,
|
||||
minutes: 1
|
||||
})
|
||||
expect(getRemainingExpiryDuration(new Date(now.getTime() + 23 * 60 * 60 * 1000 + 1), now)).toEqual({
|
||||
unit: 'hoursMinutes',
|
||||
hours: 23,
|
||||
minutes: 1
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves rounded-up day display from 24 hours onward', () => {
|
||||
const now = new Date(2026, 6, 30, 9, 0)
|
||||
|
||||
expect(getRemainingExpiryDuration(new Date(now.getTime() + 24 * 60 * 60 * 1000), now)).toEqual({
|
||||
unit: 'days',
|
||||
days: 1
|
||||
})
|
||||
expect(getRemainingExpiryDuration(new Date(now.getTime() + 24 * 60 * 60 * 1000 + 1), now)).toEqual({
|
||||
unit: 'days',
|
||||
days: 2
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -2,6 +2,12 @@ import type { UserSubscription } from '@/types'
|
||||
|
||||
const ONE_DAY_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
export type ExpirationDateRelation = 'expired' | 'today' | 'tomorrow' | 'later'
|
||||
|
||||
export type RemainingExpiryDuration =
|
||||
| { unit: 'days'; days: number }
|
||||
| { unit: 'hoursMinutes'; hours: number; minutes: number }
|
||||
|
||||
export interface RemainingDurationParts {
|
||||
days: number
|
||||
hours: number
|
||||
@@ -40,3 +46,46 @@ export function getRemainingDurationParts(
|
||||
|
||||
return { days, hours, minutes }
|
||||
}
|
||||
|
||||
export function getExpirationDateRelation(
|
||||
targetAt: Date | string,
|
||||
now: Date = new Date()
|
||||
): ExpirationDateRelation | null {
|
||||
const target = targetAt instanceof Date ? targetAt : new Date(targetAt)
|
||||
const targetTime = target.getTime()
|
||||
const nowTime = now.getTime()
|
||||
|
||||
if (!Number.isFinite(targetTime) || !Number.isFinite(nowTime)) return null
|
||||
if (targetTime <= nowTime) return 'expired'
|
||||
|
||||
const targetDay = Date.UTC(target.getFullYear(), target.getMonth(), target.getDate())
|
||||
const currentDay = Date.UTC(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
const calendarDays = Math.round((targetDay - currentDay) / ONE_DAY_MS)
|
||||
|
||||
if (calendarDays === 0) return 'today'
|
||||
if (calendarDays === 1) return 'tomorrow'
|
||||
return 'later'
|
||||
}
|
||||
|
||||
export function getRemainingExpiryDuration(
|
||||
targetAt: Date | string,
|
||||
now: Date = new Date()
|
||||
): RemainingExpiryDuration | null {
|
||||
const targetTime = targetAt instanceof Date ? targetAt.getTime() : new Date(targetAt).getTime()
|
||||
const nowTime = now.getTime()
|
||||
|
||||
if (!Number.isFinite(targetTime) || !Number.isFinite(nowTime)) return null
|
||||
|
||||
const diffMs = targetTime - nowTime
|
||||
if (diffMs <= 0) return null
|
||||
if (diffMs >= ONE_DAY_MS) {
|
||||
return { unit: 'days', days: Math.ceil(diffMs / ONE_DAY_MS) }
|
||||
}
|
||||
|
||||
const totalMinutes = Math.ceil(diffMs / (60 * 1000))
|
||||
return {
|
||||
unit: 'hoursMinutes',
|
||||
hours: Math.floor(totalMinutes / 60),
|
||||
minutes: totalMinutes % 60
|
||||
}
|
||||
}
|
||||
|
||||
@@ -353,9 +353,14 @@
|
||||
>
|
||||
{{ formatDateTimeToMinute(value) }}
|
||||
</span>
|
||||
<div v-if="getDaysRemaining(value) !== null" class="text-xs text-gray-500">
|
||||
{{ getDaysRemaining(value) }} {{ t('admin.subscriptions.daysRemaining') }}
|
||||
</div>
|
||||
<template
|
||||
v-for="remainingExpiry in [formatRemainingExpiry(value)]"
|
||||
:key="remainingExpiry ?? 'expired'"
|
||||
>
|
||||
<div v-if="remainingExpiry" class="text-xs text-gray-500">
|
||||
{{ remainingExpiry }}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<span v-else class="text-sm text-gray-500">{{
|
||||
t('admin.subscriptions.noExpiration')
|
||||
@@ -777,7 +782,12 @@ import Select from '@/components/common/Select.vue'
|
||||
import GroupBadge from '@/components/common/GroupBadge.vue'
|
||||
import GroupOptionItem from '@/components/common/GroupOptionItem.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import { getRemainingDurationParts, isOneTimeDailyQuota, type RemainingDurationParts } from '@/utils/subscriptionQuota'
|
||||
import {
|
||||
getRemainingDurationParts,
|
||||
getRemainingExpiryDuration,
|
||||
isOneTimeDailyQuota,
|
||||
type RemainingDurationParts
|
||||
} from '@/utils/subscriptionQuota'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
@@ -1334,6 +1344,21 @@ const getDaysRemaining = (expiresAt: string): number | null => {
|
||||
return Math.ceil(diff / (1000 * 60 * 60 * 24))
|
||||
}
|
||||
|
||||
const formatRemainingExpiry = (expiresAt: string): string | null => {
|
||||
const duration = getRemainingExpiryDuration(expiresAt)
|
||||
if (!duration) return null
|
||||
if (duration.unit === 'days') {
|
||||
return t('admin.subscriptions.daysRemaining', { days: duration.days })
|
||||
}
|
||||
if (duration.hours) {
|
||||
return t('admin.subscriptions.hoursMinutesRemaining', {
|
||||
hours: duration.hours,
|
||||
minutes: duration.minutes
|
||||
})
|
||||
}
|
||||
return t('admin.subscriptions.minutesRemaining', { minutes: duration.minutes })
|
||||
}
|
||||
|
||||
const isExpiringSoon = (expiresAt: string): boolean => {
|
||||
const days = getDaysRemaining(expiresAt)
|
||||
return days !== null && days <= 7
|
||||
|
||||
@@ -259,7 +259,12 @@ import Icon from '@/components/icons/Icon.vue'
|
||||
import { formatDateTimeToMinute } from '@/utils/format'
|
||||
import { hasPeakRate, formatPeakRateWindow, serverTimezoneLabel } from '@/utils/peak-rate'
|
||||
import { platformBorderClass, platformBadgeClass, platformButtonClass, platformLabel } from '@/utils/platformColors'
|
||||
import { getRemainingDurationParts, isOneTimeDailyQuota, type RemainingDurationParts } from '@/utils/subscriptionQuota'
|
||||
import {
|
||||
getExpirationDateRelation,
|
||||
getRemainingDurationParts,
|
||||
isOneTimeDailyQuota,
|
||||
type RemainingDurationParts
|
||||
} from '@/utils/subscriptionQuota'
|
||||
|
||||
function platformAccentDotClass(p: string): string {
|
||||
switch (p) {
|
||||
@@ -317,17 +322,20 @@ function formatExpirationDate(expiresAt: string): string {
|
||||
const expires = new Date(expiresAt)
|
||||
const diff = expires.getTime() - now.getTime()
|
||||
const days = Math.ceil(diff / (1000 * 60 * 60 * 24))
|
||||
const relation = getExpirationDateRelation(expires, now)
|
||||
|
||||
if (days < 0) {
|
||||
if (relation === null) return ''
|
||||
|
||||
if (relation === 'expired') {
|
||||
return t('userSubscriptions.status.expired')
|
||||
}
|
||||
|
||||
const dateStr = formatDateTimeToMinute(expires)
|
||||
|
||||
if (days === 0) {
|
||||
if (relation === 'today') {
|
||||
return `${dateStr} (${t('common.today')})`
|
||||
}
|
||||
if (days === 1) {
|
||||
if (relation === 'tomorrow') {
|
||||
return `${dateStr} (${t('common.tomorrow')})`
|
||||
}
|
||||
|
||||
@@ -340,7 +348,7 @@ function getExpirationClass(expiresAt: string): string {
|
||||
const diff = expires.getTime() - now.getTime()
|
||||
const days = Math.ceil(diff / (1000 * 60 * 60 * 24))
|
||||
|
||||
if (days <= 0) return 'text-red-600 dark:text-red-400 font-medium'
|
||||
if (diff <= 0) return 'text-red-600 dark:text-red-400 font-medium'
|
||||
if (days <= 3) return 'text-red-600 dark:text-red-400'
|
||||
if (days <= 7) return 'text-orange-600 dark:text-orange-400'
|
||||
return 'text-gray-700 dark:text-gray-300'
|
||||
|
||||
Reference in New Issue
Block a user