fix(expiry): 已过期资产不再从到期统计中消失,总览页改为到期日历
get_expiring/get_overview 去掉 expiry_date>=today 下界(仅排除已注销)并新增 overdue_count;renewal_service 续费提醒纳入已过期、文案区分已过期N天/今天到期/N天后;dashboard 用到期日历逐条显示具体到期日与倒计时;api.js 新增 Fmt.countdownText 统一文案。
This commit is contained in:
@@ -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,7 +447,12 @@ def get_overview(session: Session) -> dict:
|
||||
abnormal += 1
|
||||
if expiry_date:
|
||||
days = (expiry_date - today).days
|
||||
if 0 <= days <= 30:
|
||||
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
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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';
|
||||
},
|
||||
|
||||
@@ -10,6 +10,7 @@ const DashboardView = {
|
||||
<div class="bg-white dark:bg-slate-900 rounded-xl p-4 border border-red-200 dark:border-red-900/50">
|
||||
<div class="text-xs text-slate-500">30天内到期</div>
|
||||
<div class="text-2xl font-bold mt-1 text-red-600 dark:text-red-400">{{ store.overview.expiring_30 || 0 }}</div>
|
||||
<div v-if="store.overview.overdue_count" class="text-xs mt-1 font-medium text-red-600 dark:text-red-400">另有 {{ store.overview.overdue_count }} 项已过期</div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-slate-900 rounded-xl p-4 border border-slate-200 dark:border-slate-800">
|
||||
<div class="text-xs text-slate-500">本年支出</div>
|
||||
@@ -21,18 +22,27 @@ const DashboardView = {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4" v-if="store.expiring.length">
|
||||
<h2 class="text-sm font-semibold mb-3">续费倒计时(30天内)</h2>
|
||||
<div class="space-y-2">
|
||||
<div v-for="a in store.expiring" :key="a.id" class="flex justify-between items-center px-3 py-2 rounded-lg bg-slate-50 dark:bg-slate-800/50">
|
||||
<!-- 到期日历:不限制在 30 天窗口内,每个有到期日的产品都列出“几号到期 / 还剩多久” -->
|
||||
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h2 class="text-sm font-semibold">到期日历({{ expiryList.length }})</h2>
|
||||
<a href="#monitor" class="text-xs text-blue-600 dark:text-blue-400 hover:underline">全部倒计时 →</a>
|
||||
</div>
|
||||
<div v-if="expiryList.length" class="space-y-2">
|
||||
<div v-for="a in expiryList" :key="a.id" class="flex justify-between items-center gap-3 px-3 py-2 rounded-lg bg-slate-50 dark:bg-slate-800/50">
|
||||
<div class="min-w-0">
|
||||
<span class="font-medium text-sm truncate">{{ a.name }}</span>
|
||||
<span class="text-xs text-slate-400 ml-2">{{ a.provider_name || a.provider }}</span>
|
||||
<div class="font-medium text-sm truncate">{{ a.name }}</div>
|
||||
<div class="text-xs text-slate-400 mt-0.5 truncate">{{ meta(a) }}</div>
|
||||
</div>
|
||||
<span class="text-xs px-2 py-0.5 rounded-full shrink-0" :class="Fmt.expiryBadge(a.days_to_expiry)">{{ a.days_to_expiry }}天 · {{ a.cost }}{{ a.currency }}</span>
|
||||
<div class="text-right shrink-0">
|
||||
<div class="text-sm" :class="Fmt.expiryText(a.days_to_expiry)">{{ a.expiry_date }}</div>
|
||||
<span class="text-xs px-2 py-0.5 rounded-full" :class="Fmt.expiryBadge(a.days_to_expiry)">{{ Fmt.countdownText(a.days_to_expiry) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="text-xs text-slate-400 py-4 text-center">没有资产设置了到期日,算不出倒计时</p>
|
||||
<p v-if="noExpiryCount" class="text-xs text-slate-400 mt-2">另 {{ noExpiryCount }} 个资产未填到期日,不会出现在这里(到资产页编辑补上)</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
|
||||
<h2 class="text-sm font-semibold mb-3">按类型分布</h2>
|
||||
@@ -42,5 +52,18 @@ const DashboardView = {
|
||||
</div>
|
||||
</div>
|
||||
</div>`,
|
||||
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 };
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user