From 2f70bd4649f36a5cd14639180af321ee0d54f646 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Sun, 26 Apr 2026 14:17:56 +0800 Subject: [PATCH] feat(frontend): add image studio page --- frontend/src/components/layout/AppSidebar.vue | 16 + frontend/src/i18n/locales/en.ts | 6 + frontend/src/i18n/locales/zh.ts | 6 + .../router/__tests__/imageStudioRoute.spec.ts | 14 + frontend/src/router/index.ts | 12 + frontend/src/views/user/ImageStudioView.vue | 303 ++++++++++++++++++ .../user/__tests__/ImageStudioView.spec.ts | 138 ++++++++ 7 files changed, 495 insertions(+) create mode 100644 frontend/src/router/__tests__/imageStudioRoute.spec.ts create mode 100644 frontend/src/views/user/ImageStudioView.vue create mode 100644 frontend/src/views/user/__tests__/ImageStudioView.spec.ts diff --git a/frontend/src/components/layout/AppSidebar.vue b/frontend/src/components/layout/AppSidebar.vue index 92dcc519..b000d812 100644 --- a/frontend/src/components/layout/AppSidebar.vue +++ b/frontend/src/components/layout/AppSidebar.vue @@ -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 ? [ diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 63a50fbe..d18d04f7 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -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' diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 697058ec..5330db3f 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -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: '订阅' diff --git a/frontend/src/router/__tests__/imageStudioRoute.spec.ts b/frontend/src/router/__tests__/imageStudioRoute.spec.ts new file mode 100644 index 00000000..90e3a7ee --- /dev/null +++ b/frontend/src/router/__tests__/imageStudioRoute.spec.ts @@ -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') + }) +}) diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index b97ccb5d..5991be71 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -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', diff --git a/frontend/src/views/user/ImageStudioView.vue b/frontend/src/views/user/ImageStudioView.vue new file mode 100644 index 00000000..581658ff --- /dev/null +++ b/frontend/src/views/user/ImageStudioView.vue @@ -0,0 +1,303 @@ + + + diff --git a/frontend/src/views/user/__tests__/ImageStudioView.spec.ts b/frontend/src/views/user/__tests__/ImageStudioView.spec.ts new file mode 100644 index 00000000..94b5d6b2 --- /dev/null +++ b/frontend/src/views/user/__tests__/ImageStudioView.spec.ts @@ -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('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: '
' } + } + } + }) + 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: '
' } + } + } + }) + 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') + }) +})