@@ -24,14 +24,14 @@ const { isOnline, userInfo } = storeToRefs(userStore);
|
||||
|
||||
<el-popover
|
||||
v-if="isOnline"
|
||||
placement="bottom"
|
||||
placement="bottom-end"
|
||||
trigger="click"
|
||||
:show-arrow="false"
|
||||
width="auto"
|
||||
popper-class="!min-w-0 !p-0 !rounded-lg"
|
||||
>
|
||||
<template #reference>
|
||||
<img :src="avatar" class="h-8 w-8 rounded-full cursor-pointer" />
|
||||
<img :src="avatar" class="size-8 rounded-full cursor-pointer" />
|
||||
</template>
|
||||
|
||||
<div v-if="!$route.path.includes('login')" class="py-2">
|
||||
|
||||
@@ -69,7 +69,7 @@ watch(isOnline, () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="w-full max-w-xl space-y-3">
|
||||
<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="loadHistory">
|
||||
@@ -103,7 +103,7 @@ watch(isOnline, () => {
|
||||
v-if="item.hostedImageUrl"
|
||||
:src="item.hostedImageUrl"
|
||||
alt="历史生成图片"
|
||||
class="block w-full rounded border border-gray-200 object-contain"
|
||||
class="block w-52 rounded border border-gray-200 object-contain"
|
||||
/>
|
||||
|
||||
<p v-else class="text-sm text-gray-500">图片归档中</p>
|
||||
|
||||
@@ -3,7 +3,19 @@
|
||||
import type { IPlazaPostItem } from "#shared/types";
|
||||
import type { VueInfiniteGridEvents } from "@egjs/vue3-infinitegrid";
|
||||
import { MasonryInfiniteGrid } from "@egjs/vue3-infinitegrid";
|
||||
import { debounce } from "lodash-es";
|
||||
import { ImageService } from "~/services";
|
||||
import { ExclamationTriangleIcon } from "@heroicons/vue/24/outline";
|
||||
|
||||
// 网格组件实例引用,用于图片加载后主动触发重排
|
||||
const gridRef = ref<InstanceType<typeof MasonryInfiniteGrid> | null>(null);
|
||||
|
||||
// el-image 异步加载完成后,网格需要重新测量各项 DOM 高度才能修正布局。
|
||||
// renderItems({ useResize: true }) 会强制重新量取 DOM 尺寸(而非使用缓存),
|
||||
// 用 debounce 合并同批次多张图片的 load 事件,避免频繁重排。
|
||||
const onImageLoad = debounce(() => {
|
||||
gridRef.value?.renderItems({ useResize: true });
|
||||
}, 100);
|
||||
|
||||
type RequestAppendEvent = Parameters<VueInfiniteGridEvents["requestAppend"]>[0];
|
||||
|
||||
@@ -24,19 +36,6 @@ const GRID_GAP = 8;
|
||||
const containerEl = ref<HTMLElement | null>(null);
|
||||
const itemWidth = ref(0);
|
||||
|
||||
onMounted(() => {
|
||||
const measure = () => {
|
||||
if (!containerEl.value) return;
|
||||
itemWidth.value = Math.floor(
|
||||
(containerEl.value.offsetWidth - GRID_GAP) / 2
|
||||
);
|
||||
};
|
||||
measure();
|
||||
const ro = new ResizeObserver(measure);
|
||||
if (containerEl.value) ro.observe(containerEl.value);
|
||||
onUnmounted(() => ro.disconnect());
|
||||
});
|
||||
|
||||
const loadPlazaPosts = async (page = 1) => {
|
||||
loading.value = true;
|
||||
errorMessage.value = "";
|
||||
@@ -79,7 +78,24 @@ const onRequestAppend = async (event: RequestAppendEvent) => {
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(refreshPlazaPosts);
|
||||
onMounted(() => {
|
||||
const measure = () => {
|
||||
if (!containerEl.value) return;
|
||||
itemWidth.value = Math.floor(
|
||||
(containerEl.value.offsetWidth - GRID_GAP) / 2
|
||||
);
|
||||
};
|
||||
|
||||
measure();
|
||||
|
||||
const ro = new ResizeObserver(measure);
|
||||
if (containerEl.value) ro.observe(containerEl.value);
|
||||
onUnmounted(() => {
|
||||
ro.disconnect();
|
||||
});
|
||||
|
||||
refreshPlazaPosts();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -105,6 +121,7 @@ onMounted(refreshPlazaPosts);
|
||||
<!-- itemWidth > 0 确保容器宽度测量完毕后再挂载,避免 egjs 以 0 宽初始化 -->
|
||||
<ClientOnly v-if="plazaPosts.length > 0 && itemWidth > 0">
|
||||
<MasonryInfiniteGrid
|
||||
ref="gridRef"
|
||||
class="w-full"
|
||||
:gap="GRID_GAP"
|
||||
:column-size="itemWidth"
|
||||
@@ -114,19 +131,32 @@ onMounted(refreshPlazaPosts);
|
||||
:is-reach-end="isReachEnd"
|
||||
@request-append="onRequestAppend"
|
||||
>
|
||||
<article
|
||||
<div
|
||||
v-for="(item, index) in plazaPosts"
|
||||
:key="item.id"
|
||||
:data-grid-groupkey="getGroupKey(index)"
|
||||
:style="{ width: itemWidth + 'px' }"
|
||||
class="overflow-hidden rounded border border-gray-200 bg-white"
|
||||
>
|
||||
<img
|
||||
<el-image
|
||||
:src="item.hostedImageUrl"
|
||||
alt="广场图片"
|
||||
:max-scale="5"
|
||||
:min-scale="0.5"
|
||||
:preview-src-list="[item.hostedImageUrl]"
|
||||
:hide-on-click-modal="true"
|
||||
class="block w-full object-cover"
|
||||
/>
|
||||
|
||||
@load="onImageLoad"
|
||||
@error="onImageLoad"
|
||||
>
|
||||
<template #error>
|
||||
<div
|
||||
class="w-full flex flex-col gap-2 items-center justify-center p-4 pt-8"
|
||||
>
|
||||
<ExclamationTriangleIcon class="size-6 text-gray-400" />
|
||||
<div>图片加载失败</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-image>
|
||||
<div class="space-y-2 p-3">
|
||||
<p class="text-sm leading-6 text-gray-800">
|
||||
{{ item.prompt }}
|
||||
@@ -136,7 +166,7 @@ onMounted(refreshPlazaPosts);
|
||||
{{ item.authorDisplayName }}
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</MasonryInfiniteGrid>
|
||||
</ClientOnly>
|
||||
</section>
|
||||
|
||||
+1
-1
@@ -2,5 +2,5 @@
|
||||
import withNuxt from './.nuxt/eslint.config.mjs'
|
||||
|
||||
export default withNuxt(
|
||||
// Your custom configs here
|
||||
{ ignores: ["node_modules/**"] }
|
||||
)
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
"@prisma/adapter-mariadb": "^7.8.0",
|
||||
"@prisma/client": "^7.8.0",
|
||||
"@takumi-rs/core": "^1.0.16",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"lodash-es": "^4.18.1",
|
||||
"nuxt": "^4.4.2",
|
||||
"openai": "^6.34.0",
|
||||
"pinia": "^3.0.4",
|
||||
|
||||
Generated
+6
@@ -41,6 +41,12 @@ importers:
|
||||
'@takumi-rs/core':
|
||||
specifier: ^1.0.16
|
||||
version: 1.0.16(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||
'@types/lodash-es':
|
||||
specifier: ^4.17.12
|
||||
version: 4.17.12
|
||||
lodash-es:
|
||||
specifier: ^4.18.1
|
||||
version: 4.18.1
|
||||
nuxt:
|
||||
specifier: ^4.4.2
|
||||
version: 4.4.2(@babel/core@7.29.0)(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@electric-sql/pglite@0.4.1)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@parcel/watcher@2.5.6)(@types/node@25.6.0)(@vue/compiler-sfc@3.5.33)(cac@6.7.14)(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(eslint@10.2.1(jiti@2.6.1))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.2)(mysql2@3.15.3)(optionator@0.9.4)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.33(typescript@6.0.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.2))(rollup@4.60.2)(srvx@0.11.15)(terser@5.46.1)(typescript@6.0.3)(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(yaml@2.8.3))(vue-tsc@3.2.7(typescript@6.0.3))(yaml@2.8.3)
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
// server/utils/plazaPosts.ts - 广场发布记录数据库操作:公开、取消公开和广场列表查询。
|
||||
// server/utils/plazaPosts.ts - 广场发布记录数据库操作:公开、取消公开、广场列表查询。
|
||||
// 广场帖子与 ImageGeneration 一对一关联,状态流转:PUBLIC → HIDDEN(用户自主) / REMOVED(管理员下架,不可恢复)。
|
||||
// 本文件不向调用方暴露上游响应、图床响应或内部错误字段。
|
||||
import type {
|
||||
IImagePublicStateData,
|
||||
IPlazaPostItem,
|
||||
@@ -10,10 +12,18 @@ import {
|
||||
} from "~~/app/generated/prisma/client";
|
||||
import { prisma } from "~~/server/utils/prisma";
|
||||
|
||||
/** 作者未设置昵称时的展示兜底文案 */
|
||||
const ANONYMOUS_AUTHOR_NAME = "匿名用户";
|
||||
|
||||
/** 发布操作的三种结果:成功返回公开状态、记录不存在返回 null、已被管理员下架返回 "REMOVED" */
|
||||
export type PublishPlazaPostResult = IImagePublicStateData | null | "REMOVED";
|
||||
|
||||
/**
|
||||
* 将指定生图记录发布到广场。
|
||||
* - 记录不存在或已软删除时返回 null。
|
||||
* - 记录已被管理员 REMOVED 时返回 "REMOVED",调用方应提示用户无法重新发布。
|
||||
* - 其余情况 upsert 为 PUBLIC,允许重复调用(幂等)。
|
||||
*/
|
||||
export const publishImageGenerationToPlaza = async (
|
||||
userId: number,
|
||||
recordId: bigint
|
||||
@@ -59,6 +69,12 @@ export const publishImageGenerationToPlaza = async (
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 将指定生图记录从广场隐藏(用户主动操作)。
|
||||
* - 记录不存在或已软删除时返回 null。
|
||||
* - 帖子不存在或已被 REMOVED 时 updateMany count 为 0,同样返回 null。
|
||||
* - 不允许将 REMOVED 状态改为 HIDDEN,REMOVED 由管理员操作,不在此处处理。
|
||||
*/
|
||||
export const hideImageGenerationFromPlaza = async (
|
||||
userId: number,
|
||||
recordId: bigint
|
||||
@@ -97,6 +113,17 @@ export const hideImageGenerationFromPlaza = async (
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 分页查询广场公开列表。
|
||||
* 只返回满足以下全部条件的记录:
|
||||
* - PlazaPost.status = PUBLIC
|
||||
* - 关联的 ImageGeneration.status = SUCCEEDED
|
||||
* - 关联的 ImageGeneration 未软删除
|
||||
* - 关联的 ImageGeneration.hostedImageUrl 不为 null(图床归档完成)
|
||||
*
|
||||
* count 与 findMany 并行执行以减少网络往返耗时。
|
||||
* 返回字段仅包含广场展示所需的公开信息,不含上游响应、图床响应或错误详情。
|
||||
*/
|
||||
export const listPlazaPosts = async ({
|
||||
page,
|
||||
pageSize
|
||||
@@ -152,6 +179,11 @@ export const listPlazaPosts = async ({
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 将 Prisma 查询结果映射为广场展示条目。
|
||||
* hostedImageUrl 在 where 条件中已过滤,此处二次检查是为了满足类型收窄。
|
||||
* 映射失败(理论上不应发生)返回 null,由上层 filter 去除。
|
||||
*/
|
||||
const mapPlazaPostItem = (post: {
|
||||
id: bigint;
|
||||
publishedAt: Date;
|
||||
|
||||
Reference in New Issue
Block a user