feat: 对接生图功能

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
2026-04-25 19:14:58 +08:00
parent 5cb72f8579
commit 223845b0ed
12 changed files with 268 additions and 30 deletions
+65
View File
@@ -0,0 +1,65 @@
<script setup lang="ts">
import { ref } from "vue";
import { ImageService } from "~/services";
const prompt = ref("");
const imageUrl = ref("");
const errorMessage = ref("");
const loading = ref(false);
const generate = async () => {
const text = prompt.value.trim();
if (!text) {
errorMessage.value = "请输入图片描述";
return;
}
loading.value = true;
errorMessage.value = "";
imageUrl.value = "";
try {
const res = await ImageService.GenerateImage({
prompt: text
});
if (res.code === 0 && res.data?.imageUrl) {
imageUrl.value = res.data.imageUrl;
return;
}
errorMessage.value = res.msg || "图片生成失败";
} catch (error) {
errorMessage.value = getErrorMessage(error, "图片生成失败");
} finally {
loading.value = false;
}
};
</script>
<template>
<div class="w-full max-w-xl space-y-3">
<el-input
v-model="prompt"
type="textarea"
:rows="3"
placeholder="请输入图片描述"
:disabled="loading"
/>
<el-button type="primary" :loading="loading" @click="generate">
立即生成
</el-button>
<p v-if="errorMessage" class="text-sm text-red-500">
{{ errorMessage }}
</p>
<img
v-if="imageUrl"
:src="imageUrl"
alt="生成图片"
class="block max-w-full rounded border"
/>
</div>
</template>
+3 -6
View File
@@ -1,14 +1,11 @@
<script setup lang="ts">
import { useUserStore } from "~/stores";
definePageMeta({
layout: "index-view"
});
const userStore = useUserStore();
const { isOnline } = storeToRefs(userStore);
</script>
<template>
<div>123</div>
<div class="py-2">
<ImageGenerateCom />
</div>
</template>
+19
View File
@@ -0,0 +1,19 @@
import type {
ICommonResponse,
IImageGenerateData,
IImageGenerateRequest
} from "#shared/types";
export class ImageService {
public static basePath = "/api/images";
public static GenerateImage(request: IImageGenerateRequest) {
return $fetch<ICommonResponse<IImageGenerateData>>(
`${ImageService.basePath}/generate`,
{
method: "POST",
body: request
}
);
}
}
+1
View File
@@ -1 +1,2 @@
export * from "./image_service";
export * from "./user_service";
+1 -17
View File
@@ -1,4 +1,5 @@
import type { IUserReadyData } from "#shared/types";
import { isUnauthorizedError } from "~~/server/utils";
export default defineEventHandler(async (event) => {
try {
@@ -17,20 +18,3 @@ export default defineEventHandler(async (event) => {
return createUpstreamErrorResponse(error, "环境准备失败");
}
});
/** 鉴权失败时清理本地 cookie,避免继续携带失效的 NewAPI 登录态 */
const isUnauthorizedError = (error: unknown): boolean => {
const fetchError = error as {
response?: {
status?: number;
};
status?: number;
statusCode?: number;
};
return (
fetchError.statusCode === 401 ||
fetchError.status === 401 ||
fetchError.response?.status === 401
);
};
+44
View File
@@ -0,0 +1,44 @@
import { consola } from "consola";
import type {
IImageGenerateData,
IImageGenerateRequest
} from "#shared/types/openai";
import { isUnauthorizedError } from "~~/server/utils";
export default defineEventHandler(async (event) => {
const requestBody = await readBody<Partial<IImageGenerateRequest> | null>(
event
);
// 只接受 JSON 对象,避免数组、字符串等无效 body 被用于生成请求。
if (
requestBody !== null &&
(typeof requestBody !== "object" || Array.isArray(requestBody))
) {
return createErrorResponse(400, "请求体必须是 JSON 对象");
}
const prompt = requestBody?.prompt?.trim();
if (!prompt) {
return createErrorResponse(400, "请输入图片描述");
}
try {
const apiKey = await getAiArtStudioTokenKey(event);
const result = await askImg({
apiKey,
prompt
});
consola.info("图片生成成功", { prompt, imageUrl: result.imageUrl });
return createSuccessResponse<IImageGenerateData>(result, "图片生成成功");
} catch (error) {
if (isUnauthorizedError(error)) {
clearNewApiAuthCookies(event);
return createErrorResponse(401, "未登录");
}
return createUpstreamErrorResponse(error, "图片生成失败");
}
});
+17
View File
@@ -71,3 +71,20 @@ export const createUpstreamErrorResponse = (
errorData
);
};
/** 判断上游或本地鉴权错误是否为 401,用于统一清理失效登录态 */
export const isUnauthorizedError = (error: unknown): boolean => {
const fetchError = error as {
response?: {
status?: number;
};
status?: number;
statusCode?: number;
};
return (
fetchError.statusCode === 401 ||
fetchError.status === 401 ||
fetchError.response?.status === 401
);
};
+1
View File
@@ -2,3 +2,4 @@ export * from "./createApiResponse";
export * from "./fetch";
export * from "./newApiAuthCookies";
export * from "./newApiTokens";
export * from "./openai";
+49 -6
View File
@@ -41,6 +41,11 @@ interface INewApiWrappedResponse<T> {
success?: boolean;
}
interface INewApiTokenKeyData {
/** NewAPI 返回的完整 Token Key,仅允许服务端内部使用 */
key: string;
}
const AI_ART_STUDIO_TOKEN_PAYLOAD = {
remain_quota: 0,
remain_amount: 0,
@@ -58,8 +63,8 @@ const AI_ART_STUDIO_TOKEN_PAYLOAD = {
export const ensureAiArtStudioToken = async (
event: H3Event
): Promise<IUserReadyData> => {
const hasToken = await hasActiveAiArtStudioToken(event);
if (hasToken) {
const token = await findActiveAiArtStudioToken(event);
if (token) {
return {
ready: true,
created: false
@@ -74,8 +79,45 @@ export const ensureAiArtStudioToken = async (
};
};
/** 确保 AIArtStudio Token 可用,并获取完整 key,完整 key 不允许透出到前端 */
export const getAiArtStudioTokenKey = async (
event: H3Event
): Promise<string> => {
let token = await findActiveAiArtStudioToken(event);
if (!token) {
await createAiArtStudioToken(event);
token = await findActiveAiArtStudioToken(event);
}
if (!token) {
throw createError({
statusCode: 502,
statusMessage: "环境初始化失败"
});
}
const response = await newApiAuthedFetch<
INewApiWrappedResponse<INewApiTokenKeyData>
>(event, `/api/token/${token.id}/key`, {
method: "POST"
});
const data = unwrapNewApiResponse(response, "环境读取失败");
if (typeof data.key !== "string" || !data.key) {
throw createError({
statusCode: 502,
statusMessage: "环境读取失败"
});
}
return data.key;
};
/** 分页读取 Token 列表,只认名称为 AIArtStudio 且状态启用的未删除 Token */
const hasActiveAiArtStudioToken = async (event: H3Event): Promise<boolean> => {
const findActiveAiArtStudioToken = async (
event: H3Event
): Promise<INewApiTokenItem | null> => {
let page = 1;
let total = Number.POSITIVE_INFINITY;
@@ -87,15 +129,16 @@ const hasActiveAiArtStudioToken = async (event: H3Event): Promise<boolean> => {
});
const data = unwrapNewApiResponse(response, "环境检查失败");
if (data.items.some(isActiveAiArtStudioToken)) {
return true;
const token = data.items.find(isActiveAiArtStudioToken);
if (token) {
return token;
}
total = data.total;
page += 1;
}
return false;
return null;
};
/** 创建默认 AIArtStudio Token,参数固定,避免 handler 内重复维护 */
+49 -1
View File
@@ -1,6 +1,22 @@
// server/utils/openai.ts
import OpenAI from "openai";
import type { BaseOptions } from "#shared/types/openai";
import type { BaseOptions, IImageGenerateData } from "#shared/types/openai";
const IMAGE_GENERATION_URL = "https://api.qflink.xyz/v1/images/generations";
const IMAGE_GENERATION_MODEL = "gpt-image-2";
const IMAGE_GENERATION_SIZE = "1024x1024";
interface IImageGenerationResponse {
/** 上游创建时间 */
created?: number;
/** 上游图片生成结果列表 */
data?: Array<{
/** 上游返回的修订提示词,可能为空 */
revised_prompt?: string;
/** 生成图片地址 */
url?: string;
}>;
}
/** 通用 AI 调用函数(支持文本 / 图文 / 多模态) */
export const askAI = async ({
@@ -84,6 +100,38 @@ export const askVision = async (
return res.output_text;
};
/** 调用图片生成接口,完整 API Key 只在服务端使用 */
export const askImg = async ({
apiKey,
prompt
}: {
apiKey: string;
prompt: string;
}): Promise<IImageGenerateData> => {
const result = await $fetch<IImageGenerationResponse>(IMAGE_GENERATION_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json"
},
body: {
model: IMAGE_GENERATION_MODEL,
prompt,
size: IMAGE_GENERATION_SIZE
}
});
const image = result.data?.[0];
if (!image?.url) {
throw new Error("图片生成失败");
}
return {
imageUrl: image.url,
revisedPrompt: image.revised_prompt
};
};
/** 调用流式接口 */
export const askStream = async (
options: BaseOptions & {
+1
View File
@@ -17,4 +17,5 @@ export type ISuccessResponse<T = any> = ICommonResponse<T> & { code: 0 };
/** 语义化的错误响应类型 */
export type IErrorResponse = ICommonResponse<null> & { code: number };
export * from "./openai";
export * from "./user";
+18
View File
@@ -6,3 +6,21 @@ export type BaseOptions = {
/** 自定义 baseURL */
baseURL?: string;
};
/**
* 图片生成请求体。
*/
export interface IImageGenerateRequest {
/** 图片提示词 */
prompt?: string;
}
/**
* 图片生成成功后返回给前端的数据。
*/
export interface IImageGenerateData {
/** 生成图片的访问地址 */
imageUrl: string;
/** 上游返回的修订提示词,可能为空 */
revisedPrompt?: string;
}