feat: add subscription expiry email toggle

This commit is contained in:
shaw
2026-05-21 14:27:50 +08:00
parent bd3d4d9a24
commit a613a587ba
17 changed files with 519 additions and 34 deletions
+6 -2
View File
@@ -537,10 +537,11 @@ export interface SystemSettings {
payment_visible_method_wxpay_enabled?: boolean;
openai_advanced_scheduler_enabled?: boolean;
// Balance & quota notification
// 余额、订阅到期与账号限额通知
balance_low_notify_enabled: boolean;
balance_low_notify_threshold: number;
balance_low_notify_recharge_url: string;
subscription_expiry_notify_enabled: boolean;
account_quota_notify_enabled: boolean;
account_quota_notify_emails: NotifyEmailEntry[];
@@ -756,10 +757,11 @@ export interface UpdateSettingsRequest {
payment_visible_method_alipay_enabled?: boolean;
payment_visible_method_wxpay_enabled?: boolean;
openai_advanced_scheduler_enabled?: boolean;
// Balance & quota notification
// 余额、订阅到期与账号限额通知
balance_low_notify_enabled?: boolean;
balance_low_notify_threshold?: number;
balance_low_notify_recharge_url?: string;
subscription_expiry_notify_enabled?: boolean;
account_quota_notify_enabled?: boolean;
account_quota_notify_emails?: NotifyEmailEntry[];
@@ -862,6 +864,8 @@ export interface EmailTemplateOption {
value: string;
label?: string;
description?: string;
category?: string;
optional?: boolean;
}
export type EmailTemplateEventOption = string | EmailTemplateOption;
+6
View File
@@ -5795,6 +5795,12 @@ export default {
addEmail: 'Add Email',
emailPlaceholder: 'Enter email address',
},
subscriptionExpiryNotify: {
title: 'Subscription Expiry Reminder',
description: 'Control whether users receive subscription expiry reminder emails.',
enabled: 'Enable Subscription Expiry Reminder',
enabledHint: 'When enabled, the system sends reminders 7, 3, and 1 day before expiry.'
},
smtp: {
title: 'SMTP Settings',
description: 'Configure email sending for verification codes',
+6
View File
@@ -5954,6 +5954,12 @@ export default {
addEmail: '添加邮箱',
emailPlaceholder: '输入邮箱地址',
},
subscriptionExpiryNotify: {
title: '订阅到期提醒',
description: '控制是否向用户发送订阅即将到期的邮件提醒。',
enabled: '启用订阅到期提醒',
enabledHint: '开启后,系统会在订阅到期前 7 天、3 天、1 天各发送一次提醒。'
},
smtp: {
title: 'SMTP 设置',
description: '配置用于发送验证码的邮件服务',
+34 -2
View File
@@ -6283,6 +6283,35 @@
</div>
</div>
<!-- 订阅到期提醒 -->
<div class="card">
<div
class="border-b border-gray-100 px-6 py-4 dark:border-dark-700"
>
<h3 class="text-base font-medium text-gray-900 dark:text-white">
{{ t("admin.settings.subscriptionExpiryNotify.title") }}
</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ t("admin.settings.subscriptionExpiryNotify.description") }}
</p>
</div>
<div class="px-6 py-6">
<div class="flex items-center justify-between gap-4">
<div>
<label
class="mb-0 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{ t("admin.settings.subscriptionExpiryNotify.enabled") }}
</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t("admin.settings.subscriptionExpiryNotify.enabledHint") }}
</p>
</div>
<Toggle v-model="form.subscription_expiry_notify_enabled" />
</div>
</div>
</div>
<EmailTemplateEditor />
<!-- Balance Low Notification -->
@@ -7005,10 +7034,11 @@ const form = reactive<SettingsForm>({
rewrite_message_cache_control: false,
antigravity_user_agent_version: "",
openai_codex_user_agent: "",
// Balance & quota notification
// 余额、订阅到期与账号限额通知
balance_low_notify_enabled: false,
balance_low_notify_threshold: 0,
balance_low_notify_recharge_url: "",
subscription_expiry_notify_enabled: true,
account_quota_notify_enabled: false,
account_quota_notify_emails: [] as NotifyEmailEntry[],
// Channel Monitor feature switch
@@ -8137,12 +8167,14 @@ async function saveSettings() {
form.payment_cancel_rate_limit_window_mode,
payment_alipay_force_qrcode: form.payment_alipay_force_qrcode,
openai_advanced_scheduler_enabled: form.openai_advanced_scheduler_enabled,
// Balance & quota notification
// 余额、订阅到期与账号限额通知
balance_low_notify_enabled: form.balance_low_notify_enabled,
balance_low_notify_threshold:
Number(form.balance_low_notify_threshold) || 0,
balance_low_notify_recharge_url: (form.balance_low_notify_recharge_url =
form.balance_low_notify_recharge_url || currentOrigin),
subscription_expiry_notify_enabled:
form.subscription_expiry_notify_enabled,
account_quota_notify_enabled: form.account_quota_notify_enabled,
account_quota_notify_emails: (
form.account_quota_notify_emails || []
@@ -400,6 +400,7 @@ const baseSettingsResponse = {
balance_low_notify_enabled: false,
balance_low_notify_threshold: 0,
balance_low_notify_recharge_url: "",
subscription_expiry_notify_enabled: true,
account_quota_notify_enabled: false,
account_quota_notify_emails: [],
};
@@ -67,12 +67,9 @@
:key="option.value"
:value="option.value"
>
{{ option.label || option.value }}
{{ formatEventOptionLabel(option) }}
</option>
</select>
<p v-if="selectedEventDescription" class="input-hint">
{{ selectedEventDescription }}
</p>
</div>
<div>
<label class="input-label" for="email-template-locale">
@@ -95,6 +92,41 @@
</div>
</div>
<div
v-if="selectedEventMeta"
class="rounded-lg border border-primary-100 bg-primary-50/70 p-4 dark:border-primary-900/50 dark:bg-primary-950/20"
>
<div class="flex flex-wrap items-center gap-2">
<div class="text-sm font-semibold text-gray-900 dark:text-white">
{{ selectedEventMeta.label }}
</div>
<span
class="rounded-full bg-white px-2.5 py-1 text-xs font-medium text-gray-600 shadow-sm ring-1 ring-gray-200 dark:bg-dark-800 dark:text-gray-300 dark:ring-dark-600"
>
{{ selectedEventMeta.categoryLabel }}
</span>
<span
class="rounded-full px-2.5 py-1 text-xs font-medium"
:class="
selectedEventMeta.optional
? 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300'
: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-300'
"
>
{{ selectedEventMeta.optional ? localText("可退订通知", "Optional") : localText("事务邮件", "Transactional") }}
</span>
</div>
<p class="mt-2 text-sm leading-6 text-gray-600 dark:text-gray-300">
{{ selectedEventMeta.timing }}
</p>
<p
v-if="selectedEventDescription"
class="mt-1 text-xs text-gray-500 dark:text-gray-400"
>
{{ selectedEventDescription }}
</p>
</div>
<div
v-if="!eventOptions.length || !localeOptions.length"
class="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-700 dark:border-amber-800 dark:bg-amber-900/20 dark:text-amber-300"
@@ -274,6 +306,142 @@ const previewSubject = ref("");
const previewHtml = ref("");
const initializingSelection = ref(false);
interface EventDisplayMeta {
label: string;
timing: string;
categoryLabel: string;
}
function localText(zh: string, en: string): string {
return locale.value.toLowerCase().startsWith("zh") ? zh : en;
}
const eventDisplayMeta: Record<string, EventDisplayMeta> = {
"auth.verify_code": {
label: "邮箱验证码",
timing: "注册、绑定邮箱、OAuth 补全邮箱或 TOTP 邮箱校验时发送。",
categoryLabel: "认证安全",
},
"auth.password_reset": {
label: "密码重置",
timing: "用户请求密码重置链接时发送。",
categoryLabel: "认证安全",
},
"notification_email.verify_code": {
label: "通知邮箱验证码",
timing: "用户添加并验证额外通知邮箱时发送。",
categoryLabel: "认证安全",
},
"subscription.purchase_success": {
label: "订阅开通成功",
timing: "订阅订单完成支付并成功开通或续期后发送。",
categoryLabel: "订阅",
},
"subscription.expiry_reminder": {
label: "订阅到期提醒",
timing: "后台任务在订阅仍有效且距离到期剩余 7 天、3 天、1 天时各发送一次,可通过邮件设置中的开关关闭。",
categoryLabel: "订阅",
},
"balance.low": {
label: "余额不足提醒",
timing: "用户余额低于全局或个人配置的提醒阈值时发送。",
categoryLabel: "计费",
},
"balance.recharge_success": {
label: "余额充值成功",
timing: "余额充值订单支付完成并入账后发送。",
categoryLabel: "计费",
},
"account.quota_alert": {
label: "账号限额告警",
timing: "上游账号的用量达到配置的额度告警阈值时发送给管理员通知邮箱。",
categoryLabel: "管理告警",
},
"content_moderation.violation_notice": {
label: "内容审计违规提醒",
timing: "用户请求命中内容审计或风控规则、但尚未被禁用时发送。",
categoryLabel: "风控",
},
"content_moderation.account_disabled": {
label: "内容审计禁用账号",
timing: "内容审计违规次数达到封禁阈值并自动禁用用户账号时发送。",
categoryLabel: "风控",
},
"ops.alert": {
label: "运维告警",
timing: "运维监控规则触发告警并满足邮件通知配置时发送给运维收件人。",
categoryLabel: "运维",
},
"ops.scheduled_report": {
label: "运维定时报表",
timing: "运维日报、周报、错误摘要或账号健康报表到达配置的发送时间时发送。",
categoryLabel: "运维",
},
};
const eventDisplayMetaEn: Record<string, EventDisplayMeta> = {
"auth.verify_code": {
label: "Email Verification Code",
timing: "Sent for registration, email binding, OAuth pending email completion, or TOTP email verification.",
categoryLabel: "Auth",
},
"auth.password_reset": {
label: "Password Reset",
timing: "Sent when a user requests a password reset link.",
categoryLabel: "Auth",
},
"notification_email.verify_code": {
label: "Notification Email Verification",
timing: "Sent when a user adds and verifies an extra notification email address.",
categoryLabel: "Auth",
},
"subscription.purchase_success": {
label: "Subscription Activated",
timing: "Sent after a subscription order is paid and the subscription is activated or extended.",
categoryLabel: "Subscription",
},
"subscription.expiry_reminder": {
label: "Subscription Expiry Reminder",
timing: "Sent by the background job when an active subscription has 7, 3, or 1 day remaining. It can be disabled in Email settings.",
categoryLabel: "Subscription",
},
"balance.low": {
label: "Low Balance Alert",
timing: "Sent when a user's balance drops below the global or personal reminder threshold.",
categoryLabel: "Billing",
},
"balance.recharge_success": {
label: "Balance Recharge Success",
timing: "Sent after a balance recharge order is paid and credited.",
categoryLabel: "Billing",
},
"account.quota_alert": {
label: "Account Quota Alert",
timing: "Sent to admin notification emails when an upstream account reaches the configured quota alert threshold.",
categoryLabel: "Admin",
},
"content_moderation.violation_notice": {
label: "Risk Control Violation Notice",
timing: "Sent when a user request triggers content moderation or risk-control rules but the account is not disabled yet.",
categoryLabel: "Risk Control",
},
"content_moderation.account_disabled": {
label: "Risk Control Account Disabled",
timing: "Sent when content moderation reaches the ban threshold and automatically disables the user account.",
categoryLabel: "Risk Control",
},
"ops.alert": {
label: "Ops Alert",
timing: "Sent to ops recipients when an ops monitoring rule fires and email notification settings allow it.",
categoryLabel: "Ops",
},
"ops.scheduled_report": {
label: "Ops Scheduled Report",
timing: "Sent when a configured daily, weekly, error digest, or account health report reaches its scheduled send time.",
categoryLabel: "Ops",
},
};
function normalizeEventOption(option: EmailTemplateEventOption): EmailTemplateOption {
if (typeof option === "string") {
return { value: option };
@@ -281,10 +449,58 @@ function normalizeEventOption(option: EmailTemplateEventOption): EmailTemplateOp
return option;
}
function eventMetaFor(option?: EmailTemplateOption | null) {
if (!option) return null;
const displayMeta = (
locale.value.toLowerCase().startsWith("zh")
? eventDisplayMeta
: eventDisplayMetaEn
)[option.value];
const label = displayMeta?.label || option.label || option.value;
const timing = displayMeta?.timing || option.description || "";
const categoryLabel =
displayMeta?.categoryLabel || formatCategory(option.category || "");
return {
label,
timing,
categoryLabel,
optional: option.optional === true,
};
}
function formatEventOptionLabel(option: EmailTemplateOption): string {
const meta = eventMetaFor(option);
if (!meta) return option.label || option.value;
return meta.label;
}
function formatCategory(category: string): string {
const normalized = category.trim().toLowerCase();
if (!normalized) return localText("通知", "Notification");
const labels: Record<string, { zh: string; en: string }> = {
auth: { zh: "认证安全", en: "Auth" },
subscription: { zh: "订阅", en: "Subscription" },
billing: { zh: "计费", en: "Billing" },
admin: { zh: "管理告警", en: "Admin" },
risk_control: { zh: "风控", en: "Risk Control" },
ops: { zh: "运维", en: "Ops" },
};
const item = labels[normalized];
return item ? localText(item.zh, item.en) : category;
}
const selectedEventOption = computed(() => {
return (
eventOptions.value.find((option) => option.value === selectedEvent.value) ||
null
);
});
const selectedEventMeta = computed(() => eventMetaFor(selectedEventOption.value));
const selectedEventDescription = computed(() => {
return (
eventOptions.value.find((option) => option.value === selectedEvent.value)
?.description || ""
selectedEventOption.value?.description || ""
);
});