-
+
{{ m.name }}
+
+
+ {{
+ t('modelPlaza.table.timePricingWeekdays')
+ }}
+ {{ formatTimeWindow(period) }}
+
+ {{ t('modelPlaza.table.marginalBadge') }}
+
-
+
@@ -93,11 +111,11 @@
:key="idx"
class="whitespace-nowrap text-xs leading-5"
>
- {{ tierLabel(iv) }}
- {{ paidPerMillion(iv.input_price) }}
+ {{ tierLabel(iv) }}
+ {{ paidPerMillion(iv.input_price, period) }}
- {{ paidPerMillion(m.pricing?.input_price) }}
+ {{ paidPerMillion(m.pricing?.input_price, period) }}
@@ -105,25 +123,41 @@
v-for="(iv, idx) in tokenIntervals(m)"
:key="idx"
class="whitespace-nowrap text-xs leading-5"
+ :title="tierHint(m)"
>
- {{ tierLabel(iv) }}
- {{ paidPerMillion(iv.output_price) }}
+ {{ paidPerMillion(iv.output_price, period) }}
- {{ paidPerMillion(m.pricing?.output_price) }}
+ {{ paidPerMillion(m.pricing?.output_price, period) }}
+
+
+
+ {{ t('modelPlaza.table.cacheWriteShort') }}
+ {{ paidPerMillion(iv.cache_write_price, period) }}
+ {{ t('modelPlaza.table.cacheReadShort') }}
+ {{ paidPerMillion(iv.cache_read_price, period) }}
+
+ -
+
+
{{ t('modelPlaza.table.cacheWrite') }}
- {{ paidPerMillion(m.pricing?.cache_write_price) }}
+ {{ paidPerMillion(m.pricing?.cache_write_price, period) }}
{{ t('modelPlaza.table.cacheRead') }}
- {{ paidPerMillion(m.pricing?.cache_read_price) }}
+ {{ paidPerMillion(m.pricing?.cache_read_price, period) }}
-
@@ -157,18 +191,54 @@
-
+
- {{ official(m.official_pricing?.input_price) }}
+
+
+ {{ tierLabel(iv) }}
+ {{ official(iv.input_price) }}
+
+
+ {{ official(m.official_pricing?.input_price) }}
- {{ official(m.official_pricing?.output_price) }}
+
+
+ {{ official(iv.output_price) }}
+
+
+ {{ official(m.official_pricing?.output_price) }}
+
+
+
+ {{ t('modelPlaza.table.cacheWriteShort') }}
+ {{ official(iv.cache_write_price) }}
+ {{ t('modelPlaza.table.cacheReadShort') }}
+ {{ official(iv.cache_read_price) }}
+
+ -
+
+
-
+
{{ periodRate(period) }}x
+ {{ requestRate(m) }}x
@@ -218,7 +294,7 @@ import {
BILLING_MODE_IMAGE,
type BillingMode
} from '@/constants/channel'
-import type { PlazaModel } from '@/api/modelPlaza'
+import type { PlazaModel, PlazaTimePricingPeriod } from '@/api/modelPlaza'
import type { UserPricingInterval } from '@/api/channels'
const props = defineProps<{
@@ -232,6 +308,13 @@ const props = defineProps<{
/** 生图独立倍率:true 时图片计费模型的实付倍率取 imageRateMultiplier,不取分组/专属倍率。 */
imageRateIndependent?: boolean
imageRateMultiplier?: number | null
+ /**
+ * 高峰窗口描述(含倍率与服务器时区标注),空串/缺省 = 分组未启用高峰。
+ * 表格所有价格均为不含高峰因子的口径,该窗口仅用于分时时段行的 tooltip 披露:
+ * 与高峰重叠的部分实付还会再乘高峰倍率。
+ */
+ peakWindow?: string
+ peakRateMultiplier?: number | null
}>()
const { t } = useI18n()
@@ -279,10 +362,35 @@ function billingModeLabel(m: PlazaModel): string {
/** 价格统一保底 2 位小数,更长的有效小数原样保留。 */
const MIN_DECIMALS = 2
-/** 实付价 = 渠道单价 × 生效倍率,按 $/1M token 展示。 */
-function paidPerMillion(value: number | null | undefined): string {
+/** 表格行:每个模型一行标准价;配置了分时倍率的模型再按时段各加一行。 */
+interface PlazaRow {
+ model: PlazaModel
+ period: PlazaTimePricingPeriod | null
+ key: string
+}
+
+const rows = computed(() =>
+ sortedModels.value.flatMap((m) => {
+ const base: PlazaRow = { model: m, period: null, key: `${m.platform}:${m.name}` }
+ const periodRows = timePeriods(m).map((p, idx) => ({
+ model: m,
+ period: p,
+ key: `${m.platform}:${m.name}:${idx}`
+ }))
+ return [base, ...periodRows]
+ })
+)
+
+/** 时段行的生效倍率 = 生效倍率 × 时段倍率(去掉浮点噪声)。 */
+function periodRate(period: PlazaTimePricingPeriod): number {
+ return Math.round(effectiveRate.value * period.multiplier * 1000) / 1000
+}
+
+/** 实付价 = 渠道单价 × 生效倍率(时段行再乘时段倍率),按 $/1M token 展示。 */
+function paidPerMillion(value: number | null | undefined, period: PlazaTimePricingPeriod | null = null): string {
if (value == null) return '-'
- return formatScaled(value * effectiveRate.value, PER_MILLION, MIN_DECIMALS)
+ const rate = period ? periodRate(period) : effectiveRate.value
+ return formatScaled(value * rate, PER_MILLION, MIN_DECIMALS)
}
/** 图片计费模型且分组开启生图独立倍率:实付倍率取独立倍率,与计费口径一致。 */
@@ -322,23 +430,76 @@ function hasOfficialCache(o: NonNullable): boole
return o.cache_write_price != null || o.cache_read_price != null || o.cache_write_1h_price != null
}
-/** token 模式的阶梯定价(内联进输入/输出列)。 */
-function tokenIntervals(m: PlazaModel): UserPricingInterval[] {
- return m.pricing?.intervals ?? []
+/** 分时倍率时段(后端只给出倍率 ≠ 1 的时段,已升序)。 */
+function timePeriods(m: PlazaModel): PlazaTimePricingPeriod[] {
+ return m.time_pricing?.periods ?? []
}
+/**
+ * 时段行 tooltip:仅工作日生效的配置换用带周末回落说明的文案;
+ * 分组启用高峰倍率时追加披露——本行价格不含高峰因子,与高峰窗口重叠的部分实付再乘高峰倍率。
+ */
+function timePricingRowHint(m: PlazaModel): string {
+ const key = m.time_pricing?.weekdays_only
+ ? 'modelPlaza.table.timePricingRowHintWeekdays'
+ : 'modelPlaza.table.timePricingRowHint'
+ let hint = t(key, { timezone: m.time_pricing?.timezone })
+ if (props.peakWindow) {
+ hint += t('modelPlaza.table.timePricingRowHintPeak', {
+ window: props.peakWindow,
+ multiplier: props.peakRateMultiplier ?? 1
+ })
+ }
+ return hint
+}
+
+/** “00:30–08:30”;整分钟的 HH:mm:ss 省略秒。 */
+function formatTimeWindow(p: PlazaTimePricingPeriod): string {
+ const clock = (v: string) => v.replace(/^(\d{2}:\d{2}):00$/, '$1')
+ return `${clock(p.start_time)}–${clock(p.end_time)}`
+}
+
+/** 上下文档位按下限升序展示(后端已升序,此处兜底)。 */
+function sortByContext(intervals: UserPricingInterval[]): UserPricingInterval[] {
+ return [...intervals].sort((a, b) => a.min_tokens - b.min_tokens)
+}
+
+/** token 模式的阶梯定价(内联进输入/输出/缓存列)。 */
+function tokenIntervals(m: PlazaModel): UserPricingInterval[] {
+ return sortByContext(m.pricing?.intervals ?? [])
+}
+
+/** 官方阶梯(后端按目录规则合成,不受分组开关影响)。 */
+function officialIntervals(m: PlazaModel): UserPricingInterval[] {
+ return sortByContext(m.official_pricing?.intervals ?? [])
+}
+
+/** 任一档带缓存价才按档渲染缓存列;否则沿用平价的写入/读取两行。 */
+function hasTierCachePricing(intervals: UserPricingInterval[]): boolean {
+ return intervals.some((iv) => iv.cache_write_price != null || iv.cache_read_price != null)
+}
+
+/** 档位说明:整单按档计价,或(平台旧规则)仅超出部分按档计价。 */
+function tierHint(m: PlazaModel): string {
+ return m.long_context_basis === 'marginal'
+ ? t('modelPlaza.table.tierHintMarginal')
+ : t('modelPlaza.table.tierHint')
+}
+
+
/** 按次/按图模式的阶梯定价(仅保留配了按次价的档位)。 */
function requestIntervals(m: PlazaModel): UserPricingInterval[] {
return (m.pricing?.intervals ?? []).filter((iv) => iv.per_request_price != null)
}
-/** 档位标签:优先管理员配置的 tier_label,否则按 token 区间生成(≤200K / >200K / 200K–1M)。 */
+/**
+ * 档位标签:优先后端/管理员给出的 tier_label,否则按区间生成统一形态——
+ * 有上限为「≤上限」,末档为「>下限」;档位升序排列,相邻的 ≤100K / ≤200K 即表示 (100K,200K]。
+ */
function tierLabel(iv: UserPricingInterval): string {
if (iv.tier_label) return iv.tier_label
const { min_tokens: min, max_tokens: max } = iv
- if (max == null) return `>${formatTokenCount(min)}`
- if (min === 0) return `≤${formatTokenCount(max)}`
- return `${formatTokenCount(min)}–${formatTokenCount(max)}`
+ return max == null ? `>${formatTokenCount(min)}` : `≤${formatTokenCount(max)}`
}
function formatTokenCount(n: number): string {
diff --git a/frontend/src/components/modelPlaza/__tests__/PlazaGroupSection.spec.ts b/frontend/src/components/modelPlaza/__tests__/PlazaGroupSection.spec.ts
new file mode 100644
index 0000000000..aad4e25a35
--- /dev/null
+++ b/frontend/src/components/modelPlaza/__tests__/PlazaGroupSection.spec.ts
@@ -0,0 +1,139 @@
+import { describe, expect, it, vi } from 'vitest'
+import { mount } from '@vue/test-utils'
+import PlazaGroupSection from '../PlazaGroupSection.vue'
+import PlazaModelPricingTable from '../PlazaModelPricingTable.vue'
+import type { ModelPlazaGroup, PlazaModel } from '@/api/modelPlaza'
+
+vi.mock('vue-i18n', async () => {
+ const actual = await vi.importActual('vue-i18n')
+ return {
+ ...actual,
+ useI18n: () => ({
+ t: (key: string) => key
+ })
+ }
+})
+
+vi.mock('@/stores/app', () => ({
+ useAppStore: () => ({ cachedPublicSettings: null })
+}))
+
+function ladderModel(tiers: number): PlazaModel {
+ const intervals = Array.from({ length: tiers }, (_, i) => ({
+ min_tokens: i * 272000,
+ max_tokens: i === tiers - 1 ? null : (i + 1) * 272000,
+ tier_label: '',
+ input_price: 5e-6,
+ output_price: 3e-5,
+ cache_write_price: null,
+ cache_read_price: null,
+ per_request_price: null
+ }))
+ return {
+ name: 'gpt-5.6-sol',
+ platform: 'openai',
+ pricing: {
+ billing_mode: 'token',
+ input_price: 5e-6,
+ output_price: 3e-5,
+ cache_write_price: null,
+ cache_read_price: null,
+ image_input_price: null,
+ image_output_price: null,
+ per_request_price: null,
+ intervals: []
+ },
+ official_pricing: {
+ input_price: 5e-6,
+ output_price: 3e-5,
+ cache_write_price: null,
+ cache_read_price: null,
+ intervals
+ }
+ }
+}
+
+function group(overrides: Partial = {}): ModelPlazaGroup {
+ return {
+ id: 1,
+ name: 'g',
+ description: '',
+ platform: 'openai',
+ subscription_type: 'standard',
+ rate_multiplier: 1,
+ peak_rate_enabled: false,
+ peak_start: '',
+ peak_end: '',
+ peak_rate_multiplier: 1,
+ is_exclusive: false,
+ image_rate_independent: false,
+ image_rate_multiplier: 1,
+ long_context_pricing_enabled: true,
+ models: [ladderModel(2)],
+ ...overrides
+ }
+}
+
+function mountSection(g: ModelPlazaGroup) {
+ return mount(PlazaGroupSection, {
+ props: { group: g },
+ global: {
+ stubs: {
+ GroupBadge: true,
+ Icon: true,
+ PlazaModelPricingTable: true
+ }
+ }
+ })
+}
+
+const NOTE = 'modelPlaza.detail.longContextDisabledNote'
+
+describe('PlazaGroupSection 长上下文说明', () => {
+ it('分组关闭阶梯且组内有官方阶梯模型时显示说明', () => {
+ const wrapper = mountSection(group({ long_context_pricing_enabled: false }))
+ expect(wrapper.text()).toContain(NOTE)
+ })
+
+ it('分组开启阶梯时不显示', () => {
+ const wrapper = mountSection(group({ long_context_pricing_enabled: true }))
+ expect(wrapper.text()).not.toContain(NOTE)
+ })
+
+ it('分组关闭但没有官方阶梯模型时不显示', () => {
+ const wrapper = mountSection(
+ group({ long_context_pricing_enabled: false, models: [ladderModel(1)] })
+ )
+ expect(wrapper.text()).not.toContain(NOTE)
+ })
+
+ it('旧后端缺少开关字段时不显示', () => {
+ const g = group()
+ delete (g as Partial).long_context_pricing_enabled
+ const wrapper = mountSection(g)
+ expect(wrapper.text()).not.toContain(NOTE)
+ })
+})
+
+describe('PlazaGroupSection 高峰配置传递', () => {
+ it('分组启用高峰时把窗口描述与倍率传给价格表', () => {
+ const wrapper = mountSection(
+ group({
+ subscription_type: 'subscription',
+ peak_rate_enabled: true,
+ peak_start: '14:00',
+ peak_end: '18:00',
+ peak_rate_multiplier: 1.5
+ })
+ )
+ const table = wrapper.findComponent(PlazaModelPricingTable)
+ // appStore mock 无 server_utc_offset,窗口描述不带时区标注
+ expect(table.props('peakWindow')).toBe('14:00-18:00 ×1.5')
+ expect(table.props('peakRateMultiplier')).toBe(1.5)
+ })
+
+ it('分组未启用高峰时窗口描述为空串', () => {
+ const wrapper = mountSection(group())
+ expect(wrapper.findComponent(PlazaModelPricingTable).props('peakWindow')).toBe('')
+ })
+})
diff --git a/frontend/src/components/modelPlaza/__tests__/PlazaModelPricingTable.spec.ts b/frontend/src/components/modelPlaza/__tests__/PlazaModelPricingTable.spec.ts
index ca5f72cd6a..b4acf6efe5 100644
--- a/frontend/src/components/modelPlaza/__tests__/PlazaModelPricingTable.spec.ts
+++ b/frontend/src/components/modelPlaza/__tests__/PlazaModelPricingTable.spec.ts
@@ -43,7 +43,12 @@ function mountTable(
models: PlazaModel[],
rateMultiplier: number,
userRateMultiplier?: number | null,
- extraProps?: { imageRateIndependent?: boolean; imageRateMultiplier?: number | null }
+ extraProps?: {
+ imageRateIndependent?: boolean
+ imageRateMultiplier?: number | null
+ peakWindow?: string
+ peakRateMultiplier?: number | null
+ }
) {
return mount(PlazaModelPricingTable, {
props: { models, rateMultiplier, userRateMultiplier: userRateMultiplier ?? null, ...extraProps }
@@ -405,3 +410,225 @@ describe('PlazaModelPricingTable', () => {
expect(wrapper.text()).toContain('OpenAI')
})
})
+
+describe('PlazaModelPricingTable 长上下文阶梯', () => {
+ function ladderIntervals() {
+ return [
+ {
+ min_tokens: 0,
+ max_tokens: 272000,
+ tier_label: '≤272K',
+ input_price: 5e-6,
+ output_price: 3e-5,
+ cache_write_price: 6.25e-6,
+ cache_read_price: 5e-7,
+ per_request_price: null
+ },
+ {
+ min_tokens: 272000,
+ max_tokens: null,
+ tier_label: '>272K',
+ input_price: 1e-5,
+ output_price: 4.5e-5,
+ cache_write_price: 1.25e-5,
+ cache_read_price: 1e-6,
+ per_request_price: null
+ }
+ ]
+ }
+
+ function ladderModel(overrides: Partial = {}): PlazaModel {
+ return tokenModel({
+ name: 'gpt-5.6-sol',
+ platform: 'openai',
+ pricing: {
+ billing_mode: 'token',
+ input_price: 5e-6,
+ output_price: 3e-5,
+ cache_write_price: 6.25e-6,
+ cache_read_price: 5e-7,
+ image_input_price: null,
+ image_output_price: null,
+ per_request_price: null,
+ intervals: ladderIntervals()
+ },
+ official_pricing: {
+ input_price: 5e-6,
+ output_price: 3e-5,
+ cache_write_price: 6.25e-6,
+ cache_read_price: 5e-7,
+ intervals: ladderIntervals()
+ },
+ long_context_basis: 'whole_request',
+ ...overrides
+ })
+ }
+
+ it('实付缓存列按档分行并乘倍率,每档一行与输入/输出列对齐;档位标签只在输入列', () => {
+ const wrapper = mountTable([ladderModel()], 0.5)
+ const cells = wrapper.findAll('tbody td')
+ const cacheCell = cells[3]
+ const rows = cacheCell.findAll('.leading-5')
+ expect(rows).toHaveLength(2)
+ // 写 6.25 × 0.5 / 读 0.5 × 0.5;高档 12.5 × 0.5 / 1 × 0.5
+ expect(rows[0].text()).toContain('modelPlaza.table.cacheWriteShort')
+ expect(rows[0].text()).toContain('$3.125')
+ expect(rows[0].text()).toContain('$0.25')
+ expect(rows[1].text()).toContain('$6.25')
+ expect(rows[1].text()).toContain('$0.50')
+ // 输入列带标签,输出/缓存列只按行对齐不重复标签
+ expect(cells[1].text()).toContain('≤272K')
+ expect(cells[1].text()).toContain('>272K')
+ expect(cells[2].text()).not.toContain('272K')
+ expect(cacheCell.text()).not.toContain('272K')
+ expect(cells[1].findAll('.leading-5')).toHaveLength(2)
+ expect(cells[2].findAll('.leading-5')).toHaveLength(2)
+ })
+
+ it('官方三列按 official_pricing.intervals 分档且不乘倍率,不内联 1h', () => {
+ const wrapper = mountTable([ladderModel()], 0.5)
+ const cells = wrapper.findAll('tbody td')
+ expect(cells[4].text()).toContain('≤272K')
+ expect(cells[4].text()).toContain('$5.00')
+ expect(cells[4].text()).toContain('>272K')
+ expect(cells[4].text()).toContain('$10.00')
+ expect(cells[5].text()).toContain('$30.00')
+ expect(cells[5].text()).toContain('$45.00')
+ expect(cells[6].text()).toContain('$6.25')
+ expect(cells[6].text()).toContain('$12.50')
+ expect(cells[6].text()).toContain('$1.00')
+ expect(cells[6].text()).not.toContain('(1h')
+ })
+
+ it('整单计价的档位标签带 tooltip;边际计价在模型名旁加徽章并换用边际说明', () => {
+ const whole = mountTable([ladderModel()], 1)
+ const wholeLabels = whole.findAll('tbody td span[title="modelPlaza.table.tierHint"]')
+ expect(wholeLabels.length).toBeGreaterThan(0)
+ expect(whole.text()).not.toContain('modelPlaza.table.marginalBadge')
+
+ const marginal = mountTable([ladderModel({ long_context_basis: 'marginal' })], 1)
+ const marginalLabels = marginal.findAll('tbody td span[title="modelPlaza.table.tierHintMarginal"]')
+ expect(marginalLabels.length).toBeGreaterThan(0)
+ expect(marginal.findAll('tbody td')[0].text()).toContain('modelPlaza.table.marginalBadge')
+ })
+
+ it('无标签的多档按区间生成统一形态(≤上限 / >下限),并按下限升序展示', () => {
+ const model = ladderModel({
+ pricing: {
+ ...ladderModel().pricing!,
+ // 故意乱序:展示必须按上下文从低到高
+ intervals: [
+ { ...ladderIntervals()[1], min_tokens: 1000000, tier_label: '' },
+ { ...ladderIntervals()[0], min_tokens: 100000, max_tokens: 200000, tier_label: '' },
+ { ...ladderIntervals()[0], max_tokens: 100000, tier_label: '' },
+ { ...ladderIntervals()[1], min_tokens: 200000, max_tokens: 1000000, tier_label: '' }
+ ]
+ }
+ })
+ const rows = mountTable([model], 1).findAll('tbody td')[1].findAll('.leading-5')
+ expect(rows.map((r) => r.text().split(/\s+/)[0])).toEqual(['≤100K', '≤200K', '≤1M', '>1M'])
+ })
+
+ it('官方无 intervals 字段(旧响应)时官方列保持平价,实付无阶梯时缓存列保持两行', () => {
+ const wrapper = mountTable([tokenModel()], 1)
+ const cells = wrapper.findAll('tbody td')
+ expect(cells[3].text()).toContain('modelPlaza.table.cacheWrite')
+ expect(cells[3].text()).toContain('modelPlaza.table.cacheRead')
+ expect(cells[3].findAll('.leading-5')).toHaveLength(0)
+ expect(cells[6].text()).toContain('(1h')
+ })
+})
+
+describe('PlazaModelPricingTable 分时计价', () => {
+ function timePricedModel() {
+ return tokenModel({
+ name: 'deepseek-chat',
+ platform: 'deepseek',
+ time_pricing: {
+ timezone: 'Asia/Shanghai',
+ periods: [
+ { start_time: '00:30', end_time: '08:30:00', multiplier: 0.5 },
+ { start_time: '18:00', end_time: '22:00', multiplier: 1.2 }
+ ]
+ }
+ })
+ }
+
+ it('有分时倍率的模型展开为标准行 + 每时段一行,时段行价格按倍率折算且倍率列显示生效倍率', () => {
+ const wrapper = mountTable([timePricedModel()], 0.8)
+ const trs = wrapper.findAll('tbody tr')
+ expect(trs).toHaveLength(3)
+
+ // 标准行:输入 3 × 0.8
+ const baseCells = trs[0].findAll('td')
+ expect(baseCells[0].text()).toBe('deepseek-chat')
+ expect(baseCells[1].text()).toContain('$2.40')
+ expect(baseCells[7].text()).toContain('0.8x')
+
+ // 夜间时段行:输入 3 × 0.8 × 0.5,倍率 0.4x,标注时段不含时区
+ const nightCells = trs[1].findAll('td')
+ expect(nightCells[0].text()).toContain('deepseek-chat')
+ expect(nightCells[0].text()).toContain('00:30–08:30')
+ expect(nightCells[0].text()).not.toContain('Asia/Shanghai')
+ // 时区只放在 tooltip 里(i18n mock 不做插值,这里只断言挂了说明)
+ expect(nightCells[0].find('[title="modelPlaza.table.timePricingRowHint"]').exists()).toBe(true)
+ expect(nightCells[1].text()).toContain('$1.20')
+ expect(nightCells[2].text()).toContain('$6.00')
+ expect(nightCells[3].text()).toContain('$1.50')
+ expect(nightCells[7].text()).toContain('0.4x')
+
+ // 晚高峰行:3 × 0.8 × 1.2 = 2.88,倍率 0.96x
+ const peakCells = trs[2].findAll('td')
+ expect(peakCells[0].text()).toContain('18:00–22:00')
+ expect(peakCells[1].text()).toContain('$2.88')
+ expect(peakCells[7].text()).toContain('0.96x')
+
+ // 官方列不受时段影响
+ expect(nightCells[4].text()).toContain('$3.00')
+ })
+
+ it('仅工作日生效时时段行带工作日前缀,tooltip 换用周末回落文案', () => {
+ const model = timePricedModel()
+ model.time_pricing!.weekdays_only = true
+ const wrapper = mountTable([model], 1)
+ const trs = wrapper.findAll('tbody tr')
+ expect(trs).toHaveLength(3)
+
+ const nightCells = trs[1].findAll('td')
+ expect(nightCells[0].text()).toContain('modelPlaza.table.timePricingWeekdays')
+ expect(nightCells[0].text()).toContain('00:30–08:30')
+ expect(nightCells[0].find('[title="modelPlaza.table.timePricingRowHintWeekdays"]').exists()).toBe(true)
+ expect(nightCells[0].find('[title="modelPlaza.table.timePricingRowHint"]').exists()).toBe(false)
+ })
+
+ it('每日生效(无 weekdays_only)不渲染工作日前缀', () => {
+ const wrapper = mountTable([timePricedModel()], 1)
+ expect(wrapper.find('tbody').text()).not.toContain('modelPlaza.table.timePricingWeekdays')
+ expect(wrapper.find('[title="modelPlaza.table.timePricingRowHint"]').exists()).toBe(true)
+ })
+
+ it('分组启用高峰倍率时时段行 tooltip 追加高峰披露,价格与倍率列保持不含高峰的口径', () => {
+ const wrapper = mountTable([timePricedModel()], 0.8, null, {
+ peakWindow: '14:00-18:00 ×1.5 (UTC+08:00)',
+ peakRateMultiplier: 1.5
+ })
+ const nightCells = wrapper.findAll('tbody tr')[1].findAll('td')
+ const title = nightCells[0].find('[title*="modelPlaza.table.timePricingRowHint"]').attributes('title')
+ expect(title).toContain('modelPlaza.table.timePricingRowHintPeak')
+ // 行内数字仍是 基础倍率 × 时段倍率(0.8 × 0.5),高峰只进披露不进价格
+ expect(nightCells[1].text()).toContain('$1.20')
+ expect(nightCells[7].text()).toContain('0.4x')
+ })
+
+ it('分组未启用高峰(peakWindow 缺省)时 tooltip 不含高峰披露', () => {
+ const wrapper = mountTable([timePricedModel()], 1)
+ const badge = wrapper.find('[title*="modelPlaza.table.timePricingRowHint"]')
+ expect(badge.attributes('title')).not.toContain('modelPlaza.table.timePricingRowHintPeak')
+ })
+
+ it('无分时倍率时只有一行,不渲染时段标注', () => {
+ const wrapper = mountTable([tokenModel()], 1)
+ expect(wrapper.findAll('tbody tr')).toHaveLength(1)
+ expect(wrapper.find('[title*="modelPlaza.table.timePricingRowHint"]').exists()).toBe(false)
+ })
+})
diff --git a/frontend/src/i18n/locales/en/admin/accounts.ts b/frontend/src/i18n/locales/en/admin/accounts.ts
index e119f76d7d..ae0867b9f1 100644
--- a/frontend/src/i18n/locales/en/admin/accounts.ts
+++ b/frontend/src/i18n/locales/en/admin/accounts.ts
@@ -823,6 +823,14 @@ export default {
autoPause5hDisabled: 'Disable 5h auto-pause',
autoPause7dDisabled: 'Disable 7d auto-pause',
autoPauseDisabledHint: 'When enabled, this account is never auto-paused (even if a global default threshold is configured).',
+ autoResetCredit: {
+ title: 'Automatically use reset credits',
+ hint: 'Uses the earliest-expiring available credit only when actual usage reaches a threshold. Off by default; the account remains paused if no credit is available or reset fails.',
+ threshold5h: '5h auto-reset threshold (%)',
+ threshold7d: '7d auto-reset threshold (%)',
+ thresholdHint: 'Each window is evaluated independently. Enter 0.1–100; both default to 100.',
+ thresholdInvalid: 'Automatic reset-credit thresholds must be between 0.1% and 100%.'
+ },
// Quota control (Anthropic OAuth/SetupToken only)
quotaControl: {
title: 'Quota Control',
@@ -1517,6 +1525,14 @@ export default {
resetAccountRecoveryFailed: 'The window was reset, but account state recovery failed. Recover the account state manually.',
resetAccountRefreshFailed: 'The window, account state, and reset-credit cache were updated, but the latest account display could not be loaded.',
refreshCachePersistFailed: 'Showing the live count, but its expiration details were unavailable, so the cached details were kept.',
+ autoStatus: {
+ checking: 'Checking',
+ available: 'Credit available',
+ resetting: 'Auto-resetting',
+ success: 'Auto-reset succeeded',
+ noCredit: 'No credit',
+ failed: 'Auto-reset failed'
+ },
confirmTitle: 'Confirm Weekly Limit Reset',
confirmMessage: 'This will consume 1 reset credit to immediately restore the current window ({count} remaining). This action cannot be undone. Continue?'
},
diff --git a/frontend/src/i18n/locales/en/admin/channels.ts b/frontend/src/i18n/locales/en/admin/channels.ts
index 48ad1f0b63..404bcc17d1 100644
--- a/frontend/src/i18n/locales/en/admin/channels.ts
+++ b/frontend/src/i18n/locales/en/admin/channels.ts
@@ -140,6 +140,9 @@ export default {
intervals: 'Context Intervals (optional)',
timePricing: 'Time-based pricing (optional)',
timezone: 'Time zone',
+ timePricingDayScope: 'Effective days',
+ timePricingEveryDay: 'Every day (Monday-Sunday, including weekends)',
+ timePricingWeekdaysOnly: 'Weekdays only (Monday-Friday)',
addTimePeriod: 'Add period',
startTime: 'Start time',
endTime: 'End time',
diff --git a/frontend/src/i18n/locales/en/admin/index.ts b/frontend/src/i18n/locales/en/admin/index.ts
index 224ab94b39..b86114e058 100644
--- a/frontend/src/i18n/locales/en/admin/index.ts
+++ b/frontend/src/i18n/locales/en/admin/index.ts
@@ -6,6 +6,7 @@ import ops from './ops'
import settings from './settings'
import audit from './audit'
import promptAudit from './promptAudit'
+import plugins from './plugins'
export default {
...overview,
@@ -16,4 +17,5 @@ export default {
...settings,
...audit,
...promptAudit,
+ ...plugins,
}
diff --git a/frontend/src/i18n/locales/en/admin/ops.ts b/frontend/src/i18n/locales/en/admin/ops.ts
index fe6a7a28bb..4bb48c64da 100644
--- a/frontend/src/i18n/locales/en/admin/ops.ts
+++ b/frontend/src/i18n/locales/en/admin/ops.ts
@@ -307,6 +307,7 @@ export default {
title: 'Error Detail',
titleWithId: 'Error #{id}',
noErrorSelected: 'No error selected.',
+ backToList: 'Back to List',
resolution: 'Resolved:',
failedToUpdateResolvedStatus: 'Failed to update resolved status',
classificationKeys: {
diff --git a/frontend/src/i18n/locales/en/admin/overview.ts b/frontend/src/i18n/locales/en/admin/overview.ts
index a7521aebed..9b4040099d 100644
--- a/frontend/src/i18n/locales/en/admin/overview.ts
+++ b/frontend/src/i18n/locales/en/admin/overview.ts
@@ -513,6 +513,8 @@ export default {
statusLabel: 'Status',
selectStatus: 'Select status',
rpmLimit: 'Requests Per Minute (RPM)',
+ concurrencyPlaceholder: '0 = unlimited',
+ concurrencyHint: 'Max concurrent requests for this user; 0 = unlimited.',
rpmLimitPlaceholder: '0 = unlimited',
rpmLimitHint: 'Max requests per minute for this user; 0 = unlimited. Acts as a fallback only when the group has no rpm_limit set.'
},
@@ -577,7 +579,7 @@ export default {
failedToToggle: 'Failed to update user status',
failedToLoadApiKeys: 'Failed to load user API keys',
emailRequired: 'Please enter email',
- concurrencyMin: 'Concurrency must be at least 1',
+ concurrencyNonNegative: 'Concurrency cannot be negative; 0 = unlimited',
amountRequired: 'Please enter a valid amount',
insufficientBalance: 'Insufficient balance',
adjustBalance: 'Adjust Balance',
diff --git a/frontend/src/i18n/locales/en/admin/plugins.ts b/frontend/src/i18n/locales/en/admin/plugins.ts
new file mode 100644
index 0000000000..1d68ba6dc6
--- /dev/null
+++ b/frontend/src/i18n/locales/en/admin/plugins.ts
@@ -0,0 +1,50 @@
+export default {
+ plugins: {
+ title: 'Plugin Management',
+ description: 'Install and manage isolated OAuth outbound transport plugins. API Key flows are unchanged.',
+ upload: 'Install plugin',
+ uploadHint: 'Only .s2plugin packages are accepted; trusted publisher signatures are required by default.',
+ runtimeNotice: 'Plugin installation, enable/disable, and configuration are handled dynamically by the Sub2API host and normally do not require a host restart. Restart only when the host version or host configuration changes according to your deployment process.',
+ menuNotice: 'The Plugin Management switch in System Settings controls only sidebar visibility; it does not stop loaded or running plugins.',
+ empty: 'No plugins installed',
+ emptyHint: 'Select a local .s2plugin package. Sub2API never downloads third-party plugins automatically.',
+ configure: 'Configure',
+ enable: 'Enable',
+ disable: 'Disable',
+ test: 'Test',
+ uninstall: 'Uninstall',
+ rollout: 'OAuth traffic percentage',
+ compatibility: 'Version compatibility',
+ currentVersion: 'Current Sub2API',
+ requiredVersion: 'Required range',
+ recommendedVersion: 'Recommended version',
+ signature: 'Package signature',
+ trusted: 'Verified',
+ unsigned: 'Unsigned',
+ runtime: 'Runtime',
+ healthy: 'Healthy',
+ unhealthy: 'Not running',
+ compatible: 'Compatible',
+ untested: 'Untested version',
+ incompatible: 'Incompatible',
+ enabled: 'Enabled',
+ disabled: 'Disabled',
+ error: 'Error',
+ starting: 'Starting',
+ configTitle: '{name} configuration',
+ loadingUI: 'Loading plugin configuration UI...',
+ uiUnavailable: 'Unable to load the plugin configuration UI',
+ uploadSuccess: 'Plugin installed and kept disabled',
+ enableSuccess: 'Plugin enabled',
+ disableSuccess: 'Plugin disabled',
+ uninstallSuccess: 'Plugin uninstalled',
+ testSuccess: 'Plugin test passed',
+ confirmDisable: 'Disable this plugin? New OAuth requests immediately return to the built-in Sub2API path.',
+ confirmUninstall: 'Uninstall this plugin? It must be disabled first. Installed files and configuration will be removed.',
+ confirmUntested: 'This plugin is compatible but has not declared the current Sub2API version as tested. Enable it anyway?',
+ fileRequired: 'Select a .s2plugin file',
+ bridgeRejected: 'Plugin UI message validation failed',
+ onlyOpenAI: 'Initial capability: OpenAI OAuth outbound transport only',
+ noAccountCoupling: 'The scope is platform and account type. Account records are not changed and no per-account toggle is required.'
+ }
+}
diff --git a/frontend/src/i18n/locales/en/admin/settings.ts b/frontend/src/i18n/locales/en/admin/settings.ts
index 2d6d344097..4c23f239ae 100644
--- a/frontend/src/i18n/locales/en/admin/settings.ts
+++ b/frontend/src/i18n/locales/en/admin/settings.ts
@@ -52,6 +52,12 @@ export default {
priceDescription: 'Pricing notes (Markdown)',
priceDescriptionHint: 'Rendered at the top of the plaza page. Use it for billing rules, exchange rates, promotions, etc.',
},
+ pluginManagement: {
+ title: 'Plugin Management',
+ description: 'Controls whether the plugin management entry appears in the admin sidebar. This switch does not control plugin runtime state.',
+ enabled: 'Show Plugin Management',
+ enabledHint: 'Turning this off only hides the sidebar entry; loaded or running plugins are not stopped.',
+ },
riskControl: {
title: 'Risk Control',
description: 'Enable the content moderation menu and gateway audit entry point. Disabled by default.',
diff --git a/frontend/src/i18n/locales/en/common.ts b/frontend/src/i18n/locales/en/common.ts
index a0190ca0b1..d0afbee551 100644
--- a/frontend/src/i18n/locales/en/common.ts
+++ b/frontend/src/i18n/locales/en/common.ts
@@ -176,6 +176,7 @@ export default {
modelPlaza: 'Model Plaza',
subscriptions: 'Subscriptions',
accounts: 'Accounts',
+ plugins: 'Plugins',
proxies: 'Proxies',
redeemCodes: 'Redeem Codes',
ops: 'Ops',
diff --git a/frontend/src/i18n/locales/en/dashboard.ts b/frontend/src/i18n/locales/en/dashboard.ts
index 9411a73f27..0899f3a6b1 100644
--- a/frontend/src/i18n/locales/en/dashboard.ts
+++ b/frontend/src/i18n/locales/en/dashboard.ts
@@ -610,7 +610,8 @@ export default {
detail: {
noModels: 'No models configured for this group',
noPricing: 'Pricing not configured',
- peakNote: 'Peak hours {window}: billing rate ×{multiplier}'
+ peakNote: 'Peak hours {window}: billing rate ×{multiplier}',
+ longContextDisabledNote: 'Long-context tier pricing is disabled for this group: requests above the threshold are billed at the base tier; official tiers are for reference only'
},
table: {
model: 'Model',
@@ -619,6 +620,18 @@ export default {
cache: 'Cache',
cacheWrite: 'Write',
cacheRead: 'Read',
+ cacheWriteShort: 'W',
+ cacheReadShort: 'R',
+ tierHint: 'The whole request is billed at the tier matching its total context (input + cache write + cache read)',
+ tierHintMarginal: 'Only the portion above the threshold is billed at this tier; output is unaffected',
+ marginalBadge: 'excess-only tiers',
+ timePricingRowHint: 'Requests made within this period ({timezone} time) are billed at the prices in this row',
+ timePricingRowHintWeekdays:
+ 'On weekdays (Mon–Fri) only, requests made within this period ({timezone} time) are billed at the prices in this row; weekends use the standard prices',
+ timePricingRowHintPeak:
+ '; prices in this row exclude the peak-hour rate — where this period overlaps the peak hours {window}, the overlapping portion is additionally multiplied by ×{multiplier}',
+ timePricingWeekdays: 'Weekdays',
+ timePricingRateHint: 'Effective rate {rate} × period multiplier {multiplier}',
paidPrice: 'Your Price (Discounted)',
officialPrice: 'Official Price',
rate: 'Rate',
diff --git a/frontend/src/i18n/locales/zh/admin/accounts.ts b/frontend/src/i18n/locales/zh/admin/accounts.ts
index f73f357ec0..c277eb7c2f 100644
--- a/frontend/src/i18n/locales/zh/admin/accounts.ts
+++ b/frontend/src/i18n/locales/zh/admin/accounts.ts
@@ -486,6 +486,14 @@ export default {
resetAccountRecoveryFailed: '窗口已重置,但账号状态恢复失败,请手动恢复账号状态。',
resetAccountRefreshFailed: '窗口、账号状态和重置次数缓存已更新,但无法加载最新账号显示。',
refreshCachePersistFailed: '已显示实时次数,但到期明细获取失败,仍保留原有缓存明细。',
+ autoStatus: {
+ checking: '检测中',
+ available: '卡可用',
+ resetting: '自动重置中',
+ success: '自动重置成功',
+ noCredit: '无卡',
+ failed: '自动重置失败'
+ },
confirmTitle: '确认重置周限',
confirmMessage: '将消耗 1 次重置次数立即恢复当前窗口,剩余 {count} 次。此操作不可撤销,确定继续吗?'
},
@@ -886,6 +894,14 @@ export default {
autoPause5hDisabled: '禁用 5h 自动暂停',
autoPause7dDisabled: '禁用 7d 自动暂停',
autoPauseDisabledHint: '开启后该账号永不进入自动暂停(即使全局默认阈值已配置)。',
+ autoResetCredit: {
+ title: '自动使用重置卡',
+ hint: '仅在实际用量达到阈值时使用最早到期的可用卡;默认关闭。无卡或失败时账号保持暂停。',
+ threshold5h: '5h 自动用卡阈值(%)',
+ threshold7d: '7d 自动用卡阈值(%)',
+ thresholdHint: '两个窗口独立判断,任一达到自身阈值即触发。可填写 0.1–100,默认均为 100。',
+ thresholdInvalid: '自动使用重置卡阈值必须在 0.1% 到 100% 之间。'
+ },
// Quota control (Anthropic OAuth/SetupToken only)
quotaControl: {
title: '配额控制',
diff --git a/frontend/src/i18n/locales/zh/admin/channels.ts b/frontend/src/i18n/locales/zh/admin/channels.ts
index b7beb0398f..12272becd1 100644
--- a/frontend/src/i18n/locales/zh/admin/channels.ts
+++ b/frontend/src/i18n/locales/zh/admin/channels.ts
@@ -140,6 +140,9 @@ export default {
intervals: '上下文区间定价(可选)',
timePricing: '时间段定价(可选)',
timezone: '时区',
+ timePricingDayScope: '生效日',
+ timePricingEveryDay: '每日生效(周一至周日,含周末)',
+ timePricingWeekdaysOnly: '仅工作日生效(周一至周五)',
addTimePeriod: '添加时间段',
startTime: '开始时间',
endTime: '结束时间',
diff --git a/frontend/src/i18n/locales/zh/admin/index.ts b/frontend/src/i18n/locales/zh/admin/index.ts
index 224ab94b39..b86114e058 100644
--- a/frontend/src/i18n/locales/zh/admin/index.ts
+++ b/frontend/src/i18n/locales/zh/admin/index.ts
@@ -6,6 +6,7 @@ import ops from './ops'
import settings from './settings'
import audit from './audit'
import promptAudit from './promptAudit'
+import plugins from './plugins'
export default {
...overview,
@@ -16,4 +17,5 @@ export default {
...settings,
...audit,
...promptAudit,
+ ...plugins,
}
diff --git a/frontend/src/i18n/locales/zh/admin/ops.ts b/frontend/src/i18n/locales/zh/admin/ops.ts
index 1e521c8914..3e568e1dd7 100644
--- a/frontend/src/i18n/locales/zh/admin/ops.ts
+++ b/frontend/src/i18n/locales/zh/admin/ops.ts
@@ -307,6 +307,7 @@ export default {
title: '错误详情',
titleWithId: '错误 #{id}',
noErrorSelected: '未选择错误。',
+ backToList: '返回列表',
resolution: '已解决:',
failedToUpdateResolvedStatus: '更新解决状态失败',
classificationKeys: {
diff --git a/frontend/src/i18n/locales/zh/admin/overview.ts b/frontend/src/i18n/locales/zh/admin/overview.ts
index 00ee1fbfc1..c2e842e8b0 100644
--- a/frontend/src/i18n/locales/zh/admin/overview.ts
+++ b/frontend/src/i18n/locales/zh/admin/overview.ts
@@ -578,6 +578,8 @@ export default {
statusLabel: '状态',
selectStatus: '选择状态',
rpmLimit: '每分钟请求数 (RPM)',
+ concurrencyPlaceholder: '0 表示不限制',
+ concurrencyHint: '该用户的最大并发请求数,0 = 不限制',
rpmLimitPlaceholder: '0 表示不限制',
rpmLimitHint: '该用户每分钟最大请求数,0 = 不限制;仅在所用分组未设置 rpm_limit 时作为兜底生效'
},
@@ -598,7 +600,7 @@ export default {
failedToSave: '保存用户失败',
failedToAdjust: '调整失败',
emailRequired: '请输入邮箱',
- concurrencyMin: '并发数不能小于1',
+ concurrencyNonNegative: '并发数不能为负数,0 表示不限制',
amountRequired: '请输入有效金额',
insufficientBalance: '余额不足',
setAllowedGroups: '设置允许分组',
diff --git a/frontend/src/i18n/locales/zh/admin/plugins.ts b/frontend/src/i18n/locales/zh/admin/plugins.ts
new file mode 100644
index 0000000000..8b279bd8f3
--- /dev/null
+++ b/frontend/src/i18n/locales/zh/admin/plugins.ts
@@ -0,0 +1,50 @@
+export default {
+ plugins: {
+ title: '插件管理',
+ description: '安装和管理独立运行的 OAuth 出站传输插件。API Key 流程不受影响。',
+ upload: '安装插件',
+ uploadHint: '仅接受 .s2plugin 包;默认要求可信发布者签名。',
+ runtimeNotice: '插件安装、启用、停用和配置由 Sub2API 宿主动态处理,通常不需要重启宿主实例。只有宿主版本或宿主配置本身变化时,才按部署方式执行重启。',
+ menuNotice: '系统设置中的“插件管理”开关仅控制侧边栏菜单显示,不会停止已经加载或正在运行的插件。',
+ empty: '尚未安装插件',
+ emptyHint: '选择本机的 .s2plugin 文件进行安装。Sub2API 不会自动下载第三方插件。',
+ configure: '配置',
+ enable: '启用',
+ disable: '停用',
+ test: '测试',
+ uninstall: '卸载',
+ rollout: 'OAuth 流量比例',
+ compatibility: '版本兼容性',
+ currentVersion: '当前 Sub2API',
+ requiredVersion: '要求范围',
+ recommendedVersion: '建议版本',
+ signature: '包签名',
+ trusted: '已验证',
+ unsigned: '未签名',
+ runtime: '运行状态',
+ healthy: '运行正常',
+ unhealthy: '未运行',
+ compatible: '兼容',
+ untested: '未验证版本',
+ incompatible: '不兼容',
+ enabled: '已启用',
+ disabled: '已停用',
+ error: '异常',
+ starting: '启动中',
+ configTitle: '{name} 配置',
+ loadingUI: '正在加载插件配置界面...',
+ uiUnavailable: '无法加载插件配置界面',
+ uploadSuccess: '插件安装成功,当前保持停用',
+ enableSuccess: '插件已启用',
+ disableSuccess: '插件已停用',
+ uninstallSuccess: '插件已卸载',
+ testSuccess: '插件测试通过',
+ confirmDisable: '确定停用此插件吗?新的 OAuth 请求会立即恢复 Sub2API 原有路径。',
+ confirmUninstall: '确定卸载此插件吗?插件必须先停用。此操作会移除安装文件和配置。',
+ confirmUntested: '该插件兼容当前版本范围,但未声明已测试当前 Sub2API 版本。确定承担风险并启用吗?',
+ fileRequired: '请选择 .s2plugin 文件',
+ bridgeRejected: '插件 UI 消息校验失败',
+ onlyOpenAI: '初期能力:仅 OpenAI OAuth 出站传输',
+ noAccountCoupling: '作用域为平台与账号类型,不修改账号数据,也不需要在账号页逐个开启。'
+ }
+}
diff --git a/frontend/src/i18n/locales/zh/admin/settings.ts b/frontend/src/i18n/locales/zh/admin/settings.ts
index f315480666..fb5371e8c5 100644
--- a/frontend/src/i18n/locales/zh/admin/settings.ts
+++ b/frontend/src/i18n/locales/zh/admin/settings.ts
@@ -52,6 +52,12 @@ export default {
priceDescription: '价格说明(Markdown)',
priceDescriptionHint: '展示在模型广场页面顶部,可用于说明计费规则、汇率、优惠活动等。',
},
+ pluginManagement: {
+ title: '插件管理',
+ description: '控制管理员侧边栏是否显示插件管理入口。此开关不控制插件运行状态。',
+ enabled: '显示插件管理菜单',
+ enabledHint: '关闭后仅隐藏侧边栏菜单;已加载或正在运行的插件不会因此停止。',
+ },
riskControl: {
title: '风控中心',
description: '启用内容审计菜单和全端点请求审核入口。默认关闭。',
diff --git a/frontend/src/i18n/locales/zh/common.ts b/frontend/src/i18n/locales/zh/common.ts
index 04dd321287..c5b2c4002e 100644
--- a/frontend/src/i18n/locales/zh/common.ts
+++ b/frontend/src/i18n/locales/zh/common.ts
@@ -176,6 +176,7 @@ export default {
modelPlaza: '模型广场',
subscriptions: '订阅管理',
accounts: '账号管理',
+ plugins: '插件管理',
proxies: 'IP管理',
redeemCodes: '兑换码',
ops: '运维监控',
diff --git a/frontend/src/i18n/locales/zh/dashboard.ts b/frontend/src/i18n/locales/zh/dashboard.ts
index 3361fc89f3..501e9dbc93 100644
--- a/frontend/src/i18n/locales/zh/dashboard.ts
+++ b/frontend/src/i18n/locales/zh/dashboard.ts
@@ -615,7 +615,8 @@ export default {
detail: {
noModels: '该分组暂未配置模型',
noPricing: '未配置定价',
- peakNote: '高峰时段 {window} 计费倍率 ×{multiplier}'
+ peakNote: '高峰时段 {window} 计费倍率 ×{multiplier}',
+ longContextDisabledNote: '该分组未启用长上下文阶梯计费,超阈值请求仍按基础档计费,官方阶梯仅供参考'
},
table: {
model: '模型',
@@ -624,6 +625,17 @@ export default {
cache: '缓存',
cacheWrite: '写入',
cacheRead: '读取',
+ cacheWriteShort: '写',
+ cacheReadShort: '读',
+ tierHint: '按单次请求的总上下文(输入 + 缓存写入 + 缓存读取)所在档位对整单计价',
+ tierHintMarginal: '仅超过阈值的部分按该档计价,输出不加价',
+ marginalBadge: '超出部分计价',
+ timePricingRowHint: '按 {timezone} 时间,在该时段内发起的请求按本行价格计费',
+ timePricingRowHintWeekdays:
+ '按 {timezone} 时间,仅工作日(周一至周五)在该时段内发起的请求按本行价格计费,周末全天按标准价',
+ timePricingRowHintPeak: ';本行价格未含高峰倍率,与高峰时段 {window} 重叠的部分实付再乘 ×{multiplier}',
+ timePricingWeekdays: '工作日',
+ timePricingRateHint: '生效倍率 {rate} × 时段倍率 {multiplier}',
paidPrice: '实付价格(折后)',
officialPrice: '官方价格',
rate: '折扣倍率',
diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts
index 1221a53f6d..1191944f52 100644
--- a/frontend/src/router/index.ts
+++ b/frontend/src/router/index.ts
@@ -524,6 +524,18 @@ const routes: RouteRecordRaw[] = [
descriptionKey: 'admin.accounts.description'
}
},
+ {
+ path: '/admin/plugins',
+ name: 'AdminPlugins',
+ component: () => import('@/views/admin/PluginsView.vue'),
+ meta: {
+ requiresAuth: true,
+ requiresAdmin: true,
+ title: 'Plugin Management',
+ titleKey: 'admin.plugins.title',
+ descriptionKey: 'admin.plugins.description'
+ }
+ },
{
path: '/admin/announcements',
name: 'AdminAnnouncements',
diff --git a/frontend/src/stores/__tests__/app.spec.ts b/frontend/src/stores/__tests__/app.spec.ts
index d5cee36065..d2c55d13ef 100644
--- a/frontend/src/stores/__tests__/app.spec.ts
+++ b/frontend/src/stores/__tests__/app.spec.ts
@@ -57,6 +57,7 @@ function createPublicSettings(overrides: Partial = {}): PublicSe
available_channels_enabled: false,
model_plaza_enabled: false,
model_plaza_require_auth: false,
+ plugin_management_enabled: false,
service_quota_enabled: false,
affiliate_enabled: false,
...overrides,
diff --git a/frontend/src/stores/app.ts b/frontend/src/stores/app.ts
index e2d614ec3d..496e17d270 100644
--- a/frontend/src/stores/app.ts
+++ b/frontend/src/stores/app.ts
@@ -373,6 +373,7 @@ export const useAppStore = defineStore('app', () => {
available_channels_enabled: false,
model_plaza_enabled: false,
model_plaza_require_auth: false,
+ plugin_management_enabled: false,
risk_control_enabled: false,
service_quota_enabled: false,
affiliate_enabled: false,
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
index eaa585f15c..10a72a0a17 100644
--- a/frontend/src/types/index.ts
+++ b/frontend/src/types/index.ts
@@ -273,6 +273,7 @@ export interface PublicSettings {
available_channels_enabled: boolean
model_plaza_enabled: boolean
model_plaza_require_auth: boolean
+ plugin_management_enabled: boolean
service_quota_enabled: boolean
affiliate_enabled: boolean
allow_user_view_error_requests?: boolean
@@ -1133,6 +1134,17 @@ export interface Account {
available_count?: number
credits?: { expires_at?: string }[]
}
+ auto_reset_credit_enabled?: boolean
+ auto_reset_credit_5h_threshold?: number
+ auto_reset_credit_7d_threshold?: number
+ codex_auto_reset_credit_state?: {
+ status?: 'checking' | 'available' | 'resetting' | 'success' | 'no_credit' | 'failed'
+ trigger_window?: string
+ available_count?: number
+ checked_at?: string
+ last_result_at?: string
+ error_code?: string
+ }
} & Record)
proxy_id: number | null
proxy_fallback_origin_id?: number | null
diff --git a/frontend/src/utils/featureFlags.ts b/frontend/src/utils/featureFlags.ts
index f2e36b4a60..5cf6c46b31 100644
--- a/frontend/src/utils/featureFlags.ts
+++ b/frontend/src/utils/featureFlags.ts
@@ -109,6 +109,11 @@ export const FeatureFlags = {
mode: 'opt-in',
label: 'Model Plaza',
}),
+ pluginManagement: defineFlag({
+ key: 'plugin_management_enabled',
+ mode: 'opt-in',
+ label: 'Plugin Management',
+ }),
payment: defineFlag({
key: 'payment_enabled',
mode: 'opt-out',
diff --git a/frontend/src/views/admin/AccountsView.vue b/frontend/src/views/admin/AccountsView.vue
index 982c21b6f5..cbefbc5a03 100644
--- a/frontend/src/views/admin/AccountsView.vue
+++ b/frontend/src/views/admin/AccountsView.vue
@@ -634,7 +634,7 @@ const accountToolsDropdownStyle = computed(() => ({
width: `${accountToolsDropdownPosition.width}px`
}))
const hiddenColumns = reactive>(new Set())
-const DEFAULT_HIDDEN_COLUMNS = ['today_stats', 'proxy', 'notes', 'priority', 'scheduler_score', 'rate_multiplier']
+const DEFAULT_HIDDEN_COLUMNS = ['today_stats', 'proxy', 'notes', 'scheduler_score', 'rate_multiplier']
const HIDDEN_COLUMNS_KEY = 'account-hidden-columns'
// One-time migration: hide scheduler score for existing admins too, because showing it opt-ins to heavy backend scoring.
const HIDDEN_COLUMNS_VERSION_KEY = 'account-hidden-columns-version'
diff --git a/frontend/src/views/admin/PluginsView.vue b/frontend/src/views/admin/PluginsView.vue
new file mode 100644
index 0000000000..a947b6d74a
--- /dev/null
+++ b/frontend/src/views/admin/PluginsView.vue
@@ -0,0 +1,716 @@
+
+
+
+
+
+
+ {{ t("admin.plugins.title") }}
+
+
+ {{ t("admin.plugins.description") }}
+
+
+ {{
+ t("admin.plugins.onlyOpenAI")
+ }}
+ {{
+ t("admin.plugins.noAccountCoupling")
+ }}
+
+
+
+
+
+
+
+ {{ uploading ? t("common.processing") : t("admin.plugins.upload") }}
+
+
+
+ {{ t("common.refresh") }}
+
+
+
+
+
+ {{ t("admin.plugins.uploadHint") }}
+
+
+
+
{{ t("admin.plugins.runtimeNotice") }}
+
{{ t("admin.plugins.menuNotice") }}
+
+
+
+ {{ t("common.loading") }}
+
+
+
+
+
+ {{ t("admin.plugins.empty") }}
+
+
+ {{ t("admin.plugins.emptyHint") }}
+
+
+
+
+
+
+
+
+
+ {{ plugin.name }}
+
+ v{{ plugin.version }}
+
+ {{ t(`admin.plugins.${plugin.state}`) }}
+
+
+
+ {{ plugin.plugin_key
+ }} · {{ plugin.author }}
+
+
+ {{ plugin.description }}
+
+
+
+
+ {{ t("admin.plugins.configure") }}
+
+
+
+
+
+
+ {{ t("admin.plugins.compatibility") }}
+
+
+
+ {{ t(`admin.plugins.${plugin.compatibility.status}`) }}
+
+ {{
+ plugin.compatibility.message
+ }}
+
+
+
+ {{ t("admin.plugins.currentVersion") }}
+
+
+ {{ plugin.compatibility.current_sub2api_version }}
+
+
+ {{ t("admin.plugins.requiredVersion") }}
+
+
+ {{ plugin.compatibility.required_sub2api_version }}
+
+
+ {{ t("admin.plugins.recommendedVersion") }}
+
+
+ {{ plugin.compatibility.recommended_sub2api_version || "-" }}
+
+
+
+
+
+
+ {{ t("admin.plugins.runtime") }}
+
+
+
+ {{
+ plugin.runtime_healthy
+ ? t("admin.plugins.healthy")
+ : t("admin.plugins.unhealthy")
+ }}
+
+
+ {{ t("admin.plugins.signature") }}:
+ {{ t(`admin.plugins.${plugin.signature_status}`) }}
+
+
+
+ {{ plugin.last_error }}
+
+
+ {{ plugin.runtime_message }}
+
+
+
+
+
+ {{ t("admin.plugins.rollout") }}
+ {{
+ rolloutValues[plugin.id] ?? currentRollout(plugin)
+ }}%
+
+
+
+
+
+
+
+
+ {{ t("admin.plugins.test") }}
+
+
+
+ {{ t("admin.plugins.disable") }}
+
+
+
+ {{ t("admin.plugins.enable") }}
+
+
+
+ {{ t("admin.plugins.uninstall") }}
+
+
+
+
+
+
+
+
+ {{ t("admin.plugins.loadingUI") }}
+
+
+
+
+ {{ t("admin.plugins.uiUnavailable") }}
+
+
{{ uiError }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/views/admin/ProxiesView.vue b/frontend/src/views/admin/ProxiesView.vue
index 0572cc254b..a5cd50f07e 100644
--- a/frontend/src/views/admin/ProxiesView.vue
+++ b/frontend/src/views/admin/ProxiesView.vue
@@ -1276,6 +1276,7 @@ const handleDataImported = () => {
}
// Parse proxy URL: protocol://user:pass@host:port or protocol://host:port
+// Host may be a domain, IPv4, or bracketed IPv6 ([2001:db8::1]).
const parseProxyUrl = (
line: string
): {
@@ -1288,20 +1289,26 @@ const parseProxyUrl = (
const trimmed = line.trim()
if (!trimmed) return null
- // Regex to parse proxy URL (supports http, https, socks5, socks5h)
- const regex = /^(https?|socks5h?):\/\/(?:([^:@]+):([^@]+)@)?([^:]+):(\d+)$/i
+ // Regex to parse proxy URL (supports http, https, socks5, socks5h).
+ // Host alternatives: [bracketed-IPv6] | hostname/IPv4 (colon-free, so the
+ // match stops before the final :port).
+ const regex =
+ /^(https?|socks5h?):\/\/(?:([^:@\[\]]+):([^@\[\]]+)@)?(\[[0-9a-f:.]+\]|[^:\[\]]+):(\d+)$/i
const match = trimmed.match(regex)
if (!match) return null
- const [, protocol, username, password, host, port] = match
+ const [, protocol, username, password, rawHost, port] = match
const portNum = parseInt(port, 10)
if (portNum < 1 || portNum > 65535) return null
+ // Strip brackets from IPv6 literals; the backend re-brackets via net.JoinHostPort.
+ const host = rawHost.replace(/^\[|\]$/g, '').trim()
+
return {
protocol: protocol.toLowerCase() as ProxyProtocol,
- host: host.trim(),
+ host,
port: portNum,
username: username?.trim() || '',
password: password?.trim() || ''
diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue
index 80af0b1940..7d68bcb87b 100644
--- a/frontend/src/views/admin/SettingsView.vue
+++ b/frontend/src/views/admin/SettingsView.vue
@@ -7188,6 +7188,30 @@
+
+
+
+ {{ t('admin.settings.features.pluginManagement.title') }}
+
+
+ {{ t('admin.settings.features.pluginManagement.description') }}
+
+
+
+
+
+
+ {{ t('admin.settings.features.pluginManagement.enabled') }}
+
+
+ {{ t('admin.settings.features.pluginManagement.enabledHint') }}
+
+
+
+
+
+
+
@@ -9752,6 +9776,8 @@ const form = reactive({
model_plaza_enabled: false,
model_plaza_require_auth: false,
model_plaza_description: '',
+ // Plugin management menu visibility; plugin runtime is unaffected.
+ plugin_management_enabled: false,
// Affiliate (邀请返利) feature switch
affiliate_enabled: false,
// Allow user view error requests
@@ -11408,6 +11434,7 @@ async function saveSettings() {
model_plaza_enabled: form.model_plaza_enabled,
model_plaza_require_auth: form.model_plaza_require_auth,
model_plaza_description: form.model_plaza_description,
+ plugin_management_enabled: form.plugin_management_enabled,
// Affiliate (邀请返利) feature switch
affiliate_enabled: form.affiliate_enabled,
allow_user_view_error_requests: form.allow_user_view_error_requests,
diff --git a/frontend/src/views/admin/__tests__/AccountsView.priorityColumn.spec.ts b/frontend/src/views/admin/__tests__/AccountsView.priorityColumn.spec.ts
new file mode 100644
index 0000000000..42c1071e87
--- /dev/null
+++ b/frontend/src/views/admin/__tests__/AccountsView.priorityColumn.spec.ts
@@ -0,0 +1,152 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { flushPromises, mount } from '@vue/test-utils'
+
+import AccountsView from '../AccountsView.vue'
+
+const { listAccounts } = vi.hoisted(() => ({
+ listAccounts: vi.fn()
+}))
+
+vi.mock('@/api/admin', () => ({
+ adminAPI: {
+ accounts: {
+ list: listAccounts,
+ listWithEtag: vi.fn(),
+ getBatchTodayStats: vi.fn().mockResolvedValue({ stats: {} }),
+ getUpstreamBillingProbeSettings: vi.fn().mockResolvedValue({ enabled: true, interval_minutes: 30 }),
+ delete: vi.fn(),
+ batchClearError: vi.fn(),
+ batchRefresh: vi.fn(),
+ toggleSchedulable: vi.fn()
+ },
+ proxies: { getAll: vi.fn().mockResolvedValue([]) },
+ groups: { getAll: vi.fn().mockResolvedValue([]) }
+ }
+}))
+
+vi.mock('@/stores/app', () => ({
+ useAppStore: () => ({ showError: vi.fn(), showSuccess: vi.fn(), showInfo: vi.fn() })
+}))
+
+vi.mock('@/stores/auth', () => ({
+ useAuthStore: () => ({ token: 'test-token', isSimpleMode: false })
+}))
+
+vi.mock('vue-i18n', async () => {
+ const actual = await vi.importActual('vue-i18n')
+ return {
+ ...actual,
+ useI18n: () => ({ t: (key: string) => key })
+ }
+})
+
+const DataTableStub = {
+ props: ['columns'],
+ emits: ['sort'],
+ template: `
+
+
+ {{ column.sortable ? 'sortable' : 'fixed' }}
+
+
+
+ `
+}
+
+function mountView() {
+ return mount(AccountsView, {
+ global: {
+ stubs: {
+ AppLayout: { template: '
' },
+ TablePageLayout: {
+ template: '
'
+ },
+ DataTable: DataTableStub,
+ AccountTableActions: { template: '
' },
+ AccountTableFilters: true,
+ AccountBulkActionsBar: true,
+ Pagination: true,
+ ConfirmDialog: true,
+ AccountActionMenu: true,
+ ImportDataModal: true,
+ ReAuthAccountModal: true,
+ AccountTestModal: true,
+ AccountStatsModal: true,
+ ScheduledTestsPanel: true,
+ SyncFromCrsModal: true,
+ TempUnschedStatusModal: true,
+ ErrorPassthroughRulesModal: true,
+ TLSFingerprintProfilesModal: true,
+ CreateAccountModal: true,
+ EditAccountModal: true,
+ BulkEditAccountModal: true,
+ PlatformTypeBadge: true,
+ AccountCapacityCell: true,
+ AccountStatusIndicator: true,
+ AccountTodayStatsCell: true,
+ AccountGroupsCell: true,
+ AccountUsageCell: true,
+ HelpTooltip: true,
+ Icon: true,
+ Teleport: true
+ }
+ }
+ })
+}
+
+describe('admin AccountsView priority column preferences', () => {
+ beforeEach(() => {
+ localStorage.clear()
+ listAccounts.mockReset().mockResolvedValue({
+ items: [],
+ total: 0,
+ page: 1,
+ page_size: 20,
+ pages: 0
+ })
+ })
+
+ it('shows priority as a sortable column for fresh preferences', async () => {
+ const wrapper = mountView()
+ await flushPromises()
+
+ expect(wrapper.get('[data-column="priority"]').text()).toBe('sortable')
+
+ await wrapper.get('[data-test="sort-priority"]').trigger('click')
+ await flushPromises()
+
+ expect(listAccounts).toHaveBeenLastCalledWith(
+ 1,
+ 20,
+ expect.objectContaining({ sort_by: 'priority', sort_order: 'desc' }),
+ expect.objectContaining({ signal: expect.any(AbortSignal) })
+ )
+ })
+
+ it('preserves an existing preference that explicitly hides priority', async () => {
+ localStorage.setItem('account-hidden-columns', JSON.stringify(['priority', 'today_stats']))
+ localStorage.setItem('account-hidden-columns-version', 'scheduler-score-hidden-by-default')
+
+ const wrapper = mountView()
+ await flushPromises()
+
+ expect(wrapper.find('[data-column="priority"]').exists()).toBe(false)
+ expect(JSON.parse(localStorage.getItem('account-hidden-columns') || '[]')).toEqual([
+ 'priority',
+ 'today_stats'
+ ])
+ })
+
+ it('keeps priority visible while migrating older saved preferences', async () => {
+ localStorage.setItem('account-hidden-columns', JSON.stringify(['today_stats']))
+
+ const wrapper = mountView()
+ await flushPromises()
+
+ expect(wrapper.get('[data-column="priority"]').text()).toBe('sortable')
+ expect(JSON.parse(localStorage.getItem('account-hidden-columns') || '[]')).toEqual(
+ expect.arrayContaining(['today_stats', 'scheduler_score'])
+ )
+ expect(JSON.parse(localStorage.getItem('account-hidden-columns') || '[]')).not.toContain('priority')
+ })
+})
diff --git a/frontend/src/views/admin/__tests__/PluginsView.spec.ts b/frontend/src/views/admin/__tests__/PluginsView.spec.ts
new file mode 100644
index 0000000000..3fa4a7ed29
--- /dev/null
+++ b/frontend/src/views/admin/__tests__/PluginsView.spec.ts
@@ -0,0 +1,169 @@
+import { flushPromises, mount } from '@vue/test-utils'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import PluginsView from '../PluginsView.vue'
+
+const {
+ listPlugins,
+ uploadPlugin,
+ enablePlugin,
+ savePluginConfig,
+ createUISession,
+ stepUpRun,
+} = vi.hoisted(() => ({
+ listPlugins: vi.fn(),
+ uploadPlugin: vi.fn(),
+ enablePlugin: vi.fn(),
+ savePluginConfig: vi.fn(),
+ createUISession: vi.fn(),
+ stepUpRun: vi.fn((action: () => Promise) => action()),
+}))
+
+vi.mock('@/api/admin', () => ({
+ adminAPI: {
+ plugins: {
+ list: listPlugins,
+ upload: uploadPlugin,
+ enable: enablePlugin,
+ disable: vi.fn(),
+ remove: vi.fn(),
+ getConfig: vi.fn().mockResolvedValue({}),
+ saveConfig: savePluginConfig,
+ test: vi.fn().mockResolvedValue({ success: true, message: 'ok', latency_ms: 1 }),
+ createUISession,
+ },
+ },
+}))
+
+vi.mock('@/stores', () => ({
+ useAppStore: () => ({
+ showError: vi.fn(),
+ showSuccess: vi.fn(),
+ showInfo: vi.fn(),
+ }),
+}))
+
+vi.mock('@/composables/useStepUp', () => ({
+ useStepUp: () => ({ run: stepUpRun }),
+ isStepUpBlocked: () => false,
+ isStepUpCancelled: () => false,
+ stepUpBlockReason: () => '',
+}))
+
+vi.mock('vue-i18n', async (importOriginal) => ({
+ ...(await importOriginal()),
+ useI18n: () => ({ t: (key: string) => key }),
+}))
+
+const plugin = {
+ id: 7,
+ plugin_key: 'local.test.transport',
+ name: 'Test Transport',
+ version: '1.0.0',
+ description: '',
+ author: 'test',
+ manifest: {
+ schema_version: 1,
+ id: 'local.test.transport',
+ name: 'Test Transport',
+ version: '1.0.0',
+ requires: {
+ sub2api: '>=0.1.0',
+ plugin_protocol: 1,
+ transport_api: 1,
+ ui_bridge: 1,
+ },
+ capabilities: [],
+ ui: { entrypoint: 'ui/index.html' },
+ },
+ binary_sha256: 'a'.repeat(64),
+ signature_status: 'trusted' as const,
+ state: 'disabled' as const,
+ last_error: '',
+ installed_at: '2026-08-22T00:00:00Z',
+ updated_at: '2026-08-22T00:00:00Z',
+ bindings: [
+ {
+ id: 1,
+ plugin_id: 7,
+ capability: 'openai.oauth.outbound_transport.v1',
+ platform: 'openai',
+ account_type: 'oauth',
+ enabled: false,
+ rollout_percent: 100,
+ },
+ ],
+ compatibility: {
+ compatible: true,
+ tested: true,
+ status: 'compatible' as const,
+ message: '',
+ current_sub2api_version: '0.1.0',
+ required_sub2api_version: '>=0.1.0',
+ recommended_sub2api_version: '0.1.0',
+ plugin_protocol: 1,
+ transport_api: 1,
+ ui_bridge: 1,
+ },
+ runtime_healthy: false,
+ runtime_message: '',
+}
+
+function mountView() {
+ return mount(PluginsView, {
+ global: {
+ stubs: {
+ AppLayout: { template: '
' },
+ BaseDialog: { template: '
' },
+ Icon: true,
+ TotpStepUpDialog: true,
+ },
+ },
+ })
+}
+
+describe('管理员插件页二次验证', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ stepUpRun.mockImplementation((action: () => Promise) => action())
+ listPlugins.mockResolvedValue([plugin])
+ uploadPlugin.mockResolvedValue(plugin)
+ enablePlugin.mockResolvedValue(plugin)
+ savePluginConfig.mockResolvedValue({ enabled: true })
+ createUISession.mockResolvedValue({
+ url: '/api/v1/plugin-ui/token/index.html#bridge_token=bridge',
+ bridge_token: 'bridge',
+ ui_bridge_version: 1,
+ expires_at: '2026-08-22T01:00:00Z',
+ })
+ })
+
+ it('启用插件通过 step-up 控制器执行', async () => {
+ const wrapper = mountView()
+ await flushPromises()
+
+ const button = wrapper.findAll('button').find((item) => item.text().includes('admin.plugins.enable'))
+ expect(button).toBeDefined()
+ await button!.trigger('click')
+ await flushPromises()
+
+ expect(stepUpRun).toHaveBeenCalledTimes(1)
+ expect(enablePlugin).toHaveBeenCalledWith(7, 100, false)
+ })
+
+ it('上传插件通过 step-up 控制器执行', async () => {
+ const wrapper = mountView()
+ await flushPromises()
+ const input = wrapper.get('input[type="file"]')
+ Object.defineProperty(input.element, 'files', {
+ configurable: true,
+ value: [new File(['plugin'], 'transport.s2plugin', { type: 'application/zip' })],
+ })
+
+ await input.trigger('change')
+ await flushPromises()
+
+ expect(stepUpRun).toHaveBeenCalledTimes(1)
+ expect(uploadPlugin).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/frontend/src/views/admin/__tests__/ProxiesView.ipv6.spec.ts b/frontend/src/views/admin/__tests__/ProxiesView.ipv6.spec.ts
new file mode 100644
index 0000000000..6cea0ac86c
--- /dev/null
+++ b/frontend/src/views/admin/__tests__/ProxiesView.ipv6.spec.ts
@@ -0,0 +1,51 @@
+import { describe, expect, it } from 'vitest'
+import { readFileSync } from 'node:fs'
+import { resolve } from 'node:path'
+
+// parseProxyUrl is not exported; assert on the source to lock in the
+// bracketed-IPv6 host alternative and exercise the regex directly.
+const source = readFileSync(
+ resolve(process.cwd(), 'src/views/admin/ProxiesView.vue'),
+ 'utf8'
+)
+
+function extractRegex(): RegExp {
+ const match = source.match(/const regex =\s*\n?\s*(\/\^\(https\?[^;\n]+\/i)\n/)
+ expect(match, 'parseProxyUrl regex not found in ProxiesView.vue').toBeTruthy()
+ return new RegExp((match as RegExpMatchArray)[1].slice(1, -2), 'i')
+}
+
+describe('proxy batch URL parsing (IPv6 support)', () => {
+ it('keeps bracketed-IPv6 host alternative in the regex', () => {
+ expect(source).toContain('\\[[0-9a-f:.]+\\]')
+ })
+
+ const regex = extractRegex()
+
+ it.each([
+ ['socks5://[2001:db8::1]:1080', true],
+ ['socks5h://[2001:db8::1]:1080', true],
+ ['http://[::1]:8080', true],
+ ['socks5://user:pass@[2001:db8::1]:1080', true],
+ ['socks5://proxy.example.com:1080', true],
+ ['http://192.168.1.1:8080', true],
+ ['socks5://user:pass@proxy.example.com:1080', true],
+ // bare IPv6 without brackets is ambiguous with host:port — rejected
+ ['socks5://2001:db8::1:1080', false],
+ // unsupported schemes / malformed ports stay invalid
+ ['ftp://example.com:21', false],
+ ['socks5://example.com:port', false]
+ ])('%s => %s', (line, expected) => {
+ expect(regex.test(line)).toBe(expected)
+ })
+
+ it('extracts bare IPv6 host without brackets', () => {
+ const m = 'socks5://user:pass@[2001:db8::1]:1080'.match(regex)
+ expect(m).toBeTruthy()
+ const [, , username, password, rawHost, port] = m as RegExpMatchArray
+ expect(username).toBe('user')
+ expect(password).toBe('pass')
+ expect(rawHost.replace(/^\[|\]$/g, '')).toBe('2001:db8::1')
+ expect(port).toBe('1080')
+ })
+})
diff --git a/frontend/src/views/admin/ops/OpsDashboard.vue b/frontend/src/views/admin/ops/OpsDashboard.vue
index cd5c672963..dc482ab074 100644
--- a/frontend/src/views/admin/ops/OpsDashboard.vue
+++ b/frontend/src/views/admin/ops/OpsDashboard.vue
@@ -119,11 +119,12 @@
:platform="platform"
:group-id="groupId"
:error-type="errorDetailsType"
+ :resume-state="resumeListState"
@update:show="showErrorDetails = $event"
@openErrorDetail="openError"
/>
-
+
@@ -377,6 +379,13 @@ const requestDetailsPreset = ref({
sort: 'created_at_desc'
})
+// 记录单条错误详情来自哪个列表,便于"返回列表"时重新打开对应弹窗并保留状态。
+type DetailReturnTarget = 'errorList' | 'requestList' | null
+const detailReturnTarget = ref(null)
+
+// 从详情返回时,列表弹窗应保留上一次的筛选/分页状态而非重置。
+const resumeListState = ref(false)
+
const showSettingsDialog = ref(false)
const showAlertRulesCard = ref(false)
@@ -508,12 +517,34 @@ function onQueryModeChange(v: string | number | boolean | null) {
function openError(id: number) {
selectedErrorId.value = id
+ // 记录来源列表,便于详情页"返回列表"。
+ detailReturnTarget.value = showRequestDetails.value ? 'requestList' : showErrorDetails.value ? 'errorList' : null
// Ensure only one modal visible at a time.
showErrorDetails.value = false
showRequestDetails.value = false
showErrorModal.value = true
}
+// 从单条错误详情返回其来源列表,重新打开关联弹窗(保留筛选/分页状态)。
+function handleBackToList() {
+ const target = detailReturnTarget.value
+ resumeListState.value = true
+ if (target === 'requestList') {
+ showErrorModal.value = false
+ showErrorDetails.value = false
+ showRequestDetails.value = true
+ } else if (target === 'errorList') {
+ showErrorModal.value = false
+ showRequestDetails.value = false
+ showErrorDetails.value = true
+ }
+ detailReturnTarget.value = null
+ // 子组件 watch 在本次 show 变化中消费 resumeState 后复位,保证下次手动打开仍会重置筛选。
+ window.setTimeout(() => {
+ resumeListState.value = false
+ }, 0)
+}
+
function buildApiParams() {
const params: any = {
platform: platform.value || undefined,
diff --git a/frontend/src/views/admin/ops/components/OpsErrorDetailModal.vue b/frontend/src/views/admin/ops/components/OpsErrorDetailModal.vue
index 407c671ca9..8b0dc388f8 100644
--- a/frontend/src/views/admin/ops/components/OpsErrorDetailModal.vue
+++ b/frontend/src/views/admin/ops/components/OpsErrorDetailModal.vue
@@ -211,6 +211,16 @@
+
+
+ {{ t('admin.ops.errorDetail.backToList') }}
+
+
@@ -228,10 +238,12 @@ interface Props {
show: boolean
errorId: number | null
errorType?: 'request' | 'upstream'
+ backToList?: boolean
}
interface Emits {
(e: 'update:show', value: boolean): void
+ (e: 'back'): void
}
const props = defineProps()
@@ -363,6 +375,11 @@ function close() {
emit('update:show', false)
}
+function goBack() {
+ emit('update:show', false)
+ emit('back')
+}
+
function prettyJSON(raw?: string): string {
if (!raw) return 'N/A'
try {
diff --git a/frontend/src/views/admin/ops/components/OpsErrorDetailsModal.vue b/frontend/src/views/admin/ops/components/OpsErrorDetailsModal.vue
index 0c513a5f1d..e97e919f6a 100644
--- a/frontend/src/views/admin/ops/components/OpsErrorDetailsModal.vue
+++ b/frontend/src/views/admin/ops/components/OpsErrorDetailsModal.vue
@@ -15,6 +15,7 @@ interface Props {
platform?: string
groupId?: number | null
errorType: 'request' | 'upstream'
+ resumeState?: boolean
}
const props = defineProps()
@@ -167,6 +168,7 @@ watch(
() => props.show,
(open) => {
if (!open) return
+ if (props.resumeState) return
page.value = 1
pageSize.value = 10
resetFilters()
diff --git a/frontend/src/views/admin/ops/components/OpsRequestDetailsModal.vue b/frontend/src/views/admin/ops/components/OpsRequestDetailsModal.vue
index 33ce058149..2d8e885a0c 100644
--- a/frontend/src/views/admin/ops/components/OpsRequestDetailsModal.vue
+++ b/frontend/src/views/admin/ops/components/OpsRequestDetailsModal.vue
@@ -23,6 +23,7 @@ interface Props {
preset: OpsRequestDetailsPreset
platform?: string
groupId?: number | null
+ resumeState?: boolean
}
const props = defineProps()
@@ -98,6 +99,7 @@ watch(
() => props.modelValue,
(open) => {
if (open) {
+ if (props.resumeState) return
page.value = 1
pageSize.value = 10
fetchData()
@@ -142,7 +144,6 @@ async function handleCopyRequestId(requestId: string) {
function openErrorDetail(errorId: number | null | undefined) {
if (!errorId) return
- close()
emit('openErrorDetail', errorId)
}
diff --git a/frontend/src/views/user/PaymentResultView.vue b/frontend/src/views/user/PaymentResultView.vue
index a5f32f0a8d..981377ce97 100644
--- a/frontend/src/views/user/PaymentResultView.vue
+++ b/frontend/src/views/user/PaymentResultView.vue
@@ -107,6 +107,7 @@ import {
readPaymentRecoverySnapshot,
} from '@/components/payment/paymentFlow'
import { usePaymentStore } from '@/stores/payment'
+import { useAuthStore } from '@/stores/auth'
import { paymentAPI } from '@/api/payment'
import type { PublicOrderVerifyResult } from '@/api/payment'
import type { OrderStatus, PaymentOrder } from '@/types/payment'
@@ -118,6 +119,7 @@ const { t } = i18n
const route = useRoute()
const router = useRouter()
const paymentStore = usePaymentStore()
+const authStore = useAuthStore()
type ResolvedOrder = PaymentOrder | PublicOrderVerifyResult
@@ -139,6 +141,7 @@ const STATUS_REFRESH_INTERVAL_MS = 2000
const STATUS_REFRESH_MAX_ATTEMPTS = 15
let statusRefreshTimer: ReturnType | null = null
+let userBalanceRefreshStarted = false
const refreshAttempts = ref(0)
/** 充值金额 = pay_amount / (1 + fee_rate/100),fee_rate=0 时等于 pay_amount */
@@ -197,6 +200,21 @@ function setResolvedOrder(nextOrder: ResolvedOrder | null): void {
if (nextOrder && 'currency' in nextOrder && nextOrder.currency) {
currency.value = normalizePaymentCurrency(nextOrder.currency)
}
+ refreshUserBalanceForSuccessfulOrder(nextOrder)
+}
+
+function refreshUserBalanceForSuccessfulOrder(nextOrder: ResolvedOrder | null): void {
+ if (!nextOrder || userBalanceRefreshStarted || normalizeOrderStatus(nextOrder.status) !== 'COMPLETED') {
+ return
+ }
+ if ('order_type' in nextOrder && nextOrder.order_type !== 'balance') {
+ return
+ }
+
+ userBalanceRefreshStarted = true
+ void authStore.refreshUser().catch(() => {
+ // The order result remains authoritative even if refreshing profile data fails.
+ })
}
function hasOrderId(nextOrder: ResolvedOrder | null): nextOrder is PaymentOrder {
diff --git a/frontend/src/views/user/__tests__/PaymentResultView.spec.ts b/frontend/src/views/user/__tests__/PaymentResultView.spec.ts
index 5edae835c0..751e7d83c4 100644
--- a/frontend/src/views/user/__tests__/PaymentResultView.spec.ts
+++ b/frontend/src/views/user/__tests__/PaymentResultView.spec.ts
@@ -10,6 +10,7 @@ const pollOrderStatus = vi.hoisted(() => vi.fn())
const verifyOrder = vi.hoisted(() => vi.fn())
const verifyOrderPublic = vi.hoisted(() => vi.fn())
const resolveOrderPublicByResumeToken = vi.hoisted(() => vi.fn())
+const refreshUser = vi.hoisted(() => vi.fn())
vi.mock('vue-router', async () => {
const actual = await vi.importActual('vue-router')
@@ -36,6 +37,12 @@ vi.mock('@/stores/payment', () => ({
}),
}))
+vi.mock('@/stores/auth', () => ({
+ useAuthStore: () => ({
+ refreshUser,
+ }),
+}))
+
vi.mock('@/api/payment', () => ({
paymentAPI: {
verifyOrder,
@@ -91,6 +98,8 @@ describe('PaymentResultView', () => {
verifyOrder.mockReset()
verifyOrderPublic.mockReset()
resolveOrderPublicByResumeToken.mockReset()
+ refreshUser.mockReset()
+ refreshUser.mockResolvedValue({})
window.localStorage.clear()
})
@@ -171,7 +180,7 @@ describe('PaymentResultView', () => {
}))
resolveOrderPublicByResumeToken.mockResolvedValue({
data: {
- ...orderFactory('PAID'),
+ ...orderFactory('COMPLETED'),
amount: 100,
pay_amount: 103,
fee_rate: 3,
@@ -190,13 +199,14 @@ describe('PaymentResultView', () => {
expect(pollOrderStatus).not.toHaveBeenCalled()
expect(resolveOrderPublicByResumeToken).toHaveBeenCalledWith('resume-authoritative')
+ expect(refreshUser).toHaveBeenCalledTimes(1)
expect(wrapper.text()).toContain('payment.result.success')
expect(wrapper.text()).toContain('103.00')
expect(wrapper.text()).toContain('100.00')
expect(window.localStorage.getItem(PAYMENT_RECOVERY_STORAGE_KEY)).toBeNull()
})
- it('refreshes a pending resume-token result until the order becomes paid', async () => {
+ it('waits for completed fulfillment before refreshing the user balance', async () => {
vi.useFakeTimers()
routeState.query = {
resume_token: 'resume-77',
@@ -210,7 +220,7 @@ describe('PaymentResultView', () => {
data: orderFactory('PENDING'),
})
.mockResolvedValueOnce({
- data: orderFactory('PAID'),
+ data: orderFactory('COMPLETED'),
})
const wrapper = mount(PaymentResultView, {
@@ -224,6 +234,7 @@ describe('PaymentResultView', () => {
await flushPromises()
expect(resolveOrderPublicByResumeToken).toHaveBeenCalledTimes(1)
+ expect(refreshUser).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('payment.result.processing')
expect(window.localStorage.getItem(PAYMENT_RECOVERY_STORAGE_KEY)).not.toBeNull()
@@ -231,11 +242,36 @@ describe('PaymentResultView', () => {
await flushPromises()
expect(resolveOrderPublicByResumeToken).toHaveBeenCalledTimes(2)
+ expect(refreshUser).toHaveBeenCalledTimes(1)
expect(wrapper.text()).toContain('payment.result.success')
expect(wrapper.text()).not.toContain('payment.result.failed')
expect(window.localStorage.getItem(PAYMENT_RECOVERY_STORAGE_KEY)).toBeNull()
})
+ it('keeps the successful result when refreshing the user balance fails', async () => {
+ routeState.query = {
+ resume_token: 'resume-refresh-failure',
+ }
+ resolveOrderPublicByResumeToken.mockResolvedValue({
+ data: orderFactory('COMPLETED'),
+ })
+ refreshUser.mockRejectedValueOnce(new Error('profile refresh failed'))
+
+ const wrapper = mount(PaymentResultView, {
+ global: {
+ stubs: {
+ OrderStatusBadge: true,
+ },
+ },
+ })
+
+ await flushPromises()
+
+ expect(refreshUser).toHaveBeenCalledTimes(1)
+ expect(wrapper.text()).toContain('payment.result.success')
+ expect(wrapper.text()).not.toContain('payment.result.failed')
+ })
+
it('falls back to order_id polling when resume-token recovery fails', async () => {
routeState.query = {
resume_token: 'resume-fail',