('vue-i18n')
+ return {
+ ...actual,
+ useI18n: () => ({
+ t: (key: string) => messages[key] ?? key,
+ }),
+ }
+})
+
+vi.mock('vue-chartjs', () => ({
+ Line: {
+ props: ['data', 'options'],
+ template: '{{ JSON.stringify(data) }}
',
+ },
+}))
+
+describe('TokenUsageTrend', () => {
+ it('calculates cache hit rate against all prompt tokens', () => {
+ const wrapper = mount(TokenUsageTrend, {
+ props: {
+ trendData: [
+ {
+ date: '2026-05-08',
+ requests: 1,
+ input_tokens: 500,
+ output_tokens: 100,
+ cache_creation_tokens: 0,
+ cache_read_tokens: 1500,
+ cost: 0.01,
+ actual_cost: 0.005,
+ },
+ ],
+ },
+ global: {
+ stubs: {
+ LoadingSpinner: true,
+ },
+ },
+ })
+
+ const chartData = JSON.parse(wrapper.find('.chart-data').text())
+ const hitRateDataset = chartData.datasets.find(
+ (ds: any) => ds.label === 'Cache Hit Rate'
+ )
+ // Hit rate = 1500 / (500 + 1500 + 0) * 100 = 75%
+ expect(hitRateDataset.data[0]).toBe(75)
+ })
+
+ it('returns 0 hit rate when all prompt tokens are zero', () => {
+ const wrapper = mount(TokenUsageTrend, {
+ props: {
+ trendData: [
+ {
+ date: '2026-05-08',
+ requests: 0,
+ input_tokens: 0,
+ output_tokens: 0,
+ cache_creation_tokens: 0,
+ cache_read_tokens: 0,
+ cost: 0,
+ actual_cost: 0,
+ },
+ ],
+ },
+ global: {
+ stubs: {
+ LoadingSpinner: true,
+ },
+ },
+ })
+
+ const chartData = JSON.parse(wrapper.find('.chart-data').text())
+ const hitRateDataset = chartData.datasets.find(
+ (ds: any) => ds.label === 'Cache Hit Rate'
+ )
+ expect(hitRateDataset.data[0]).toBe(0)
+ })
+
+ it('includes cache_creation_tokens in denominator for Anthropic models', () => {
+ const wrapper = mount(TokenUsageTrend, {
+ props: {
+ trendData: [
+ {
+ date: '2026-05-08',
+ requests: 1,
+ input_tokens: 200,
+ output_tokens: 50,
+ cache_creation_tokens: 300,
+ cache_read_tokens: 500,
+ cost: 0.02,
+ actual_cost: 0.01,
+ },
+ ],
+ },
+ global: {
+ stubs: {
+ LoadingSpinner: true,
+ },
+ },
+ })
+
+ const chartData = JSON.parse(wrapper.find('.chart-data').text())
+ const hitRateDataset = chartData.datasets.find(
+ (ds: any) => ds.label === 'Cache Hit Rate'
+ )
+ // Hit rate = 500 / (200 + 500 + 300) * 100 = 50%
+ expect(hitRateDataset.data[0]).toBe(50)
+ })
+})
\ No newline at end of file
diff --git a/frontend/src/components/common/ProxyAdBanner.vue b/frontend/src/components/common/ProxyAdBanner.vue
new file mode 100644
index 00000000..52e107fb
--- /dev/null
+++ b/frontend/src/components/common/ProxyAdBanner.vue
@@ -0,0 +1,18 @@
+
+
+ {{ t('admin.proxies.ad.inline') }}
+
+
+
+
+
diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts
index 661c9b15..7a8bb607 100644
--- a/frontend/src/i18n/locales/en.ts
+++ b/frontend/src/i18n/locales/en.ts
@@ -424,6 +424,7 @@ export default {
emailSuffixNotAllowed: 'This email domain is not allowed for registration.',
emailSuffixNotAllowedWithAllowed:
'This email domain is not allowed. Allowed domains: {suffixes}',
+ emailSuffixAllowedMore: 'and {count} more',
loginSuccess: 'Login successful! Welcome back.',
accountCreatedSuccess: 'Account created successfully! Welcome to {siteName}.',
reloginRequired: 'Session expired. Please log in again.',
@@ -2360,6 +2361,8 @@ export default {
webSearchEmulationGlobalDisabled: 'Please enable the global switch first in Settings → Gateway → Web Search Emulation',
codexImageGenerationBridge: 'Codex Image Generation Bridge',
codexImageGenerationBridgeHint: 'When enabled, Codex /responses text requests in OpenAI groups may be automatically given the image_generation tool. Keep off unless the routed accounts support image generation.',
+ bedrockCCCompat: 'Bedrock CC Compatibility',
+ bedrockCCCompatHint: '⚠️ When enabled, requests to Bedrock accounts in this channel will be transformed for Claude Code compatibility (thinking type conversion, tool_use ID sanitization).',
basicSettings: 'Basic Settings',
addPlatform: 'Add Platform',
noPlatforms: 'Click "Add Platform" to start configuring the channel',
@@ -2532,6 +2535,20 @@ export default {
selectedGroups: 'Selected Groups',
searchGroups: 'Search group name or platform',
noGroups: 'No groups available',
+ modelFilter: 'Model scope',
+ modelFilterHint: 'Moderate by the client-requested model name; channel model mappings do not change this match.',
+ modelFilterAll: 'All models',
+ modelFilterAllDesc: 'All model requests go through content moderation.',
+ modelFilterInclude: 'Only selected',
+ modelFilterIncludeDesc: 'Only listed models go through content moderation.',
+ modelFilterExclude: 'Exclude selected',
+ modelFilterExcludeDesc: 'Listed models skip content moderation; other models are moderated.',
+ modelFilterModels: 'Model list',
+ modelFilterModelCount: '{count} models configured',
+ modelFilterModelsRequired: 'This model scope requires at least 1 model',
+ modelFilterAllSummary: 'Applies to all models',
+ modelFilterIncludeSummary: 'Applies to {count} models',
+ modelFilterExcludeSummary: 'Excludes {count} models',
emptyLogs: 'No audit records',
workerStatus: 'Worker Runtime',
workerStatusHint: 'Queue and worker pool status for asynchronous observation tasks.',
@@ -4012,6 +4029,9 @@ export default {
createProxy: 'Create Proxy',
editProxy: 'Edit Proxy',
deleteProxy: 'Delete Proxy',
+ ad: {
+ inline: 'Need proxy IP?'
+ },
dataImport: 'Import',
dataExportSelected: 'Export Selected',
dataImportTitle: 'Import Proxies',
@@ -5307,9 +5327,9 @@ export default {
emailVerificationHint: 'Require email verification for new registrations',
emailSuffixWhitelist: 'Email Domain Whitelist',
emailSuffixWhitelistHint:
- "Only email addresses from the specified domains can register (for example, {'@'}qq.com, {'@'}gmail.com)",
- emailSuffixWhitelistPlaceholder: 'example.com',
- emailSuffixWhitelistInputHint: 'Leave empty for no restriction',
+ "Only email addresses from the specified domains can register (for example, {'@'}qq.com, {'@'}gmail.com, *.edu.cn)",
+ emailSuffixWhitelistPlaceholder: "{'@'}example.com, *.edu.cn",
+ emailSuffixWhitelistInputHint: 'Leave empty for no restriction. Use *.edu.cn to match edu.cn and its subdomains.',
promoCode: 'Promo Code',
promoCodeHint: 'Allow users to use promo codes during registration',
invitationCode: 'Invitation Code Registration',
@@ -5334,7 +5354,15 @@ export default {
siteKeyHint: 'Get this from your Cloudflare Dashboard',
cloudflareDashboard: 'Cloudflare Dashboard',
secretKeyHint: 'Server-side verification key (keep this secret)',
- secretKeyConfiguredHint: 'Secret key configured. Leave empty to keep the current value.' },
+ secretKeyConfiguredHint: 'Secret key configured. Leave empty to keep the current value.'
+ },
+ apiKeyAcl: {
+ title: 'API Key IP Access Control',
+ description: 'Choose which client IP is used by API Key allowlists and denylists',
+ trustForwardedIp: 'Trust forwarded client IP',
+ trustForwardedIpHint:
+ 'Disabled by default. Enable only when the origin is reachable only through Cloudflare or Nginx reverse proxy. When enabled, API Key IP allowlists and denylists use CF-Connecting-IP, X-Real-IP, or X-Forwarded-For, matching the request IP shown in usage records.'
+ },
linuxdo: {
title: 'LinuxDo Connect Login',
description: 'Configure LinuxDo Connect OAuth for Sub2API end-user login',
@@ -5812,6 +5840,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',
diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts
index f50e218b..b23caa8a 100644
--- a/frontend/src/i18n/locales/zh.ts
+++ b/frontend/src/i18n/locales/zh.ts
@@ -423,6 +423,7 @@ export default {
registrationFailed: '注册失败,请重试。',
emailSuffixNotAllowed: '该邮箱域名不在允许注册范围内。',
emailSuffixNotAllowedWithAllowed: '该邮箱域名不被允许。可用域名:{suffixes}',
+ emailSuffixAllowedMore: '等 {count} 项',
loginSuccess: '登录成功!欢迎回来。',
accountCreatedSuccess: '账户创建成功!欢迎使用 {siteName}。',
reloginRequired: '会话已过期,请重新登录。',
@@ -2437,6 +2438,8 @@ export default {
webSearchEmulationGlobalDisabled: '请先在系统设置 → 网关 → Web Search 模拟中启用全局开关',
codexImageGenerationBridge: 'Codex 图片生成桥接',
codexImageGenerationBridgeHint: '开启后,OpenAI 分组的 Codex /responses 文本请求可能会被自动注入 image_generation 工具。仅在路由账号支持图片生成时开启。',
+ bedrockCCCompat: 'Bedrock CC 兼容',
+ bedrockCCCompatHint: '⚠️ 开启后,该渠道下 Bedrock 账号的请求将进行 Claude Code 兼容处理(thinking 类型转换、tool_use ID 清理)',
basicSettings: '基础设置',
addPlatform: '添加平台',
noPlatforms: '点击"添加平台"开始配置渠道',
@@ -2609,6 +2612,20 @@ export default {
selectedGroups: '指定分组',
searchGroups: '搜索分组名称或平台',
noGroups: '暂无可用分组',
+ modelFilter: '模型范围',
+ modelFilterHint: '按客户端请求的模型名决定是否执行内容审计,模型映射后仍以请求模型判断。',
+ modelFilterAll: '所有模型',
+ modelFilterAllDesc: '所有模型请求都会进入内容审计。',
+ modelFilterInclude: '仅指定模型',
+ modelFilterIncludeDesc: '只有列表中的模型会执行内容审计。',
+ modelFilterExclude: '排除指定模型',
+ modelFilterExcludeDesc: '列表中的模型跳过内容审计,其余模型执行审计。',
+ modelFilterModels: '模型列表',
+ modelFilterModelCount: '已配置 {count} 个模型',
+ modelFilterModelsRequired: '当前模型范围至少需要配置 1 个模型',
+ modelFilterAllSummary: '全部模型生效',
+ modelFilterIncludeSummary: '仅 {count} 个模型生效',
+ modelFilterExcludeSummary: '排除 {count} 个模型',
emptyLogs: '暂无审核记录',
workerStatus: 'Worker 运行状态',
workerStatusHint: '异步观察任务的队列和 worker 池状态。',
@@ -4107,6 +4124,9 @@ export default {
createProxy: '添加代理',
editProxy: '编辑代理',
deleteProxy: '删除代理',
+ ad: {
+ inline: '正在寻找合适的代理 IP?'
+ },
deleteConfirmMessage: "确定要删除代理 '{name}' 吗?",
testProxy: '测试代理',
dataImport: '导入',
@@ -5470,9 +5490,9 @@ export default {
emailVerificationHint: '新用户注册时需要验证邮箱',
emailSuffixWhitelist: '邮箱域名白名单',
emailSuffixWhitelistHint:
- "仅允许使用指定域名的邮箱注册账号(例如 {'@'}qq.com, {'@'}gmail.com)",
- emailSuffixWhitelistPlaceholder: 'example.com',
- emailSuffixWhitelistInputHint: '留空则不限制',
+ "仅允许使用指定域名的邮箱注册账号(例如 {'@'}qq.com, {'@'}gmail.com, *.edu.cn)",
+ emailSuffixWhitelistPlaceholder: "{'@'}example.com, *.edu.cn",
+ emailSuffixWhitelistInputHint: '留空则不限制。使用 *.edu.cn 可匹配 edu.cn 及其子域名。',
promoCode: '优惠码',
promoCodeHint: '允许用户在注册时使用优惠码',
invitationCode: '邀请码注册',
@@ -5499,6 +5519,13 @@ export default {
secretKeyHint: '服务端验证密钥(请保密)',
secretKeyConfiguredHint: '密钥已配置,留空以保留当前值。'
},
+ apiKeyAcl: {
+ title: 'API Key IP 访问控制',
+ description: '控制 API Key 白名单和黑名单使用哪个客户端 IP 判断',
+ trustForwardedIp: '信任反代传递的客户端 IP',
+ trustForwardedIpHint:
+ '默认关闭。仅在源站只允许 Cloudflare 或 Nginx 反代访问时开启;开启后 API Key IP 白/黑名单会使用 CF-Connecting-IP、X-Real-IP 或 X-Forwarded-For,与使用记录中的请求 IP 保持一致。'
+ },
linuxdo: {
title: 'LinuxDo Connect 登录',
description: '配置 LinuxDo Connect OAuth,用于 Sub2API 用户登录',
@@ -5972,6 +5999,12 @@ export default {
addEmail: '添加邮箱',
emailPlaceholder: '输入邮箱地址',
},
+ subscriptionExpiryNotify: {
+ title: '订阅到期提醒',
+ description: '控制是否向用户发送订阅即将到期的邮件提醒。',
+ enabled: '启用订阅到期提醒',
+ enabledHint: '开启后,系统会在订阅到期前 7 天、3 天、1 天各发送一次提醒。'
+ },
smtp: {
title: 'SMTP 设置',
description: '配置用于发送验证码的邮件服务',
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
index fbdee743..68162e53 100644
--- a/frontend/src/types/index.ts
+++ b/frontend/src/types/index.ts
@@ -1477,12 +1477,13 @@ export interface RedeemCode {
code: string
type: RedeemCodeType
value: number
- status: 'active' | 'used' | 'expired' | 'unused'
+ status: 'active' | 'used' | 'expired' | 'unused' | 'disabled'
used_by: number | null
used_at: string | null
created_at: string
expires_at?: string | null
updated_at?: string
+ notes?: string
group_id?: number | null // 订阅类型专用
validity_days?: number // 订阅类型专用
user?: User
@@ -1499,6 +1500,18 @@ export interface GenerateRedeemCodesRequest {
expires_in_days?: number
}
+export interface BatchUpdateRedeemCodeFields {
+ status?: 'unused' | 'disabled'
+ expires_at?: string | null
+ notes?: string
+ group_id?: number | null
+}
+
+export interface BatchUpdateRedeemCodesRequest {
+ ids: number[]
+ fields: BatchUpdateRedeemCodeFields
+}
+
export interface RedeemCodeRequest {
code: string
}
diff --git a/frontend/src/utils/__tests__/registrationEmailPolicy.spec.ts b/frontend/src/utils/__tests__/registrationEmailPolicy.spec.ts
index 021f0fc4..492a9a3d 100644
--- a/frontend/src/utils/__tests__/registrationEmailPolicy.spec.ts
+++ b/frontend/src/utils/__tests__/registrationEmailPolicy.spec.ts
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import {
+ formatRegistrationEmailSuffixWhitelistForMessage,
isRegistrationEmailSuffixAllowed,
isRegistrationEmailSuffixDomainValid,
normalizeRegistrationEmailSuffixDomain,
@@ -11,6 +12,7 @@ import {
describe('registrationEmailPolicy utils', () => {
it('normalizeRegistrationEmailSuffixDomain lowercases, strips @, and ignores invalid chars', () => {
expect(normalizeRegistrationEmailSuffixDomain(' @Exa!mple.COM ')).toBe('example.com')
+ expect(normalizeRegistrationEmailSuffixDomain(' *.EDU!.CN ')).toBe('*.edu.cn')
})
it('normalizeRegistrationEmailSuffixDomains deduplicates normalized domains', () => {
@@ -22,14 +24,20 @@ describe('registrationEmailPolicy utils', () => {
'-invalid.com',
'foo..bar.com',
' @foo.bar ',
- '@foo.bar'
+ '@foo.bar',
+ '*.EDU.CN',
+ '*.edu.cn'
])
- ).toEqual(['example.com', 'foo.bar'])
+ ).toEqual(['example.com', 'foo.bar', '*.edu.cn'])
})
it('parseRegistrationEmailSuffixWhitelistInput supports separators and deduplicates', () => {
- const input = '\n @example.com,example.com,@foo.bar\t@FOO.bar '
- expect(parseRegistrationEmailSuffixWhitelistInput(input)).toEqual(['example.com', 'foo.bar'])
+ const input = '\n @example.com,example.com,@foo.bar\t@FOO.bar *.EDU.CN '
+ expect(parseRegistrationEmailSuffixWhitelistInput(input)).toEqual([
+ 'example.com',
+ 'foo.bar',
+ '*.edu.cn'
+ ])
})
it('parseRegistrationEmailSuffixWhitelistInput drops tokens containing invalid chars', () => {
@@ -38,7 +46,7 @@ describe('registrationEmailPolicy utils', () => {
})
it('parseRegistrationEmailSuffixWhitelistInput drops structurally invalid domains', () => {
- const input = '@-bad.com, @foo..bar.com, @foo.bar, @xn--ok.com'
+ const input = '@-bad.com, @foo..bar.com, @foo.bar, @xn--ok.com, *., *, *.@, *.foo'
expect(parseRegistrationEmailSuffixWhitelistInput(input)).toEqual(['foo.bar', 'xn--ok.com'])
})
@@ -53,17 +61,22 @@ describe('registrationEmailPolicy utils', () => {
'foo.bar',
'',
'-invalid.com',
- ' @foo.bar '
+ ' @foo.bar ',
+ '*.EDU.CN'
])
- ).toEqual(['@example.com', '@foo.bar'])
+ ).toEqual(['@example.com', '@foo.bar', '*.edu.cn'])
})
it('isRegistrationEmailSuffixDomainValid matches backend-compatible domain rules', () => {
expect(isRegistrationEmailSuffixDomainValid('example.com')).toBe(true)
expect(isRegistrationEmailSuffixDomainValid('foo-bar.example.com')).toBe(true)
+ expect(isRegistrationEmailSuffixDomainValid('*.edu.cn')).toBe(true)
expect(isRegistrationEmailSuffixDomainValid('-bad.com')).toBe(false)
expect(isRegistrationEmailSuffixDomainValid('foo..bar.com')).toBe(false)
expect(isRegistrationEmailSuffixDomainValid('localhost')).toBe(false)
+ expect(isRegistrationEmailSuffixDomainValid('*.foo')).toBe(false)
+ expect(isRegistrationEmailSuffixDomainValid('*')).toBe(false)
+ expect(isRegistrationEmailSuffixDomainValid('*.@')).toBe(false)
})
it('isRegistrationEmailSuffixAllowed allows any email when whitelist is empty', () => {
@@ -73,5 +86,36 @@ describe('registrationEmailPolicy utils', () => {
it('isRegistrationEmailSuffixAllowed applies exact suffix matching', () => {
expect(isRegistrationEmailSuffixAllowed('user@example.com', ['@example.com'])).toBe(true)
expect(isRegistrationEmailSuffixAllowed('user@sub.example.com', ['@example.com'])).toBe(false)
+ expect(isRegistrationEmailSuffixAllowed('user@qq.com', ['@qq.com'])).toBe(true)
+ expect(isRegistrationEmailSuffixAllowed('user@sub.qq.com', ['@qq.com'])).toBe(false)
+ })
+
+ it('isRegistrationEmailSuffixAllowed applies wildcard suffix matching', () => {
+ expect(isRegistrationEmailSuffixAllowed('student@cs.edu.cn', ['*.edu.cn'])).toBe(true)
+ expect(isRegistrationEmailSuffixAllowed('student@edu.cn', ['*.edu.cn'])).toBe(true)
+ expect(isRegistrationEmailSuffixAllowed('student@foo.cn', ['*.edu.cn'])).toBe(false)
+ })
+
+ it('isRegistrationEmailSuffixAllowed supports mixed exact and wildcard entries', () => {
+ const whitelist = ['@a.com', '*.b.cn']
+ expect(isRegistrationEmailSuffixAllowed('user@a.com', whitelist)).toBe(true)
+ expect(isRegistrationEmailSuffixAllowed('user@school.b.cn', whitelist)).toBe(true)
+ expect(isRegistrationEmailSuffixAllowed('user@b.cn', whitelist)).toBe(true)
+ expect(isRegistrationEmailSuffixAllowed('user@c.cn', whitelist)).toBe(false)
+ })
+
+ it('formatRegistrationEmailSuffixWhitelistForMessage lists up to five entries', () => {
+ expect(
+ formatRegistrationEmailSuffixWhitelistForMessage(
+ ['@a.com', '@b.com', '@c.com', '@d.com', '@e.com'],
+ { separator: ', ', more: (count) => `and ${count} more` }
+ )
+ ).toBe('@a.com, @b.com, @c.com, @d.com, @e.com')
+ expect(
+ formatRegistrationEmailSuffixWhitelistForMessage(
+ ['@a.com', '@b.com', '@c.com', '@d.com', '@e.com', '*.edu.cn', '@f.com'],
+ { separator: ', ', more: (count) => `and ${count} more` }
+ )
+ ).toBe('@a.com, @b.com, @c.com, @d.com, @e.com, and 2 more')
})
})
diff --git a/frontend/src/utils/registrationEmailPolicy.ts b/frontend/src/utils/registrationEmailPolicy.ts
index 74d63fc4..bdb3dbc5 100644
--- a/frontend/src/utils/registrationEmailPolicy.ts
+++ b/frontend/src/utils/registrationEmailPolicy.ts
@@ -2,19 +2,21 @@ const EMAIL_SUFFIX_TOKEN_SPLIT_RE = /[\s,,]+/
const EMAIL_SUFFIX_INVALID_CHAR_RE = /[^a-z0-9.-]/g
const EMAIL_SUFFIX_INVALID_CHAR_CHECK_RE = /[^a-z0-9.-]/
const EMAIL_SUFFIX_PREFIX_RE = /^@+/
+const EMAIL_SUFFIX_WILDCARD_PREFIX = '*.'
+const EMAIL_SUFFIX_MESSAGE_VISIBLE_LIMIT = 5
const EMAIL_SUFFIX_DOMAIN_PATTERN =
/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/
// normalizeRegistrationEmailSuffixDomain converts raw input into a canonical domain token.
-// It removes leading "@", lowercases input, and strips all invalid characters.
+// Exact domains are returned without "@"; wildcard domains keep the "*." prefix.
export function normalizeRegistrationEmailSuffixDomain(raw: string): string {
let value = String(raw || '').trim().toLowerCase()
if (!value) {
return ''
}
+
value = value.replace(EMAIL_SUFFIX_PREFIX_RE, '')
- value = value.replace(EMAIL_SUFFIX_INVALID_CHAR_RE, '')
- return value
+ return normalizeRegistrationEmailSuffixToken(value, false)
}
export function normalizeRegistrationEmailSuffixDomains(
@@ -60,7 +62,7 @@ export function parseRegistrationEmailSuffixWhitelistInput(input: string): strin
export function normalizeRegistrationEmailSuffixWhitelist(
items: string[] | null | undefined
): string[] {
- return normalizeRegistrationEmailSuffixDomains(items).map((domain) => `@${domain}`)
+ return normalizeRegistrationEmailSuffixDomains(items).map(toCanonicalRegistrationEmailSuffix)
}
function extractRegistrationEmailDomain(email: string): string {
@@ -91,7 +93,32 @@ export function isRegistrationEmailSuffixAllowed(
return false
}
const emailSuffix = `@${emailDomain}`
- return normalizedWhitelist.includes(emailSuffix)
+ return normalizedWhitelist.some((allowed) => {
+ if (allowed.startsWith('@')) {
+ return allowed === emailSuffix
+ }
+ if (allowed.startsWith(EMAIL_SUFFIX_WILDCARD_PREFIX)) {
+ const base = allowed.slice(EMAIL_SUFFIX_WILDCARD_PREFIX.length)
+ return emailDomain === base || emailDomain.endsWith(`.${base}`)
+ }
+ return false
+ })
+}
+
+export function formatRegistrationEmailSuffixWhitelistForMessage(
+ whitelist: string[] | null | undefined,
+ options: {
+ separator: string
+ more: (count: number) => string
+ }
+): string {
+ const normalizedWhitelist = normalizeRegistrationEmailSuffixWhitelist(whitelist)
+ const visible = normalizedWhitelist.slice(0, EMAIL_SUFFIX_MESSAGE_VISIBLE_LIMIT)
+ const hiddenCount = normalizedWhitelist.length - visible.length
+ if (hiddenCount > 0) {
+ visible.push(options.more(hiddenCount))
+ }
+ return visible.join(options.separator)
}
// Pasted domains should be strict: any invalid character drops the whole token.
@@ -101,15 +128,38 @@ function normalizeRegistrationEmailSuffixDomainStrict(raw: string): string {
return ''
}
value = value.replace(EMAIL_SUFFIX_PREFIX_RE, '')
- if (!value || EMAIL_SUFFIX_INVALID_CHAR_CHECK_RE.test(value)) {
- return ''
- }
- return value
+ return normalizeRegistrationEmailSuffixToken(value, true)
}
export function isRegistrationEmailSuffixDomainValid(domain: string): boolean {
if (!domain) {
return false
}
- return EMAIL_SUFFIX_DOMAIN_PATTERN.test(domain)
+ if (domain.startsWith(EMAIL_SUFFIX_WILDCARD_PREFIX)) {
+ return EMAIL_SUFFIX_DOMAIN_PATTERN.test(domain.slice(EMAIL_SUFFIX_WILDCARD_PREFIX.length))
+ }
+ return !domain.includes('*') && EMAIL_SUFFIX_DOMAIN_PATTERN.test(domain)
+}
+
+function normalizeRegistrationEmailSuffixToken(value: string, strict: boolean): string {
+ if (value.startsWith(EMAIL_SUFFIX_WILDCARD_PREFIX)) {
+ const domain = value.slice(EMAIL_SUFFIX_WILDCARD_PREFIX.length)
+ if (strict && (!domain || EMAIL_SUFFIX_INVALID_CHAR_CHECK_RE.test(domain))) {
+ return ''
+ }
+ return `${EMAIL_SUFFIX_WILDCARD_PREFIX}${domain.replace(EMAIL_SUFFIX_INVALID_CHAR_RE, '')}`
+ }
+
+ if (value === '*') {
+ return strict ? '' : value
+ }
+
+ if (strict && EMAIL_SUFFIX_INVALID_CHAR_CHECK_RE.test(value)) {
+ return ''
+ }
+ return value.replace(/[*]/g, '').replace(EMAIL_SUFFIX_INVALID_CHAR_RE, '')
+}
+
+function toCanonicalRegistrationEmailSuffix(domain: string): string {
+ return domain.startsWith(EMAIL_SUFFIX_WILDCARD_PREFIX) ? domain : `@${domain}`
}
diff --git a/frontend/src/views/admin/ChannelsView.vue b/frontend/src/views/admin/ChannelsView.vue
index 93724aa6..4924ce55 100644
--- a/frontend/src/views/admin/ChannelsView.vue
+++ b/frontend/src/views/admin/ChannelsView.vue
@@ -354,6 +354,21 @@
+
+
+
+
+
+ {{ t('admin.channels.form.bedrockCCCompat') }}
+
+
+ {{ t('admin.channels.form.bedrockCCCompatHint') }}
+
+
+
+
+
+
@@ -669,6 +684,7 @@ interface PlatformSection {
model_pricing: PricingFormEntry[]
web_search_emulation: boolean
codex_image_generation_bridge: boolean
+ bedrock_cc_compat: boolean
account_stats_pricing_rules: FormPricingRule[]
}
@@ -765,6 +781,7 @@ function addPlatformSection(platform: GroupPlatform) {
model_pricing: [],
web_search_emulation: false,
codex_image_generation_bridge: false,
+ bedrock_cc_compat: false,
account_stats_pricing_rules: [],
})
}
@@ -1125,6 +1142,19 @@ function formToAPI(): { group_ids: number[], model_pricing: ChannelModelPricing[
delete featuresConfig.codex_image_generation_bridge
}
+ const bedrockCCCompat: Record
= {}
+ for (const section of form.platforms) {
+ if (!section.enabled) continue
+ if (section.platform === 'anthropic') {
+ bedrockCCCompat[section.platform] = !!section.bedrock_cc_compat
+ }
+ }
+ if (Object.keys(bedrockCCCompat).length > 0) {
+ featuresConfig.bedrock_cc_compat = bedrockCCCompat
+ } else {
+ delete featuresConfig.bedrock_cc_compat
+ }
+
return { group_ids, model_pricing, model_mapping, features_config: featuresConfig }
}
@@ -1175,6 +1205,8 @@ function apiToForm(channel: Channel): PlatformSection[] {
const webSearchEnabled = wsEmulation?.[platform] === true
const codexImageGenerationBridge = fc?.codex_image_generation_bridge as Record | undefined
const codexImageGenerationBridgeEnabled = codexImageGenerationBridge?.[platform] === true
+ const bedrockCCCompat = fc?.bedrock_cc_compat as Record | undefined
+ const bedrockCCCompatEnabled = bedrockCCCompat?.[platform] === true
sections.push({
platform,
@@ -1185,6 +1217,7 @@ function apiToForm(channel: Channel): PlatformSection[] {
model_pricing: pricing,
web_search_emulation: webSearchEnabled,
codex_image_generation_bridge: codexImageGenerationBridgeEnabled,
+ bedrock_cc_compat: bedrockCCCompatEnabled,
account_stats_pricing_rules: [],
})
}
diff --git a/frontend/src/views/admin/ProxiesView.vue b/frontend/src/views/admin/ProxiesView.vue
index 1e4df356..27f38307 100644
--- a/frontend/src/views/admin/ProxiesView.vue
+++ b/frontend/src/views/admin/ProxiesView.vue
@@ -357,45 +357,50 @@
@close="closeCreateModal"
>
-
-
-
- {{ t('admin.proxies.standardAdd') }}
-
-
-
+
+
-
-
- {{ t('admin.proxies.batchAdd') }}
-
+
+ {{ t('admin.proxies.standardAdd') }}
+
+
+
+
+
+ {{ t('admin.proxies.batchAdd') }}
+
+
+
@@ -887,6 +892,7 @@ import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import EmptyState from '@/components/common/EmptyState.vue'
import ImportDataModal from '@/components/admin/proxy/ImportDataModal.vue'
import Select from '@/components/common/Select.vue'
+import ProxyAdBanner from '@/components/common/ProxyAdBanner.vue'
import Icon from '@/components/icons/Icon.vue'
import PlatformTypeBadge from '@/components/common/PlatformTypeBadge.vue'
import { useClipboard } from '@/composables/useClipboard'
diff --git a/frontend/src/views/admin/RedeemView.vue b/frontend/src/views/admin/RedeemView.vue
index b8e0e936..faae7439 100644
--- a/frontend/src/views/admin/RedeemView.vue
+++ b/frontend/src/views/admin/RedeemView.vue
@@ -39,6 +39,15 @@
{{ t('admin.redeem.exportCsv') }}
+
+
+ {{ t('admin.redeem.batchUpdate') }}
+
{{ t('admin.redeem.generateCodes') }}
@@ -56,6 +65,28 @@
default-sort-order="desc"
@sort="handleSort"
>
+
+
+
+
+
+
+
+
{{ value }}
@@ -174,6 +205,31 @@
+
+
+ {{ t('admin.redeem.selectedCount', { count: selectedCount }) }}
+
+
+
+ {{ t('admin.redeem.clearSelection') }}
+
+
+ {{ t('admin.redeem.batchUpdate') }}
+
+
+
+
+
+
+
+
+
+
+ {{ t('admin.redeem.batchUpdateTitle') }}
+
+
+ {{ t('admin.redeem.selectedCount', { count: selectedCount }) }}
+
+
+
+
+
+
+
@@ -445,10 +612,18 @@ import { ref, reactive, computed, onMounted, onUnmounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { useClipboard } from '@/composables/useClipboard'
+import { useTableSelection } from '@/composables/useTableSelection'
import { getPersistedPageSize } from '@/composables/usePersistedPageSize'
import { adminAPI } from '@/api/admin'
import { formatDateTime } from '@/utils/format'
-import type { RedeemCode, RedeemCodeType, Group, GroupPlatform, SubscriptionType } from '@/types'
+import type {
+ RedeemCode,
+ RedeemCodeType,
+ Group,
+ GroupPlatform,
+ SubscriptionType,
+ BatchUpdateRedeemCodeFields
+} from '@/types'
import type { Column } from '@/components/common/types'
import AppLayout from '@/components/layout/AppLayout.vue'
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
@@ -492,6 +667,11 @@ const subscriptionGroupOptions = computed(() => {
}))
})
+const batchGroupOptions = computed(() => [
+ { value: null, label: t('admin.redeem.clearGroup') },
+ ...subscriptionGroupOptions.value
+])
+
const generatedCodesText = computed(() => {
return generatedCodes.value.map((code) => code.code).join('\n')
})
@@ -540,6 +720,7 @@ const downloadGeneratedCodes = () => {
}
const columns = computed(() => [
+ { key: 'select', label: '' },
{ key: 'code', label: t('admin.redeem.columns.code') },
{ key: 'type', label: t('admin.redeem.columns.type'), sortable: true },
{ key: 'value', label: t('admin.redeem.columns.value'), sortable: true },
@@ -569,12 +750,24 @@ const filterStatusOptions = computed(() => [
{ value: '', label: t('admin.redeem.allStatus') },
{ value: 'unused', label: t('admin.redeem.unused') },
{ value: 'used', label: t('admin.redeem.used') },
- { value: 'expired', label: t('admin.redeem.status.expired') }
+ { value: 'expired', label: t('admin.redeem.status.expired') },
+ { value: 'disabled', label: t('admin.redeem.status.disabled') }
+])
+
+const batchStatusOptions = computed(() => [
+ { value: 'unused', label: t('admin.redeem.status.unused') },
+ { value: 'disabled', label: t('admin.redeem.status.disabled') }
+])
+
+const batchExpiryModeOptions = computed(() => [
+ { value: 'clear', label: t('admin.redeem.neverExpires') },
+ { value: 'custom', label: t('admin.redeem.customExpiry') }
])
const codes = ref([])
const loading = ref(false)
const generating = ref(false)
+const batchUpdating = ref(false)
const searchQuery = ref('')
const filters = reactive({
type: '',
@@ -595,9 +788,35 @@ let abortController: AbortController | null = null
const showDeleteDialog = ref(false)
const showDeleteUnusedDialog = ref(false)
+const showBatchUpdateDialog = ref(false)
const deletingCode = ref(null)
const copiedCode = ref(null)
+const {
+ selectedSet: selectedCodeIds,
+ selectedCount,
+ allVisibleSelected,
+ select,
+ deselect,
+ clear: clearSelectedCodes,
+ toggleVisible
+} = useTableSelection({
+ rows: codes,
+ getId: (code) => code.id
+})
+
+const batchUpdateForm = reactive({
+ update_status: false,
+ status: 'disabled' as 'unused' | 'disabled',
+ update_expires_at: false,
+ expires_mode: 'clear' as 'clear' | 'custom',
+ expires_at_local: '',
+ update_notes: false,
+ notes: '',
+ update_group_id: false,
+ group_id: null as number | null
+})
+
type RedeemCodeExpiryOption = 'never' | '1' | '3' | '7' | 'custom'
const redeemCodeExpiryOptions = computed<{ value: RedeemCodeExpiryOption; label: string }[]>(() => [
@@ -632,7 +851,7 @@ watch(
const buildRedeemQueryFilters = () => ({
type: (filters.type || undefined) as RedeemCodeType | undefined,
- status: (filters.status || undefined) as 'used' | 'expired' | 'unused' | undefined,
+ status: (filters.status || undefined) as 'used' | 'expired' | 'unused' | 'disabled' | undefined,
search: searchQuery.value || undefined,
sort_by: sortState.sort_by,
sort_order: sortState.sort_order
@@ -705,6 +924,20 @@ const handleSort = (key: string, order: 'asc' | 'desc') => {
loadCodes()
}
+const toggleSelectRow = (id: number, event: Event) => {
+ const target = event.target as HTMLInputElement
+ if (target.checked) {
+ select(id)
+ return
+ }
+ deselect(id)
+}
+
+const toggleSelectAllVisible = (event: Event) => {
+ const target = event.target as HTMLInputElement
+ toggleVisible(target.checked)
+}
+
const getRedeemCodeExpiresInDays = () => {
if (generateForm.expiry_option === 'never') {
return undefined
@@ -721,6 +954,69 @@ const getRedeemCodeExpiresInDays = () => {
return Number(generateForm.expiry_option)
}
+const toDatetimeLocalInputValue = (date: Date) => {
+ const pad = (value: number) => String(value).padStart(2, '0')
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(
+ date.getHours()
+ )}:${pad(date.getMinutes())}`
+}
+
+const resetBatchUpdateForm = () => {
+ batchUpdateForm.update_status = false
+ batchUpdateForm.status = 'disabled'
+ batchUpdateForm.update_expires_at = false
+ batchUpdateForm.expires_mode = 'clear'
+ batchUpdateForm.expires_at_local = toDatetimeLocalInputValue(
+ new Date(Date.now() + 24 * 60 * 60 * 1000)
+ )
+ batchUpdateForm.update_notes = false
+ batchUpdateForm.notes = ''
+ batchUpdateForm.update_group_id = false
+ batchUpdateForm.group_id = null
+}
+
+const openBatchUpdateDialog = () => {
+ if (selectedCount.value === 0) {
+ appStore.showInfo(t('admin.redeem.selectCodesFirst'))
+ return
+ }
+ resetBatchUpdateForm()
+ showBatchUpdateDialog.value = true
+}
+
+const closeBatchUpdateDialog = () => {
+ showBatchUpdateDialog.value = false
+}
+
+const buildBatchUpdateFields = (): BatchUpdateRedeemCodeFields | null => {
+ const fields: BatchUpdateRedeemCodeFields = {}
+
+ if (batchUpdateForm.update_status) {
+ fields.status = batchUpdateForm.status
+ }
+ if (batchUpdateForm.update_expires_at) {
+ if (batchUpdateForm.expires_mode === 'clear') {
+ fields.expires_at = null
+ } else {
+ const expiresAt = new Date(batchUpdateForm.expires_at_local)
+ if (!batchUpdateForm.expires_at_local || Number.isNaN(expiresAt.getTime())) {
+ appStore.showError(t('admin.redeem.expiryDaysRequired'))
+ return null
+ }
+ fields.expires_at = expiresAt.toISOString()
+ }
+ }
+ if (batchUpdateForm.update_notes) {
+ fields.notes = batchUpdateForm.notes
+ }
+ if (batchUpdateForm.update_group_id) {
+ fields.group_id =
+ batchUpdateForm.group_id == null ? null : Number(batchUpdateForm.group_id)
+ }
+
+ return Object.keys(fields).length > 0 ? fields : null
+}
+
const handleGenerateCodes = async () => {
// 订阅类型必须选择分组
if (generateForm.type === 'subscription' && !generateForm.group_id) {
@@ -834,6 +1130,43 @@ const confirmDeleteUnused = async () => {
}
}
+const handleBatchUpdate = async () => {
+ const ids = Array.from(selectedCodeIds.value)
+ if (ids.length === 0) {
+ appStore.showInfo(t('admin.redeem.selectCodesFirst'))
+ return
+ }
+
+ const hasSelectedFields =
+ batchUpdateForm.update_status ||
+ batchUpdateForm.update_expires_at ||
+ batchUpdateForm.update_notes ||
+ batchUpdateForm.update_group_id
+ if (!hasSelectedFields) {
+ appStore.showError(t('admin.redeem.noBatchFieldsSelected'))
+ return
+ }
+
+ const fields = buildBatchUpdateFields()
+ if (!fields) {
+ return
+ }
+
+ batchUpdating.value = true
+ try {
+ const result = await adminAPI.redeem.batchUpdate(ids, fields)
+ appStore.showSuccess(t('admin.redeem.batchUpdateSuccess', { count: result.updated }))
+ showBatchUpdateDialog.value = false
+ clearSelectedCodes()
+ loadCodes()
+ } catch (error: any) {
+ appStore.showError(error.response?.data?.detail || t('admin.redeem.failedToBatchUpdate'))
+ console.error('Error batch updating codes:', error)
+ } finally {
+ batchUpdating.value = false
+ }
+}
+
// 加载订阅类型分组
const loadSubscriptionGroups = async () => {
try {
diff --git a/frontend/src/views/admin/RiskControlView.vue b/frontend/src/views/admin/RiskControlView.vue
index acfcec77..4d56b492 100644
--- a/frontend/src/views/admin/RiskControlView.vue
+++ b/frontend/src/views/admin/RiskControlView.vue
@@ -145,6 +145,26 @@
+
+
+
+ {{ t('admin.riskControl.modelFilter') }}
+ {{ modelFilterSummary }}
+
+
+
+ {{ model }}
+
+
+ +{{ hiddenModelFilterModelCount }}
+
+
+
+
@@ -628,6 +648,52 @@
{{ t('admin.riskControl.noGroups') }}
+
+
+
+
+
{{ t('admin.riskControl.modelFilter') }}
+
{{ t('admin.riskControl.modelFilterHint') }}
+
+
+ {{ modelFilterSummary }}
+
+
+
+
+
+
+ {{ option.label }}
+
+
+
+
+ {{ option.description }}
+
+
+
+
+
{{ t('admin.riskControl.modelFilterModels') }}
+
+
+ {{ t('admin.riskControl.modelFilterModelCount', { count: modelFilterModelCount }) }}
+
+
+
@@ -887,11 +953,14 @@ import Icon from '@/components/icons/Icon.vue'
import Select from '@/components/common/Select.vue'
import Toggle from '@/components/common/Toggle.vue'
import Pagination from '@/components/common/Pagination.vue'
+import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.vue'
import { adminAPI } from '@/api/admin'
import type {
ContentModerationAPIKeyStatus,
ContentModerationConfig,
ContentModerationLog,
+ ContentModerationModelFilter,
+ ContentModerationModelFilterType,
ContentModerationRuntimeStatus,
ContentModerationTestAuditResult,
KeywordBlockingMode,
@@ -987,6 +1056,8 @@ const configForm = reactive({
pre_hash_check_enabled: false,
blocked_keywords_text: '',
keyword_blocking_mode: 'keyword_and_api' as KeywordBlockingMode,
+ model_filter_type: 'all' as ContentModerationModelFilterType,
+ model_filter_models: [] as string[],
})
const pagination = reactive({
@@ -1038,6 +1109,24 @@ const keywordBlockingModeOptions = computed
>(() => [
+ {
+ value: 'all',
+ label: t('admin.riskControl.modelFilterAll'),
+ description: t('admin.riskControl.modelFilterAllDesc'),
+ },
+ {
+ value: 'include',
+ label: t('admin.riskControl.modelFilterInclude'),
+ description: t('admin.riskControl.modelFilterIncludeDesc'),
+ },
+ {
+ value: 'exclude',
+ label: t('admin.riskControl.modelFilterExclude'),
+ description: t('admin.riskControl.modelFilterExcludeDesc'),
+ },
+])
+
type KeywordNoticeView = {
title: string
description: string
@@ -1120,6 +1209,22 @@ const groupFilterOptions = computed(() => [
const selectedGroupCount = computed(() => String(configForm.group_ids.length))
+const modelFilterModelCount = computed(() => configForm.model_filter_models.length)
+
+const modelFilterSummary = computed(() => {
+ if (configForm.model_filter_type === 'include') {
+ return t('admin.riskControl.modelFilterIncludeSummary', { count: modelFilterModelCount.value })
+ }
+ if (configForm.model_filter_type === 'exclude') {
+ return t('admin.riskControl.modelFilterExcludeSummary', { count: modelFilterModelCount.value })
+ }
+ return t('admin.riskControl.modelFilterAllSummary')
+})
+
+const modelFilterPreviewModels = computed(() => configForm.model_filter_models.slice(0, 6))
+
+const hiddenModelFilterModelCount = computed(() => Math.max(0, configForm.model_filter_models.length - modelFilterPreviewModels.value.length))
+
const filteredGroups = computed(() => {
const keyword = groupSearch.value.trim().toLowerCase()
if (!keyword) return groups.value
@@ -1238,7 +1343,7 @@ const overviewItems = computed(() => [
key: 'scope',
label: t('admin.riskControl.overview.groupScope'),
value: configForm.all_groups ? t('admin.riskControl.allGroups') : selectedGroupCount.value,
- meta: configForm.all_groups ? t('admin.riskControl.allGroupsHint') : t('admin.riskControl.selectedGroupsHint'),
+ meta: modelFilterSummary.value,
icon: 'users',
iconClass: 'bg-violet-50 text-violet-600 dark:bg-violet-900/20 dark:text-violet-300',
},
@@ -1342,6 +1447,9 @@ function applyConfig(config: ContentModerationConfig) {
configForm.pre_hash_check_enabled = config.pre_hash_check_enabled ?? false
configForm.blocked_keywords_text = Array.isArray(config.blocked_keywords) ? config.blocked_keywords.join('\n') : ''
configForm.keyword_blocking_mode = normalizeKeywordBlockingMode(config.keyword_blocking_mode)
+ const modelFilter = normalizeModelFilter(config.model_filter)
+ configForm.model_filter_type = modelFilter.type
+ configForm.model_filter_models = modelFilter.models
}
async function loadAll() {
@@ -1388,6 +1496,11 @@ async function loadStatus(silent = true) {
async function saveConfig() {
saving.value = true
try {
+ const modelFilterPayload = buildModelFilterPayload()
+ if (modelFilterPayload.type !== 'all' && modelFilterPayload.models.length === 0) {
+ appStore.showError(t('admin.riskControl.modelFilterModelsRequired'))
+ return
+ }
const payload: UpdateContentModerationConfig = {
enabled: configForm.enabled,
mode: configForm.mode,
@@ -1413,6 +1526,7 @@ async function saveConfig() {
pre_hash_check_enabled: configForm.pre_hash_check_enabled,
blocked_keywords: blockedKeywordList.value,
keyword_blocking_mode: configForm.keyword_blocking_mode,
+ model_filter: modelFilterPayload,
}
const keys = parseApiKeys(configForm.api_keys_text)
if (!payload.clear_api_key && configForm.api_keys_mode === 'replace' && keys.length === 0) {
@@ -1568,6 +1682,13 @@ function setAPIKeysMode(mode: APIKeysWriteMode) {
}
}
+function setModelFilterType(type: ContentModerationModelFilterType) {
+ configForm.model_filter_type = type
+ if (type === 'all') {
+ configForm.model_filter_models = []
+ }
+}
+
async function testApiKeys(useInputKeys: boolean) {
const keys = useInputKeys ? parseApiKeys(configForm.api_keys_text) : []
if (useInputKeys && keys.length === 0) {
@@ -1824,6 +1945,49 @@ function normalizeKeywordBlockingMode(value: unknown): KeywordBlockingMode {
return 'keyword_and_api'
}
+function normalizeModelFilter(value: unknown): ContentModerationModelFilter {
+ if (!value || typeof value !== 'object') {
+ return { type: 'all', models: [] }
+ }
+ const raw = value as Partial
+ const type = normalizeModelFilterType(raw.type)
+ const models = type === 'all' ? [] : normalizeModelNames(raw.models)
+ return { type, models }
+}
+
+function normalizeModelFilterType(value: unknown): ContentModerationModelFilterType {
+ if (value === 'include' || value === 'exclude' || value === 'all') {
+ return value
+ }
+ return 'all'
+}
+
+function normalizeModelNames(models: unknown): string[] {
+ if (!Array.isArray(models)) return []
+ const seen = new Set()
+ const out: string[] = []
+ for (const item of models) {
+ const model = String(item ?? '').trim()
+ if (!model) continue
+ const key = model.toLowerCase()
+ if (seen.has(key)) continue
+ seen.add(key)
+ out.push(model)
+ }
+ return out
+}
+
+function buildModelFilterPayload(): ContentModerationModelFilter {
+ const type = normalizeModelFilterType(configForm.model_filter_type)
+ if (type === 'all') {
+ return { type: 'all', models: [] }
+ }
+ return {
+ type,
+ models: normalizeModelNames(configForm.model_filter_models),
+ }
+}
+
function parseBlockedKeywords(value: string): string[] {
const seen = new Set()
const out: string[] = []
diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue
index 9a648796..6e542e52 100644
--- a/frontend/src/views/admin/SettingsView.vue
+++ b/frontend/src/views/admin/SettingsView.vue
@@ -1415,7 +1415,6 @@
:key="suffix"
class="inline-flex items-center gap-1 rounded bg-gray-100 px-2 py-1 text-xs font-mono text-gray-700 dark:bg-dark-600 dark:text-gray-200"
>
- @
{{ suffix }}
- @
+
+
+
+
+ {{ t("admin.settings.apiKeyAcl.title") }}
+
+
+ {{ t("admin.settings.apiKeyAcl.description") }}
+
+
+
+
+
+
+ {{ t("admin.settings.apiKeyAcl.trustForwardedIp") }}
+
+
+ {{ t("admin.settings.apiKeyAcl.trustForwardedIpHint") }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ t("admin.settings.subscriptionExpiryNotify.title") }}
+
+
+ {{ t("admin.settings.subscriptionExpiryNotify.description") }}
+
+
+
+
+
+
+ {{ t("admin.settings.subscriptionExpiryNotify.enabled") }}
+
+
+ {{ t("admin.settings.subscriptionExpiryNotify.enabledHint") }}
+
+
+
+
+
+
+
@@ -6868,6 +6919,7 @@ const form = reactive
({
turnstile_site_key: "",
turnstile_secret_key: "",
turnstile_secret_key_configured: false,
+ api_key_acl_trust_forwarded_ip: false,
// LinuxDo Connect OAuth 登录
linuxdo_connect_enabled: false,
linuxdo_connect_client_id: "",
@@ -6978,10 +7030,11 @@ const form = reactive({
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
@@ -7926,8 +7979,8 @@ async function saveSettings() {
registration_enabled: form.registration_enabled,
email_verify_enabled: form.email_verify_enabled,
registration_email_suffix_whitelist:
- registrationEmailSuffixWhitelistTags.value.map(
- (suffix) => `@${suffix}`,
+ registrationEmailSuffixWhitelistTags.value.map((suffix) =>
+ suffix.startsWith("*.") ? suffix : `@${suffix}`,
),
promo_code_enabled: form.promo_code_enabled,
invitation_code_enabled: form.invitation_code_enabled,
@@ -7973,6 +8026,7 @@ async function saveSettings() {
turnstile_enabled: form.turnstile_enabled,
turnstile_site_key: form.turnstile_site_key,
turnstile_secret_key: form.turnstile_secret_key || undefined,
+ api_key_acl_trust_forwarded_ip: form.api_key_acl_trust_forwarded_ip,
linuxdo_connect_enabled: form.linuxdo_connect_enabled,
linuxdo_connect_client_id: form.linuxdo_connect_client_id,
linuxdo_connect_client_secret:
@@ -8110,12 +8164,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 || []
diff --git a/frontend/src/views/admin/__tests__/RedeemView.batchUpdate.spec.ts b/frontend/src/views/admin/__tests__/RedeemView.batchUpdate.spec.ts
new file mode 100644
index 00000000..083d374f
--- /dev/null
+++ b/frontend/src/views/admin/__tests__/RedeemView.batchUpdate.spec.ts
@@ -0,0 +1,187 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { flushPromises, mount } from '@vue/test-utils'
+
+import RedeemView from '../RedeemView.vue'
+
+const { listRedeemCodes, batchUpdateRedeemCodes, getAllGroups, showSuccess, showError, showInfo } =
+ vi.hoisted(() => ({
+ listRedeemCodes: vi.fn(),
+ batchUpdateRedeemCodes: vi.fn(),
+ getAllGroups: vi.fn(),
+ showSuccess: vi.fn(),
+ showError: vi.fn(),
+ showInfo: vi.fn()
+ }))
+
+vi.mock('@/api/admin', () => ({
+ adminAPI: {
+ redeem: {
+ list: listRedeemCodes,
+ generate: vi.fn(),
+ delete: vi.fn(),
+ batchDelete: vi.fn(),
+ batchUpdate: batchUpdateRedeemCodes,
+ exportCodes: vi.fn()
+ },
+ groups: {
+ getAll: getAllGroups
+ }
+ }
+}))
+
+vi.mock('@/stores/app', () => ({
+ useAppStore: () => ({
+ showSuccess,
+ showError,
+ showInfo
+ })
+}))
+
+vi.mock('@/composables/useClipboard', () => ({
+ useClipboard: () => ({
+ copyToClipboard: vi.fn()
+ })
+}))
+
+vi.mock('vue-i18n', async () => {
+ const actual = await vi.importActual('vue-i18n')
+ return {
+ ...actual,
+ useI18n: () => ({
+ t: (key: string) => key
+ })
+ }
+})
+
+const DataTableStub = {
+ props: ['columns', 'data'],
+ template: `
+
+
+
+
+ {{ column.label }}
+
+
+
+
+
+
+
+ {{ row[column.key] }}
+
+
+
+
+
+ `
+}
+
+const SelectStub = {
+ props: ['modelValue', 'options'],
+ emits: ['update:modelValue', 'change'],
+ setup(props: { options: Array<{ value: unknown; label: string }> }, { emit }: { emit: (event: string, ...args: unknown[]) => void }) {
+ const onChange = (event: Event) => {
+ const raw = (event.target as HTMLSelectElement).value
+ const option = props.options.find((item) => String(item.value ?? '') === raw)
+ const value = option ? option.value : raw
+ emit('update:modelValue', value)
+ emit('change', value, option ?? null)
+ }
+ return { onChange }
+ },
+ template: `
+
+
+ {{ option.label }}
+
+
+ `
+}
+
+describe('admin RedeemView batch update', () => {
+ beforeEach(() => {
+ localStorage.clear()
+ document.body.innerHTML = ''
+
+ listRedeemCodes.mockReset()
+ batchUpdateRedeemCodes.mockReset()
+ getAllGroups.mockReset()
+ showSuccess.mockReset()
+ showError.mockReset()
+ showInfo.mockReset()
+
+ listRedeemCodes.mockResolvedValue({
+ items: [
+ {
+ id: 1,
+ code: 'CODE-1',
+ type: 'balance',
+ value: 10,
+ status: 'unused',
+ used_by: null,
+ used_at: null,
+ created_at: '2026-01-01T00:00:00Z',
+ expires_at: null
+ },
+ {
+ id: 2,
+ code: 'CODE-2',
+ type: 'balance',
+ value: 20,
+ status: 'unused',
+ used_by: null,
+ used_at: null,
+ created_at: '2026-01-01T00:00:00Z',
+ expires_at: null
+ }
+ ],
+ total: 2,
+ page: 1,
+ page_size: 20,
+ pages: 1
+ })
+ batchUpdateRedeemCodes.mockResolvedValue({ updated: 1, message: 'ok' })
+ getAllGroups.mockResolvedValue([])
+ })
+
+ it('submits only checked fields for selected redeem codes', async () => {
+ const wrapper = mount(RedeemView, {
+ attachTo: document.body,
+ global: {
+ stubs: {
+ AppLayout: { template: '
' },
+ TablePageLayout: {
+ template: '
'
+ },
+ DataTable: DataTableStub,
+ Pagination: true,
+ ConfirmDialog: true,
+ Select: SelectStub,
+ GroupBadge: true,
+ GroupOptionItem: true,
+ Icon: true,
+ Teleport: true
+ }
+ }
+ })
+
+ await flushPromises()
+ await wrapper.findAll('[data-test="select-code"]')[0].setValue(true)
+ await wrapper.get('[data-test="batch-update-open"]').trigger('click')
+ await flushPromises()
+
+ await wrapper.get('[data-test="batch-field-status"]').setValue(true)
+ await wrapper.get('[data-test="batch-status-select"]').setValue('disabled')
+ await wrapper.get('[data-test="batch-field-notes"]').setValue(true)
+ await wrapper.get('[data-test="batch-notes-input"]').setValue('maintenance')
+ await wrapper.get('[data-test="batch-update-form"]').trigger('submit')
+ await flushPromises()
+
+ expect(batchUpdateRedeemCodes).toHaveBeenCalledWith([1], {
+ status: 'disabled',
+ notes: 'maintenance'
+ })
+ expect(showSuccess).toHaveBeenCalledWith('admin.redeem.batchUpdateSuccess')
+ })
+})
diff --git a/frontend/src/views/admin/__tests__/RiskControlView.spec.ts b/frontend/src/views/admin/__tests__/RiskControlView.spec.ts
new file mode 100644
index 00000000..b528a278
--- /dev/null
+++ b/frontend/src/views/admin/__tests__/RiskControlView.spec.ts
@@ -0,0 +1,227 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { defineComponent, h } from 'vue'
+import { flushPromises, mount } from '@vue/test-utils'
+import type { DOMWrapper, VueWrapper } from '@vue/test-utils'
+
+import RiskControlView from '../RiskControlView.vue'
+import type { ContentModerationConfig, UpdateContentModerationConfig } from '@/api/admin/riskControl'
+
+const {
+ getConfig,
+ updateConfig,
+ getStatus,
+ listLogs,
+ getGroups,
+ showError,
+ showSuccess,
+} = vi.hoisted(() => ({
+ getConfig: vi.fn(),
+ updateConfig: vi.fn(),
+ getStatus: vi.fn(),
+ listLogs: vi.fn(),
+ getGroups: vi.fn(),
+ showError: vi.fn(),
+ showSuccess: vi.fn(),
+}))
+
+vi.mock('@/api/admin', () => ({
+ adminAPI: {
+ riskControl: {
+ getConfig,
+ updateConfig,
+ getStatus,
+ listLogs,
+ testAPIKeys: vi.fn(),
+ deleteFlaggedHash: vi.fn(),
+ clearFlaggedHashes: vi.fn(),
+ unbanUser: vi.fn(),
+ },
+ groups: {
+ getAll: getGroups,
+ },
+ },
+}))
+
+vi.mock('@/stores/app', () => ({
+ useAppStore: () => ({
+ showError,
+ showSuccess,
+ }),
+}))
+
+vi.mock('@/utils/apiError', () => ({
+ extractApiErrorMessage: (_err: unknown, fallback: string) => fallback,
+}))
+
+vi.mock('vue-i18n', async () => {
+ const actual = await vi.importActual('vue-i18n')
+ return {
+ ...actual,
+ useI18n: () => ({
+ t: (key: string, params?: Record) =>
+ key.replace(/\{(\w+)\}/g, (_, token) => String(params?.[token] ?? `{${token}}`)),
+ }),
+ }
+})
+
+const baseConfig = (): ContentModerationConfig => ({
+ enabled: true,
+ mode: 'pre_block',
+ base_url: 'https://api.openai.com',
+ model: 'omni-moderation-latest',
+ api_key_configured: false,
+ api_key_masked: '',
+ api_key_count: 0,
+ api_key_masks: [],
+ api_key_statuses: [],
+ timeout_ms: 3000,
+ sample_rate: 100,
+ all_groups: true,
+ group_ids: [],
+ record_non_hits: false,
+ worker_count: 4,
+ queue_size: 32768,
+ block_status: 403,
+ block_message: '内容审计命中风险规则,请调整输入后重试',
+ email_on_hit: true,
+ auto_ban_enabled: true,
+ ban_threshold: 10,
+ violation_window_hours: 720,
+ retry_count: 2,
+ hit_retention_days: 180,
+ non_hit_retention_days: 3,
+ pre_hash_check_enabled: false,
+ blocked_keywords: [],
+ keyword_blocking_mode: 'keyword_and_api',
+ model_filter: {
+ type: 'all',
+ models: [],
+ },
+})
+
+const runtimeStatus = () => ({
+ enabled: true,
+ risk_control_enabled: true,
+ mode: 'pre_block',
+ worker_count: 4,
+ max_workers: 32,
+ active_workers: 0,
+ idle_workers: 4,
+ queue_size: 32768,
+ queue_length: 0,
+ queue_usage_percent: 0,
+ enqueued: 0,
+ dropped: 0,
+ processed: 0,
+ errors: 0,
+ api_key_statuses: [],
+ flagged_hash_count: 0,
+ last_cleanup_deleted_hit: 0,
+ last_cleanup_deleted_non_hit: 0,
+})
+
+const AppLayoutStub = { template: '
' }
+const BaseDialogStub = defineComponent({
+ props: {
+ show: {
+ type: Boolean,
+ default: false,
+ },
+ },
+ template: '
',
+})
+const ModelWhitelistSelectorStub = defineComponent({
+ props: {
+ modelValue: {
+ type: Array,
+ default: () => [],
+ },
+ },
+ emits: ['update:modelValue'],
+ setup(props, { emit }) {
+ const onInput = (event: Event) => {
+ const value = (event.target as HTMLInputElement).value
+ emit(
+ 'update:modelValue',
+ value
+ .split(/[,\n]/)
+ .map((item) => item.trim())
+ .filter(Boolean)
+ )
+ }
+ return () =>
+ h('input', {
+ 'data-test': 'model-filter-input',
+ value: (props.modelValue as string[]).join('\n'),
+ onInput,
+ })
+ },
+})
+
+function findButtonByText(wrapper: VueWrapper, text: string): DOMWrapper {
+ const button = wrapper.findAll('button').find((item) => item.text().includes(text))
+ if (!button) {
+ throw new Error(`button not found: ${text}`)
+ }
+ return button
+}
+
+describe('admin RiskControlView', () => {
+ beforeEach(() => {
+ getConfig.mockReset()
+ updateConfig.mockReset()
+ getStatus.mockReset()
+ listLogs.mockReset()
+ getGroups.mockReset()
+ showError.mockReset()
+ showSuccess.mockReset()
+
+ getConfig.mockResolvedValue(baseConfig())
+ getStatus.mockResolvedValue(runtimeStatus())
+ listLogs.mockResolvedValue({ items: [], total: 0, page: 1, page_size: 20, pages: 1 })
+ getGroups.mockResolvedValue([])
+ updateConfig.mockImplementation(async (payload: UpdateContentModerationConfig) => ({
+ ...baseConfig(),
+ ...payload,
+ model_filter: payload.model_filter ?? baseConfig().model_filter,
+ api_key_configured: false,
+ api_key_masked: '',
+ api_key_count: 0,
+ api_key_masks: [],
+ api_key_statuses: [],
+ }))
+ })
+
+ it('saves the selected model filter mode and models', async () => {
+ const wrapper = mount(RiskControlView, {
+ global: {
+ stubs: {
+ AppLayout: AppLayoutStub,
+ BaseDialog: BaseDialogStub,
+ Icon: true,
+ Select: true,
+ Toggle: true,
+ Pagination: true,
+ ModelWhitelistSelector: ModelWhitelistSelectorStub,
+ },
+ },
+ })
+
+ await flushPromises()
+
+ await findButtonByText(wrapper, 'admin.riskControl.openSettings').trigger('click')
+ await findButtonByText(wrapper, 'admin.riskControl.tabs.scope').trigger('click')
+ await findButtonByText(wrapper, 'admin.riskControl.modelFilterInclude').trigger('click')
+ await wrapper.get('[data-test="model-filter-input"]').setValue('gpt-5.5, gpt-5.4')
+ await findButtonByText(wrapper, 'admin.riskControl.saveConfig').trigger('click')
+ await flushPromises()
+
+ expect(updateConfig).toHaveBeenCalledWith(expect.objectContaining({
+ model_filter: {
+ type: 'include',
+ models: ['gpt-5.5', 'gpt-5.4'],
+ },
+ }))
+ expect(showError).not.toHaveBeenCalled()
+ })
+})
diff --git a/frontend/src/views/admin/__tests__/SettingsView.spec.ts b/frontend/src/views/admin/__tests__/SettingsView.spec.ts
index 0d4ab7d2..34d77c12 100644
--- a/frontend/src/views/admin/__tests__/SettingsView.spec.ts
+++ b/frontend/src/views/admin/__tests__/SettingsView.spec.ts
@@ -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: [],
};
diff --git a/frontend/src/views/admin/settings/EmailTemplateEditor.vue b/frontend/src/views/admin/settings/EmailTemplateEditor.vue
index 6643f799..5fcb371e 100644
--- a/frontend/src/views/admin/settings/EmailTemplateEditor.vue
+++ b/frontend/src/views/admin/settings/EmailTemplateEditor.vue
@@ -67,12 +67,9 @@
:key="option.value"
:value="option.value"
>
- {{ option.label || option.value }}
+ {{ formatEventOptionLabel(option) }}
-
- {{ selectedEventDescription }}
-
@@ -95,6 +92,41 @@
+
+
+
+ {{ selectedEventMeta.label }}
+
+
+ {{ selectedEventMeta.categoryLabel }}
+
+
+ {{ selectedEventMeta.optional ? localText("可退订通知", "Optional") : localText("事务邮件", "Transactional") }}
+
+
+
+ {{ selectedEventMeta.timing }}
+
+
+ {{ selectedEventDescription }}
+
+
+
= {
+ "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 = {
+ "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 = {
+ 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 || ""
);
});
diff --git a/frontend/src/views/auth/EmailVerifyView.vue b/frontend/src/views/auth/EmailVerifyView.vue
index 46c51b83..71678404 100644
--- a/frontend/src/views/auth/EmailVerifyView.vue
+++ b/frontend/src/views/auth/EmailVerifyView.vue
@@ -164,6 +164,7 @@ import {
import { apiClient } from '@/api/client'
import { buildAuthErrorMessage } from '@/utils/authError'
import {
+ formatRegistrationEmailSuffixWhitelistForMessage,
isRegistrationEmailSuffixAllowed,
normalizeRegistrationEmailSuffixWhitelist
} from '@/utils/registrationEmailPolicy'
@@ -574,7 +575,10 @@ function buildEmailSuffixNotAllowedMessage(): string {
}
const separator = String(locale.value || '').toLowerCase().startsWith('zh') ? '、' : ', '
return t('auth.emailSuffixNotAllowedWithAllowed', {
- suffixes: normalizedWhitelist.join(separator)
+ suffixes: formatRegistrationEmailSuffixWhitelistForMessage(normalizedWhitelist, {
+ separator,
+ more: (count) => t('auth.emailSuffixAllowedMore', { count })
+ })
})
}
diff --git a/frontend/src/views/auth/RegisterView.vue b/frontend/src/views/auth/RegisterView.vue
index fbd3716f..a88acdbf 100644
--- a/frontend/src/views/auth/RegisterView.vue
+++ b/frontend/src/views/auth/RegisterView.vue
@@ -318,6 +318,7 @@ import {
} from '@/api/auth'
import { buildAuthErrorMessage } from '@/utils/authError'
import {
+ formatRegistrationEmailSuffixWhitelistForMessage,
isRegistrationEmailSuffixAllowed,
normalizeRegistrationEmailSuffixWhitelist
} from '@/utils/registrationEmailPolicy'
@@ -739,7 +740,10 @@ function buildEmailSuffixNotAllowedMessage(): string {
}
const separator = String(locale.value || '').toLowerCase().startsWith('zh') ? '、' : ', '
return t('auth.emailSuffixNotAllowedWithAllowed', {
- suffixes: normalizedWhitelist.join(separator)
+ suffixes: formatRegistrationEmailSuffixWhitelistForMessage(normalizedWhitelist, {
+ separator,
+ more: (count) => t('auth.emailSuffixAllowedMore', { count })
+ })
})
}