diff --git a/app/services/asset_service.py b/app/services/asset_service.py index da4f9e7..82d2036 100644 --- a/app/services/asset_service.py +++ b/app/services/asset_service.py @@ -434,6 +434,7 @@ def get_overview(session: Session) -> dict: by_status: dict = {} abnormal = 0 expiring_30 = 0 + overdue = 0 year_cost = 0.0 month_cost = 0.0 @@ -446,8 +447,13 @@ def get_overview(session: Session) -> dict: abnormal += 1 if expiry_date: days = (expiry_date - today).days - if 0 <= days <= 30: - expiring_30 += 1 + if status_val != AssetStatus.CANCELLED: + # 已过期单独计数:过了续费日却还没处理的资产,比 30 天内的更需要被看到, + # 不能因为「不在未来窗口内」就从统计里消失 + if days < 0: + overdue += 1 + elif days <= 30: + expiring_30 += 1 if expiry_date.year == today.year: year_cost += cost if expiry_date.month == today.month: @@ -459,23 +465,24 @@ def get_overview(session: Session) -> dict: "by_status": by_status, "abnormal_count": abnormal, "expiring_30": expiring_30, + "overdue_count": overdue, "year_cost": round(year_cost, 2), "month_cost": round(month_cost, 2), } def get_expiring(session: Session, days: int = 30) -> List[AssetRead]: - """N 天内到期资产列表(按剩余天数升序) + """N 天内到期资产列表,含已过期未处理的(按剩余天数升序,最紧急在前) - 性能优化:过滤条件下推到 SQL 层,仅查询 [today, today+days] 区间内的资产。 + 过滤条件下推到 SQL 层:到期日上界取 today+days,但不设下界 —— 昨天到期的 + 资产比 30 天后到期的更该出现在倒计时里;只排除已注销资产。 """ - today = date.today() - deadline = today + timedelta(days=days) + deadline = date.today() + timedelta(days=days) assets = session.exec( select(Asset).where( Asset.expiry_date.is_not(None), - Asset.expiry_date >= today, Asset.expiry_date <= deadline, + Asset.status.notin_([AssetStatus.CANCELLED]), ) ).all() batch = _get_details_batch(session, assets) diff --git a/app/services/renewal_service.py b/app/services/renewal_service.py index 0c6c9e5..ea3038c 100644 --- a/app/services/renewal_service.py +++ b/app/services/renewal_service.py @@ -16,8 +16,9 @@ _SKIP_STATUS = {AssetStatus.CANCELLED} def get_expiring_assets(session: Session, threshold_days: int) -> list: - """返回 threshold_days 天内到期的资产列表 [(asset, days), ...],按剩余天数升序 + """返回 threshold_days 天内到期(含已过期未处理)的资产列表 [(asset, days), ...],按剩余天数升序 + 到期日不设下界:过了续费日仍未注销/续费的资产,提醒不能就此消失。 性能优化:日期范围与状态过滤均下推到 SQL 层。 """ today = date.today() @@ -25,7 +26,6 @@ def get_expiring_assets(session: Session, threshold_days: int) -> list: assets = session.exec( select(Asset).where( Asset.expiry_date.is_not(None), - Asset.expiry_date >= today, Asset.expiry_date <= deadline, Asset.status.notin_(_SKIP_STATUS), ) @@ -39,9 +39,19 @@ def build_renewal_message(expiring: list, threshold_days: int) -> str: """生成续费提醒消息(纯文本,兼容 Telegram/邮件)""" if not expiring: return "" - lines = [f"⏰ 资产续费提醒({threshold_days} 天内到期,共 {len(expiring)} 项)", ""] + overdue = sum(1 for _, d in expiring if d < 0) + if overdue: + head = f"⏰ 资产续费提醒(已过期 {overdue} 项 + {threshold_days} 天内到期 {len(expiring) - overdue} 项)" + else: + head = f"⏰ 资产续费提醒({threshold_days} 天内到期,共 {len(expiring)} 项)" + lines = [head, ""] for asset, days in expiring: - tag = "(已过期)" if days == 0 else f"({days} 天后)" + if days < 0: + tag = f"(已过期 {-days} 天)" + elif days == 0: + tag = "(今天到期)" + else: + tag = f"({days} 天后)" lines.append(f"• {asset.name} [{asset.provider}] {tag} 到期 {asset.expiry_date}") return "\n".join(lines) diff --git a/static/js/api.js b/static/js/api.js index 2cd37a4..a849a77 100644 --- a/static/js/api.js +++ b/static/js/api.js @@ -124,6 +124,13 @@ window.VpsFmt = { if (d !== null && d !== undefined && d <= 30) return 'bg-amber-500/10 text-amber-600 dark:text-amber-400'; return 'bg-slate-500/10 text-slate-500'; }, + /* 倒计时文案:统一「距到期还有多久」的表述,负数归一为「已过期 N 天」,避免直出 "-3 天" */ + countdownText(d) { + if (d === null || d === undefined) return '未设到期日'; + if (d < 0) return '已过期 ' + Math.abs(d) + ' 天'; + if (d === 0) return '今天到期'; + return d + ' 天后'; + }, checkBadge(s) { return { pass: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400', warn: 'bg-amber-500/10 text-amber-600 dark:text-amber-400', fail: 'bg-red-500/10 text-red-600 dark:text-red-400', unknown: 'bg-slate-500/10 text-slate-500' }[s] || 'bg-slate-500/10 text-slate-500'; }, diff --git a/static/js/views/dashboard.js b/static/js/views/dashboard.js index c7216a1..1ae5482 100644 --- a/static/js/views/dashboard.js +++ b/static/js/views/dashboard.js @@ -10,6 +10,7 @@ const DashboardView = {
30天内到期
{{ store.overview.expiring_30 || 0 }}
+
另有 {{ store.overview.overdue_count }} 项已过期
本年支出
@@ -21,17 +22,26 @@ const DashboardView = {
-
-

续费倒计时(30天内)

-
-
+ +
+
+

到期日历({{ expiryList.length }})

+ 全部倒计时 → +
+
+
- {{ a.name }} - {{ a.provider_name || a.provider }} +
{{ a.name }}
+
{{ meta(a) }}
+
+
+
{{ a.expiry_date }}
+ {{ Fmt.countdownText(a.days_to_expiry) }}
- {{ a.days_to_expiry }}天 · {{ a.cost }}{{ a.currency }}
+

没有资产设置了到期日,算不出倒计时

+

另 {{ noExpiryCount }} 个资产未填到期日,不会出现在这里(到资产页编辑补上)

@@ -42,5 +52,18 @@ const DashboardView = {
`, - setup() { return { store, Fmt }; }, + setup() { + // 到期日历:只看未注销且有到期日的资产,最紧急(含已过期)排最前 + const expiryList = Vue.computed(() => store.assets + .filter(a => a.expiry_date && a.status !== 'cancelled') + .sort((x, y) => (x.days_to_expiry ?? 10 ** 9) - (y.days_to_expiry ?? 10 ** 9))); + const noExpiryCount = Vue.computed(() => store.assets.filter(a => !a.expiry_date && a.status !== 'cancelled').length); + // 副标题:类型 · 平台 · 费用/周期,缺失的字段不占位(避免出现 "VPS · · 3.5USD") + function meta(a) { + const bits = [Fmt.TYPE_LABELS[a.asset_type] || a.asset_type, a.provider_name || a.provider]; + if (a.cost) bits.push(a.cost + (a.currency || '') + '/' + (Fmt.CYCLE_LABELS[a.renewal_cycle] || '未设周期')); + return bits.filter(Boolean).join(' · '); + } + return { store, Fmt, expiryList, noExpiryCount, meta }; + }, };