feat(frontend): add image studio page
This commit is contained in:
@ -279,6 +279,21 @@ const GiftIcon = {
|
||||
)
|
||||
}
|
||||
|
||||
const SparklesIcon = {
|
||||
render: () =>
|
||||
h(
|
||||
'svg',
|
||||
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
|
||||
[
|
||||
h('path', {
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
d: 'M9.813 15.904 9 18.75l-.813-2.846a4.5 4.5 0 0 0-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 0 0 3.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 0 0 3.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 0 0-3.09 3.09ZM18.259 8.715 18 9.75l-.259-1.035a3.375 3.375 0 0 0-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 0 0 2.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 0 0 2.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 0 0-2.456 2.456Z'
|
||||
})
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
const UserIcon = {
|
||||
render: () =>
|
||||
h(
|
||||
@ -608,6 +623,7 @@ const personalNavItems = computed((): NavItem[] => {
|
||||
const items: NavItem[] = [
|
||||
{ path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon },
|
||||
{ path: '/usage', label: t('nav.usage'), icon: ChartIcon, hideInSimpleMode: true },
|
||||
{ path: '/image-studio', label: t('nav.imageStudio', '生图工作台'), icon: SparklesIcon, hideInSimpleMode: true },
|
||||
{ path: '/subscriptions', label: t('nav.mySubscriptions'), icon: CreditCardIcon, hideInSimpleMode: true },
|
||||
...(appStore.cachedPublicSettings?.payment_enabled
|
||||
? [
|
||||
|
||||
@ -360,6 +360,7 @@ export default {
|
||||
buySubscription: 'Recharge / Subscription',
|
||||
docs: 'Docs',
|
||||
myOrders: 'My Orders',
|
||||
imageStudio: 'Image Studio',
|
||||
orderManagement: 'Orders',
|
||||
paymentDashboard: 'Payment Dashboard',
|
||||
paymentConfig: 'Payment Config',
|
||||
@ -594,6 +595,11 @@ export default {
|
||||
addBalanceWithCode: 'Add balance with a code'
|
||||
},
|
||||
|
||||
imageStudio: {
|
||||
title: 'AI Image Studio',
|
||||
description: 'Uses gpt-image-2 by default and can generate with your own API key.'
|
||||
},
|
||||
|
||||
// Groups (shared)
|
||||
groups: {
|
||||
subscription: 'Sub'
|
||||
|
||||
@ -360,6 +360,7 @@ export default {
|
||||
buySubscription: '充值/订阅',
|
||||
docs: '文档',
|
||||
myOrders: '我的订单',
|
||||
imageStudio: '生图工作台',
|
||||
orderManagement: '订单管理',
|
||||
paymentDashboard: '支付概览',
|
||||
paymentConfig: '支付配置',
|
||||
@ -593,6 +594,11 @@ export default {
|
||||
addBalanceWithCode: '使用兑换码充值'
|
||||
},
|
||||
|
||||
imageStudio: {
|
||||
title: 'AI 生图工作台',
|
||||
description: '默认调用 gpt-image-2,可直接用你自己的 API Key 出图。'
|
||||
},
|
||||
|
||||
// Groups (shared)
|
||||
groups: {
|
||||
subscription: '订阅'
|
||||
|
||||
14
frontend/src/router/__tests__/imageStudioRoute.spec.ts
Normal file
14
frontend/src/router/__tests__/imageStudioRoute.spec.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import router from '@/router'
|
||||
|
||||
describe('image studio route', () => {
|
||||
it('registers the authenticated image studio page', () => {
|
||||
const match = router.getRoutes().find((route) => route.path === '/image-studio')
|
||||
|
||||
expect(match).toBeTruthy()
|
||||
expect(match?.name).toBe('ImageStudio')
|
||||
expect(match?.meta.requiresAuth).toBe(true)
|
||||
expect(match?.meta.requiresAdmin).toBe(false)
|
||||
expect(match?.meta.titleKey).toBe('imageStudio.title')
|
||||
})
|
||||
})
|
||||
@ -185,6 +185,18 @@ const routes: RouteRecordRaw[] = [
|
||||
descriptionKey: 'usage.description'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/image-studio',
|
||||
name: 'ImageStudio',
|
||||
component: () => import('@/views/user/ImageStudioView.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
requiresAdmin: false,
|
||||
title: 'Image Studio',
|
||||
titleKey: 'imageStudio.title',
|
||||
descriptionKey: 'imageStudio.description'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/redeem',
|
||||
name: 'Redeem',
|
||||
|
||||
303
frontend/src/views/user/ImageStudioView.vue
Normal file
303
frontend/src/views/user/ImageStudioView.vue
Normal file
@ -0,0 +1,303 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="grid grid-cols-1 gap-6 xl:grid-cols-[420px,minmax(0,1fr)]">
|
||||
<div class="card p-5">
|
||||
<div class="mb-5">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('imageStudio.title', 'AI 生图工作台') }}
|
||||
</h2>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-dark-400">
|
||||
{{ t('imageStudio.description', '默认调用 gpt-image-2,可直接用你自己的 API Key 出图。') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form class="space-y-5" @submit.prevent="handleSubmit">
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-dark-200">
|
||||
{{ t('imageStudio.keySourceLabel', '密钥来源') }}
|
||||
</label>
|
||||
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
<label class="flex cursor-pointer items-center gap-3 rounded-xl border border-gray-200 p-3 text-sm dark:border-dark-600">
|
||||
<input v-model="keySource" type="radio" value="account" class="h-4 w-4" />
|
||||
<span>{{ t('imageStudio.keySourceAccount', '使用我的 API Key') }}</span>
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center gap-3 rounded-xl border border-gray-200 p-3 text-sm dark:border-dark-600">
|
||||
<input v-model="keySource" type="radio" value="manual" class="h-4 w-4" />
|
||||
<span>{{ t('imageStudio.keySourceManual', '手动输入 API Key') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="keySource === 'account'" class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-dark-200">
|
||||
{{ t('imageStudio.accountKeyLabel', '我的 API Key') }}
|
||||
</label>
|
||||
<select v-model="selectedKeyId" class="input w-full">
|
||||
<option value="">{{ t('imageStudio.accountKeyPlaceholder', '请选择一个可用密钥') }}</option>
|
||||
<option v-for="key in availableKeys" :key="key.id" :value="String(key.id)">
|
||||
{{ key.name }} · {{ maskKey(key.key) }}
|
||||
</option>
|
||||
</select>
|
||||
<p class="text-xs text-gray-500 dark:text-dark-400">
|
||||
{{ t('imageStudio.accountKeyHint', '优先使用你账号下已启用的 Sub2API Key。') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-dark-200">
|
||||
{{ t('imageStudio.manualKeyLabel', '手动 API Key') }}
|
||||
</label>
|
||||
<input
|
||||
v-model.trim="manualApiKey"
|
||||
type="password"
|
||||
class="input w-full"
|
||||
:placeholder="t('imageStudio.manualKeyPlaceholder', '请输入 sk-...')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-dark-200">
|
||||
{{ t('imageStudio.modelLabel', '模型') }}
|
||||
</label>
|
||||
<input v-model.trim="model" type="text" class="input w-full" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-dark-200">
|
||||
{{ t('imageStudio.sizeLabel', '尺寸') }}
|
||||
</label>
|
||||
<select v-model="size" class="input w-full">
|
||||
<option value="1024x1024">1024 × 1024</option>
|
||||
<option value="1536x1024">1536 × 1024</option>
|
||||
<option value="1024x1536">1024 × 1536</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-dark-200">
|
||||
{{ t('imageStudio.promptLabel', '提示词') }}
|
||||
</label>
|
||||
<textarea
|
||||
v-model.trim="prompt"
|
||||
class="input min-h-[180px] w-full resize-y"
|
||||
:placeholder="t('imageStudio.promptPlaceholder', '描述你想生成的画面…')"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-dark-200">
|
||||
{{ t('imageStudio.uploadLabel', '参考图上传(可选)') }}
|
||||
</label>
|
||||
<label class="flex cursor-pointer flex-col items-center justify-center rounded-2xl border border-dashed border-gray-300 px-4 py-6 text-center dark:border-dark-600">
|
||||
<input type="file" accept="image/*" class="hidden" @change="handleFileChange" />
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-dark-200">
|
||||
{{ selectedFile ? selectedFile.name : t('imageStudio.uploadPlaceholder', '点击上传参考图,可不传') }}
|
||||
</span>
|
||||
<span class="mt-1 text-xs text-gray-500 dark:text-dark-400">
|
||||
{{ t('imageStudio.uploadHint', '上传后将自动调用 /v1/images/edits;不上传则走 /v1/images/generations。') }}
|
||||
</span>
|
||||
</label>
|
||||
<button v-if="selectedFile" type="button" class="btn btn-secondary btn-sm" @click="clearFile">
|
||||
{{ t('imageStudio.removeUpload', '移除图片') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-3 rounded-xl bg-gray-50 px-4 py-3 text-xs text-gray-500 dark:bg-dark-800 dark:text-dark-300">
|
||||
<span>{{ endpointLabel }}</span>
|
||||
<span>{{ t('imageStudio.defaultModelHint', '默认模型:gpt-image-2') }}</span>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary w-full" :disabled="submitting || !canSubmit">
|
||||
{{ submitting ? t('imageStudio.generating', '生成中...') : t('imageStudio.generateButton', '开始生成') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card min-h-[520px] p-5">
|
||||
<div class="mb-4 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('imageStudio.resultTitle', '图片生成区') }}
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-dark-400">
|
||||
{{ t('imageStudio.resultDescription', '生成结果会显示在这里,支持 base64 或 URL 图片回显。') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="errorMessage" class="mb-4 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-600 dark:border-red-900/50 dark:bg-red-950/30 dark:text-red-300">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<div v-if="generatedImages.length === 0" class="flex min-h-[420px] items-center justify-center rounded-2xl border border-dashed border-gray-200 bg-gray-50 p-8 text-center dark:border-dark-700 dark:bg-dark-900/40">
|
||||
<div class="max-w-md">
|
||||
<p class="text-base font-medium text-gray-700 dark:text-dark-200">
|
||||
{{ t('imageStudio.emptyTitle', '还没有生成结果') }}
|
||||
</p>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-dark-400">
|
||||
{{ t('imageStudio.emptyDescription', '左侧填写提示词,必要时上传参考图,然后开始生成。') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<div
|
||||
v-for="(image, index) in generatedImages"
|
||||
:key="`${image.src}-${index}`"
|
||||
class="overflow-hidden rounded-2xl border border-gray-200 bg-white dark:border-dark-700 dark:bg-dark-900"
|
||||
>
|
||||
<img :src="image.src" :alt="`Generated image ${index + 1}`" class="h-full w-full object-contain" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import { keysAPI } from '@/api/keys'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import type { ApiKey } from '@/types'
|
||||
|
||||
interface GeneratedImage {
|
||||
src: string
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const availableKeys = ref<ApiKey[]>([])
|
||||
const keySource = ref<'account' | 'manual'>('account')
|
||||
const selectedKeyId = ref('')
|
||||
const manualApiKey = ref('')
|
||||
const model = ref('gpt-image-2')
|
||||
const size = ref('1024x1024')
|
||||
const prompt = ref('')
|
||||
const selectedFile = ref<File | null>(null)
|
||||
const generatedImages = ref<GeneratedImage[]>([])
|
||||
const submitting = ref(false)
|
||||
const errorMessage = ref('')
|
||||
|
||||
const endpointLabel = computed(() => selectedFile.value
|
||||
? t('imageStudio.endpointEdit', '当前接口:/v1/images/edits')
|
||||
: t('imageStudio.endpointGenerate', '当前接口:/v1/images/generations'))
|
||||
|
||||
const resolvedApiKey = computed(() => {
|
||||
if (keySource.value === 'manual') return manualApiKey.value.trim()
|
||||
const selected = availableKeys.value.find((item) => String(item.id) === selectedKeyId.value)
|
||||
return selected?.key ?? ''
|
||||
})
|
||||
|
||||
const canSubmit = computed(() => prompt.value.trim().length > 0 && resolvedApiKey.value.length > 0)
|
||||
|
||||
function maskKey(key: string): string {
|
||||
if (key.length <= 10) return key
|
||||
return `${key.slice(0, 6)}...${key.slice(-4)}`
|
||||
}
|
||||
|
||||
async function loadKeys() {
|
||||
const response = await keysAPI.list(1, 100, { status: 'active' })
|
||||
availableKeys.value = response.items ?? []
|
||||
if (!selectedKeyId.value && availableKeys.value.length > 0) {
|
||||
selectedKeyId.value = String(availableKeys.value[0].id)
|
||||
}
|
||||
}
|
||||
|
||||
function handleFileChange(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
selectedFile.value = input.files?.[0] ?? null
|
||||
}
|
||||
|
||||
function clearFile() {
|
||||
selectedFile.value = null
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!canSubmit.value) {
|
||||
errorMessage.value = t('imageStudio.validationMessage', '请先填写提示词并提供可用 API Key。')
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
errorMessage.value = ''
|
||||
generatedImages.value = []
|
||||
|
||||
try {
|
||||
const endpoint = selectedFile.value ? '/v1/images/edits' : '/v1/images/generations'
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${resolvedApiKey.value}`
|
||||
}
|
||||
|
||||
let body: FormData | string
|
||||
if (selectedFile.value) {
|
||||
const formData = new FormData()
|
||||
formData.append('model', model.value.trim() || 'gpt-image-2')
|
||||
formData.append('prompt', prompt.value.trim())
|
||||
formData.append('size', size.value)
|
||||
formData.append('image', selectedFile.value)
|
||||
body = formData
|
||||
} else {
|
||||
headers['Content-Type'] = 'application/json'
|
||||
body = JSON.stringify({
|
||||
model: model.value.trim() || 'gpt-image-2',
|
||||
prompt: prompt.value.trim(),
|
||||
size: size.value,
|
||||
response_format: 'b64_json'
|
||||
})
|
||||
}
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body
|
||||
})
|
||||
|
||||
const payload = await response.json().catch(() => ({} as any))
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.error?.message || payload?.detail || `HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
generatedImages.value = normalizeImages(payload)
|
||||
if (generatedImages.value.length === 0) {
|
||||
throw new Error(t('imageStudio.emptyResponse', '接口已返回,但没有拿到可展示图片。'))
|
||||
}
|
||||
|
||||
appStore.showSuccess?.(t('imageStudio.generateSuccess', '图片生成成功'))
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : t('imageStudio.generateFailed', '图片生成失败')
|
||||
errorMessage.value = message
|
||||
appStore.showError?.(message)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeImages(payload: any): GeneratedImage[] {
|
||||
const items = Array.isArray(payload?.data) ? payload.data : []
|
||||
return items
|
||||
.map((item: any) => {
|
||||
if (typeof item?.url === 'string' && item.url.trim()) {
|
||||
return { src: item.url.trim() }
|
||||
}
|
||||
if (typeof item?.b64_json === 'string' && item.b64_json.trim()) {
|
||||
return { src: `data:image/png;base64,${item.b64_json.trim()}` }
|
||||
}
|
||||
return null
|
||||
})
|
||||
.filter(Boolean) as GeneratedImage[]
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await loadKeys()
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : t('keys.failedToLoad', '加载 API 密钥失败')
|
||||
errorMessage.value = message
|
||||
appStore.showError?.(message)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
138
frontend/src/views/user/__tests__/ImageStudioView.spec.ts
Normal file
138
frontend/src/views/user/__tests__/ImageStudioView.spec.ts
Normal file
@ -0,0 +1,138 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import ImageStudioView from '../ImageStudioView.vue'
|
||||
|
||||
const { listKeys, showSuccess, showError } = vi.hoisted(() => ({
|
||||
listKeys: vi.fn(),
|
||||
showSuccess: vi.fn(),
|
||||
showError: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/api/keys', () => ({
|
||||
keysAPI: {
|
||||
list: listKeys
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/app', () => ({
|
||||
useAppStore: () => ({
|
||||
showSuccess,
|
||||
showError
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', async () => {
|
||||
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
|
||||
return {
|
||||
...actual,
|
||||
useI18n: () => ({
|
||||
t: (key: string, fallback?: string) => fallback || key
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
function mockFetchJsonOnce(data: unknown) {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => data
|
||||
} as Response) as any
|
||||
}
|
||||
|
||||
describe('ImageStudioView', () => {
|
||||
beforeEach(() => {
|
||||
listKeys.mockResolvedValue({
|
||||
items: [
|
||||
{ id: 1, name: 'Primary Key', key: 'sk-sub2api-primary', status: 'active' },
|
||||
{ id: 2, name: 'Backup Key', key: 'sk-sub2api-backup', status: 'inactive' }
|
||||
],
|
||||
total: 2,
|
||||
page: 1,
|
||||
page_size: 100
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('默认使用 gpt-image-2 和自己的活动 key 发起生图请求', async () => {
|
||||
mockFetchJsonOnce({
|
||||
data: [
|
||||
{ b64_json: 'QUJDRA==' }
|
||||
]
|
||||
})
|
||||
|
||||
const wrapper = mount(ImageStudioView, {
|
||||
global: {
|
||||
stubs: {
|
||||
AppLayout: { template: '<div><slot /></div>' }
|
||||
}
|
||||
}
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
const prompt = wrapper.find('textarea')
|
||||
await prompt.setValue('一只戴宇航头盔的橘猫')
|
||||
|
||||
await wrapper.find('form').trigger('submit.prevent')
|
||||
await flushPromises()
|
||||
|
||||
expect(listKeys).toHaveBeenCalledWith(1, 100, { status: 'active' })
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1)
|
||||
const [url, request] = (global.fetch as any).mock.calls[0]
|
||||
expect(url).toBe('/v1/images/generations')
|
||||
expect(request.headers.Authorization).toBe('Bearer sk-sub2api-primary')
|
||||
expect(request.headers['Content-Type']).toBe('application/json')
|
||||
expect(JSON.parse(request.body)).toMatchObject({
|
||||
model: 'gpt-image-2',
|
||||
prompt: '一只戴宇航头盔的橘猫'
|
||||
})
|
||||
|
||||
const generated = wrapper.find('img[alt="Generated image 1"]')
|
||||
expect(generated.exists()).toBe(true)
|
||||
expect(generated.attributes('src')).toBe('data:image/png;base64,QUJDRA==')
|
||||
})
|
||||
|
||||
it('上传图片后会改走图片编辑接口并提交 multipart form data', async () => {
|
||||
mockFetchJsonOnce({
|
||||
data: [
|
||||
{ url: 'https://example.com/generated.png' }
|
||||
]
|
||||
})
|
||||
|
||||
const wrapper = mount(ImageStudioView, {
|
||||
global: {
|
||||
stubs: {
|
||||
AppLayout: { template: '<div><slot /></div>' }
|
||||
}
|
||||
}
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.find('textarea').setValue('把这张图改成赛博朋克海报')
|
||||
|
||||
const file = new File(['demo'], 'seed.png', { type: 'image/png' })
|
||||
const fileInput = wrapper.find('input[type="file"]')
|
||||
Object.defineProperty(fileInput.element, 'files', {
|
||||
value: [file],
|
||||
configurable: true
|
||||
})
|
||||
await fileInput.trigger('change')
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.find('form').trigger('submit.prevent')
|
||||
await flushPromises()
|
||||
|
||||
const [url, request] = (global.fetch as any).mock.calls[0]
|
||||
expect(url).toBe('/v1/images/edits')
|
||||
expect(request.headers.Authorization).toBe('Bearer sk-sub2api-primary')
|
||||
expect(request.body).toBeInstanceOf(FormData)
|
||||
const formData = request.body as FormData
|
||||
expect(formData.get('model')).toBe('gpt-image-2')
|
||||
expect(formData.get('prompt')).toBe('把这张图改成赛博朋克海报')
|
||||
expect(formData.get('image')).toBe(file)
|
||||
|
||||
const generated = wrapper.find('img[alt="Generated image 1"]')
|
||||
expect(generated.attributes('src')).toBe('https://example.com/generated.png')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user