feat(frontend): add image studio page
Some checks failed
CI / test (push) Waiting to run
CI / frontend (push) Waiting to run
CI / golangci-lint (push) Waiting to run
Security Scan / backend-security (push) Has been cancelled
Security Scan / frontend-security (push) Has been cancelled

This commit is contained in:
Hermes Agent
2026-04-26 14:17:56 +08:00
parent 0a80ec80e3
commit 2f70bd4649
7 changed files with 495 additions and 0 deletions

View File

@ -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
? [ ? [

View File

@ -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'

View File

@ -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: '订阅'

View 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')
})
})

View File

@ -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',

View 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>

View 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')
})
})