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 = {
|
const UserIcon = {
|
||||||
render: () =>
|
render: () =>
|
||||||
h(
|
h(
|
||||||
@ -608,6 +623,7 @@ const personalNavItems = computed((): NavItem[] => {
|
|||||||
const items: NavItem[] = [
|
const items: NavItem[] = [
|
||||||
{ path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon },
|
{ path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon },
|
||||||
{ path: '/usage', label: t('nav.usage'), icon: ChartIcon, hideInSimpleMode: true },
|
{ 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 },
|
{ path: '/subscriptions', label: t('nav.mySubscriptions'), icon: CreditCardIcon, hideInSimpleMode: true },
|
||||||
...(appStore.cachedPublicSettings?.payment_enabled
|
...(appStore.cachedPublicSettings?.payment_enabled
|
||||||
? [
|
? [
|
||||||
|
|||||||
@ -360,6 +360,7 @@ export default {
|
|||||||
buySubscription: 'Recharge / Subscription',
|
buySubscription: 'Recharge / Subscription',
|
||||||
docs: 'Docs',
|
docs: 'Docs',
|
||||||
myOrders: 'My Orders',
|
myOrders: 'My Orders',
|
||||||
|
imageStudio: 'Image Studio',
|
||||||
orderManagement: 'Orders',
|
orderManagement: 'Orders',
|
||||||
paymentDashboard: 'Payment Dashboard',
|
paymentDashboard: 'Payment Dashboard',
|
||||||
paymentConfig: 'Payment Config',
|
paymentConfig: 'Payment Config',
|
||||||
@ -594,6 +595,11 @@ export default {
|
|||||||
addBalanceWithCode: 'Add balance with a code'
|
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 (shared)
|
||||||
groups: {
|
groups: {
|
||||||
subscription: 'Sub'
|
subscription: 'Sub'
|
||||||
|
|||||||
@ -360,6 +360,7 @@ export default {
|
|||||||
buySubscription: '充值/订阅',
|
buySubscription: '充值/订阅',
|
||||||
docs: '文档',
|
docs: '文档',
|
||||||
myOrders: '我的订单',
|
myOrders: '我的订单',
|
||||||
|
imageStudio: '生图工作台',
|
||||||
orderManagement: '订单管理',
|
orderManagement: '订单管理',
|
||||||
paymentDashboard: '支付概览',
|
paymentDashboard: '支付概览',
|
||||||
paymentConfig: '支付配置',
|
paymentConfig: '支付配置',
|
||||||
@ -593,6 +594,11 @@ export default {
|
|||||||
addBalanceWithCode: '使用兑换码充值'
|
addBalanceWithCode: '使用兑换码充值'
|
||||||
},
|
},
|
||||||
|
|
||||||
|
imageStudio: {
|
||||||
|
title: 'AI 生图工作台',
|
||||||
|
description: '默认调用 gpt-image-2,可直接用你自己的 API Key 出图。'
|
||||||
|
},
|
||||||
|
|
||||||
// Groups (shared)
|
// Groups (shared)
|
||||||
groups: {
|
groups: {
|
||||||
subscription: '订阅'
|
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'
|
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',
|
path: '/redeem',
|
||||||
name: '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