feat: 完善广场功能
This commit is contained in:
@@ -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>
|
||||
@@ -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,7 +1,7 @@
|
||||
<template>
|
||||
<main class="w-full h-dvh flex flex-col">
|
||||
<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
|
||||
class="w-full"
|
||||
:class="{ 'm-auto max-w-sm': $route.path.includes('login') }"
|
||||
|
||||
+4
-1
@@ -5,7 +5,10 @@ definePageMeta({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="py-2">
|
||||
<div class="space-y-8 py-2">
|
||||
<ImageGenerateCom />
|
||||
<ClientOnly>
|
||||
<ImageHistoryListCom />
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import type {
|
||||
ICommonResponse,
|
||||
IImageGenerateData,
|
||||
IImageGenerateRequest
|
||||
IImageGenerateRequest,
|
||||
IImageHistoryListData,
|
||||
IImagePublicStateData,
|
||||
IPlazaPostListData
|
||||
} from "#shared/types";
|
||||
|
||||
export class ImageService {
|
||||
public static basePath = "/api/images";
|
||||
|
||||
/** 发起生图请求,成功后返回当前可展示的图片地址。 */
|
||||
public static GenerateImage(request: IImageGenerateRequest) {
|
||||
return $fetch<ICommonResponse<IImageGenerateData>>(
|
||||
`${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
@@ -1,4 +1,4 @@
|
||||
-- 表1:用户表
|
||||
-- 表1:用户快照表
|
||||
CREATE TABLE IF NOT EXISTS `users` (
|
||||
`id` INT NOT NULL COMMENT 'NewAPI 用户 ID,作为本站用户主键',
|
||||
`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 '创建时间',
|
||||
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户表';
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户快照表';
|
||||
|
||||
-- 表2:生图记录表
|
||||
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 '生图开始时间',
|
||||
`ended_at` DATETIME(3) NULL COMMENT '生图结束时间',
|
||||
`duration_ms` INT NULL COMMENT '生图耗时,单位毫秒',
|
||||
`image_url` VARCHAR(2048) NULL COMMENT 'NewAPI 上游返回的图片 URL',
|
||||
`hosted_image_url` VARCHAR(2048) NULL COMMENT 'Lsky 图床归档后的图片 URL',
|
||||
`image_url` VARCHAR(2048) NULL COMMENT '生成图片访问地址',
|
||||
`hosted_image_url` VARCHAR(2048) NULL COMMENT '图床归档后的稳定图片地址',
|
||||
`image_mime_type` VARCHAR(191) NULL COMMENT '图片 MIME 类型',
|
||||
`revised_prompt` TEXT NULL COMMENT '上游返回的修订提示词',
|
||||
`upstream_response` JSON NULL COMMENT '完整上游生图接口响应',
|
||||
`hosted_response` JSON NULL COMMENT '完整 Lsky 图床上传接口响应',
|
||||
`revised_prompt` TEXT NULL COMMENT '修订提示词',
|
||||
`upstream_response` JSON NULL COMMENT '完整上游生图响应,仅服务端排查使用',
|
||||
`hosted_response` JSON NULL COMMENT '完整图床归档响应,仅服务端排查使用',
|
||||
`error_message` TEXT NULL COMMENT '失败原因或图床归档失败提示',
|
||||
`archive_status` ENUM('NOT_REQUIRED', 'PENDING', 'RUNNING', 'SUCCEEDED', 'FAILED') NOT NULL DEFAULT 'NOT_REQUIRED' 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
|
||||
) 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` (
|
||||
`id` VARCHAR(32) NOT NULL COMMENT '统计行 ID,固定为 global',
|
||||
`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;
|
||||
@@ -27,6 +27,12 @@ enum ImageArchiveStatus {
|
||||
FAILED
|
||||
}
|
||||
|
||||
enum PlazaPostStatus {
|
||||
PUBLIC
|
||||
HIDDEN
|
||||
REMOVED
|
||||
}
|
||||
|
||||
model User {
|
||||
id Int @id
|
||||
username String @default("") @db.VarChar(191)
|
||||
@@ -37,6 +43,7 @@ model User {
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
imageGenerations ImageGeneration[]
|
||||
plazaPosts PlazaPost[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
@@ -67,6 +74,7 @@ model ImageGeneration {
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
plazaPost PlazaPost?
|
||||
|
||||
@@index([userId, createdAt])
|
||||
@@index([status])
|
||||
@@ -76,6 +84,24 @@ model ImageGeneration {
|
||||
@@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 {
|
||||
id String @id @db.VarChar(32)
|
||||
totalRequests Int @default(0) @map("total_requests")
|
||||
|
||||
@@ -1,16 +1,5 @@
|
||||
// server/api/images/history/[id].get.ts - 生图历史详情接口:返回当前用户单条记录的展示字段与归档状态。
|
||||
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
|
||||
|
||||
@@ -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
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -10,6 +10,7 @@ import { consola } from "consola";
|
||||
import {
|
||||
ImageArchiveStatus,
|
||||
ImageGenerationStatus,
|
||||
PlazaPostStatus,
|
||||
Prisma
|
||||
} from "~~/app/generated/prisma/client";
|
||||
import { prisma } from "~~/server/utils/prisma";
|
||||
@@ -368,10 +369,17 @@ export const listImageGenerationHistory = async ({
|
||||
deletedAt: null
|
||||
};
|
||||
|
||||
const [total, records] = await prisma.$transaction([
|
||||
const [total, records] = await Promise.all([
|
||||
prisma.imageGeneration.count({ where }),
|
||||
prisma.imageGeneration.findMany({
|
||||
where,
|
||||
include: {
|
||||
plazaPost: {
|
||||
select: {
|
||||
status: true
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc"
|
||||
},
|
||||
@@ -398,6 +406,13 @@ export const getImageGenerationDetail = async (
|
||||
id: recordId,
|
||||
userId,
|
||||
deletedAt: null
|
||||
},
|
||||
include: {
|
||||
plazaPost: {
|
||||
select: {
|
||||
status: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -549,6 +564,9 @@ const mapImageGenerationItem = (record: {
|
||||
revisedPrompt: string | null;
|
||||
errorMessage: string | null;
|
||||
createdAt: Date;
|
||||
plazaPost?: {
|
||||
status: PlazaPostStatus;
|
||||
} | null;
|
||||
}): IImageHistoryItem => {
|
||||
return {
|
||||
id: record.id.toString(),
|
||||
@@ -563,6 +581,7 @@ const mapImageGenerationItem = (record: {
|
||||
hostedImageUrl: record.hostedImageUrl,
|
||||
revisedPrompt: record.revisedPrompt,
|
||||
errorMessage: getPublicRecordMessage(record.status, record.errorMessage),
|
||||
isPublic: record.plazaPost?.status === PlazaPostStatus.PUBLIC,
|
||||
createdAt: record.createdAt.toISOString()
|
||||
};
|
||||
};
|
||||
|
||||
@@ -8,4 +8,5 @@ export * from "./lsky";
|
||||
export * from "./newApiAuthCookies";
|
||||
export * from "./newApiTokens";
|
||||
export * from "./openai";
|
||||
export * from "./plazaPosts";
|
||||
export * from "./prisma";
|
||||
|
||||
@@ -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()
|
||||
};
|
||||
};
|
||||
@@ -62,6 +62,8 @@ export interface IImageHistoryItem {
|
||||
revisedPrompt: string | null;
|
||||
/** 面向前端的本地泛化错误提示;为空且 hostedImageUrl 也为空时表示仍在归档中 */
|
||||
errorMessage: string | null;
|
||||
/** 此图片是否已发布到广场 */
|
||||
isPublic: boolean;
|
||||
/** 记录创建时间,ISO 字符串 */
|
||||
createdAt: string;
|
||||
}
|
||||
@@ -92,6 +94,37 @@ export interface IImageHistoryListData {
|
||||
items: IImageHistoryItem[];
|
||||
}
|
||||
|
||||
export interface IPlazaPostItem {
|
||||
/** 广场发布记录 ID,BigInt 会以字符串返回 */
|
||||
id: string;
|
||||
/** 生图记录 ID,BigInt 会以字符串返回 */
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 全局生图统计响应。
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user