mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-30 16:23:31 +00:00
Merge remote-tracking branch 'origin/main' into feature/batch-image-foundation
# Conflicts: # .github/audit-exceptions.yml
This commit is contained in:
@@ -1,14 +1,16 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import ImportDataModal from '@/components/admin/account/ImportDataModal.vue'
|
||||
|
||||
const showError = vi.fn()
|
||||
const showSuccess = vi.fn()
|
||||
const showWarning = vi.fn()
|
||||
|
||||
vi.mock('@/stores/app', () => ({
|
||||
useAppStore: () => ({
|
||||
showError,
|
||||
showSuccess
|
||||
showSuccess,
|
||||
showWarning
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -26,49 +28,187 @@ vi.mock('vue-i18n', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
const mountModal = () =>
|
||||
mount(ImportDataModal, {
|
||||
props: { show: true },
|
||||
global: {
|
||||
stubs: {
|
||||
BaseDialog: { template: '<div><slot /><slot name="footer" /></div>' }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const makeJsonFile = (name: string, content: string, type = 'application/json') => {
|
||||
const file = new File([content], name, { type })
|
||||
Object.defineProperty(file, 'text', {
|
||||
value: () => Promise.resolve(content)
|
||||
})
|
||||
return file
|
||||
}
|
||||
|
||||
const setInputFiles = (element: Element, files: File[]) => {
|
||||
Object.defineProperty(element, 'files', {
|
||||
value: files,
|
||||
configurable: true
|
||||
})
|
||||
}
|
||||
|
||||
describe('ImportDataModal', () => {
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
showError.mockReset()
|
||||
showSuccess.mockReset()
|
||||
showWarning.mockReset()
|
||||
const { adminAPI } = await import('@/api/admin')
|
||||
vi.mocked(adminAPI.accounts.importData).mockReset()
|
||||
})
|
||||
|
||||
it('未选择文件时提示错误', async () => {
|
||||
const wrapper = mount(ImportDataModal, {
|
||||
props: { show: true },
|
||||
global: {
|
||||
stubs: {
|
||||
BaseDialog: { template: '<div><slot /><slot name="footer" /></div>' }
|
||||
}
|
||||
}
|
||||
})
|
||||
const wrapper = mountModal()
|
||||
|
||||
await wrapper.find('form').trigger('submit')
|
||||
expect(showError).toHaveBeenCalledWith('admin.accounts.dataImportSelectFile')
|
||||
})
|
||||
|
||||
it('无效 JSON 时提示解析失败', async () => {
|
||||
const wrapper = mount(ImportDataModal, {
|
||||
props: { show: true },
|
||||
global: {
|
||||
stubs: {
|
||||
BaseDialog: { template: '<div><slot /><slot name="footer" /></div>' }
|
||||
}
|
||||
}
|
||||
})
|
||||
it('无效 JSON 时按文件名提示解析失败', async () => {
|
||||
const { adminAPI } = await import('@/api/admin')
|
||||
const wrapper = mountModal()
|
||||
|
||||
const input = wrapper.find('input[type="file"]')
|
||||
const file = new File(['invalid json'], 'data.json', { type: 'application/json' })
|
||||
Object.defineProperty(file, 'text', {
|
||||
value: () => Promise.resolve('invalid json')
|
||||
})
|
||||
Object.defineProperty(input.element, 'files', {
|
||||
value: [file]
|
||||
})
|
||||
setInputFiles(input.element, [makeJsonFile('data.json', 'invalid json')])
|
||||
|
||||
await input.trigger('change')
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await Promise.resolve()
|
||||
await flushPromises()
|
||||
|
||||
expect(showError).toHaveBeenCalledWith('admin.accounts.dataImportParseFailed')
|
||||
expect(showError).toHaveBeenCalledWith('admin.accounts.dataImportParseFailedFile')
|
||||
expect(adminAPI.accounts.importData).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('不是导出数据的 JSON 按文件名拒绝', async () => {
|
||||
const { adminAPI } = await import('@/api/admin')
|
||||
const wrapper = mountModal()
|
||||
|
||||
const input = wrapper.find('input[type="file"]')
|
||||
setInputFiles(input.element, [makeJsonFile('random.json', JSON.stringify({ name: 'test' }))])
|
||||
|
||||
await input.trigger('change')
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
expect(showError).toHaveBeenCalledWith('admin.accounts.dataImportInvalidFile')
|
||||
expect(adminAPI.accounts.importData).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('无有效 JSON 的选择不清空已有选择', async () => {
|
||||
const { adminAPI } = await import('@/api/admin')
|
||||
vi.mocked(adminAPI.accounts.importData).mockResolvedValue({
|
||||
proxy_created: 0,
|
||||
proxy_reused: 0,
|
||||
proxy_failed: 0,
|
||||
account_created: 1,
|
||||
account_failed: 0
|
||||
})
|
||||
|
||||
const wrapper = mountModal()
|
||||
const input = wrapper.find('input[type="file"]')
|
||||
|
||||
const valid = makeJsonFile(
|
||||
'valid.json',
|
||||
JSON.stringify({ exported_at: '2026-07-05T00:00:00Z', proxies: [], accounts: [{ name: 'a' }] })
|
||||
)
|
||||
setInputFiles(input.element, [valid])
|
||||
await input.trigger('change')
|
||||
|
||||
setInputFiles(input.element, [new File(['hello'], 'notes.txt', { type: 'text/plain' })])
|
||||
await input.trigger('change')
|
||||
expect(showError).toHaveBeenCalledWith('admin.accounts.dataImportSelectFile')
|
||||
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
expect(adminAPI.accounts.importData).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
accounts: [{ name: 'a' }]
|
||||
}),
|
||||
skip_default_group_bind: true
|
||||
})
|
||||
})
|
||||
|
||||
it('merges multiple selected JSON files before importing', async () => {
|
||||
const { adminAPI } = await import('@/api/admin')
|
||||
vi.mocked(adminAPI.accounts.importData).mockResolvedValue({
|
||||
proxy_created: 0,
|
||||
proxy_reused: 0,
|
||||
proxy_failed: 0,
|
||||
account_created: 2,
|
||||
account_failed: 0
|
||||
})
|
||||
|
||||
const wrapper = mountModal()
|
||||
|
||||
const input = wrapper.find('input[type="file"]')
|
||||
const first = makeJsonFile(
|
||||
'first.json',
|
||||
JSON.stringify({ exported_at: '2026-07-05T00:00:00Z', proxies: [], accounts: [{ name: 'a' }] })
|
||||
)
|
||||
const second = makeJsonFile(
|
||||
'second.json',
|
||||
JSON.stringify({
|
||||
exported_at: '2026-07-05T00:00:01Z',
|
||||
proxies: [{ proxy_key: 'p' }],
|
||||
accounts: [{ name: 'b' }]
|
||||
})
|
||||
)
|
||||
setInputFiles(input.element, [first, second])
|
||||
|
||||
await input.trigger('change')
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
expect(adminAPI.accounts.importData).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
proxies: [{ proxy_key: 'p' }],
|
||||
accounts: [{ name: 'a' }, { name: 'b' }]
|
||||
}),
|
||||
skip_default_group_bind: true
|
||||
})
|
||||
expect(showSuccess).toHaveBeenCalledWith('admin.accounts.dataImportSuccess')
|
||||
})
|
||||
|
||||
it('部分成功时关闭弹窗仍通知父组件刷新', async () => {
|
||||
const { adminAPI } = await import('@/api/admin')
|
||||
vi.mocked(adminAPI.accounts.importData).mockResolvedValue({
|
||||
proxy_created: 0,
|
||||
proxy_reused: 0,
|
||||
proxy_failed: 0,
|
||||
account_created: 1,
|
||||
account_failed: 1
|
||||
})
|
||||
|
||||
const wrapper = mountModal()
|
||||
const input = wrapper.find('input[type="file"]')
|
||||
setInputFiles(input.element, [
|
||||
makeJsonFile(
|
||||
'mixed.json',
|
||||
JSON.stringify({
|
||||
exported_at: '2026-07-05T00:00:00Z',
|
||||
proxies: [],
|
||||
accounts: [{ name: 'a' }, { name: 'b' }]
|
||||
})
|
||||
)
|
||||
])
|
||||
|
||||
await input.trigger('change')
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
expect(showError).toHaveBeenCalledWith('admin.accounts.dataImportCompletedWithErrors')
|
||||
expect(wrapper.emitted('imported')).toBeUndefined()
|
||||
|
||||
// 第二个 btn-secondary 是 footer 的取消按钮(第一个是选择文件)
|
||||
await wrapper.findAll('button.btn-secondary')[1]!.trigger('click')
|
||||
|
||||
expect(wrapper.emitted('imported')).toHaveLength(1)
|
||||
expect(wrapper.emitted('close')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -25,6 +25,7 @@ export interface AdminPaymentConfig {
|
||||
balance_disabled: boolean
|
||||
balance_recharge_multiplier: number
|
||||
subscription_usd_to_cny_rate: number
|
||||
recharge_fee_rate: number
|
||||
load_balance_strategy: string
|
||||
product_name_prefix: string
|
||||
product_name_suffix: string
|
||||
@@ -44,6 +45,7 @@ export interface UpdatePaymentConfigRequest {
|
||||
balance_disabled?: boolean
|
||||
balance_recharge_multiplier?: number
|
||||
subscription_usd_to_cny_rate?: number
|
||||
recharge_fee_rate?: number
|
||||
load_balance_strategy?: string
|
||||
product_name_prefix?: string
|
||||
product_name_suffix?: string
|
||||
|
||||
@@ -55,6 +55,17 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="isOpenAIAccount" class="space-y-1.5">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.accounts.openai.testMode') }}
|
||||
</label>
|
||||
<Select
|
||||
v-model="testMode"
|
||||
:options="openAITestModeOptions"
|
||||
:disabled="status === 'connecting'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="supportsImageTest" class="space-y-1.5">
|
||||
<TextArea
|
||||
v-model="testPrompt"
|
||||
@@ -276,6 +287,12 @@ const loadingModels = ref(false)
|
||||
let abortController: AbortController | null = null
|
||||
const generatedImages = ref<PreviewImage[]>([])
|
||||
const previewImageUrl = ref('')
|
||||
const testMode = ref<'default' | 'compact'>('default')
|
||||
const isOpenAIAccount = computed(() => props.account?.platform === 'openai')
|
||||
const openAITestModeOptions = computed(() => [
|
||||
{ value: 'default', label: t('admin.accounts.openai.testModeDefault') },
|
||||
{ value: 'compact', label: t('admin.accounts.openai.testModeCompact') }
|
||||
])
|
||||
const prioritizedGeminiModels = ['gemini-3.1-flash-image', 'gemini-2.5-flash-image', 'gemini-3.5-flash', 'gemini-2.5-flash', 'gemini-2.5-pro', 'gemini-3-flash-preview', 'gemini-3-pro-preview', 'gemini-2.0-flash']
|
||||
const supportsGeminiImageTest = computed(() => {
|
||||
const modelID = selectedModelId.value.toLowerCase()
|
||||
@@ -309,6 +326,7 @@ watch(
|
||||
async (newVal) => {
|
||||
if (newVal && props.account) {
|
||||
testPrompt.value = ''
|
||||
testMode.value = 'default'
|
||||
resetState()
|
||||
await loadAvailableModels()
|
||||
} else {
|
||||
@@ -400,6 +418,18 @@ const startTest = async () => {
|
||||
abortController = new AbortController()
|
||||
|
||||
try {
|
||||
const requestBody: {
|
||||
model_id: string
|
||||
prompt: string
|
||||
mode?: 'default' | 'compact'
|
||||
} = {
|
||||
model_id: selectedModelId.value,
|
||||
prompt: supportsImageTest.value ? testPrompt.value.trim() : ''
|
||||
}
|
||||
if (isOpenAIAccount.value) {
|
||||
requestBody.mode = testMode.value
|
||||
}
|
||||
|
||||
// Use the configured API base; EventSource does not support POST.
|
||||
const url = buildApiUrl(`/admin/accounts/${props.account.id}/test`)
|
||||
|
||||
@@ -410,10 +440,7 @@ const startTest = async () => {
|
||||
Authorization: `Bearer ${localStorage.getItem('auth_token')}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model_id: selectedModelId.value,
|
||||
prompt: supportsImageTest.value ? testPrompt.value.trim() : ''
|
||||
}),
|
||||
body: JSON.stringify(requestBody),
|
||||
signal: abortController.signal
|
||||
})
|
||||
|
||||
@@ -505,6 +532,12 @@ const handleEvent = (event: {
|
||||
}
|
||||
break
|
||||
|
||||
case 'status':
|
||||
if (event.text) {
|
||||
addLine(event.text, 'text-cyan-300')
|
||||
}
|
||||
break
|
||||
|
||||
case 'test_complete':
|
||||
// Move streaming content to output lines
|
||||
if (streamingContent.value) {
|
||||
|
||||
@@ -19,13 +19,23 @@
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.accounts.dataImportFile') }}</label>
|
||||
<div
|
||||
class="flex items-center justify-between gap-3 rounded-lg border border-dashed border-gray-300 bg-gray-50 px-4 py-3 dark:border-dark-600 dark:bg-dark-800"
|
||||
class="flex items-center justify-between gap-3 rounded-lg border border-dashed px-4 py-3 transition-colors"
|
||||
:class="dragActive
|
||||
? 'border-primary-400 bg-primary-50/70 dark:border-primary-500 dark:bg-primary-900/20'
|
||||
: 'border-gray-300 bg-gray-50 dark:border-dark-600 dark:bg-dark-800'"
|
||||
@dragenter.prevent="handleDragEnter"
|
||||
@dragover.prevent
|
||||
@dragleave.prevent="handleDragLeave"
|
||||
@drop.prevent="handleDrop"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-sm text-gray-700 dark:text-dark-200">
|
||||
{{ fileName || t('admin.accounts.dataImportSelectFile') }}
|
||||
<div class="truncate text-sm text-gray-700 dark:text-dark-200" :title="fileListTitle">
|
||||
{{ selectedFilesLabel || t('admin.accounts.dataImportSelectFile') }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-dark-400">
|
||||
JSON (.json)
|
||||
<span v-if="files.length > 1"> · {{ fileListTitle }}</span>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-dark-400">JSON (.json)</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-secondary shrink-0" @click="openFilePicker">
|
||||
{{ t('common.chooseFile') }}
|
||||
@@ -36,6 +46,7 @@
|
||||
type="file"
|
||||
class="hidden"
|
||||
accept="application/json,.json"
|
||||
multiple
|
||||
@change="handleFileChange"
|
||||
/>
|
||||
</div>
|
||||
@@ -90,7 +101,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import type { AdminDataImportResult } from '@/types'
|
||||
import type { AdminDataImportResult, AdminDataPayload } from '@/types'
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
@@ -108,11 +119,19 @@ const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const importing = ref(false)
|
||||
const file = ref<File | null>(null)
|
||||
const files = ref<File[]>([])
|
||||
const dragDepth = ref(0)
|
||||
const dragActive = computed(() => dragDepth.value > 0)
|
||||
const hasCreatedData = ref(false)
|
||||
const result = ref<AdminDataImportResult | null>(null)
|
||||
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const fileName = computed(() => file.value?.name || '')
|
||||
const selectedFilesLabel = computed(() => {
|
||||
if (files.value.length === 0) return ''
|
||||
if (files.value.length === 1) return files.value[0]?.name || ''
|
||||
return t('admin.accounts.selectedCount', { count: files.value.length })
|
||||
})
|
||||
const fileListTitle = computed(() => files.value.map((item) => item.name).join(', '))
|
||||
|
||||
const errorItems = computed(() => result.value?.errors || [])
|
||||
|
||||
@@ -120,7 +139,9 @@ watch(
|
||||
() => props.show,
|
||||
(open) => {
|
||||
if (open) {
|
||||
file.value = null
|
||||
files.value = []
|
||||
dragDepth.value = 0
|
||||
hasCreatedData.value = false
|
||||
result.value = null
|
||||
if (fileInput.value) {
|
||||
fileInput.value.value = ''
|
||||
@@ -135,14 +156,56 @@ const openFilePicker = () => {
|
||||
|
||||
const handleFileChange = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
file.value = target.files?.[0] || null
|
||||
setSelectedFiles(target.files)
|
||||
target.value = ''
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
if (importing.value) return
|
||||
if (hasCreatedData.value) {
|
||||
hasCreatedData.value = false
|
||||
emit('imported')
|
||||
}
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const isJsonFile = (sourceFile: File) => {
|
||||
const name = sourceFile.name.toLowerCase()
|
||||
return name.endsWith('.json') || sourceFile.type === 'application/json'
|
||||
}
|
||||
|
||||
const setSelectedFiles = (sourceFiles: FileList | File[] | null | undefined) => {
|
||||
if (importing.value) return
|
||||
const incoming = Array.from(sourceFiles || [])
|
||||
const picked = incoming.filter(isJsonFile)
|
||||
if (!picked.length) {
|
||||
appStore.showError(t('admin.accounts.dataImportSelectFile'))
|
||||
return
|
||||
}
|
||||
if (picked.length < incoming.length) {
|
||||
appStore.showWarning(
|
||||
t('admin.accounts.dataImportIgnoredFiles', { count: incoming.length - picked.length })
|
||||
)
|
||||
}
|
||||
files.value = picked
|
||||
result.value = null
|
||||
}
|
||||
|
||||
const handleDragEnter = () => {
|
||||
if (importing.value) return
|
||||
dragDepth.value += 1
|
||||
}
|
||||
|
||||
const handleDragLeave = () => {
|
||||
dragDepth.value = Math.max(0, dragDepth.value - 1)
|
||||
}
|
||||
|
||||
const handleDrop = (event: DragEvent) => {
|
||||
dragDepth.value = 0
|
||||
if (importing.value) return
|
||||
setSelectedFiles(event.dataTransfer?.files)
|
||||
}
|
||||
|
||||
const readFileAsText = async (sourceFile: File): Promise<string> => {
|
||||
if (typeof sourceFile.text === 'function') {
|
||||
return sourceFile.text()
|
||||
@@ -161,16 +224,74 @@ const readFileAsText = async (sourceFile: File): Promise<string> => {
|
||||
})
|
||||
}
|
||||
|
||||
const SUPPORTED_DATA_TYPES = ['sub2api-data', 'sub2api-bundle']
|
||||
const SUPPORTED_DATA_VERSION = 1
|
||||
|
||||
// 与后端 validateDataHeader 对齐:合并前逐文件校验,避免坏文件混入合并 payload 后
|
||||
// 报错无法定位来源,或绕过后端本会对单文件做的 type/version 检查。
|
||||
const isValidDataPayload = (payload: unknown): payload is AdminDataPayload => {
|
||||
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return false
|
||||
const candidate = payload as Record<string, unknown>
|
||||
if (
|
||||
candidate.type !== undefined &&
|
||||
candidate.type !== '' &&
|
||||
!SUPPORTED_DATA_TYPES.includes(candidate.type as string)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
candidate.version !== undefined &&
|
||||
candidate.version !== 0 &&
|
||||
candidate.version !== SUPPORTED_DATA_VERSION
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return Array.isArray(candidate.proxies) && Array.isArray(candidate.accounts)
|
||||
}
|
||||
|
||||
const mergeDataPayloads = (payloads: AdminDataPayload[]): AdminDataPayload => {
|
||||
const [firstPayload] = payloads
|
||||
if (payloads.length === 1 && firstPayload) return firstPayload
|
||||
|
||||
return {
|
||||
type: payloads.find((item) => typeof item.type === 'string')?.type,
|
||||
version: payloads.find((item) => typeof item.version === 'number')?.version,
|
||||
exported_at: new Date().toISOString(),
|
||||
proxies: payloads.flatMap((item) => item.proxies),
|
||||
accounts: payloads.flatMap((item) => item.accounts),
|
||||
skipped_shadows: payloads.reduce((sum, item) => {
|
||||
const count = Number(item.skipped_shadows || 0)
|
||||
return Number.isFinite(count) ? sum + count : sum
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!file.value) {
|
||||
if (files.value.length === 0) {
|
||||
appStore.showError(t('admin.accounts.dataImportSelectFile'))
|
||||
return
|
||||
}
|
||||
|
||||
importing.value = true
|
||||
try {
|
||||
const text = await readFileAsText(file.value)
|
||||
const dataPayload = JSON.parse(text)
|
||||
const dataPayloads: AdminDataPayload[] = []
|
||||
for (const sourceFile of files.value) {
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(await readFileAsText(sourceFile))
|
||||
} catch {
|
||||
appStore.showError(
|
||||
t('admin.accounts.dataImportParseFailedFile', { name: sourceFile.name })
|
||||
)
|
||||
return
|
||||
}
|
||||
if (!isValidDataPayload(parsed)) {
|
||||
appStore.showError(t('admin.accounts.dataImportInvalidFile', { name: sourceFile.name }))
|
||||
return
|
||||
}
|
||||
dataPayloads.push(parsed)
|
||||
}
|
||||
const dataPayload = mergeDataPayloads(dataPayloads)
|
||||
|
||||
const res = await adminAPI.accounts.importData({
|
||||
data: dataPayload,
|
||||
@@ -187,17 +308,17 @@ const handleImport = async () => {
|
||||
proxy_failed: res.proxy_failed,
|
||||
}
|
||||
if (res.account_failed > 0 || res.proxy_failed > 0) {
|
||||
// 部分成功也创建了数据;弹窗关闭时通过 imported 通知父组件刷新列表
|
||||
if (res.account_created > 0 || res.proxy_created > 0) {
|
||||
hasCreatedData.value = true
|
||||
}
|
||||
appStore.showError(t('admin.accounts.dataImportCompletedWithErrors', msgParams))
|
||||
} else {
|
||||
appStore.showSuccess(t('admin.accounts.dataImportSuccess', msgParams))
|
||||
emit('imported')
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error instanceof SyntaxError) {
|
||||
appStore.showError(t('admin.accounts.dataImportParseFailed'))
|
||||
} else {
|
||||
appStore.showError(error?.message || t('admin.accounts.dataImportFailed'))
|
||||
}
|
||||
appStore.showError(error?.message || t('admin.accounts.dataImportFailed'))
|
||||
} finally {
|
||||
importing.value = false
|
||||
}
|
||||
|
||||
@@ -182,4 +182,38 @@ describe('AccountTestModal', () => {
|
||||
prompt: ''
|
||||
})
|
||||
})
|
||||
|
||||
it('OpenAI Compact 探测会携带 compact 测试模式', async () => {
|
||||
getAvailableModels.mockResolvedValue([
|
||||
{ id: 'gpt-5.4', display_name: 'GPT-5.4' }
|
||||
])
|
||||
global.fetch = vi.fn().mockResolvedValue(
|
||||
createStreamResponse([
|
||||
'data: {"type":"test_complete","success":true}\n'
|
||||
])
|
||||
) as any
|
||||
|
||||
const wrapper = mountModal({
|
||||
id: 42,
|
||||
name: 'OpenAI OAuth',
|
||||
platform: 'openai',
|
||||
type: 'oauth',
|
||||
status: 'active'
|
||||
})
|
||||
await wrapper.setProps({ show: true })
|
||||
await flushPromises()
|
||||
|
||||
;(wrapper.vm as any).selectedModelId = 'gpt-5.4'
|
||||
;(wrapper.vm as any).testMode = 'compact'
|
||||
await (wrapper.vm as any).startTest()
|
||||
await flushPromises()
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1)
|
||||
const [, request] = (global.fetch as any).mock.calls[0]
|
||||
expect(JSON.parse(request.body)).toMatchObject({
|
||||
model_id: 'gpt-5.4',
|
||||
prompt: '',
|
||||
mode: 'compact'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3172,6 +3172,9 @@ export default {
|
||||
dataImporting: 'Importing...',
|
||||
dataImportSelectFile: 'Please select a data file',
|
||||
dataImportParseFailed: 'Failed to parse data file',
|
||||
dataImportParseFailedFile: 'Failed to parse {name}',
|
||||
dataImportInvalidFile: '{name} is not a supported data export file',
|
||||
dataImportIgnoredFiles: 'Ignored {count} non-JSON file(s)',
|
||||
dataImportFailed: 'Data import failed',
|
||||
dataImportResult: 'Import Result',
|
||||
dataImportResultSummary: 'Proxies created {proxy_created}, reused {proxy_reused}, failed {proxy_failed}; Accounts created {account_created}, failed {account_failed}',
|
||||
@@ -7486,6 +7489,8 @@ export default {
|
||||
deletePlanConfirm: 'Are you sure you want to delete this plan?',
|
||||
originalPrice: 'Original Price',
|
||||
price: 'Price',
|
||||
subscriptionCnyPayPreview: 'CNY channel charge preview: {amount}',
|
||||
subscriptionCnyPayPreviewWithFee: '({feeRate}% fee included: {total})',
|
||||
validityDays: 'Validity (days)',
|
||||
validityUnit: 'Validity Unit',
|
||||
sortOrder: 'Sort Order',
|
||||
|
||||
@@ -3246,6 +3246,9 @@ export default {
|
||||
dataImporting: '导入中...',
|
||||
dataImportSelectFile: '请选择数据文件',
|
||||
dataImportParseFailed: '数据解析失败',
|
||||
dataImportParseFailedFile: '文件 {name} 解析失败',
|
||||
dataImportInvalidFile: '文件 {name} 不是受支持的导出数据文件',
|
||||
dataImportIgnoredFiles: '已忽略 {count} 个非 JSON 文件',
|
||||
dataImportFailed: '数据导入失败',
|
||||
dataImportResult: '导入结果',
|
||||
dataImportResultSummary: '代理创建 {proxy_created},复用 {proxy_reused},失败 {proxy_failed};账号创建 {account_created},失败 {account_failed}',
|
||||
@@ -7663,6 +7666,8 @@ export default {
|
||||
deletePlanConfirm: '确定要删除此套餐吗?',
|
||||
originalPrice: '原价',
|
||||
price: '价格',
|
||||
subscriptionCnyPayPreview: 'CNY 通道实扣预览:{amount}',
|
||||
subscriptionCnyPayPreviewWithFee: '(含 {feeRate}% 手续费:{total})',
|
||||
validityDays: '有效期(天)',
|
||||
validityUnit: '有效期单位',
|
||||
sortOrder: '排序',
|
||||
|
||||
@@ -789,11 +789,7 @@
|
||||
|
||||
<!-- 图片生成计费配置 -->
|
||||
<div
|
||||
v-if="
|
||||
createForm.platform === 'antigravity' ||
|
||||
createForm.platform === 'gemini' ||
|
||||
createForm.platform === 'openai'
|
||||
"
|
||||
v-if="supportsImagePricingPlatform(createForm.platform)"
|
||||
class="border-t pt-4"
|
||||
>
|
||||
<label
|
||||
@@ -2180,11 +2176,7 @@
|
||||
|
||||
<!-- 图片生成计费配置 -->
|
||||
<div
|
||||
v-if="
|
||||
editForm.platform === 'antigravity' ||
|
||||
editForm.platform === 'gemini' ||
|
||||
editForm.platform === 'openai'
|
||||
"
|
||||
v-if="supportsImagePricingPlatform(editForm.platform)"
|
||||
class="border-t pt-4"
|
||||
>
|
||||
<label
|
||||
@@ -3320,6 +3312,7 @@ import {
|
||||
} from "./groupsModelsList";
|
||||
import { createModelsListCandidatesTracker } from "./groupsModelsListCandidates";
|
||||
import { normalizeSupportedModelScopesForPlatform } from "./groupsSupportedModelScopes";
|
||||
import { supportsImagePricingPlatform } from "./groupsImagePricing";
|
||||
|
||||
const { t } = useI18n();
|
||||
const appStore = useAppStore();
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
imagePricingPlatforms,
|
||||
supportsImagePricingPlatform,
|
||||
} from "../groupsImagePricing";
|
||||
|
||||
describe("groups image pricing platform support", () => {
|
||||
it("includes Grok media groups", () => {
|
||||
expect(supportsImagePricingPlatform("grok")).toBe(true);
|
||||
expect(imagePricingPlatforms.has("grok")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps non-media group platforms out of the image pricing controls", () => {
|
||||
expect(supportsImagePricingPlatform("anthropic")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
export const imagePricingPlatforms = new Set([
|
||||
"antigravity",
|
||||
"gemini",
|
||||
"grok",
|
||||
"openai",
|
||||
]);
|
||||
|
||||
export const supportsImagePricingPlatform = (platform: string): boolean =>
|
||||
imagePricingPlatforms.has(platform);
|
||||
@@ -67,7 +67,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Plan Edit Dialog -->
|
||||
<PlanEditDialog :show="showPlanDialog" :plan="editingPlan" :groups="groups" @close="showPlanDialog = false" @saved="loadPlans" />
|
||||
<PlanEditDialog :show="showPlanDialog" :plan="editingPlan" :groups="groups" :payment-config="paymentConfig" @close="showPlanDialog = false" @saved="loadPlans" />
|
||||
|
||||
<ConfirmDialog :show="showDeletePlanDialog" :title="t('payment.admin.deletePlan')" :message="t('payment.admin.deletePlanConfirm')" :confirm-text="t('common.delete')" danger @confirm="handleDeletePlan" @cancel="showDeletePlanDialog = false" />
|
||||
</AppLayout>
|
||||
@@ -78,6 +78,7 @@ import { ref, computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { adminPaymentAPI } from '@/api/admin/payment'
|
||||
import type { AdminPaymentConfig } from '@/api/admin/payment'
|
||||
import { extractI18nErrorMessage } from '@/utils/apiError'
|
||||
import adminAPI from '@/api/admin'
|
||||
import type { SubscriptionPlan } from '@/types/payment'
|
||||
@@ -97,6 +98,7 @@ const appStore = useAppStore()
|
||||
// ==================== Groups ====================
|
||||
|
||||
const groups = ref<AdminGroup[]>([])
|
||||
const paymentConfig = ref<AdminPaymentConfig | null>(null)
|
||||
|
||||
async function loadGroups() {
|
||||
try {
|
||||
@@ -104,6 +106,13 @@ async function loadGroups() {
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
async function loadPaymentConfig() {
|
||||
try {
|
||||
const res = await adminPaymentAPI.getConfig()
|
||||
paymentConfig.value = res.data
|
||||
} catch { /* preview only */ }
|
||||
}
|
||||
|
||||
function getGroup(id: number): AdminGroup | undefined {
|
||||
return groups.value.find(g => g.id === id)
|
||||
}
|
||||
@@ -181,6 +190,7 @@ async function handleDeletePlan() {
|
||||
|
||||
onMounted(() => {
|
||||
loadGroups()
|
||||
loadPaymentConfig()
|
||||
loadPlans()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -35,7 +35,16 @@
|
||||
|
||||
<div><label class="input-label">{{ t('payment.admin.planDescription') }} <span class="text-red-500">*</span></label><textarea v-model="planForm.description" rows="2" class="input" required></textarea></div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div><label class="input-label">{{ t('payment.admin.price') }} <span class="text-red-500">*</span></label><input v-model.number="planForm.price" type="number" step="0.01" min="0.01" class="input" required /></div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('payment.admin.price') }} <span class="text-red-500">*</span></label>
|
||||
<input v-model.number="planForm.price" type="number" step="0.01" min="0.01" class="input" required />
|
||||
<p v-if="subscriptionCnyPreview" class="mt-1 text-xs font-medium text-primary-600 dark:text-primary-400">
|
||||
{{ t('payment.admin.subscriptionCnyPayPreview', { amount: subscriptionCnyPreview.amount }) }}
|
||||
<span v-if="subscriptionCnyPreview.feeRate > 0">
|
||||
{{ t('payment.admin.subscriptionCnyPayPreviewWithFee', { feeRate: subscriptionCnyPreview.feeRate, total: subscriptionCnyPreview.total }) }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div><label class="input-label">{{ t('payment.admin.originalPrice') }}</label><input v-model.number="planForm.original_price" type="number" step="0.01" min="0" class="input" /></div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
@@ -81,7 +90,9 @@ import { ref, reactive, computed, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { adminPaymentAPI } from '@/api/admin/payment'
|
||||
import type { AdminPaymentConfig } from '@/api/admin/payment'
|
||||
import { extractApiErrorMessage } from '@/utils/apiError'
|
||||
import { formatPaymentAmount } from '@/components/payment/currency'
|
||||
import type { SubscriptionPlan } from '@/types/payment'
|
||||
import type { AdminGroup } from '@/types'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
@@ -94,6 +105,7 @@ const props = defineProps<{
|
||||
show: boolean
|
||||
plan: SubscriptionPlan | null
|
||||
groups: AdminGroup[]
|
||||
paymentConfig?: AdminPaymentConfig | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -129,6 +141,31 @@ const selectedGroupInfo = computed(() => {
|
||||
return props.groups.find(g => g.id === planForm.group_id) || null
|
||||
})
|
||||
|
||||
function roundCnyAmount(value: number): number {
|
||||
return Math.round(value * 100) / 100
|
||||
}
|
||||
|
||||
function ceilCnyAmount(value: number): number {
|
||||
return Math.ceil(value * 100) / 100
|
||||
}
|
||||
|
||||
const subscriptionCnyPreview = computed(() => {
|
||||
const price = Number(planForm.price) || 0
|
||||
const rate = Number(props.paymentConfig?.subscription_usd_to_cny_rate) || 0
|
||||
if (price <= 0 || rate <= 0) return null
|
||||
|
||||
const amount = roundCnyAmount(price * rate)
|
||||
const feeRate = Number(props.paymentConfig?.recharge_fee_rate) || 0
|
||||
const fee = feeRate > 0 ? ceilCnyAmount((amount * feeRate) / 100) : 0
|
||||
const total = feeRate > 0 ? roundCnyAmount(amount + fee) : amount
|
||||
|
||||
return {
|
||||
amount: formatPaymentAmount(amount, 'CNY'),
|
||||
feeRate,
|
||||
total: formatPaymentAmount(total, 'CNY'),
|
||||
}
|
||||
})
|
||||
|
||||
// Reset form when dialog opens
|
||||
watch(() => props.show, (visible) => {
|
||||
if (!visible) return
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import PlanEditDialog from '../PlanEditDialog.vue'
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string, params?: Record<string, unknown>) => {
|
||||
if (key === 'payment.admin.subscriptionCnyPayPreview') return `preview ${params?.amount}`
|
||||
if (key === 'payment.admin.subscriptionCnyPayPreviewWithFee') return `fee ${params?.feeRate} ${params?.total}`
|
||||
return key
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/app', () => ({
|
||||
useAppStore: () => ({
|
||||
showError: vi.fn(),
|
||||
showSuccess: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/admin/payment', () => ({
|
||||
adminPaymentAPI: {
|
||||
createPlan: vi.fn(),
|
||||
updatePlan: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
function mountDialog(paymentConfig: Record<string, unknown> | null) {
|
||||
return mount(PlanEditDialog, {
|
||||
props: {
|
||||
show: true,
|
||||
plan: null,
|
||||
groups: [],
|
||||
paymentConfig,
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
BaseDialog: {
|
||||
props: ['show'],
|
||||
template: '<div v-if="show"><slot /><slot name="footer" /></div>',
|
||||
},
|
||||
Select: true,
|
||||
Icon: true,
|
||||
GroupBadge: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('PlanEditDialog subscription CNY payment preview', () => {
|
||||
it('shows CNY channel charge using the configured subscription rate and fee', async () => {
|
||||
const wrapper = mountDialog({
|
||||
subscription_usd_to_cny_rate: 7.15,
|
||||
recharge_fee_rate: 2.5,
|
||||
})
|
||||
|
||||
await wrapper.find('input[type="number"]').setValue('9.99')
|
||||
|
||||
expect(wrapper.text()).toContain('preview')
|
||||
expect(wrapper.text()).toContain('¥71.43')
|
||||
expect(wrapper.text()).toContain('fee 2.5')
|
||||
expect(wrapper.text()).toContain('¥73.22')
|
||||
})
|
||||
|
||||
it('hides the preview when the subscription rate is not configured', async () => {
|
||||
const wrapper = mountDialog({
|
||||
subscription_usd_to_cny_rate: 0,
|
||||
recharge_fee_rate: 2.5,
|
||||
})
|
||||
|
||||
await wrapper.find('input[type="number"]').setValue('9.99')
|
||||
|
||||
expect(wrapper.text()).not.toContain('preview')
|
||||
expect(wrapper.text()).not.toContain('¥71.43')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user