feat: 完善广场功能

This commit is contained in:
2026-04-26 19:17:15 +08:00
parent 3e8e2353f0
commit eff52f6063
16 changed files with 833 additions and 23 deletions
+124
View File
@@ -0,0 +1,124 @@
<script setup lang="ts">
import type { IImageHistoryItem } from "#shared/types";
import { ImageService } from "~/services";
import { useUserStore } from "~/stores";
const userStore = useUserStore();
const { isOnline } = storeToRefs(userStore);
const histories = ref<IImageHistoryItem[]>([]);
const loading = ref(false);
const errorMessage = ref("");
const operatingRecordId = ref("");
const loadHistory = async () => {
if (!isOnline.value) {
histories.value = [];
errorMessage.value = "";
loading.value = false;
return;
}
loading.value = true;
errorMessage.value = "";
try {
const res = await ImageService.GetImageHistory(1, 20);
if (res.code === 0 && res.data) {
histories.value = res.data.items;
return;
}
errorMessage.value = res.msg || "获取生图历史失败";
} catch (error) {
errorMessage.value = getErrorMessage(error, "获取生图历史失败");
} finally {
loading.value = false;
}
};
const updatePublicState = async (item: IImageHistoryItem) => {
operatingRecordId.value = item.id;
errorMessage.value = "";
try {
const res = item.isPublic
? await ImageService.HideHistoryImage(item.id)
: await ImageService.PublishHistoryImage(item.id);
if (res.code === 0 && res.data) {
item.isPublic = res.data.isPublic;
return;
}
errorMessage.value = res.msg || "更新公开状态失败";
} catch (error) {
errorMessage.value = getErrorMessage(error, "更新公开状态失败");
} finally {
operatingRecordId.value = "";
}
};
onMounted(loadHistory);
watch(isOnline, () => {
loadHistory();
});
</script>
<template>
<section class="w-full max-w-xl space-y-3">
<div class="flex items-center justify-between">
<h2 class="text-base font-medium text-gray-900">历史记录</h2>
<el-button size="small" :loading="loading" @click="loadHistory">
刷新
</el-button>
</div>
<p v-if="errorMessage" class="text-sm text-red-500">
{{ errorMessage }}
</p>
<p
v-else-if="!loading && histories.length === 0"
class="text-sm text-gray-500"
>
暂无历史记录
</p>
<div v-else class="space-y-4">
<article
v-for="item in histories"
:key="item.id"
class="space-y-2 border-b border-gray-200 pb-4"
>
<p class="text-sm leading-6 text-gray-800">
{{ item.prompt }}
</p>
<img
v-if="item.hostedImageUrl"
:src="item.hostedImageUrl"
alt="历史生成图片"
class="block w-full rounded border border-gray-200 object-contain"
/>
<p v-else class="text-sm text-gray-500">图片归档中</p>
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500">
{{ item.isPublic ? "已公开" : "未公开" }}
</span>
<el-button
size="small"
:loading="operatingRecordId === item.id"
@click="updatePublicState(item)"
>
{{ item.isPublic ? "取消公开" : "公开" }}
</el-button>
</div>
</article>
</div>
</section>
</template>
+73
View File
@@ -0,0 +1,73 @@
<script setup lang="ts">
import type { IPlazaPostItem } from "#shared/types";
import { ImageService } from "~/services";
const plazaPosts = ref<IPlazaPostItem[]>([]);
const loading = ref(false);
const errorMessage = ref("");
const loadPlazaPosts = async () => {
loading.value = true;
errorMessage.value = "";
try {
const res = await ImageService.GetPlazaPosts(1, 20);
if (res.code === 0 && res.data) {
plazaPosts.value = res.data.items;
return;
}
errorMessage.value = res.msg || "获取广场图片失败";
} catch (error) {
errorMessage.value = getErrorMessage(error, "获取广场图片失败");
} finally {
loading.value = false;
}
};
onMounted(loadPlazaPosts);
</script>
<template>
<section class="w-full space-y-3">
<div class="flex items-center justify-between">
<h2 class="text-base font-medium text-gray-900">广场</h2>
<el-button size="small" :loading="loading" @click="loadPlazaPosts">
刷新
</el-button>
</div>
<p v-if="errorMessage" class="text-sm text-red-500">
{{ errorMessage }}
</p>
<p v-else-if="!loading && plazaPosts.length === 0" class="text-sm text-gray-500">
暂无广场图片
</p>
<div v-else class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<article
v-for="item in plazaPosts"
:key="item.id"
class="overflow-hidden rounded border border-gray-200 bg-white"
>
<img
:src="item.hostedImageUrl"
alt="广场图片"
class="block w-full object-cover"
/>
<div class="space-y-2 p-3">
<p class="text-sm leading-6 text-gray-800">
{{ item.prompt }}
</p>
<p class="text-xs text-gray-500">
{{ item.authorDisplayName }}
</p>
</div>
</article>
</div>
</section>
</template>
+1 -1
View File
@@ -1,7 +1,7 @@
<template> <template>
<main class="w-full h-dvh flex flex-col"> <main class="w-full h-dvh flex flex-col">
<HeaderCom /> <HeaderCom />
<div class="min-h-0 flex-1 px-4 overflow-y-auto flex"> <div class="min-h-0 flex-1 px-4 overflow-y-auto flex bg-gray-50">
<div <div
class="w-full" class="w-full"
:class="{ 'm-auto max-w-sm': $route.path.includes('login') }" :class="{ 'm-auto max-w-sm': $route.path.includes('login') }"
+4 -1
View File
@@ -5,7 +5,10 @@ definePageMeta({
</script> </script>
<template> <template>
<div class="py-2"> <div class="space-y-8 py-2">
<ImageGenerateCom /> <ImageGenerateCom />
<ClientOnly>
<ImageHistoryListCom />
</ClientOnly>
</div> </div>
</template> </template>
+53 -1
View File
@@ -1,12 +1,16 @@
import type { import type {
ICommonResponse, ICommonResponse,
IImageGenerateData, IImageGenerateData,
IImageGenerateRequest IImageGenerateRequest,
IImageHistoryListData,
IImagePublicStateData,
IPlazaPostListData
} from "#shared/types"; } from "#shared/types";
export class ImageService { export class ImageService {
public static basePath = "/api/images"; public static basePath = "/api/images";
/** 发起生图请求,成功后返回当前可展示的图片地址。 */
public static GenerateImage(request: IImageGenerateRequest) { public static GenerateImage(request: IImageGenerateRequest) {
return $fetch<ICommonResponse<IImageGenerateData>>( return $fetch<ICommonResponse<IImageGenerateData>>(
`${ImageService.basePath}/generate`, `${ImageService.basePath}/generate`,
@@ -16,4 +20,52 @@ export class ImageService {
} }
); );
} }
/** 分页读取当前登录用户的生图历史记录。 */
public static GetImageHistory(page = 1, size = 10) {
return $fetch<ICommonResponse<IImageHistoryListData>>(
`${ImageService.basePath}/history`,
{
method: "GET",
query: {
page,
size
}
}
);
}
/** 分页读取广场中所有用户可见的公开图片。 */
public static GetPlazaPosts(page = 1, size = 20) {
return $fetch<ICommonResponse<IPlazaPostListData>>(
`${ImageService.basePath}/plaza`,
{
method: "GET",
query: {
page,
size
}
}
);
}
/** 将当前用户的一条生图历史发布到广场。 */
public static PublishHistoryImage(recordId: string) {
return $fetch<ICommonResponse<IImagePublicStateData>>(
`${ImageService.basePath}/history/${recordId}/public`,
{
method: "POST"
}
);
}
/** 取消公开当前用户的一条广场图片,服务端会软隐藏发布记录。 */
public static HideHistoryImage(recordId: string) {
return $fetch<ICommonResponse<IImagePublicStateData>>(
`${ImageService.basePath}/history/${recordId}/public`,
{
method: "DELETE"
}
);
}
} }
+31 -8
View File
@@ -1,4 +1,4 @@
-- 表1:用户表 -- 表1:用户快照
CREATE TABLE IF NOT EXISTS `users` ( CREATE TABLE IF NOT EXISTS `users` (
`id` INT NOT NULL COMMENT 'NewAPI 用户 ID,作为本站用户主键', `id` INT NOT NULL COMMENT 'NewAPI 用户 ID,作为本站用户主键',
`username` VARCHAR(191) NOT NULL DEFAULT '' COMMENT '用户名快照', `username` VARCHAR(191) NOT NULL DEFAULT '' COMMENT '用户名快照',
@@ -9,7 +9,7 @@ CREATE TABLE IF NOT EXISTS `users` (
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY (`id`) PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户表'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户快照';
-- 表2:生图记录表 -- 表2:生图记录表
CREATE TABLE IF NOT EXISTS `image_generations` ( CREATE TABLE IF NOT EXISTS `image_generations` (
@@ -21,12 +21,12 @@ CREATE TABLE IF NOT EXISTS `image_generations` (
`started_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '生图开始时间', `started_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '生图开始时间',
`ended_at` DATETIME(3) NULL COMMENT '生图结束时间', `ended_at` DATETIME(3) NULL COMMENT '生图结束时间',
`duration_ms` INT NULL COMMENT '生图耗时,单位毫秒', `duration_ms` INT NULL COMMENT '生图耗时,单位毫秒',
`image_url` VARCHAR(2048) NULL COMMENT 'NewAPI 上游返回的图片 URL', `image_url` VARCHAR(2048) NULL COMMENT '生成图片访问地址',
`hosted_image_url` VARCHAR(2048) NULL COMMENT 'Lsky 图床归档后的图片 URL', `hosted_image_url` VARCHAR(2048) NULL COMMENT '图床归档后的稳定图片地址',
`image_mime_type` VARCHAR(191) NULL COMMENT '图片 MIME 类型', `image_mime_type` VARCHAR(191) NULL COMMENT '图片 MIME 类型',
`revised_prompt` TEXT NULL COMMENT '上游返回的修订提示词', `revised_prompt` TEXT NULL COMMENT '修订提示词',
`upstream_response` JSON NULL COMMENT '完整上游生图接口响应', `upstream_response` JSON NULL COMMENT '完整上游生图响应,仅服务端排查使用',
`hosted_response` JSON NULL COMMENT '完整 Lsky 图床上传接口响应', `hosted_response` JSON NULL COMMENT '完整图床归档响应,仅服务端排查使用',
`error_message` TEXT NULL COMMENT '失败原因或图床归档失败提示', `error_message` TEXT NULL COMMENT '失败原因或图床归档失败提示',
`archive_status` ENUM('NOT_REQUIRED', 'PENDING', 'RUNNING', 'SUCCEEDED', 'FAILED') NOT NULL DEFAULT 'NOT_REQUIRED' COMMENT '图床归档任务状态', `archive_status` ENUM('NOT_REQUIRED', 'PENDING', 'RUNNING', 'SUCCEEDED', 'FAILED') NOT NULL DEFAULT 'NOT_REQUIRED' COMMENT '图床归档任务状态',
`archive_attempts` INT NOT NULL DEFAULT 0 COMMENT '图床归档已尝试次数', `archive_attempts` INT NOT NULL DEFAULT 0 COMMENT '图床归档已尝试次数',
@@ -48,7 +48,30 @@ CREATE TABLE IF NOT EXISTS `image_generations` (
ON DELETE CASCADE ON UPDATE CASCADE ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='生图记录表'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='生图记录表';
-- 表3生图统计 -- 表3广场发布
CREATE TABLE IF NOT EXISTS `plaza_posts` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '广场发布记录 ID',
`image_generation_id` BIGINT UNSIGNED NOT NULL COMMENT '关联的生图记录 ID',
`user_id` INT NOT NULL COMMENT 'NewAPI 用户 ID',
`status` ENUM('PUBLIC', 'HIDDEN', 'REMOVED') NOT NULL DEFAULT 'PUBLIC' COMMENT '广场发布状态',
`published_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '发布时间',
`hidden_at` DATETIME(3) NULL COMMENT '用户取消公开时间',
`removed_at` DATETIME(3) NULL COMMENT '管理侧下架时间',
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE INDEX `plaza_posts_image_generation_id_key` (`image_generation_id`),
INDEX `plaza_posts_status_published_at_idx` (`status`, `published_at`),
INDEX `plaza_posts_user_id_published_at_idx` (`user_id`, `published_at`),
CONSTRAINT `plaza_posts_image_generation_id_fkey`
FOREIGN KEY (`image_generation_id`) REFERENCES `image_generations` (`id`)
ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT `plaza_posts_user_id_fkey`
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`)
ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='广场发布表';
-- 表4:生图统计表
CREATE TABLE IF NOT EXISTS `generation_stats` ( CREATE TABLE IF NOT EXISTS `generation_stats` (
`id` VARCHAR(32) NOT NULL COMMENT '统计行 ID,固定为 global', `id` VARCHAR(32) NOT NULL COMMENT '统计行 ID,固定为 global',
`total_requests` INT NOT NULL DEFAULT 0 COMMENT '总生图请求数,包含成功和失败', `total_requests` INT NOT NULL DEFAULT 0 COMMENT '总生图请求数,包含成功和失败',
@@ -0,0 +1,21 @@
CREATE TABLE `plaza_posts` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`image_generation_id` BIGINT UNSIGNED NOT NULL,
`user_id` INT NOT NULL,
`status` ENUM('PUBLIC', 'HIDDEN', 'REMOVED') NOT NULL DEFAULT 'PUBLIC',
`published_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`hidden_at` DATETIME(3) NULL,
`removed_at` DATETIME(3) NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
UNIQUE INDEX `plaza_posts_image_generation_id_key` (`image_generation_id`),
INDEX `plaza_posts_status_published_at_idx` (`status`, `published_at`),
INDEX `plaza_posts_user_id_published_at_idx` (`user_id`, `published_at`),
CONSTRAINT `plaza_posts_image_generation_id_fkey`
FOREIGN KEY (`image_generation_id`) REFERENCES `image_generations` (`id`)
ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT `plaza_posts_user_id_fkey`
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`)
ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+26
View File
@@ -27,6 +27,12 @@ enum ImageArchiveStatus {
FAILED FAILED
} }
enum PlazaPostStatus {
PUBLIC
HIDDEN
REMOVED
}
model User { model User {
id Int @id id Int @id
username String @default("") @db.VarChar(191) username String @default("") @db.VarChar(191)
@@ -37,6 +43,7 @@ model User {
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
imageGenerations ImageGeneration[] imageGenerations ImageGeneration[]
plazaPosts PlazaPost[]
@@map("users") @@map("users")
} }
@@ -67,6 +74,7 @@ model ImageGeneration {
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at") deletedAt DateTime? @map("deleted_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
plazaPost PlazaPost?
@@index([userId, createdAt]) @@index([userId, createdAt])
@@index([status]) @@index([status])
@@ -76,6 +84,24 @@ model ImageGeneration {
@@map("image_generations") @@map("image_generations")
} }
model PlazaPost {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
imageGenerationId BigInt @unique @map("image_generation_id") @db.UnsignedBigInt
userId Int @map("user_id")
status PlazaPostStatus @default(PUBLIC)
publishedAt DateTime @default(now()) @map("published_at")
hiddenAt DateTime? @map("hidden_at")
removedAt DateTime? @map("removed_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
imageGeneration ImageGeneration @relation(fields: [imageGenerationId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([status, publishedAt])
@@index([userId, publishedAt])
@@map("plaza_posts")
}
model GenerationStats { model GenerationStats {
id String @id @db.VarChar(32) id String @id @db.VarChar(32)
totalRequests Int @default(0) @map("total_requests") totalRequests Int @default(0) @map("total_requests")
-11
View File
@@ -1,16 +1,5 @@
// server/api/images/history/[id].get.ts - 生图历史详情接口:返回当前用户单条记录的展示字段与归档状态。 // server/api/images/history/[id].get.ts - 生图历史详情接口:返回当前用户单条记录的展示字段与归档状态。
import type { IImageHistoryDetail } from "#shared/types/openai"; import type { IImageHistoryDetail } from "#shared/types/openai";
import {
clearNewApiAuthCookies,
createApiLogger,
createErrorResponse,
createSuccessResponse,
createUpstreamErrorResponse,
getImageGenerationDetail,
getNewApiUserIdFromCookie,
isUnauthorizedError,
toSafeLogError
} from "~~/server/utils";
/** /**
* GET /api/images/history/:id * GET /api/images/history/:id
@@ -0,0 +1,92 @@
// server/api/images/history/[id]/public.delete.ts - 生图历史取消公开接口:将当前用户广场发布记录软隐藏。
import type { IImagePublicStateData } from "#shared/types/openai";
import {
clearNewApiAuthCookies,
createApiLogger,
createErrorResponse,
createSuccessResponse,
createUpstreamErrorResponse,
getNewApiUserIdFromCookie,
hideImageGenerationFromPlaza,
isUnauthorizedError,
toSafeLogError
} from "~~/server/utils";
/**
* DELETE /api/images/history/:id/public
*
* 流程:
* 1. 从 httpOnly cookie 读取当前用户 ID。
* 2. 只允许取消公开当前用户、未软删除的生图记录。
* 3. 不删除广场发布记录,只改为隐藏并记录 hiddenAt。
* 4. 不返回上游响应、图床响应或内部错误详情。
*/
export default defineEventHandler(async (event) => {
const logger = createApiLogger("images.history.public.hide");
logger.info("开始");
try {
const userId = getNewApiUserIdFromCookie(event);
const recordId = parseRecordId(getRouterParam(event, "id"));
logger.info("取消公开参数", {
userId,
recordId: recordId.toString()
});
const result = await hideImageGenerationFromPlaza(userId, recordId);
if (!result) {
logger.warn("公开记录不存在", {
userId,
recordId: recordId.toString()
});
return createErrorResponse(404, "公开记录不存在");
}
logger.done("成功", {
userId,
recordId: recordId.toString()
});
return createSuccessResponse<IImagePublicStateData>(result, "已取消公开");
} catch (error) {
if (isUnauthorizedError(error)) {
clearNewApiAuthCookies(event);
logger.warn("未登录或登录态失效", {
error: toSafeLogError(error)
});
return createErrorResponse(401, "未登录");
}
if (isBadRequestError(error)) {
return createErrorResponse(400, "生图记录 ID 不正确");
}
logger.error("失败", {
error: toSafeLogError(error)
});
return createUpstreamErrorResponse(error, "取消公开图片失败");
}
});
/** 校验并解析路由里的生图记录 ID */
const parseRecordId = (value: string | undefined): bigint => {
if (!value || !/^\d+$/.test(value)) {
throw createError({
statusCode: 400,
statusMessage: "生图记录 ID 不正确"
});
}
return BigInt(value);
};
const isBadRequestError = (error: unknown): boolean => {
return (
typeof error === "object" &&
error !== null &&
"statusCode" in error &&
error.statusCode === 400
);
};
@@ -0,0 +1,100 @@
// server/api/images/history/[id]/public.post.ts - 生图历史公开接口:将当前用户单条生图记录发布到广场。
import type { IImagePublicStateData } from "#shared/types/openai";
import {
clearNewApiAuthCookies,
createApiLogger,
createErrorResponse,
createSuccessResponse,
createUpstreamErrorResponse,
getNewApiUserIdFromCookie,
isUnauthorizedError,
publishImageGenerationToPlaza,
toSafeLogError
} from "~~/server/utils";
/**
* POST /api/images/history/:id/public
*
* 流程:
* 1. 从 httpOnly cookie 读取当前用户 ID。
* 2. 只允许公开当前用户、未软删除的生图记录。
* 3. 允许图片尚未归档时先创建公开记录;广场列表会等 hostedImageUrl 可用后再展示。
* 4. 不返回上游响应、图床响应或内部错误详情。
*/
export default defineEventHandler(async (event) => {
const logger = createApiLogger("images.history.public.publish");
logger.info("开始");
try {
const userId = getNewApiUserIdFromCookie(event);
const recordId = parseRecordId(getRouterParam(event, "id"));
logger.info("公开参数", {
userId,
recordId: recordId.toString()
});
const result = await publishImageGenerationToPlaza(userId, recordId);
if (!result) {
logger.warn("记录不存在", {
userId,
recordId: recordId.toString()
});
return createErrorResponse(404, "生图记录不存在");
}
if (result === "REMOVED") {
logger.warn("记录已下架,不能重新公开", {
userId,
recordId: recordId.toString()
});
return createErrorResponse(403, "图片已下架,无法公开");
}
logger.done("成功", {
userId,
recordId: recordId.toString()
});
return createSuccessResponse<IImagePublicStateData>(result, "图片已公开");
} catch (error) {
if (isUnauthorizedError(error)) {
clearNewApiAuthCookies(event);
logger.warn("未登录或登录态失效", {
error: toSafeLogError(error)
});
return createErrorResponse(401, "未登录");
}
if (isBadRequestError(error)) {
return createErrorResponse(400, "生图记录 ID 不正确");
}
logger.error("失败", {
error: toSafeLogError(error)
});
return createUpstreamErrorResponse(error, "公开图片失败");
}
});
/** 校验并解析路由里的生图记录 ID */
const parseRecordId = (value: string | undefined): bigint => {
if (!value || !/^\d+$/.test(value)) {
throw createError({
statusCode: 400,
statusMessage: "生图记录 ID 不正确"
});
}
return BigInt(value);
};
const isBadRequestError = (error: unknown): boolean => {
return (
typeof error === "object" &&
error !== null &&
"statusCode" in error &&
error.statusCode === 400
);
};
+75
View File
@@ -0,0 +1,75 @@
// server/api/images/plaza.get.ts - 广场列表接口:分页返回所有用户已公开且已归档成功的生图作品。
import type { IPlazaPostListData } from "#shared/types/openai";
import {
createApiLogger,
createSuccessResponse,
createUpstreamErrorResponse,
listPlazaPosts,
toSafeLogError
} from "~~/server/utils";
const DEFAULT_PAGE = 1;
const DEFAULT_PAGE_SIZE = 20;
const MAX_PAGE_SIZE = 50;
/**
* GET /api/images/plaza
*
* 流程:
* 1. 读取 page/size 查询参数,并限制最大 pageSize。
* 2. 只返回公开、生成成功、未删除、且已有图床归档地址的记录。
* 3. 返回广场展示字段,不返回上游响应、图床响应或内部错误详情。
*/
export default defineEventHandler(async (event) => {
const logger = createApiLogger("images.plaza.list");
logger.info("开始");
try {
const query = getQuery(event);
const page = normalizePositiveInt(query.page, DEFAULT_PAGE);
const pageSize = Math.min(
normalizePositiveInt(query.size, DEFAULT_PAGE_SIZE),
MAX_PAGE_SIZE
);
logger.info("查询参数", {
page,
pageSize
});
const result = await listPlazaPosts({
page,
pageSize
});
logger.done("成功", {
page,
pageSize,
total: result.total,
itemCount: result.items.length
});
return createSuccessResponse<IPlazaPostListData>(
result,
"获取广场图片成功"
);
} catch (error) {
logger.error("失败", {
error: toSafeLogError(error)
});
return createUpstreamErrorResponse(error, "获取广场图片失败");
}
});
/** 将 query 参数归一化为正整数,非法值回退到默认值 */
const normalizePositiveInt = (
value: unknown,
fallbackValue: number
): number => {
const rawValue = Array.isArray(value) ? value[0] : value;
const numberValue = Number.parseInt(String(rawValue ?? ""), 10);
return Number.isInteger(numberValue) && numberValue > 0
? numberValue
: fallbackValue;
};
+20 -1
View File
@@ -10,6 +10,7 @@ import { consola } from "consola";
import { import {
ImageArchiveStatus, ImageArchiveStatus,
ImageGenerationStatus, ImageGenerationStatus,
PlazaPostStatus,
Prisma Prisma
} from "~~/app/generated/prisma/client"; } from "~~/app/generated/prisma/client";
import { prisma } from "~~/server/utils/prisma"; import { prisma } from "~~/server/utils/prisma";
@@ -368,10 +369,17 @@ export const listImageGenerationHistory = async ({
deletedAt: null deletedAt: null
}; };
const [total, records] = await prisma.$transaction([ const [total, records] = await Promise.all([
prisma.imageGeneration.count({ where }), prisma.imageGeneration.count({ where }),
prisma.imageGeneration.findMany({ prisma.imageGeneration.findMany({
where, where,
include: {
plazaPost: {
select: {
status: true
}
}
},
orderBy: { orderBy: {
createdAt: "desc" createdAt: "desc"
}, },
@@ -398,6 +406,13 @@ export const getImageGenerationDetail = async (
id: recordId, id: recordId,
userId, userId,
deletedAt: null deletedAt: null
},
include: {
plazaPost: {
select: {
status: true
}
}
} }
}); });
@@ -549,6 +564,9 @@ const mapImageGenerationItem = (record: {
revisedPrompt: string | null; revisedPrompt: string | null;
errorMessage: string | null; errorMessage: string | null;
createdAt: Date; createdAt: Date;
plazaPost?: {
status: PlazaPostStatus;
} | null;
}): IImageHistoryItem => { }): IImageHistoryItem => {
return { return {
id: record.id.toString(), id: record.id.toString(),
@@ -563,6 +581,7 @@ const mapImageGenerationItem = (record: {
hostedImageUrl: record.hostedImageUrl, hostedImageUrl: record.hostedImageUrl,
revisedPrompt: record.revisedPrompt, revisedPrompt: record.revisedPrompt,
errorMessage: getPublicRecordMessage(record.status, record.errorMessage), errorMessage: getPublicRecordMessage(record.status, record.errorMessage),
isPublic: record.plazaPost?.status === PlazaPostStatus.PUBLIC,
createdAt: record.createdAt.toISOString() createdAt: record.createdAt.toISOString()
}; };
}; };
+1
View File
@@ -8,4 +8,5 @@ export * from "./lsky";
export * from "./newApiAuthCookies"; export * from "./newApiAuthCookies";
export * from "./newApiTokens"; export * from "./newApiTokens";
export * from "./openai"; export * from "./openai";
export * from "./plazaPosts";
export * from "./prisma"; export * from "./prisma";
+179
View File
@@ -0,0 +1,179 @@
// server/utils/plazaPosts.ts - 广场发布记录数据库操作:公开、取消公开和广场列表查询。
import type {
IImagePublicStateData,
IPlazaPostItem,
IPlazaPostListData
} from "#shared/types/openai";
import {
ImageGenerationStatus,
PlazaPostStatus
} from "~~/app/generated/prisma/client";
import { prisma } from "~~/server/utils/prisma";
const ANONYMOUS_AUTHOR_NAME = "匿名用户";
export type PublishPlazaPostResult = IImagePublicStateData | null | "REMOVED";
export const publishImageGenerationToPlaza = async (
userId: number,
recordId: bigint
): Promise<PublishPlazaPostResult> => {
const record = await prisma.imageGeneration.findFirst({
where: {
id: recordId,
userId,
deletedAt: null
},
select: {
id: true,
userId: true,
plazaPost: {
select: {
status: true
}
}
}
});
if (!record) return null;
if (record.plazaPost?.status === PlazaPostStatus.REMOVED) return "REMOVED";
await prisma.plazaPost.upsert({
where: {
imageGenerationId: record.id
},
create: {
imageGenerationId: record.id,
userId: record.userId,
status: PlazaPostStatus.PUBLIC
},
update: {
status: PlazaPostStatus.PUBLIC,
hiddenAt: null,
removedAt: null
}
});
return {
isPublic: true
};
};
export const hideImageGenerationFromPlaza = async (
userId: number,
recordId: bigint
): Promise<IImagePublicStateData | null> => {
const record = await prisma.imageGeneration.findFirst({
where: {
id: recordId,
userId,
deletedAt: null
},
select: {
id: true
}
});
if (!record) return null;
const result = await prisma.plazaPost.updateMany({
where: {
imageGenerationId: record.id,
userId,
status: {
not: PlazaPostStatus.REMOVED
}
},
data: {
status: PlazaPostStatus.HIDDEN,
hiddenAt: new Date()
}
});
if (result.count === 0) return null;
return {
isPublic: false
};
};
export const listPlazaPosts = async ({
page,
pageSize
}: {
page: number;
pageSize: number;
}): Promise<IPlazaPostListData> => {
const where = {
status: PlazaPostStatus.PUBLIC,
imageGeneration: {
status: ImageGenerationStatus.SUCCEEDED,
deletedAt: null,
hostedImageUrl: {
not: null
}
}
};
const [total, posts] = await Promise.all([
prisma.plazaPost.count({ where }),
prisma.plazaPost.findMany({
where,
include: {
imageGeneration: {
select: {
id: true,
prompt: true,
hostedImageUrl: true
}
},
user: {
select: {
displayName: true,
username: true
}
}
},
orderBy: {
publishedAt: "desc"
},
skip: (page - 1) * pageSize,
take: pageSize
})
]);
return {
page,
pageSize,
total,
items: posts
.map(mapPlazaPostItem)
.filter((item): item is IPlazaPostItem => Boolean(item))
};
};
const mapPlazaPostItem = (post: {
id: bigint;
publishedAt: Date;
imageGeneration: {
id: bigint;
prompt: string;
hostedImageUrl: string | null;
};
user: {
displayName: string;
username: string;
};
}): IPlazaPostItem | null => {
if (!post.imageGeneration.hostedImageUrl) return null;
return {
id: post.id.toString(),
imageGenerationId: post.imageGeneration.id.toString(),
prompt: post.imageGeneration.prompt,
hostedImageUrl: post.imageGeneration.hostedImageUrl,
authorDisplayName:
post.user.displayName || post.user.username || ANONYMOUS_AUTHOR_NAME,
publishedAt: post.publishedAt.toISOString()
};
};
+33
View File
@@ -62,6 +62,8 @@ export interface IImageHistoryItem {
revisedPrompt: string | null; revisedPrompt: string | null;
/** 面向前端的本地泛化错误提示;为空且 hostedImageUrl 也为空时表示仍在归档中 */ /** 面向前端的本地泛化错误提示;为空且 hostedImageUrl 也为空时表示仍在归档中 */
errorMessage: string | null; errorMessage: string | null;
/** 此图片是否已发布到广场 */
isPublic: boolean;
/** 记录创建时间,ISO 字符串 */ /** 记录创建时间,ISO 字符串 */
createdAt: string; createdAt: string;
} }
@@ -92,6 +94,37 @@ export interface IImageHistoryListData {
items: IImageHistoryItem[]; items: IImageHistoryItem[];
} }
export interface IPlazaPostItem {
/** 广场发布记录 ID,BigInt 会以字符串返回 */
id: string;
/** 生图记录 IDBigInt 会以字符串返回 */
imageGenerationId: string;
/** 广场卡片展示的用户提示词 */
prompt: string;
/** 广场使用的图床归档图片地址 */
hostedImageUrl: string;
/** 广场展示的作者名称 */
authorDisplayName: string;
/** 发布到广场的时间,ISO 字符串 */
publishedAt: string;
}
export interface IPlazaPostListData {
/** 当前页码 */
page: number;
/** 每页数量 */
pageSize: number;
/** 当前可见广场记录总数 */
total: number;
/** 当前页广场记录 */
items: IPlazaPostItem[];
}
export interface IImagePublicStateData {
/** 此图片是否已发布到广场 */
isPublic: boolean;
}
/** /**
* 全局生图统计响应。 * 全局生图统计响应。
*/ */