diff --git a/components/admin/AiModelManagementPanel.test.tsx b/components/admin/AiModelManagementPanel.test.tsx
new file mode 100644
index 0000000..9f3cc5d
--- /dev/null
+++ b/components/admin/AiModelManagementPanel.test.tsx
@@ -0,0 +1,243 @@
+import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
+import AiModelManagementPanel from './AiModelManagementPanel'
+import type { AiModelAdminState } from '@/lib/ai-model-types'
+
+function response(body: unknown, status = 200) { return { ok: status < 400, status, json: async () => body } as Response }
+const initial = (): AiModelAdminState => ({ providers: [], revision: 0, defaults: { analysis: null, copy: null, image: null }, encryptionConfigured: true, legacyAvailable: true })
+const configured = (): AiModelAdminState => ({
+ ...initial(), revision: 2, legacyAvailable: false,
+ providers: [{ id: 'provider-1', name: '现有上游', baseUrl: 'https://api.example.com', enabled: true, credentialSource: 'environment', hasApiKey: true, revision: 3, syncedAt: null, models: [
+ { key: 'model-text', id: 'gemini-2.5-flash', enabled: true, stages: ['analysis', 'copy'], present: true, compatibility: 'confirmed', capabilities: { kind: 'vision', allowedStages: ['analysis', 'copy'], source: 'verified', reason: '支持图片理解与文字输出' } },
+ { key: 'model-image', id: 'nano-banana-2', enabled: true, stages: ['image'], present: true, compatibility: 'confirmed', capabilities: { kind: 'image', allowedStages: ['image'], source: 'verified', reason: '支持图片生成' } },
+ { key: 'model-new', id: 'new-vision-model', enabled: false, stages: [], present: true, compatibility: 'unconfirmed', capabilities: { kind: 'vision', allowedStages: ['analysis', 'copy'], source: 'upstream', reason: '上游声明图片输入、文字输出' } },
+ { key: 'model-missing', id: 'removed-model', enabled: false, stages: [], present: false, compatibility: 'unconfirmed', capabilities: { kind: 'unknown', allowedStages: [], source: 'unknown', reason: '目录未提供能力信息,待核验' } },
+ ] }],
+ defaults: { analysis: 'model-text', copy: 'model-text', image: 'model-image' },
+})
+
+afterEach(() => jest.restoreAllMocks())
+
+test('显式迁入当前连接并同步上游目录,使用服务器版本号', async () => {
+ const next = configured()
+ global.fetch = jest.fn(async (url: string, init: RequestInit = {}) => {
+ if (url.endsWith('/import-environment')) return response(next)
+ if (url.endsWith('/sync')) return response({ ...next, providers: next.providers.map((provider) => ({ ...provider, revision: 4, syncedAt: '2026-10-12T02:00:00Z' })) })
+ return response(initial())
+ }) as typeof fetch
+ render(
)
+ fireEvent.click(await screen.findByRole('button', { name: '迁入当前连接' }))
+ expect(await screen.findByText('gemini-2.5-flash')).toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: '迁入当前连接' })).not.toBeInTheDocument()
+ fireEvent.click(screen.getByRole('button', { name: '同步 现有上游 的模型目录' }))
+ await screen.findByText('模型目录已同步')
+ const call = (global.fetch as jest.Mock).mock.calls.find(([url]) => url.endsWith('/sync'))
+ expect(call[1].method).toBe('POST')
+ expect(JSON.parse(call[1].body)).toEqual({ revision: 3 })
+ expect((global.fetch as jest.Mock).mock.calls.every(([url]) => !url.includes('generate') && !url.includes('chat'))).toBe(true)
+})
+
+test('搜索与状态筛选仅影响显示,配置保存包含全部模型和各版本号', async () => {
+ const next = configured()
+ global.fetch = jest.fn(async (_url: string, init: RequestInit = {}) => response(init.method === 'PUT' ? { ...next, revision: 3 } : next)) as typeof fetch
+ render(
)
+ await screen.findByText('new-vision-model')
+ fireEvent.change(screen.getByLabelText('搜索模型或上游'), { target: { value: 'new-vision' } })
+ expect(screen.queryByText('removed-model')).not.toBeInTheDocument()
+ const model = screen.getByLabelText('new-vision-model · 现有上游', { selector: 'div' })
+ expect(within(model).getByRole('checkbox', { name: '开放 new-vision-model · 现有上游 给用户' })).toBeDisabled()
+ fireEvent.click(within(model).getByRole('checkbox', { name: '确认 new-vision-model · 现有上游 接口兼容' }))
+ fireEvent.click(within(model).getByRole('checkbox', { name: 'new-vision-model · 现有上游 用于资料分析' }))
+ fireEvent.click(within(model).getByRole('checkbox', { name: '开放 new-vision-model · 现有上游 给用户' }))
+ fireEvent.change(screen.getByLabelText('资料分析默认模型', { exact: false }), { target: { value: 'model-new' } })
+ expect(screen.getByRole('button', { name: '同步 现有上游 的模型目录' })).toBeDisabled()
+ fireEvent.click(screen.getByRole('button', { name: '保存配置' }))
+ await screen.findByText('模型配置已保存')
+ const call = (global.fetch as jest.Mock).mock.calls.find(([, init]) => init.method === 'PUT')
+ const body = JSON.parse(call[1].body)
+ expect(body.revision).toBe(2)
+ expect(body.defaults.analysis).toBe('model-new')
+ expect(body.providers[0].revision).toBe(3)
+ expect(body.providers[0].models).toHaveLength(4)
+ expect(body.providers[0].models.find((entry: any) => entry.key === 'model-new')).toEqual({ key: 'model-new', enabled: true, stages: ['analysis'], compatibility: 'confirmed' })
+ expect(JSON.stringify(body)).not.toContain('apiKey')
+ fireEvent.change(screen.getByLabelText('搜索模型或上游'), { target: { value: '' } })
+ fireEvent.change(screen.getByLabelText('按状态筛选模型'), { target: { value: 'missing' } })
+ expect(screen.getByText('removed-model')).toBeInTheDocument()
+ expect(screen.queryByText('nano-banana-2')).not.toBeInTheDocument()
+})
+
+test('编辑密钥不回显,留空保存不发送 apiKey', async () => {
+ const next = configured()
+ global.fetch = jest.fn(async () => response(next)) as typeof fetch
+ render(
)
+ fireEvent.click(await screen.findByRole('button', { name: '编辑 现有上游' }))
+ const password = screen.getByLabelText('API Key')
+ expect(password).toHaveAttribute('type', 'password')
+ expect(password).toHaveValue('')
+ expect(password).toHaveAttribute('placeholder', '留空保留当前密钥')
+ fireEvent.change(screen.getByLabelText('上游名称'), { target: { value: '更新后的名称' } })
+ fireEvent.click(screen.getByRole('button', { name: '保存上游' }))
+ await screen.findByText('上游已保存')
+ const call = (global.fetch as jest.Mock).mock.calls.find(([, init]) => init.method === 'PATCH')
+ expect(JSON.parse(call[1].body)).toEqual({ name: '更新后的名称', baseUrl: 'https://api.example.com', revision: 3 })
+ expect(screen.queryByLabelText('API Key')).not.toBeInTheDocument()
+})
+
+test('新增上游保存失败保留输入,就近反馈错误,取消清除密钥', async () => {
+ global.fetch = jest.fn(async (_url: string, init: RequestInit = {}) => response(init.method === 'POST' ? { error: '该 API 地址已存在,请编辑已有上游' } : initial(), init.method === 'POST' ? 409 : 200)) as typeof fetch
+ render(
)
+ fireEvent.click(await screen.findByRole('button', { name: '新增上游' }))
+ fireEvent.change(screen.getByLabelText('上游名称'), { target: { value: '新上游' } })
+ fireEvent.change(screen.getByLabelText('API 地址'), { target: { value: 'https://new.example.com' } })
+ fireEvent.change(screen.getByLabelText('API Key'), { target: { value: 'replace-only-test-key' } })
+ fireEvent.submit(screen.getByRole('form', { name: '新增上游连接' }))
+ expect(await screen.findByRole('alert')).toHaveTextContent('该 API 地址已存在,请编辑已有上游')
+ expect(screen.getByLabelText('API Key')).toHaveValue('replace-only-test-key')
+ expect(screen.queryByText('replace-only-test-key')).not.toBeInTheDocument()
+ fireEvent.click(screen.getByRole('button', { name: '取消' }))
+ expect(screen.queryByLabelText('API Key')).not.toBeInTheDocument()
+ fireEvent.click(screen.getByRole('button', { name: '新增上游' }))
+ expect(screen.getByLabelText('API Key')).toHaveValue('')
+})
+
+test('目录同步进行时禁止重复请求和配置修改', async () => {
+ let finish!: (value: Response) => void
+ global.fetch = jest.fn((url: string) => url.endsWith('/sync') ? new Promise
((resolve) => { finish = resolve }) : Promise.resolve(response(configured()))) as typeof fetch
+ render()
+ const sync = await screen.findByRole('button', { name: '同步 现有上游 的模型目录' })
+ fireEvent.click(sync); fireEvent.click(sync)
+ expect(sync).toBeDisabled()
+ expect(screen.getByRole('checkbox', { name: '确认 new-vision-model · 现有上游 接口兼容' })).toBeDisabled()
+ expect((global.fetch as jest.Mock).mock.calls.filter(([url]) => url.endsWith('/sync'))).toHaveLength(1)
+ finish(response(configured()))
+ await waitFor(() => expect(sync).toBeEnabled())
+})
+
+test('缺少加密配置时禁止新增密钥,仍可显式迁入服务端连接', async () => {
+ global.fetch = jest.fn(async () => response({ ...initial(), encryptionConfigured: false })) as typeof fetch
+ render()
+ expect(await screen.findByRole('button', { name: '新增上游' })).toBeDisabled()
+ expect(screen.getByRole('button', { name: '迁入当前连接' })).toBeEnabled()
+ expect(screen.getByText('服务端尚未配置密钥加密,暂不能新增或替换 API Key。')).toBeInTheDocument()
+})
+
+test('目录按 20 条分页,搜索回第一页,保存仍包含不可见模型', async () => {
+ const next = configured()
+ next.providers[0].models = Array.from({ length: 45 }, (_, index) => ({ key: `key-${index}`, id: `catalog-model-${String(index).padStart(2, '0')}`, enabled: false, stages: [], present: true, compatibility: 'unconfirmed', capabilities: { kind: 'vision', allowedStages: ['analysis', 'copy'], source: 'upstream', reason: '支持图片理解与文字输出' } }))
+ next.defaults = { analysis: null, copy: null, image: null }
+ global.fetch = jest.fn(async () => response(next)) as typeof fetch
+ render()
+ await screen.findByText('45 个模型 · 第 1 / 3 页')
+ expect(screen.getByLabelText('catalog-model-00 · 现有上游', { selector: 'div' })).toBeInTheDocument()
+ expect(screen.queryByLabelText('catalog-model-20 · 现有上游', { selector: 'div' })).not.toBeInTheDocument()
+ fireEvent.click(screen.getByRole('button', { name: '下一页' }))
+ expect(screen.getByLabelText('catalog-model-20 · 现有上游', { selector: 'div' })).toBeInTheDocument()
+ fireEvent.change(screen.getByLabelText('搜索模型或上游'), { target: { value: 'catalog-model-44' } })
+ expect(screen.getByText('1 个模型 · 第 1 / 1 页')).toBeInTheDocument()
+ fireEvent.click(screen.getByRole('checkbox', { name: '确认 catalog-model-44 · 现有上游 接口兼容' }))
+ fireEvent.click(screen.getByRole('button', { name: '保存配置' }))
+ await screen.findByText('模型配置已保存')
+ const call = (global.fetch as jest.Mock).mock.calls.find(([, init]) => init.method === 'PUT')
+ expect(JSON.parse(call[1].body).providers[0].models).toHaveLength(45)
+})
+
+test('按能力筛选未开放模型,纯对话不能分配生图或图片分析', async () => {
+ const next = configured()
+ next.providers[0].models[2] = { ...next.providers[0].models[2], id: 'text-chat-only', capabilities: { kind: 'text', allowedStages: ['copy'], source: 'upstream', reason: '仅支持文字输入与文字输出' } }
+ global.fetch = jest.fn(async () => response(next)) as typeof fetch
+ render()
+ fireEvent.change(await screen.findByLabelText('按能力筛选模型'), { target: { value: 'text' } })
+ expect(screen.getByText('text-chat-only')).toBeInTheDocument()
+ expect(screen.queryByText('nano-banana-2')).not.toBeInTheDocument()
+ expect(screen.queryByText('new-vision-model')).not.toBeInTheDocument()
+ const image = screen.getByRole('checkbox', { name: 'text-chat-only · 现有上游 用于图片生成' })
+ expect(image).toBeDisabled()
+ expect(image).toHaveAccessibleDescription('仅支持文字输入与文字输出')
+ expect(image.closest('label')).toHaveAttribute('title', '不支持图片输出')
+ expect(screen.getByRole('checkbox', { name: 'text-chat-only · 现有上游 用于资料分析' })).toBeDisabled()
+ expect(screen.getByRole('checkbox', { name: 'text-chat-only · 现有上游 用于卖点文案' })).toBeEnabled()
+ fireEvent.click(screen.getByRole('checkbox', { name: '确认 text-chat-only · 现有上游 接口兼容' }))
+ expect(image).toBeDisabled()
+ expect(screen.getByRole('checkbox', { name: '开放 text-chat-only · 现有上游 给用户' })).toBeDisabled()
+ fireEvent.click(screen.getByRole('checkbox', { name: 'text-chat-only · 现有上游 用于卖点文案' }))
+ fireEvent.click(screen.getByRole('checkbox', { name: '开放 text-chat-only · 现有上游 给用户' }))
+ const analysis = screen.getByLabelText('资料分析默认模型', { exact: false })
+ const copy = screen.getByLabelText('卖点文案默认模型', { exact: false })
+ expect(within(analysis).queryByRole('option', { name: 'text-chat-only · 现有上游' })).not.toBeInTheDocument()
+ expect(within(copy).getByRole('option', { name: 'text-chat-only · 现有上游' })).toBeInTheDocument()
+})
+
+test('能力待核验模型不能通过人工兼容确认解锁任何用途', async () => {
+ const next = configured()
+ next.providers[0].models[2].capabilities = { kind: 'unknown', allowedStages: [], source: 'unknown', reason: '目录未提供能力信息,待核验' }
+ global.fetch = jest.fn(async () => response(next)) as typeof fetch
+ render()
+ fireEvent.click(await screen.findByRole('checkbox', { name: '确认 new-vision-model · 现有上游 接口兼容' }))
+ for (const label of ['资料分析', '卖点文案', '图片生成']) expect(screen.getByRole('checkbox', { name: `new-vision-model · 现有上游 用于${label}` })).toBeDisabled()
+ expect(screen.getByRole('checkbox', { name: '开放 new-vision-model · 现有上游 给用户' })).toBeDisabled()
+ fireEvent.change(screen.getByLabelText('按能力筛选模型'), { target: { value: 'unknown' } })
+ expect(screen.getByText('new-vision-model')).toBeInTheDocument()
+ expect(screen.queryByText('gemini-2.5-flash')).not.toBeInTheDocument()
+})
+
+test('已识别生图能力但协议未适配时显示待适配,不误称无法生图', async () => {
+ const next = configured()
+ next.providers[0].models[2] = { ...next.providers[0].models[2], id: 'image-other-protocol', capabilities: { kind: 'image', allowedStages: [], source: 'upstream', reason: '具备图片输出能力,当前参考图协议待适配' } }
+ global.fetch = jest.fn(async () => response(next)) as typeof fetch
+ render()
+ const image = await screen.findByRole('checkbox', { name: 'image-other-protocol · 现有上游 用于图片生成' })
+ expect(image).toBeDisabled()
+ expect(image.closest('label')).toHaveAttribute('title', '当前详情页未适配此用途')
+ expect(image).toHaveAccessibleDescription('具备图片输出能力,当前参考图协议待适配')
+ expect(screen.getByText('生图 · 待适配')).toBeInTheDocument()
+ fireEvent.change(screen.getByLabelText('按能力筛选模型'), { target: { value: 'image' } })
+ expect(screen.getByText('image-other-protocol')).toBeInTheDocument()
+ expect(screen.queryByText('gemini-2.5-flash')).not.toBeInTheDocument()
+})
+
+test('取消最后用途同步关闭开放并清空默认值,保存不会携带失效默认模型', async () => {
+ const next = configured()
+ global.fetch = jest.fn(async () => response(next)) as typeof fetch
+ render()
+ fireEvent.click(await screen.findByRole('checkbox', { name: 'nano-banana-2 · 现有上游 用于图片生成' }))
+ expect(screen.getByRole('checkbox', { name: '开放 nano-banana-2 · 现有上游 给用户' })).not.toBeChecked()
+ expect(screen.getByRole('checkbox', { name: '开放 nano-banana-2 · 现有上游 给用户' })).toBeDisabled()
+ expect(screen.getByLabelText('图片生成默认模型', { exact: false })).toHaveValue('')
+ expect(screen.getByLabelText('资料分析默认模型', { exact: false })).toHaveValue('model-text')
+ fireEvent.click(screen.getByRole('button', { name: '保存配置' }))
+ await screen.findByText('模型配置已保存')
+ const call = (global.fetch as jest.Mock).mock.calls.find(([, init]) => init.method === 'PUT')
+ const body = JSON.parse(call[1].body)
+ expect(body.defaults).toEqual({ analysis: 'model-text', copy: 'model-text', image: null })
+ expect(body.providers[0].models.find((entry: any) => entry.key === 'model-image')).toMatchObject({ enabled: false, stages: [] })
+ expect(JSON.stringify(body)).not.toContain('capabilities')
+})
+
+test('关闭开放清空关联默认值,撤销可恢复;取消单一用途保留其它开放用途', async () => {
+ global.fetch = jest.fn(async () => response(configured())) as typeof fetch
+ render()
+ fireEvent.click(await screen.findByRole('checkbox', { name: '开放 gemini-2.5-flash · 现有上游 给用户' }))
+ expect(screen.getByLabelText('资料分析默认模型', { exact: false })).toHaveValue('')
+ expect(screen.getByLabelText('卖点文案默认模型', { exact: false })).toHaveValue('')
+ fireEvent.click(screen.getByRole('button', { name: '撤销' }))
+ expect(screen.getByRole('checkbox', { name: '开放 gemini-2.5-flash · 现有上游 给用户' })).toBeChecked()
+ expect(screen.getByLabelText('资料分析默认模型', { exact: false })).toHaveValue('model-text')
+ fireEvent.click(screen.getByRole('checkbox', { name: 'gemini-2.5-flash · 现有上游 用于资料分析' }))
+ expect(screen.getByRole('checkbox', { name: '开放 gemini-2.5-flash · 现有上游 给用户' })).toBeChecked()
+ expect(screen.getByLabelText('资料分析默认模型', { exact: false })).toHaveValue('')
+ expect(screen.getByLabelText('卖点文案默认模型', { exact: false })).toHaveValue('model-text')
+})
+
+test('旧配置中能力不符的用途允许取消,取消后不能重新选入', async () => {
+ const next = configured()
+ next.providers[0].models[0].stages.push('image')
+ global.fetch = jest.fn(async () => response(next)) as typeof fetch
+ render()
+ const image = await screen.findByRole('checkbox', { name: 'gemini-2.5-flash · 现有上游 用于图片生成' })
+ expect(image).toBeChecked()
+ expect(image).toBeEnabled()
+ fireEvent.click(image)
+ expect(image).not.toBeChecked()
+ expect(image).toBeDisabled()
+ expect(screen.getByRole('checkbox', { name: '开放 gemini-2.5-flash · 现有上游 给用户' })).toBeChecked()
+})
diff --git a/components/admin/AiModelManagementPanel.tsx b/components/admin/AiModelManagementPanel.tsx
new file mode 100644
index 0000000..9e58391
--- /dev/null
+++ b/components/admin/AiModelManagementPanel.tsx
@@ -0,0 +1,185 @@
+'use client'
+
+import { useEffect, useMemo, useRef, useState } from 'react'
+import { Plus, RefreshCw, Save, Search } from 'lucide-react'
+import type { AiModelAdminState, AiModelCatalogEntry, AiModelProvider, AiModelStage } from '@/lib/ai-model-types'
+
+const stages: Array<{ id: AiModelStage; label: string; shortLabel: string }> = [
+ { id: 'analysis', label: '资料分析', shortLabel: '分析' }, { id: 'copy', label: '卖点文案', shortLabel: '文案' }, { id: 'image', label: '图片生成', shortLabel: '生图' },
+]
+const fieldClass = 'min-h-11 w-full min-w-0 rounded-lg border border-[#cfd8e3] bg-white px-3 py-2 text-sm text-[#101828] placeholder:text-[#66758b] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] disabled:bg-[#f8fafc] disabled:text-[#66758b]'
+const buttonClass = 'inline-flex min-h-11 shrink-0 items-center justify-center gap-2 rounded-lg border border-[#cfd8e3] bg-white px-3 text-sm font-medium text-[#344054] transition-colors hover:bg-[#f8fafc] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50'
+const primaryClass = `${buttonClass} !border-[#0050cb] !bg-[#0050cb] !text-white hover:!bg-[#003da3]`
+const checkboxClass = 'h-4 w-4 shrink-0 accent-[#0050cb] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2 disabled:cursor-not-allowed'
+const capabilityLabels = { text: '对话', vision: '视觉理解', image: '生图', other: '其他', unknown: '待核验' } as const
+
+interface ProviderForm { id: string | null; revision: number; name: string; baseUrl: string; apiKey: string }
+
+async function request(path = '', init: RequestInit = {}): Promise {
+ const response = await fetch(`/api/admin/ai-models${path}`, { ...init, cache: 'no-store', headers: { 'Content-Type': 'application/json', ...init.headers } })
+ const body = await response.json().catch(() => ({}))
+ if (!response.ok) throw new Error(typeof body.error === 'string' ? body.error : body.error?.message || '操作未完成,请重试')
+ return body as AiModelAdminState
+}
+
+function usable(provider: AiModelProvider, model: AiModelCatalogEntry, stage: AiModelStage) {
+ return provider.enabled && provider.hasApiKey && model.present && model.enabled && model.compatibility === 'confirmed' && model.stages.includes(stage) && model.capabilities.allowedStages.includes(stage)
+}
+
+export default function AiModelManagementPanel({ onStateChange }: { onStateChange?: (state: { dirty: boolean; busy: boolean }) => void }) {
+ const [state, setState] = useState(null)
+ const [draft, setDraft] = useState(null)
+ const [loading, setLoading] = useState(true)
+ const [busy, setBusy] = useState('')
+ const [error, setError] = useState('')
+ const [notice, setNotice] = useState('')
+ const [formError, setFormError] = useState('')
+ const [form, setForm] = useState(null)
+ const [search, setSearch] = useState('')
+ const [capabilityFilter, setCapabilityFilter] = useState('all')
+ const [statusFilter, setStatusFilter] = useState('all')
+ const [page, setPage] = useState(1)
+ const busyRef = useRef(false)
+ const dirty = !!state && !!draft && JSON.stringify(state) !== JSON.stringify(draft)
+
+ function accept(next: AiModelAdminState) { setState(next); setDraft(next) }
+
+ useEffect(() => {
+ let cancelled = false
+ request().then((next) => { if (!cancelled) accept(next) }).catch((cause) => { if (!cancelled) setError(cause.message) }).finally(() => { if (!cancelled) setLoading(false) })
+ return () => { cancelled = true }
+ }, [])
+
+ useEffect(() => {
+ onStateChange?.({ dirty: dirty || !!form, busy: !!busy })
+ }, [dirty, form, busy, onStateChange])
+
+ useEffect(() => () => { onStateChange?.({ dirty: false, busy: false }) }, [onStateChange])
+
+ useEffect(() => {
+ if (!dirty && !form) return
+ const prevent = (event: BeforeUnloadEvent) => { event.preventDefault(); event.returnValue = '' }
+ window.addEventListener('beforeunload', prevent)
+ return () => window.removeEventListener('beforeunload', prevent)
+ }, [dirty, form])
+
+ async function run(identity: string, action: () => Promise, success: string, closeForm = false) {
+ if (busyRef.current) return
+ busyRef.current = true; setBusy(identity); setError(''); setFormError(''); setNotice('')
+ try {
+ accept(await action())
+ setNotice(success)
+ if (closeForm) setForm(null)
+ } catch (cause) {
+ const message = cause instanceof Error ? cause.message : '操作未完成,请重试'
+ if (identity === 'provider-save') setFormError(message)
+ else setError(message)
+ } finally { busyRef.current = false; setBusy('') }
+ }
+
+ function updateModel(providerId: string, key: string, change: Partial) {
+ setDraft((current) => {
+ if (!current) return current
+ const defaults = { ...current.defaults }
+ const providers = current.providers.map((provider) => provider.id !== providerId ? provider : {
+ ...provider,
+ models: provider.models.map((model) => {
+ if (model.key !== key) return model
+ const updated = { ...model, ...change }
+ if (!updated.stages.some((stage) => updated.capabilities.allowedStages.includes(stage))) updated.enabled = false
+ for (const stage of stages) if (defaults[stage.id] === key && !usable(provider, updated, stage.id)) defaults[stage.id] = null
+ return updated
+ }),
+ })
+ return { ...current, providers, defaults }
+ })
+ setError(''); setNotice('')
+ }
+
+ function saveProvider(event: React.FormEvent) {
+ event.preventDefault()
+ if (!form) return
+ const body = { name: form.name.trim(), baseUrl: form.baseUrl.trim(), ...(form.apiKey.trim() ? { apiKey: form.apiKey.trim() } : {}), ...(form.id ? { revision: form.revision } : {}) }
+ void run('provider-save', () => request(form.id ? `/providers/${encodeURIComponent(form.id)}` : '/providers', { method: form.id ? 'PATCH' : 'POST', body: JSON.stringify(body) }), form.id ? '上游已保存' : '上游已添加,请同步模型目录', true)
+ }
+
+ const rows = useMemo(() => (draft?.providers || []).flatMap((provider) => provider.models.map((model) => ({ provider, model }))), [draft])
+ const visibleRows = rows.filter(({ provider, model }) => {
+ const query = search.trim().toLowerCase()
+ if (query && !`${model.id} ${provider.name}`.toLowerCase().includes(query)) return false
+ if (capabilityFilter !== 'all' && model.capabilities.kind !== capabilityFilter) return false
+ if (statusFilter === 'enabled' && !model.enabled) return false
+ if (statusFilter === 'disabled' && model.enabled) return false
+ if (statusFilter === 'missing' && model.present) return false
+ if (statusFilter === 'unconfirmed' && model.compatibility !== 'unconfirmed') return false
+ return true
+ })
+ const providerBlocked = !!busy || dirty || !!form
+ const pageCount = Math.max(1, Math.ceil(visibleRows.length / 20))
+ const currentPage = Math.min(page, pageCount)
+ const pageRows = visibleRows.slice((currentPage - 1) * 20, currentPage * 20)
+
+ if (loading) return
+ if (!draft) return {error || 'AI 模型配置未能加载'}
+
+ return
+
+
AI 模型接入
+
+ {draft.legacyAvailable &&
}
+
+
+
+ {!draft.encryptionConfigured && 服务端尚未配置密钥加密,暂不能新增或替换 API Key。
}
+ {(error || notice) && {error || notice}
}
+
+ 上游连接
+ {!draft.providers.length && !form && {draft.legacyAvailable ? '迁入当前连接,或新增上游后同步模型目录。' : '新增上游后同步模型目录。'}
}
+
+ {draft.providers.map((provider) =>
+
{provider.name}{provider.enabled ? '已启用' : '已停用'}
{provider.baseUrl}
{provider.hasApiKey ? (provider.credentialSource === 'environment' ? '使用服务端密钥' : '密钥已安全保存') : '未配置密钥'} · {provider.models.filter((model) => model.present).length} 个目录模型{provider.syncedAt ? ` · 同步于 ${new Date(provider.syncedAt).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })}` : ' · 尚未同步'}
+
+
)}
+
+ {form && }
+
+
+ 用户可选模型
{dirty && 有未保存修改}
+
+ {!!rows.length && <>
+
+ 用户可自行选择已开放的模型;不支持的用途不可勾选。
+ 模型 / 上游开放给用户用户可选用途接口兼容
+
+ {!visibleRows.length &&
没有匹配模型
}
+ {pageRows.map(({ provider, model }) =>
)}
+
+
+ >}
+
+
+}
diff --git a/components/detail-generator/DetailGeneratorWorkspace.test.tsx b/components/detail-generator/DetailGeneratorWorkspace.test.tsx
index c443704..baf268c 100644
--- a/components/detail-generator/DetailGeneratorWorkspace.test.tsx
+++ b/components/detail-generator/DetailGeneratorWorkspace.test.tsx
@@ -17,7 +17,7 @@ const config: DetailConfig = {
const createProject = (): DetailProject => ({
id: 'project-a', ownerId: 'superadmin-a', revision: 3,
createdAt: '2026-10-12T00:00:00Z', updatedAt: '2026-10-12T00:00:00Z',
- document: { ...newDetailDocument(), name: '座椅项目', product: { ...newDetailDocument().product, name: '橡木座椅', facts: '材质:橡木', assetIds: ['product-image'] }, brand: { ...newDetailDocument().brand, name: '品牌甲' } },
+ document: { ...newDetailDocument(DETAIL_MODEL_DEFAULTS), name: '座椅项目', product: { ...newDetailDocument().product, name: '橡木座椅', facts: '材质:橡木', assetIds: ['product-image'] }, brand: { ...newDetailDocument().brand, name: '品牌甲' } },
})
const createJob = (status: DetailJob['status']): DetailJob => ({ id: 'job-a', projectId: 'project-a', ownerId: 'superadmin-a', kind: 'generate', status, payload: {}, checkpoint: {}, progress: 0, error: null, requestId: null, createdAt: '2026-10-12T00:00:00Z', updatedAt: '2026-10-12T00:00:00Z' })
function response(body: unknown, status = 200) { return { ok: status >= 200 && status < 300, status, json: async () => body } as Response }
@@ -51,6 +51,58 @@ test('模型未配置时仍可准备资料,不能发起模型调用', async ()
expect(fetchMock.mock.calls.some(([, init]) => init?.method === 'POST')).toBe(false)
})
+test('新项目使用管理员阶段默认值,模型选择显示原模型和上游名称', async () => {
+ const dynamic = { ...config, defaults: { analysis: 'model-a', copy: 'model-c', image: 'model-i' }, models: { analysis: ['model-a'], copy: ['model-c'], image: ['model-i'] }, modelOptions: {
+ analysis: [{ value: 'model-a', label: 'vision-analysis', providerName: '上游甲' }], copy: [{ value: 'model-c', label: 'copy-text', providerName: '上游乙' }], image: [{ value: 'model-i', label: 'image-reference', providerName: '上游甲' }],
+ } }
+ global.fetch = jest.fn(async () => response({ config: dynamic, projects: [] })) as typeof fetch
+ render()
+ const analysis = await screen.findByLabelText('资料分析', { exact: true })
+ expect(analysis).toHaveValue('model-a')
+ expect(screen.getByLabelText('卖点文案', { exact: true })).toHaveValue('model-c')
+ expect(screen.getByLabelText('图片生成', { exact: true })).toHaveValue('model-i')
+ expect(within(analysis).getByRole('option', { name: 'vision-analysis · 上游甲' })).toBeInTheDocument()
+})
+
+test('用户可在管理员开放范围内自主更换模型,保留其他阶段选择', async () => {
+ const dynamic = { ...config, defaults: { analysis: 'model-a', copy: 'model-c', image: 'model-i' }, models: { analysis: ['model-a'], copy: ['model-c'], image: ['model-i', 'model-i-other'] }, modelOptions: {
+ analysis: [{ value: 'model-a', label: 'vision-analysis', providerName: '上游甲' }], copy: [{ value: 'model-c', label: 'copy-text', providerName: '上游乙' }], image: [{ value: 'model-i', label: 'image-default', providerName: '上游甲' }, { value: 'model-i-other', label: 'image-alternative', providerName: '上游乙' }],
+ } }
+ global.fetch = jest.fn(async () => response({ config: dynamic, projects: [] })) as typeof fetch
+ render()
+ const image = await screen.findByLabelText('图片生成', { exact: true })
+ expect(screen.getByText('模型选择', { selector: 'summary' })).toBeInTheDocument()
+ expect(within(image).getAllByRole('option').map((option) => (option as HTMLOptionElement).value)).toEqual(['model-i', 'model-i-other'])
+ expect(within(image).queryByRole('option', { name: 'copy-text · 上游乙' })).not.toBeInTheDocument()
+ fireEvent.change(image, { target: { value: 'model-i-other' } })
+ expect(image).toHaveValue('model-i-other')
+ expect(screen.getByLabelText('资料分析', { exact: true })).toHaveValue('model-a')
+ expect(screen.getByLabelText('卖点文案', { exact: true })).toHaveValue('model-c')
+ expect((global.fetch as jest.Mock).mock.calls.some(([, init]) => init?.method === 'POST')).toBe(false)
+})
+
+test('旧项目模型已停用时保留原选择和文字,可编辑导出但不重新调用模型', async () => {
+ const project = createProject()
+ project.document.approvedAt = '2026-10-12T01:00:00Z'
+ project.document.analysis = { sellingPoints: ['橡木'], warnings: [], screens: [{ id: 'screen-a', title: '橡木', body: '', imagePrompt: '橡木座椅' }] }
+ project.document.screens = [{ id: 'screen-a', title: '橡木', body: '', imagePrompt: '橡木座椅', width: 750, height: 1000, backgroundColor: '#ffffff', texts: [], image: { assetId: 'product-image', x: 0, y: 0, width: 750, height: 1000 } }]
+ const fetchMock = jest.fn(async (url: string) => url === '/api/detail-generator' ? response({ config: { ...config, models: { analysis: [], copy: [], image: ['new-image'] } }, projects: [project] }) : response({ project, assets: [], jobs: [] }))
+ global.fetch = fetchMock as typeof fetch
+ render()
+ fireEvent.click(await screen.findByRole('button', { name: /座椅项目/ }))
+ expect(await screen.findByRole('button', { name: '重新生成此屏' })).toBeDisabled()
+ expect(screen.getByRole('button', { name: '导出' })).toBeEnabled()
+ expect(screen.getByLabelText('替换当前屏图片')).toBeEnabled()
+ expect(screen.getByText('项目模型当前不可用。可修改模型选择,已有内容仍可编辑和导出。')).toBeInTheDocument()
+ expect(fetchMock.mock.calls.some(([, init]: any) => init?.method === 'POST')).toBe(false)
+})
+
+test('已显式迁入且仍允许的 legacy 模型保留原选择并可执行分析', async () => {
+ await openProject(createProject(), [], undefined, { ...config, models: { analysis: ['model-a'], copy: ['model-a'], image: ['model-i'] }, legacyModels: { analysis: ['gemini-2.5-flash'], copy: ['gemini-2.5-flash'], image: ['nano-banana-2'] } })
+ expect(screen.getByLabelText('资料分析', { exact: true })).toHaveValue('gemini-2.5-flash')
+ expect(screen.getByRole('button', { name: '分析卖点与大纲' })).toBeEnabled()
+})
+
test('保存遇到版本冲突时保留用户未保存的产品信息', async () => {
const fetchMock = await openProject(createProject(), [], (_url, init) => init.method === 'PATCH' ? response({ error: '项目已被更新,请重新打开后合并修改' }, 409) : undefined)
fireEvent.change(screen.getByLabelText('已确认的产品信息'), { target: { value: '新增真实参数:座高 45cm' } })
@@ -105,6 +157,34 @@ test('分析完成后必须确认大纲才能生成,确认后分别调用审
expect(JSON.parse(String(generation?.[1]?.body)).idempotencyKey).toEqual(expect.any(String))
})
+test('确认大纲前更换图片模型,先保存选择再提交生成', async () => {
+ let project = createProject()
+ project.document.analysis = { sellingPoints: ['橡木材质'], warnings: [], screens: [{ id: 'screen-a', title: '橡木座椅', body: '自然质感', imagePrompt: '浅色环境中的橡木座椅,不生成文字' }] }
+ const fetchMock = jest.fn(async (url: string, init: RequestInit = {}) => {
+ if (url === '/api/detail-generator') return response({ config: { ...config, models: { ...config.models, image: ['nano-banana-2', 'model-new-image'] } }, projects: [project] })
+ if (init.method === 'PATCH') { project = { ...project, revision: 4, document: JSON.parse(String(init.body)).document }; return response({ project }) }
+ if (url.endsWith('/approve')) return response({ project: { ...project, revision: 5, document: { ...project.document, approvedAt: '2026-10-12T01:00:00Z' } } })
+ if (url.endsWith('/generate')) return response({ job: createJob('queued') })
+ return response({ project, assets: [], jobs: [] })
+ })
+ global.fetch = fetchMock as typeof fetch
+ render()
+ fireEvent.click(await screen.findByRole('button', { name: /座椅项目/ }))
+ await screen.findByRole('button', { name: '确认大纲并生成' })
+ fireEvent.change(await screen.findByLabelText('图片生成', { exact: true }), { target: { value: 'model-new-image' } })
+ fireEvent.click(screen.getByRole('button', { name: '确认大纲并生成' }))
+ await waitFor(() => expect(fetchMock.mock.calls.some(([url]) => url.endsWith('/generate'))).toBe(true))
+ const writes = fetchMock.mock.calls.filter((call): call is [string, RequestInit] => !!call[1] && (call[1].method === 'PATCH' || call[1].method === 'POST'))
+ expect(writes.map(([url]) => url)).toEqual(['/api/detail-generator/projects/project-a', '/api/detail-generator/projects/project-a/approve', '/api/detail-generator/projects/project-a/generate'])
+ const saveCall = writes.find(([, init]) => init.method === 'PATCH')
+ const approveCall = writes.find(([url]) => url.endsWith('/approve'))
+ expect(saveCall).toBeDefined()
+ expect(approveCall).toBeDefined()
+ if (!saveCall || !approveCall) throw new Error('模型选择必须先保存,再确认大纲')
+ expect(JSON.parse(String(saveCall[1].body)).document.models.image).toBe('model-new-image')
+ expect(JSON.parse(String(approveCall[1].body)).revision).toBe(4)
+})
+
test('结果未知时停止生成与导出操作,不自动重新请求模型', async () => {
const project = createProject()
project.document.approvedAt = '2026-10-12T01:00:00Z'
diff --git a/components/detail-generator/DetailGeneratorWorkspace.tsx b/components/detail-generator/DetailGeneratorWorkspace.tsx
index 9dafd2e..cc4f73a 100644
--- a/components/detail-generator/DetailGeneratorWorkspace.tsx
+++ b/components/detail-generator/DetailGeneratorWorkspace.tsx
@@ -5,10 +5,10 @@ import { ArrowLeft, Check, Download, Loader2, Plus, Save, WandSparkles } from 'l
import Link from 'next/link'
import type { DetailAsset, DetailAssetKind, DetailBrandProfileRecord, DetailConfig, DetailDocument, DetailExportFormat, DetailJob, DetailProject, DetailScreen } from '@/lib/detail-generator/types'
import { reflowDetailScreen } from '@/lib/detail-generator/layout'
-import DetailProductForm, { detailButtonClass, detailPrimaryClass } from './DetailProductForm'
+import DetailProductForm, { DetailModelOptions, detailButtonClass, detailPrimaryClass } from './DetailProductForm'
import DetailOutlineEditor from './DetailOutlineEditor'
import DetailScreenEditor from './DetailScreenEditor'
-import { actionKey, assetUrl, DetailClientError, detailRequest, newDetailDocument } from './client'
+import { actionKey, assetUrl, DetailClientError, detailModelsAvailable, detailRequest, newDetailDocument } from './client'
import { useUnsavedDetailGuard } from './useUnsavedDetailGuard'
interface ProjectDetail { project: DetailProject; assets: DetailAsset[]; jobs: DetailJob[] }
@@ -41,6 +41,8 @@ export default function DetailGeneratorWorkspace() {
const currentProjectId = project?.id
const unknownJob = jobs.find((job) => job.status === 'unknown')
const unavailable = busy || !!activeJob || locked
+ const analysisAvailable = detailModelsAvailable(config, draft.models, ['analysis', 'copy'])
+ const imageAvailable = detailModelsAvailable(config, draft.models, ['image'])
const updateProject = useCallback((next: DetailProject) => {
pendingCanvasRef.current.clear()
@@ -79,6 +81,7 @@ export default function DetailGeneratorWorkspace() {
const result = await detailRequest<{ config: DetailConfig; projects: DetailProject[] }>('')
if (cancelled) return
setConfig(result.config)
+ if (!selectedRef.current) setDraft(newDetailDocument(result.config.defaults))
setProjects(result.projects)
const requested = new URLSearchParams(window.location.search).get('project')
if (requested && result.projects.some((item) => item.id === requested)) await selectProject(requested)
@@ -144,6 +147,7 @@ export default function DetailGeneratorWorkspace() {
}
function analyze() {
+ if (!analysisAvailable) return
void run(async () => {
if (!draft.product.name.trim()) throw new Error('请填写产品名称')
if (!draft.product.facts.trim() && !draft.product.documentAssetIds.length) throw new Error('请填写真实产品信息或上传产品资料')
@@ -155,10 +159,11 @@ export default function DetailGeneratorWorkspace() {
}
function approveAndGenerate() {
+ if (!imageAvailable) return
void run(async () => {
if (!draft.analysis?.screens.length) throw new Error('请至少保留一屏大纲')
if (draft.analysis.screens.some((screen) => !screen.title.trim() || !screen.imagePrompt.trim())) throw new Error('请补全各屏标题与画面要求')
- const saved = project || await saveDocument()
+ const saved = dirty || !project ? await saveDocument() : project
const result = await detailRequest<{ project: DetailProject }>(`/projects/${encodeURIComponent(saved.id)}/approve`, { method: 'POST', body: JSON.stringify({ analysis: draft.analysis, revision: saved.revision }) })
updateProject(result.project)
await queueJob(result.project, '/generate', 'generate')
@@ -256,7 +261,7 @@ export default function DetailGeneratorWorkspace() {
})
}
- function regenerate(screenId: string) { void run(async () => { if (project) await queueJob(project, `/screens/${encodeURIComponent(screenId)}`, `regenerate:${screenId}`) }) }
+ function regenerate(screenId: string) { if (imageAvailable) void run(async () => { if (project) await queueJob(project, `/screens/${encodeURIComponent(screenId)}`, `regenerate:${screenId}`) }) }
function recordPendingText(screenId: string, layerId: string, text: string) {
const pending = pendingCanvasRef.current.get(screenId) || new Map()
pending.set(layerId, text)
@@ -280,7 +285,7 @@ export default function DetailGeneratorWorkspace() {
function exportProject(format: DetailExportFormat) { void run(async () => { if (project) await queueJob(project, '/exports', `export:${format}`, { format }) }) }
function newProject() {
pendingCanvasRef.current.clear()
- setProject(null); selectedRef.current = null; setDraft(newDetailDocument()); setAssets([]); setJobs([]); setDirty(false); setMatches(null); setError(''); setNotice('')
+ setProject(null); selectedRef.current = null; setDraft(newDetailDocument(config?.defaults)); setAssets([]); setJobs([]); setDirty(false); setMatches(null); setError(''); setNotice('')
window.history.replaceState(null, '', '/assets/detail-generator')
}
@@ -294,15 +299,17 @@ export default function DetailGeneratorWorkspace() {
详情页生成
内测{dirty &&
有未保存修改}
{(error || notice) && {error || notice}{error && }
}
{!config?.configured && 模型服务尚未配置。可先准备项目资料。
}
+ {config?.configured && (!analysisAvailable || !imageAvailable) && 项目模型当前不可用。可修改模型选择,已有内容仍可编辑和导出。
}
{activeJob &&
{jobLabel[activeJob.kind]}{activeJob.status === 'queued' ? ' · 等待执行' : ' · 进行中'}{activeJob.kind === 'generate' && draft.analysis ? ` · ${draft.screens.filter((screen) => screen.image).length}/${draft.analysis.screens.length} 屏` : ''}
}
{unknownJob &&
模型结果待确认
{unknownJob.error || '请求已经发出,结果尚未确认。请勿重复生成。'}
{unknownJob.requestId &&
请求编号:{unknownJob.requestId}
}
}
{latestFailure && !activeJob && !unknownJob &&
{jobLabel[latestFailure.kind]}未完成:{latestFailure.error || '请检查项目资料后重试'}
}
- {!draft.analysis && !draft.approvedAt ? <>
{matches &&
匹配素材
{!matches.length && 没有找到当前可用的素材,可直接上传产品图。
}{matches.map((match) => {match.variant || match.skuCode || match.productModel}
{match.assets.map((asset) =>
)}
)}}
> : !draft.approvedAt && draft.analysis ? <>
changeDocument({ ...draft, analysis })} />> : <> !!screen.image)} busy={unavailable || !!unknownJob} dirty={dirty} onChange={changeScreen} onUnsavedEdit={() => setDirty(true)} onPendingTextChange={recordPendingText} onSave={saveScreen} onReplaceImage={replaceImage} onRegenerate={regenerate} onExport={exportProject} />{draft.screens.filter((screen) => screen.image).length < (draft.analysis?.screens.length || 1) && !activeJob && }>}
+ {draft.analysis &&
}
+ {!draft.analysis && !draft.approvedAt ? <>{matches && 匹配素材
{!matches.length && 没有找到当前可用的素材,可直接上传产品图。
}{matches.map((match) => {match.variant || match.skuCode || match.productModel}
{match.assets.map((asset) =>
)}
)}}> : !draft.approvedAt && draft.analysis ? <> changeDocument({ ...draft, analysis })} />> : <> !!screen.image)} busy={unavailable || !!unknownJob} dirty={dirty} onChange={changeScreen} onUnsavedEdit={() => setDirty(true)} onPendingTextChange={recordPendingText} onSave={saveScreen} onReplaceImage={replaceImage} onRegenerate={regenerate} onExport={exportProject} />{draft.screens.filter((screen) => screen.image).length < (draft.analysis?.screens.length || 1) && !activeJob && }>}
{!!exportJobs.length && }
- {dirty && !unavailable && }
+ {dirty && !unavailable && }
diff --git a/components/detail-generator/DetailProductForm.tsx b/components/detail-generator/DetailProductForm.tsx
index 04366cd..d076827 100644
--- a/components/detail-generator/DetailProductForm.tsx
+++ b/components/detail-generator/DetailProductForm.tsx
@@ -79,10 +79,25 @@ export default function DetailProductForm({ document, config, assets, busy, onCh
{([{ key: 'primaryColor', label: '品牌色' }, { key: 'backgroundColor', label: '背景色' }, { key: 'textColor', label: '文字色' }] as const).map((color) => )}
-
- 高级选项
- {([{ key: 'analysis', label: '资料分析' }, { key: 'copy', label: '卖点文案' }, { key: 'image', label: '图片生成' }] as const).map((stage) => )}
-
+
)
}
+
+export function DetailModelOptions({ document, config, busy, onChange }: {
+ document: DetailDocument
+ config: DetailConfig | null
+ busy: boolean
+ onChange: (document: DetailDocument) => void
+}) {
+ return
+}
diff --git a/components/detail-generator/client.ts b/components/detail-generator/client.ts
index 04c6021..e5c9b36 100644
--- a/components/detail-generator/client.ts
+++ b/components/detail-generator/client.ts
@@ -1,4 +1,4 @@
-import { DETAIL_FONT_FAMILY, DETAIL_MODEL_DEFAULTS, type DetailDocument } from '@/lib/detail-generator/types'
+import { DETAIL_FONT_FAMILY, type DetailConfig, type DetailDocument } from '@/lib/detail-generator/types'
export class DetailClientError extends Error {
constructor(message: string, readonly status: number) { super(message) }
@@ -15,19 +15,23 @@ export async function detailRequest