mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-31 08:25:35 +00:00
Merge remote-tracking branch 'upstream/main' into fix/api-double-billing
# Conflicts: # frontend/src/components/account/EditAccountModal.vue
This commit is contained in:
@@ -1874,6 +1874,24 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OpenAI 订阅档位手动覆盖(Plus/Pro/Free),仅 OAuth 非影子账号 -->
|
||||
<div
|
||||
v-if="account?.platform === 'openai' && account?.type === 'oauth' && !isSparkShadow"
|
||||
class="border-t border-gray-200 pt-4 dark:border-dark-600"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<label class="input-label mb-0">{{ t('admin.accounts.openai.planType') }}</label>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.openai.planTypeDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="w-44 flex-shrink-0">
|
||||
<Select v-model="editPlanType" :options="planTypeOptions" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="account?.platform === 'openai' && (account?.type === 'oauth' || account?.type === 'setup-token' || account?.type === 'apikey')"
|
||||
class="border-t border-gray-200 pt-4 dark:border-dark-600 space-y-4"
|
||||
@@ -2575,6 +2593,9 @@ import {
|
||||
applyAntigravityProjectID,
|
||||
applyHeaderOverride,
|
||||
applyInterceptWarmup,
|
||||
applyPlanType,
|
||||
buildPlanTypeOptions,
|
||||
readPlanType,
|
||||
getHeaderOverrideTemplate,
|
||||
isHeaderOverridePlatform,
|
||||
splitHeaderOverridesObject,
|
||||
@@ -2795,6 +2816,8 @@ const customBaseUrl = ref('')
|
||||
// OpenAI 自动透传开关(OAuth/API Key)
|
||||
const openaiPassthroughEnabled = ref(false)
|
||||
const openAILongContextBillingEnabled = ref(true)
|
||||
// OpenAI 订阅档位(Plus/Pro/Free)手动覆盖值,存于 credentials.plan_type;'' 表示清空/自动识别
|
||||
const editPlanType = ref<string>('')
|
||||
const openAICompactMode = ref<OpenAICompactMode>('auto')
|
||||
const openAIResponsesMode = ref<OpenAIResponsesMode>('auto')
|
||||
const openAIEndpointCapabilities = ref<OpenAIEndpointCapability[]>(['chat_completions', 'embeddings'])
|
||||
@@ -2922,6 +2945,10 @@ const openAICompactModeOptions = computed(() => [
|
||||
{ value: 'force_on', label: t('admin.accounts.openai.compactModeForceOn') },
|
||||
{ value: 'force_off', label: t('admin.accounts.openai.compactModeForceOff') }
|
||||
])
|
||||
// OpenAI 订阅档位手动覆盖选项(清空 + Plus/Pro/Free;别名/自定义值友好显示且保留 canonical)
|
||||
const planTypeOptions = computed(() =>
|
||||
buildPlanTypeOptions(editPlanType.value, t('admin.accounts.openai.planTypeClear'))
|
||||
)
|
||||
const openAIResponsesModeOptions = computed(() => [
|
||||
{ value: 'auto', label: t('admin.accounts.openai.responsesModeAuto') },
|
||||
{ value: 'force_responses', label: t('admin.accounts.openai.responsesModeForceResponses') },
|
||||
@@ -3223,6 +3250,7 @@ const syncFormFromAccount = (newAccount: Account | null) => {
|
||||
// Load OpenAI passthrough toggle (OpenAI OAuth/SetupToken/API Key)
|
||||
openaiPassthroughEnabled.value = false
|
||||
openAILongContextBillingEnabled.value = true
|
||||
editPlanType.value = ''
|
||||
openAICompactMode.value = 'auto'
|
||||
openAIResponsesMode.value = 'auto'
|
||||
openAIEndpointCapabilities.value = ['chat_completions', 'embeddings']
|
||||
@@ -3239,6 +3267,10 @@ const syncFormFromAccount = (newAccount: Account | null) => {
|
||||
openaiPassthroughEnabled.value = extra?.openai_passthrough === true || extra?.openai_oauth_passthrough === true
|
||||
const longContextBillingValue = extra?.openai_long_context_billing_enabled
|
||||
openAILongContextBillingEnabled.value = longContextBillingValue === undefined || longContextBillingValue === true
|
||||
// plan_type 手动覆盖仅 OAuth 有实际调度语义(IsOpenAIChatGPTSubscription 要求 oauth),故只对 oauth 回填
|
||||
editPlanType.value = newAccount.type === 'oauth'
|
||||
? readPlanType(newAccount.credentials as Record<string, unknown> | undefined)
|
||||
: ''
|
||||
openAICompactMode.value = (extra?.openai_compact_mode as OpenAICompactMode) || 'auto'
|
||||
if (newAccount.type === 'apikey') {
|
||||
openAIResponsesMode.value = normalizeOpenAIResponsesMode(extra?.openai_responses_mode)
|
||||
@@ -4221,6 +4253,14 @@ const handleSubmit = async () => {
|
||||
updatePayload.credentials = newCredentials
|
||||
}
|
||||
|
||||
// OpenAI: 手动覆盖订阅档位 plan_type(Plus/Pro/Free)。仅 OAuth 非影子账号:
|
||||
// 影子账号凭据由母账号管理(且后端会 sanitize),setup-token 无订阅调度语义。
|
||||
if (props.account.platform === 'openai' && props.account.type === 'oauth' && !isSparkShadow.value) {
|
||||
const currentCredentials = (updatePayload.credentials as Record<string, unknown>) ||
|
||||
((props.account.credentials as Record<string, unknown>) || {})
|
||||
updatePayload.credentials = applyPlanType({ ...currentCredentials }, editPlanType.value)
|
||||
}
|
||||
|
||||
// Antigravity: persist model mapping to credentials (applies to all antigravity types)
|
||||
// Antigravity 只支持映射模式
|
||||
if (props.account.platform === 'antigravity') {
|
||||
|
||||
@@ -6,9 +6,13 @@ import {
|
||||
applyAntigravityProjectID,
|
||||
applyHeaderOverride,
|
||||
applyInterceptWarmup,
|
||||
applyPlanType,
|
||||
buildHeaderOverridesObject,
|
||||
buildPlanTypeOptions,
|
||||
getHeaderOverrideTemplate,
|
||||
isHeaderOverridePlatform,
|
||||
planTypeDisplayLabel,
|
||||
readPlanType,
|
||||
splitHeaderOverridesObject,
|
||||
validateHeaderOverrideRows
|
||||
} from '../credentialsBuilder'
|
||||
@@ -289,3 +293,88 @@ describe('validateHeaderOverrideRows session isolation headers', () => {
|
||||
expect(validateHeaderOverrideRows([{ name: 'x'.repeat(201), value: 'v' }])).toBe('invalidName')
|
||||
})
|
||||
})
|
||||
|
||||
describe('plan_type helpers', () => {
|
||||
describe('planTypeDisplayLabel', () => {
|
||||
it('maps canonical + alias values to friendly labels', () => {
|
||||
expect(planTypeDisplayLabel('plus')).toBe('Plus')
|
||||
expect(planTypeDisplayLabel('pro')).toBe('Pro')
|
||||
expect(planTypeDisplayLabel('chatgptpro')).toBe('Pro')
|
||||
expect(planTypeDisplayLabel('free')).toBe('Free')
|
||||
expect(planTypeDisplayLabel('team')).toBe('Team')
|
||||
expect(planTypeDisplayLabel('CHATGPTPRO')).toBe('Pro')
|
||||
})
|
||||
it('returns unknown values verbatim', () => {
|
||||
expect(planTypeDisplayLabel('self_serve_business')).toBe('self_serve_business')
|
||||
})
|
||||
})
|
||||
|
||||
describe('readPlanType', () => {
|
||||
it('reads a string plan_type', () => {
|
||||
expect(readPlanType({ plan_type: 'plus' })).toBe('plus')
|
||||
})
|
||||
it('treats non-string / missing values as empty', () => {
|
||||
expect(readPlanType({ plan_type: 42 })).toBe('')
|
||||
expect(readPlanType({ plan_type: true })).toBe('')
|
||||
expect(readPlanType({})).toBe('')
|
||||
expect(readPlanType(undefined)).toBe('')
|
||||
expect(readPlanType(null)).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildPlanTypeOptions', () => {
|
||||
const clear = 'Clear'
|
||||
it('returns clear + presets when current is empty', () => {
|
||||
expect(buildPlanTypeOptions('', clear)).toEqual([
|
||||
{ value: '', label: clear },
|
||||
{ value: 'plus', label: 'Plus' },
|
||||
{ value: 'pro', label: 'Pro' },
|
||||
{ value: 'free', label: 'Free' }
|
||||
])
|
||||
})
|
||||
it('keeps canonical chatgptpro under a single friendly "Pro" option (no duplicate)', () => {
|
||||
const opts = buildPlanTypeOptions('chatgptpro', clear)
|
||||
const pros = opts.filter(o => o.label === 'Pro')
|
||||
expect(pros).toHaveLength(1)
|
||||
expect(pros[0].value).toBe('chatgptpro')
|
||||
expect(opts.map(o => o.value)).toEqual(['', 'plus', 'chatgptpro', 'free'])
|
||||
})
|
||||
it('appends an unknown-but-labeled value (team) as its own option', () => {
|
||||
const opts = buildPlanTypeOptions('team', clear)
|
||||
expect(opts.find(o => o.value === 'team')).toEqual({ value: 'team', label: 'Team' })
|
||||
// presets untouched
|
||||
expect(opts.map(o => o.value)).toEqual(['', 'plus', 'pro', 'free', 'team'])
|
||||
})
|
||||
it('appends a fully custom value with a raw label', () => {
|
||||
const opts = buildPlanTypeOptions('weird_x', clear)
|
||||
expect(opts.at(-1)).toEqual({ value: 'weird_x', label: 'weird_x' })
|
||||
})
|
||||
it('does not duplicate an exact preset value', () => {
|
||||
const opts = buildPlanTypeOptions('pro', clear)
|
||||
expect(opts.filter(o => o.value === 'pro')).toHaveLength(1)
|
||||
expect(opts.map(o => o.value)).toEqual(['', 'plus', 'pro', 'free'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyPlanType', () => {
|
||||
it('sets plan_type and preserves all other credential keys', () => {
|
||||
const creds = {
|
||||
chatgpt_account_id: 'acc',
|
||||
email: 'a@b.c',
|
||||
subscription_expires_at: '2026-01-01',
|
||||
model_mapping: { x: 'y' }
|
||||
}
|
||||
const out = applyPlanType({ ...creds }, 'plus')
|
||||
expect(out).toEqual({ ...creds, plan_type: 'plus' })
|
||||
})
|
||||
it('trims the value', () => {
|
||||
expect(applyPlanType({}, ' pro ')).toEqual({ plan_type: 'pro' })
|
||||
})
|
||||
it('deletes the key when cleared (empty), keeping other keys', () => {
|
||||
const out = applyPlanType({ plan_type: 'pro', email: 'a@b.c' }, '')
|
||||
expect(out).toEqual({ email: 'a@b.c' })
|
||||
expect('plan_type' in out).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -201,3 +201,87 @@ export function applyHeaderOverride(
|
||||
delete credentials[HEADER_OVERRIDES_CREDENTIAL_KEY]
|
||||
}
|
||||
}
|
||||
|
||||
// ===== OpenAI plan_type (ChatGPT 订阅档位) 手动覆盖 =====
|
||||
|
||||
export interface PlanTypeOption {
|
||||
value: string
|
||||
label: string
|
||||
// 兼容 common/Select.vue 的 SelectOption(含索引签名)
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* plan_type 值的友好显示标签,镜像 PlatformTypeBadge 的映射
|
||||
* (canonical 值 chatgptpro 显示为 Pro,team 显示为 Team)。未知值原样返回。
|
||||
*/
|
||||
export function planTypeDisplayLabel(value: string): string {
|
||||
switch (value.trim().toLowerCase()) {
|
||||
case 'plus':
|
||||
return 'Plus'
|
||||
case 'pro':
|
||||
case 'chatgptpro':
|
||||
return 'Pro'
|
||||
case 'free':
|
||||
return 'Free'
|
||||
case 'team':
|
||||
return 'Team'
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从凭据里读取 plan_type,仅接受字符串(脏数据 42/true 等一律视为空,
|
||||
* 避免被当作合法自定义项保留)。
|
||||
*/
|
||||
export function readPlanType(credentials: Record<string, unknown> | undefined | null): string {
|
||||
const v = credentials?.plan_type
|
||||
return typeof v === 'string' ? v : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建 plan_type 下拉选项:清空 + Plus/Pro/Free 预设。
|
||||
* 若当前值是某预设的别名(如 chatgptpro↔Pro),用当前的 canonical 值占据该
|
||||
* 标签位(保留 canonical,显示友好标签,避免重复项);若是完全预设外的值
|
||||
* (如 team 或异常值),追加为一项,避免编辑时下拉丢失原值。
|
||||
*/
|
||||
export function buildPlanTypeOptions(current: string, clearLabel: string): PlanTypeOption[] {
|
||||
const cur = (current || '').trim()
|
||||
const curLabel = cur ? planTypeDisplayLabel(cur) : ''
|
||||
const presets: PlanTypeOption[] = [
|
||||
{ value: 'plus', label: 'Plus' },
|
||||
{ value: 'pro', label: 'Pro' },
|
||||
{ value: 'free', label: 'Free' }
|
||||
]
|
||||
const opts: PlanTypeOption[] = [{ value: '', label: clearLabel }]
|
||||
for (const p of presets) {
|
||||
if (cur && p.value !== cur.toLowerCase() && p.label === curLabel) {
|
||||
// 当前值是该预设的别名:用 canonical 当前值占位,标签仍显示友好名
|
||||
opts.push({ value: cur, label: p.label })
|
||||
} else {
|
||||
opts.push(p)
|
||||
}
|
||||
}
|
||||
if (cur && !opts.some(o => o.value.toLowerCase() === cur.toLowerCase())) {
|
||||
opts.push({ value: cur, label: planTypeDisplayLabel(cur) })
|
||||
}
|
||||
return opts
|
||||
}
|
||||
|
||||
/**
|
||||
* 把手动选择的 plan_type 写入凭据:非空则设置,空则删除该键(清空/自动识别)。
|
||||
* 直接修改传入对象并返回。
|
||||
*/
|
||||
export function applyPlanType(
|
||||
credentials: Record<string, unknown>,
|
||||
planType: string
|
||||
): Record<string, unknown> {
|
||||
const pt = (planType || '').trim()
|
||||
if (pt) {
|
||||
credentials.plan_type = pt
|
||||
} else {
|
||||
delete credentials.plan_type
|
||||
}
|
||||
return credentials
|
||||
}
|
||||
|
||||
@@ -154,7 +154,7 @@
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Data rows (virtual scroll) -->
|
||||
<!-- Data rows: windowed when large, fully rendered when small (shared row/cell template) -->
|
||||
<template v-else>
|
||||
<tr v-if="virtualPaddingTop > 0" aria-hidden="true">
|
||||
<td :colspan="columns.length"
|
||||
@@ -162,14 +162,14 @@
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
v-for="virtualRow in virtualItems"
|
||||
:key="resolveRowKey(sortedData[virtualRow.index], virtualRow.index)"
|
||||
:data-row-id="resolveRowKey(sortedData[virtualRow.index], virtualRow.index)"
|
||||
:data-index="virtualRow.index"
|
||||
:ref="measureElement"
|
||||
v-for="item in renderRows"
|
||||
:key="resolveRowKey(item.row, item.index)"
|
||||
:data-row-id="resolveRowKey(item.row, item.index)"
|
||||
:data-index="item.index"
|
||||
:ref="item.measure ? measureElement : undefined"
|
||||
class="hover:bg-gray-50 dark:hover:bg-dark-800"
|
||||
:class="{ 'cursor-pointer': clickableRows }"
|
||||
@click="clickableRows && emit('rowClick', sortedData[virtualRow.index])"
|
||||
@click="clickableRows && emit('rowClick', item.row)"
|
||||
>
|
||||
<td
|
||||
v-for="(column, colIndex) in columns"
|
||||
@@ -182,12 +182,12 @@
|
||||
]"
|
||||
>
|
||||
<slot :name="`cell-${column.key}`"
|
||||
:row="sortedData[virtualRow.index]"
|
||||
:value="sortedData[virtualRow.index][column.key]"
|
||||
:row="item.row"
|
||||
:value="item.row[column.key]"
|
||||
:expanded="actionsExpanded">
|
||||
{{ column.formatter
|
||||
? column.formatter(sortedData[virtualRow.index][column.key], sortedData[virtualRow.index])
|
||||
: sortedData[virtualRow.index][column.key] }}
|
||||
? column.formatter(item.row[column.key], item.row)
|
||||
: item.row[column.key] }}
|
||||
</slot>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -397,6 +397,12 @@ interface Props {
|
||||
estimateRowHeight?: number
|
||||
/** Number of rows to render beyond the visible area (default 5) */
|
||||
overscan?: number
|
||||
/**
|
||||
* Only virtualize when the row count exceeds this threshold (default 100).
|
||||
* Smaller lists render in full, avoiding the scroll-compensation jank caused by
|
||||
* estimated-vs-actual row heights when rows have variable height.
|
||||
*/
|
||||
virtualizeThreshold?: number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
@@ -627,9 +633,21 @@ const sortedData = computed(() => {
|
||||
})
|
||||
|
||||
// --- Virtual scrolling ---
|
||||
// 是否启用虚拟化:仅桌面端且行数超过阈值时开启。小列表全量渲染,彻底绕开虚拟器的
|
||||
// 估算/测量/滚动补偿链路,消除可变行高导致的滚动抖动。
|
||||
const shouldVirtualize = computed(() =>
|
||||
isDesktopViewport.value && (sortedData.value?.length ?? 0) > (props.virtualizeThreshold ?? 100)
|
||||
)
|
||||
|
||||
const rowVirtualizer = useVirtualizer(computed(() => ({
|
||||
count: isDesktopViewport.value ? (sortedData.value?.length ?? 0) : 0,
|
||||
count: shouldVirtualize.value ? (sortedData.value?.length ?? 0) : 0,
|
||||
getScrollElement: () => tableWrapperRef.value,
|
||||
// 用行主键(与模板 :key 一致)而非默认的 index 作为 itemSizeCache 键,
|
||||
// 这样排序/筛选/跨阈值来回都能复用正确的已测行高,而不是残留的按 index 缓存 → 消除高度校正抖动。
|
||||
getItemKey: (index: number) => {
|
||||
const row = sortedData.value?.[index]
|
||||
return row != null ? resolveRowKey(row, index) : index
|
||||
},
|
||||
estimateSize: () => props.estimateRowHeight ?? 56,
|
||||
overscan: props.overscan ?? 5,
|
||||
// 兜底高度:首个有效高度读数到来前,先按一屏渲染,避免空白帧
|
||||
@@ -659,6 +677,16 @@ const measureElement = (el: any) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 统一的渲染行列表:虚拟化开启时只取窗口内的行(需 measure 交给虚拟器测量),
|
||||
// 关闭时取全部行(无需测量)。模板据此渲染,两种模式共用同一套单元格结构。
|
||||
const renderRows = computed<Array<{ index: number; row: any; measure: boolean }>>(() => {
|
||||
const data = sortedData.value ?? []
|
||||
if (shouldVirtualize.value) {
|
||||
return virtualItems.value.map(vr => ({ index: vr.index, row: data[vr.index], measure: true }))
|
||||
}
|
||||
return data.map((row, index) => ({ index, row, measure: false }))
|
||||
})
|
||||
|
||||
const hasActionsColumn = computed(() => {
|
||||
return props.columns.some(column => column.key === 'actions')
|
||||
})
|
||||
@@ -758,6 +786,7 @@ watch(
|
||||
|
||||
defineExpose({
|
||||
virtualizer: rowVirtualizer,
|
||||
shouldVirtualize,
|
||||
sortedData,
|
||||
resolveRowKey,
|
||||
tableWrapperEl: tableWrapperRef,
|
||||
|
||||
@@ -62,4 +62,63 @@ describe('DataTable', () => {
|
||||
expect(nameHeader.findAll('svg')[0].classes()).toContain('text-gray-300')
|
||||
expect(nameHeader.findAll('svg')[1].classes()).toContain('text-primary-600')
|
||||
})
|
||||
|
||||
it('renders every row with no virtual padding spacer for small datasets (virtualization off)', async () => {
|
||||
const data = Array.from({ length: 8 }, (_, i) => ({ id: i + 1, name: `Row ${i + 1}` }))
|
||||
const wrapper = mount(DataTable, {
|
||||
props: {
|
||||
columns: [{ key: 'name', label: 'Name' }],
|
||||
data
|
||||
}
|
||||
})
|
||||
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
// Virtualization is OFF for a small list…
|
||||
expect((wrapper.vm as any).shouldVirtualize).toBe(false)
|
||||
// …every row is in the DOM…
|
||||
expect(wrapper.findAll('tbody tr[data-index]')).toHaveLength(data.length)
|
||||
// …and there are no aria-hidden virtual padding spacer rows.
|
||||
expect(wrapper.findAll('tbody tr[aria-hidden="true"]')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('switches to windowed rendering once row count exceeds virtualizeThreshold', async () => {
|
||||
const data = Array.from({ length: 12 }, (_, i) => ({ id: i + 1, name: `Row ${i + 1}` }))
|
||||
const wrapper = mount(DataTable, {
|
||||
props: {
|
||||
columns: [{ key: 'name', label: 'Name' }],
|
||||
data,
|
||||
virtualizeThreshold: 3
|
||||
}
|
||||
})
|
||||
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
// Virtualization is ON: the mode-switch decision flipped…
|
||||
expect((wrapper.vm as any).shouldVirtualize).toBe(true)
|
||||
// …and the virtualizer drives off the full row count.
|
||||
const exposed = (wrapper.vm as any).virtualizer
|
||||
const instance = exposed?.value ?? exposed
|
||||
expect(instance.options.count).toBe(data.length)
|
||||
})
|
||||
|
||||
it('keys the virtualizer size cache by row identity, not index (avoids stale heights on sort/filter)', async () => {
|
||||
const data = Array.from({ length: 12 }, (_, i) => ({ id: 100 + i, name: `Row ${i + 1}` }))
|
||||
const wrapper = mount(DataTable, {
|
||||
props: {
|
||||
columns: [{ key: 'name', label: 'Name' }],
|
||||
data,
|
||||
rowKey: 'id',
|
||||
virtualizeThreshold: 3
|
||||
}
|
||||
})
|
||||
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const exposed = (wrapper.vm as any).virtualizer
|
||||
const instance = exposed?.value ?? exposed
|
||||
// getItemKey must resolve to the row's stable key (id), not the positional index.
|
||||
expect(instance.options.getItemKey(0)).toBe(100)
|
||||
expect(instance.options.getItemKey(5)).toBe(105)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user