|
| 1 | +<script setup lang="ts"> |
| 2 | +import { imageServiceOptions } from '@md/shared/configs' |
| 3 | +import { DEFAULT_SERVICE_TYPE } from '@md/shared/constants' |
| 4 | +import { Info } from 'lucide-vue-next' |
| 5 | +import { Button } from '@/components/ui/button' |
| 6 | +import useAIImageConfigStore from '@/stores/AIImageConfig' |
| 7 | +
|
| 8 | +/* -------------------------- 基础数据 -------------------------- */ |
| 9 | +
|
| 10 | +const emit = defineEmits([`saved`]) |
| 11 | +
|
| 12 | +const AIImageConfigStore = useAIImageConfigStore() |
| 13 | +const { type, endpoint, model, apiKey, size, quality, style } |
| 14 | + = storeToRefs(AIImageConfigStore) |
| 15 | +
|
| 16 | +/** 本地草稿 */ |
| 17 | +const config = reactive({ |
| 18 | + type: ``, |
| 19 | + endpoint: ``, |
| 20 | + apiKey: ``, |
| 21 | + model: ``, |
| 22 | + size: `1024x1024`, |
| 23 | + quality: `standard`, |
| 24 | + style: `natural`, |
| 25 | +}) |
| 26 | +
|
| 27 | +/** UI 状态 */ |
| 28 | +const loading = ref(false) |
| 29 | +const testResult = ref(``) |
| 30 | +
|
| 31 | +/** 当前服务信息 */ |
| 32 | +const currentService = computed( |
| 33 | + () => imageServiceOptions.find(s => s.value === config.type) || imageServiceOptions[0], |
| 34 | +) |
| 35 | +
|
| 36 | +/* -------------------------- 同步函数 -------------------------- */ |
| 37 | +
|
| 38 | +function pullFromStore(): void { |
| 39 | + config.type = type.value |
| 40 | + config.endpoint = endpoint.value |
| 41 | + config.apiKey = apiKey.value |
| 42 | + config.model = model.value |
| 43 | + config.size = size.value |
| 44 | + config.quality = quality.value |
| 45 | + config.style = style.value |
| 46 | +} |
| 47 | +
|
| 48 | +function pushToStore(): void { |
| 49 | + type.value = config.type |
| 50 | + apiKey.value = config.apiKey |
| 51 | + model.value = config.model |
| 52 | + size.value = config.size |
| 53 | + quality.value = config.quality |
| 54 | + style.value = config.style |
| 55 | +} |
| 56 | +
|
| 57 | +function handleServiceChange(): void { |
| 58 | + const svc = imageServiceOptions.find(s => s.value === config.type) || imageServiceOptions[0] |
| 59 | +
|
| 60 | + // 更新端点 |
| 61 | + config.endpoint = svc.endpoint |
| 62 | +
|
| 63 | + // 读取或回退模型 |
| 64 | + const saved = localStorage.getItem(`openai_image_model_${config.type}`) || `` |
| 65 | + config.model = svc.models.includes(saved) ? saved : svc.models[0] |
| 66 | +
|
| 67 | + // 重置 API Key |
| 68 | + config.apiKey = localStorage.getItem(`openai_image_key_${config.type}`) || `` |
| 69 | +} |
| 70 | +
|
| 71 | +/* -------------------------- 生命周期 -------------------------- */ |
| 72 | +
|
| 73 | +onMounted(() => { |
| 74 | + pullFromStore() |
| 75 | +}) |
| 76 | +
|
| 77 | +/* -------------------------- 表单提交 -------------------------- */ |
| 78 | +
|
| 79 | +function saveConfig() { |
| 80 | + if (!config.endpoint.trim() || !config.model.trim()) { |
| 81 | + testResult.value = `❌ 请检查配置项是否完整` |
| 82 | + return |
| 83 | + } |
| 84 | +
|
| 85 | + if (config.type !== DEFAULT_SERVICE_TYPE && !config.apiKey.trim()) { |
| 86 | + testResult.value = `❌ 请输入 API Key` |
| 87 | + return |
| 88 | + } |
| 89 | +
|
| 90 | + try { |
| 91 | + // eslint-disable-next-line no-new |
| 92 | + new URL(config.endpoint) |
| 93 | + } |
| 94 | + catch { |
| 95 | + testResult.value = `❌ 端点格式有误` |
| 96 | + return |
| 97 | + } |
| 98 | +
|
| 99 | + if (config.type === DEFAULT_SERVICE_TYPE) { |
| 100 | + config.apiKey = `` |
| 101 | + } |
| 102 | +
|
| 103 | + pushToStore() |
| 104 | + testResult.value = `✅ 配置已保存` |
| 105 | + emit(`saved`) |
| 106 | +} |
| 107 | +
|
| 108 | +function clearConfig() { |
| 109 | + AIImageConfigStore.reset() |
| 110 | + pullFromStore() |
| 111 | + testResult.value = `🗑️ 当前 AI 图像配置已清除` |
| 112 | +} |
| 113 | +
|
| 114 | +async function testConnection() { |
| 115 | + testResult.value = `` |
| 116 | + loading.value = true |
| 117 | +
|
| 118 | + const headers: Record<string, string> = { 'Content-Type': `application/json` } |
| 119 | + if (config.apiKey && config.type !== DEFAULT_SERVICE_TYPE) |
| 120 | + headers.Authorization = `Bearer ${config.apiKey}` |
| 121 | +
|
| 122 | + try { |
| 123 | + const url = new URL(config.endpoint) |
| 124 | + if (!url.pathname.includes(`/images/`) && !url.pathname.endsWith(`/images/generations`)) { |
| 125 | + url.pathname = url.pathname.replace(/\/?$/, `/images/generations`) |
| 126 | + } |
| 127 | +
|
| 128 | + const payload = { |
| 129 | + model: config.model, |
| 130 | + prompt: `test connection`, |
| 131 | + size: config.size, |
| 132 | + quality: config.quality, |
| 133 | + style: config.style, |
| 134 | + n: 1, |
| 135 | + } |
| 136 | +
|
| 137 | + const res = await window.fetch(url.toString(), { |
| 138 | + method: `POST`, |
| 139 | + headers, |
| 140 | + body: JSON.stringify(payload), |
| 141 | + }) |
| 142 | +
|
| 143 | + if (res.ok) { |
| 144 | + testResult.value = `✅ 连接成功` |
| 145 | + } |
| 146 | + else { |
| 147 | + const errorText = await res.text() |
| 148 | + testResult.value = `❌ 连接失败:${res.status} ${errorText}` |
| 149 | + } |
| 150 | + } |
| 151 | + catch (error) { |
| 152 | + testResult.value = `❌ 连接失败:${(error as Error).message}` |
| 153 | + } |
| 154 | + finally { |
| 155 | + loading.value = false |
| 156 | + } |
| 157 | +} |
| 158 | +
|
| 159 | +/* -------------------------- 图像尺寸选项 -------------------------- */ |
| 160 | +
|
| 161 | +const sizeOptions = [ |
| 162 | + { label: `正方形 (1024x1024)`, value: `1024x1024` }, |
| 163 | + { label: `横版 (1792x1024)`, value: `1792x1024` }, |
| 164 | + { label: `竖版 (1024x1792)`, value: `1024x1792` }, |
| 165 | +] |
| 166 | +
|
| 167 | +const qualityOptions = [ |
| 168 | + { label: `标准`, value: `standard` }, |
| 169 | + { label: `高清`, value: `hd` }, |
| 170 | +] |
| 171 | +
|
| 172 | +const styleOptions = [ |
| 173 | + { label: `自然`, value: `natural` }, |
| 174 | + { label: `鲜明`, value: `vivid` }, |
| 175 | +] |
| 176 | +</script> |
| 177 | + |
| 178 | +<template> |
| 179 | + <div class="space-y-4 max-w-full"> |
| 180 | + <div class="text-lg font-semibold border-b pb-2"> |
| 181 | + AI 图像生成配置 |
| 182 | + </div> |
| 183 | + |
| 184 | + <!-- 服务商选择 --> |
| 185 | + <div> |
| 186 | + <label class="text-sm font-medium">服务商</label> |
| 187 | + <select |
| 188 | + v-model="config.type" |
| 189 | + class="w-full mt-1 p-2 border rounded-md bg-background focus:ring-2 focus:ring-primary focus:border-primary transition-colors" |
| 190 | + @change="handleServiceChange" |
| 191 | + > |
| 192 | + <option |
| 193 | + v-for="option in imageServiceOptions" |
| 194 | + :key="option.value" |
| 195 | + :value="option.value" |
| 196 | + > |
| 197 | + {{ option.label }} |
| 198 | + </option> |
| 199 | + </select> |
| 200 | + </div> |
| 201 | + |
| 202 | + <!-- 端点配置 --> |
| 203 | + <div> |
| 204 | + <label class="text-sm font-medium">API 端点</label> |
| 205 | + <input |
| 206 | + v-model="config.endpoint" |
| 207 | + type="url" |
| 208 | + class="w-full mt-1 p-2 border rounded-md bg-background focus:ring-2 focus:ring-primary focus:border-primary transition-colors" |
| 209 | + placeholder="https://api.openai.com/v1" |
| 210 | + readonly |
| 211 | + > |
| 212 | + </div> |
| 213 | + |
| 214 | + <!-- API Key --> |
| 215 | + <div v-if="config.type !== 'default'"> |
| 216 | + <label class="text-sm font-medium">API Key</label> |
| 217 | + <input |
| 218 | + v-model="config.apiKey" |
| 219 | + type="password" |
| 220 | + class="w-full mt-1 p-2 border rounded-md bg-background focus:ring-2 focus:ring-primary focus:border-primary transition-colors" |
| 221 | + placeholder="sk-..." |
| 222 | + > |
| 223 | + </div> |
| 224 | + |
| 225 | + <!-- 模型选择 --> |
| 226 | + <div> |
| 227 | + <label class="text-sm font-medium">模型</label> |
| 228 | + <select |
| 229 | + v-model="config.model" |
| 230 | + class="w-full mt-1 p-2 border rounded-md bg-background focus:ring-2 focus:ring-primary focus:border-primary transition-colors" |
| 231 | + > |
| 232 | + <option |
| 233 | + v-for="modelName in currentService.models" |
| 234 | + :key="modelName" |
| 235 | + :value="modelName" |
| 236 | + > |
| 237 | + {{ modelName }} |
| 238 | + </option> |
| 239 | + </select> |
| 240 | + </div> |
| 241 | + |
| 242 | + <!-- 图像尺寸 --> |
| 243 | + <div> |
| 244 | + <label class="text-sm font-medium">图像尺寸</label> |
| 245 | + <select |
| 246 | + v-model="config.size" |
| 247 | + class="w-full mt-1 p-2 border rounded-md bg-background focus:ring-2 focus:ring-primary focus:border-primary transition-colors" |
| 248 | + > |
| 249 | + <option |
| 250 | + v-for="option in sizeOptions" |
| 251 | + :key="option.value" |
| 252 | + :value="option.value" |
| 253 | + > |
| 254 | + {{ option.label }} |
| 255 | + </option> |
| 256 | + </select> |
| 257 | + </div> |
| 258 | + |
| 259 | + <!-- 图像质量 --> |
| 260 | + <div v-if="config.model.includes('dall-e')"> |
| 261 | + <label class="text-sm font-medium">图像质量</label> |
| 262 | + <select |
| 263 | + v-model="config.quality" |
| 264 | + class="w-full mt-1 p-2 border rounded-md bg-background focus:ring-2 focus:ring-primary focus:border-primary transition-colors" |
| 265 | + > |
| 266 | + <option |
| 267 | + v-for="option in qualityOptions" |
| 268 | + :key="option.value" |
| 269 | + :value="option.value" |
| 270 | + > |
| 271 | + {{ option.label }} |
| 272 | + </option> |
| 273 | + </select> |
| 274 | + </div> |
| 275 | + |
| 276 | + <!-- 图像风格 --> |
| 277 | + <div v-if="config.model.includes('dall-e')"> |
| 278 | + <label class="text-sm font-medium">图像风格</label> |
| 279 | + <select |
| 280 | + v-model="config.style" |
| 281 | + class="w-full mt-1 p-2 border rounded-md bg-background focus:ring-2 focus:ring-primary focus:border-primary transition-colors" |
| 282 | + > |
| 283 | + <option |
| 284 | + v-for="option in styleOptions" |
| 285 | + :key="option.value" |
| 286 | + :value="option.value" |
| 287 | + > |
| 288 | + {{ option.label }} |
| 289 | + </option> |
| 290 | + </select> |
| 291 | + </div> |
| 292 | + |
| 293 | + <!-- 说明 --> |
| 294 | + <div v-if="config.type === 'default'" class="flex items-start gap-2 p-3 bg-blue-50 dark:bg-blue-950/30 rounded-md text-sm"> |
| 295 | + <Info class="h-4 w-4 text-blue-500 mt-0.5 flex-shrink-0" /> |
| 296 | + <div class="text-blue-700 dark:text-blue-300"> |
| 297 | + <p class="font-medium"> |
| 298 | + 默认图像服务 |
| 299 | + </p> |
| 300 | + <p>免费使用,无需配置 API Key,支持 Kwai-Kolors/Kolors 模型。</p> |
| 301 | + </div> |
| 302 | + </div> |
| 303 | + |
| 304 | + <!-- 操作按钮 --> |
| 305 | + <div class="flex flex-wrap gap-2"> |
| 306 | + <Button |
| 307 | + type="button" |
| 308 | + class="flex-1 min-w-[100px]" |
| 309 | + @click="saveConfig" |
| 310 | + > |
| 311 | + 保存配置 |
| 312 | + </Button> |
| 313 | + <Button |
| 314 | + variant="outline" |
| 315 | + type="button" |
| 316 | + class="flex-1 min-w-[80px]" |
| 317 | + @click="clearConfig" |
| 318 | + > |
| 319 | + 清空 |
| 320 | + </Button> |
| 321 | + <Button |
| 322 | + size="sm" |
| 323 | + variant="outline" |
| 324 | + class="flex-1 min-w-[100px]" |
| 325 | + :disabled="loading" |
| 326 | + @click="testConnection" |
| 327 | + > |
| 328 | + {{ loading ? '测试中...' : '测试连接' }} |
| 329 | + </Button> |
| 330 | + </div> |
| 331 | + |
| 332 | + <!-- 测试结果显示 --> |
| 333 | + <div v-if="testResult" class="mt-1 text-xs text-gray-500"> |
| 334 | + {{ testResult }} |
| 335 | + </div> |
| 336 | + </div> |
| 337 | +</template> |
0 commit comments