From fb40211305dbe90948f0497c2404838776f1d914 Mon Sep 17 00:00:00 2001 From: wucm667 Date: Thu, 30 Jul 2026 12:47:05 +0800 Subject: [PATCH] fix(frontend): correct subscription expiry labels --- .../src/i18n/locales/en/admin/channels.ts | 4 +- .../src/i18n/locales/zh/admin/channels.ts | 4 +- .../utils/__tests__/subscriptionQuota.spec.ts | 64 +++++++++++++++++++ frontend/src/utils/subscriptionQuota.ts | 49 ++++++++++++++ .../src/views/admin/SubscriptionsView.vue | 33 ++++++++-- frontend/src/views/user/SubscriptionsView.vue | 18 ++++-- 6 files changed, 161 insertions(+), 11 deletions(-) create mode 100644 frontend/src/utils/__tests__/subscriptionQuota.spec.ts diff --git a/frontend/src/i18n/locales/en/admin/channels.ts b/frontend/src/i18n/locales/en/admin/channels.ts index 96c0a6cbd..7907b1ee9 100644 --- a/frontend/src/i18n/locales/en/admin/channels.ts +++ b/frontend/src/i18n/locales/en/admin/channels.ts @@ -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: { diff --git a/frontend/src/i18n/locales/zh/admin/channels.ts b/frontend/src/i18n/locales/zh/admin/channels.ts index cdfb185c8..b95be9352 100644 --- a/frontend/src/i18n/locales/zh/admin/channels.ts +++ b/frontend/src/i18n/locales/zh/admin/channels.ts @@ -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: { diff --git a/frontend/src/utils/__tests__/subscriptionQuota.spec.ts b/frontend/src/utils/__tests__/subscriptionQuota.spec.ts new file mode 100644 index 000000000..9cc8bc707 --- /dev/null +++ b/frontend/src/utils/__tests__/subscriptionQuota.spec.ts @@ -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 + }) + }) +}) diff --git a/frontend/src/utils/subscriptionQuota.ts b/frontend/src/utils/subscriptionQuota.ts index 719737beb..d4135aa4b 100644 --- a/frontend/src/utils/subscriptionQuota.ts +++ b/frontend/src/utils/subscriptionQuota.ts @@ -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 + } +} diff --git a/frontend/src/views/admin/SubscriptionsView.vue b/frontend/src/views/admin/SubscriptionsView.vue index ff44e52a2..b23fa2fdc 100644 --- a/frontend/src/views/admin/SubscriptionsView.vue +++ b/frontend/src/views/admin/SubscriptionsView.vue @@ -353,9 +353,14 @@ > {{ formatDateTimeToMinute(value) }} -
- {{ getDaysRemaining(value) }} {{ t('admin.subscriptions.daysRemaining') }} -
+ {{ 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 diff --git a/frontend/src/views/user/SubscriptionsView.vue b/frontend/src/views/user/SubscriptionsView.vue index 744529848..7b7a71d3c 100644 --- a/frontend/src/views/user/SubscriptionsView.vue +++ b/frontend/src/views/user/SubscriptionsView.vue @@ -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'