feat: 瀑布流布局

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
2026-04-26 22:36:37 +08:00
parent 547b5ad063
commit f7bdedf7d2
22 changed files with 608 additions and 67 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

+1
View File
@@ -1,4 +1,5 @@
:root {
--layout-max-width: 820px !important;
--el-color-primary: #2563eb !important;
--el-border-radius-base: 12px !important;
--el-border-radius-small: 8px !important;
+35
View File
@@ -0,0 +1,35 @@
<!-- 底部导航栏组件 -->
<script setup lang="ts">
const nav = [
{
name: "首页",
path: "/",
icon: ""
},
{
name: "创作",
path: "/create",
icon: ""
}
];
</script>
<template>
<div class="absolute bottom-2 left-1/2 transform -translate-x-1/2 z-10">
<div
class="flex items-center justify-center gap-4 bg-white/80 shadow-lg border rounded-full px-4 py-2 backdrop-blur-sm"
>
<div
v-for="value in nav"
:key="value.path"
class="cursor-pointer"
:class="{
'text-blue-600': $route.path === value.path
}"
@click="$router.push(value.path)"
>
{{ value.name }}
</div>
</div>
</div>
</template>
+32 -6
View File
@@ -1,13 +1,17 @@
<!-- 头部组件 -->
<script setup lang="ts">
import { useUserStore } from "~/stores";
import logo from "~/assets/logo.png";
import avatar from "~/assets/avatar.png";
const userStore = useUserStore();
const { isOnline } = storeToRefs(userStore);
const { isOnline, userInfo } = storeToRefs(userStore);
</script>
<template>
<div class="w-full h-16 flex items-center justify-between border-b px-4">
<div
class="w-full max-w-[var(--layout-max-width)] mx-auto h-16 flex items-center justify-between px-4"
>
<img
:src="logo"
alt="AI Art Studio logo"
@@ -17,9 +21,31 @@ const { isOnline } = storeToRefs(userStore);
}"
@click="$router.push('/')"
/>
<div v-if="!$route.path.includes('login')">
<el-button v-if="isOnline" @click="userStore.logout()">退出</el-button>
<el-button v-else @click="$router.push('/login')">登录</el-button>
</div>
<el-popover
v-if="isOnline"
placement="bottom"
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" />
</template>
<div v-if="!$route.path.includes('login')" class="py-2">
<div class="text-center text-lg font-medium border-b mb-2 px-4 pb-1">
{{ userInfo?.display_name }}
</div>
<el-button
@click="userStore.logout()"
class="w-full !border-none !p-0 text-center !rounded-none"
>
退出
</el-button>
</div>
</el-popover>
<el-button v-else @click="$router.push('/login')">登录</el-button>
</div>
</template>
+1
View File
@@ -1,3 +1,4 @@
<!-- 图片生成组件 -->
<script setup lang="ts">
import { ImageService } from "~/services";
import { useUserStore } from "~/stores";
+30 -27
View File
@@ -1,3 +1,4 @@
<!-- 历史记录组件 -->
<script setup lang="ts">
import type { IImageHistoryItem } from "#shared/types";
import { ImageService } from "~/services";
@@ -88,37 +89,39 @@ watch(isOnline, () => {
</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>
<ClientOnly>
<div
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"
/>
<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>
<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>
<el-button
size="small"
:loading="operatingRecordId === item.id"
@click="updatePublicState(item)"
>
{{ item.isPublic ? "取消公开" : "公开" }}
</el-button>
</div>
</div>
</article>
</ClientOnly>
</div>
</section>
</template>
+97 -27
View File
@@ -1,20 +1,54 @@
<!-- 广场图片列表组件 -->
<script setup lang="ts">
import type { IPlazaPostItem } from "#shared/types";
import type { VueInfiniteGridEvents } from "@egjs/vue3-infinitegrid";
import { MasonryInfiniteGrid } from "@egjs/vue3-infinitegrid";
import { ImageService } from "~/services";
type RequestAppendEvent = Parameters<VueInfiniteGridEvents["requestAppend"]>[0];
const pageSize = 20;
const plazaPosts = ref<IPlazaPostItem[]>([]);
const loading = ref(false);
const errorMessage = ref("");
const currentPage = ref(1);
const total = ref(0);
const isReachEnd = computed(
() => plazaPosts.value.length > 0 && plazaPosts.value.length >= total.value
);
const loadPlazaPosts = async () => {
const getGroupKey = (index: number) => Math.floor(index / pageSize) + 1;
// 始终保持两列:监听容器宽度,动态计算每列宽度 = floor((容器宽 - gap) / 2)
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 = "";
try {
const res = await ImageService.GetPlazaPosts(1, 20);
const res = await ImageService.GetPlazaPosts(page, pageSize);
if (res.code === 0 && res.data) {
plazaPosts.value = res.data.items;
currentPage.value = res.data.page;
total.value = res.data.total;
plazaPosts.value =
page === 1 ? res.data.items : [...plazaPosts.value, ...res.data.items];
return;
}
@@ -26,14 +60,33 @@ const loadPlazaPosts = async () => {
}
};
onMounted(loadPlazaPosts);
const refreshPlazaPosts = () => loadPlazaPosts(1);
const onRequestAppend = async (event: RequestAppendEvent) => {
if (loading.value) return;
if (isReachEnd.value) {
event.reachEnd();
return;
}
event.wait();
try {
await loadPlazaPosts(currentPage.value + 1);
} finally {
event.ready();
}
};
onMounted(refreshPlazaPosts);
</script>
<template>
<section class="w-full space-y-3">
<section ref="containerEl" 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 size="small" :loading="loading" @click="refreshPlazaPosts">
刷新
</el-button>
</div>
@@ -42,32 +95,49 @@ onMounted(loadPlazaPosts);
{{ errorMessage }}
</p>
<p v-else-if="!loading && plazaPosts.length === 0" class="text-sm text-gray-500">
<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"
<!-- itemWidth > 0 确保容器宽度测量完毕后再挂载避免 egjs 0 宽初始化 -->
<ClientOnly v-if="plazaPosts.length > 0 && itemWidth > 0">
<MasonryInfiniteGrid
class="w-full"
:gap="GRID_GAP"
:column-size="itemWidth"
:threshold="500"
:use-recycle="false"
:use-first-render="false"
:is-reach-end="isReachEnd"
@request-append="onRequestAppend"
>
<img
:src="item.hostedImageUrl"
alt="广场图片"
class="block w-full object-cover"
/>
<article
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
: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>
<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>
<p class="text-xs text-gray-500">
{{ item.authorDisplayName }}
</p>
</div>
</article>
</MasonryInfiniteGrid>
</ClientOnly>
</section>
</template>
+88
View File
@@ -0,0 +1,88 @@
<!-- 预设提示词列表组件 -->
<script setup lang="ts">
import type { IPromptPresetItem } from "#shared/types";
import { ImageService } from "~/services";
const presets = ref<IPromptPresetItem[]>([]);
const loading = ref(false);
const errorMessage = ref("");
const loadPresets = async () => {
loading.value = true;
errorMessage.value = "";
try {
const res = await ImageService.GetPromptPresets(1, 20);
if (res.code === 0 && res.data) {
presets.value = res.data.items;
return;
}
errorMessage.value = res.msg || "获取预设提示词失败";
} catch (error) {
errorMessage.value = getErrorMessage(error, "获取预设提示词失败");
} finally {
loading.value = false;
}
};
const getPreviewUrl = (item: IPromptPresetItem) => {
return item.compressedPreviewUrl || item.previewUrl;
};
onMounted(loadPresets);
</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="loadPresets">
刷新
</el-button>
</div>
<p v-if="errorMessage" class="text-sm text-red-500">
{{ errorMessage }}
</p>
<p v-else-if="!loading && presets.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 presets"
:key="item.id"
class="space-y-2 rounded border border-gray-200 bg-white p-3"
>
<img
v-if="getPreviewUrl(item)"
:src="getPreviewUrl(item)"
alt="预设提示词预览图"
class="block w-full rounded border border-gray-200 object-cover"
/>
<div class="space-y-1">
<div class="flex items-center justify-between gap-2">
<h3 class="text-sm font-medium text-gray-900">
{{ item.name }}
</h3>
<span class="text-xs text-gray-500">
{{ item.aspectRatio }}
</span>
</div>
<p class="text-xs text-gray-500">
{{ item.category }}
</p>
</div>
<p class="text-sm leading-6 text-gray-800">
{{ item.prompt }}
</p>
</article>
</div>
</section>
</template>
+1
View File
@@ -1,3 +1,4 @@
<!-- 登录组件 -->
<script setup lang="ts">
import type {
ICommonResponse,
+1
View File
@@ -1,3 +1,4 @@
<!-- 注册组件 -->
<script setup lang="ts">
import type {
ICommonResponse,
+2 -2
View File
@@ -3,8 +3,8 @@
<HeaderCom />
<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') }"
class="w-full max-w-[var(--layout-max-width)] mx-auto"
:class="{ 'max-w-sm': $route.path.includes('login') }"
>
<slot />
</div>
+14
View File
@@ -0,0 +1,14 @@
<script setup lang="ts">
definePageMeta({
layout: "index-view"
});
</script>
<template>
<div class="space-y-8 py-2">
<ImageHistoryListCom />
<!-- 底部导航栏 -->
<BottomNavCom />
</div>
</template>
+8 -4
View File
@@ -1,3 +1,4 @@
<!-- 首页 -->
<script setup lang="ts">
definePageMeta({
layout: "index-view"
@@ -6,9 +7,12 @@ definePageMeta({
<template>
<div class="space-y-8 py-2">
<ImageGenerateCom />
<ClientOnly>
<ImageHistoryListCom />
</ClientOnly>
<!-- <ImageGenerateCom /> -->
<!-- 广场 -->
<PlazaImageListCom />
<!-- 底部导航栏 -->
<BottomNavCom />
</div>
</template>
+1
View File
@@ -1,3 +1,4 @@
<!-- 登录页 -->
<script setup lang="ts">
import LogInCom from "~/components/login/LogInCom.vue";
import SignUpCom from "~/components/login/SignUpCom.vue";
+16 -1
View File
@@ -4,7 +4,8 @@ import type {
IImageGenerateRequest,
IImageHistoryListData,
IImagePublicStateData,
IPlazaPostListData
IPlazaPostListData,
IPromptPresetListData
} from "#shared/types";
export class ImageService {
@@ -50,6 +51,20 @@ export class ImageService {
}
/** 将当前用户的一条生图历史发布到广场。 */
/** 分页读取已启用的生图预设提示词。 */
public static GetPromptPresets(page = 1, size = 20) {
return $fetch<ICommonResponse<IPromptPresetListData>>(
`${ImageService.basePath}/presets`,
{
method: "GET",
query: {
page,
size
}
}
);
}
public static PublishHistoryImage(recordId: string) {
return $fetch<ICommonResponse<IImagePublicStateData>>(
`${ImageService.basePath}/history/${recordId}/public`,
+2
View File
@@ -12,7 +12,9 @@
"typecheck": "vue-tsc --noEmit"
},
"dependencies": {
"@egjs/vue3-infinitegrid": "^4.13.0",
"@element-plus/nuxt": "1.1.5",
"@heroicons/vue": "^2.2.0",
"@nuxt/eslint": "1.15.2",
"@nuxtjs/i18n": "10.2.4",
"@nuxtjs/seo": "5.1.3",
+81
View File
@@ -8,9 +8,15 @@ importers:
.:
dependencies:
'@egjs/vue3-infinitegrid':
specifier: ^4.13.0
version: 4.13.0
'@element-plus/nuxt':
specifier: 1.1.5
version: 1.1.5(@element-plus/icons-vue@2.3.2(vue@3.5.33(typescript@6.0.3)))(element-plus@2.13.7(typescript@6.0.3)(vue@3.5.33(typescript@6.0.3)))(magicast@0.5.2)
'@heroicons/vue':
specifier: ^2.2.0
version: 2.2.0(vue@3.5.33(typescript@6.0.3))
'@nuxt/eslint':
specifier: 1.15.2
version: 1.15.2(@typescript-eslint/utils@8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3))(@vue/compiler-sfc@3.5.33)(eslint@10.2.1(jiti@2.6.1))(magicast@0.5.2)(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))
@@ -218,6 +224,12 @@ packages:
resolution: {integrity: sha512-VERIM64vtTP1C4mxQ5thVT9fK0apjPFobqybMtA1UdUujWka24ERHbRHFGmpbbhp73MhV+KSsHQH9C6uOTdEQA==}
engines: {node: '>=18'}
'@cfcs/core@0.0.24':
resolution: {integrity: sha512-feB38qu+eDk0Pggh/yR7gjaNmvUYA2uCxHP3Pz2MLE4LZ/9jPdtu8bzCSI47yTEhWyZCF5Pk698hdz8IN2mTjA==}
'@cfcs/core@0.0.5':
resolution: {integrity: sha512-TZ/RXKV7MUrqFpiX+16uaaptv5jgChWNsuE6w8Rn3eJbO1PFd4Wpn25zeJbii7ch46ck8/ZIIYCcW3pHiYtm4Q==}
'@clack/core@1.2.0':
resolution: {integrity: sha512-qfxof/3T3t9DPU/Rj3OmcFyZInceqj/NVtO9rwIuJqCUgh32gwPjpFQQp/ben07qKlhpwq7GzfWpST4qdJ5Drg==}
@@ -258,6 +270,27 @@ packages:
'@dxup/unimport@0.1.2':
resolution: {integrity: sha512-/B8YJGPzaYq1NbsQmwgP8EZqg40NpTw4ZB3suuI0TplbxKHeK94jeaawLmVhCv+YwUnOpiWEz9U6SeThku/8JQ==}
'@egjs/children-differ@1.0.1':
resolution: {integrity: sha512-DRvyqMf+CPCOzAopQKHtW+X8iN6Hy6SFol+/7zCUiE5y4P/OB8JP8FtU4NxtZwtafvSL4faD5KoQYPj3JHzPFQ==}
'@egjs/component@3.0.5':
resolution: {integrity: sha512-cLcGizTrrUNA2EYE3MBmEDt2tQv1joVP1Q3oDisZ5nw0MZDx2kcgEXM+/kZpfa/PAkFvYVhRUZwytIQWoN3V/w==}
'@egjs/grid@1.18.0':
resolution: {integrity: sha512-4iTFbSwk4/XEciFRh0ggpFz3dzja24JQ9dn3/7u3tz0E1YLvtoM0FfcQOwkV+g8P+XNb90L2ZICLhy4OIpW0mw==}
'@egjs/imready@1.4.2':
resolution: {integrity: sha512-ckV7KlBa23Bw6iZP59W2cMZfAmV5q0BtMoOz7ZkzIvjoOtkxfLZkqVJROSgDP5Ha0hsnskyL4vtom9Bt7FZSRQ==}
'@egjs/infinitegrid@4.13.0':
resolution: {integrity: sha512-gh0FjsBjRCoUYS92Syl1ykUFcI7CDft/pR/KdIm+0MENu8OVQFb8KpAOKUcnAevFA9l0CeJNPQnRCMvyFBATQg==}
'@egjs/list-differ@1.0.1':
resolution: {integrity: sha512-OTFTDQcWS+1ZREOdCWuk5hCBgYO4OsD30lXcOCyVOAjXMhgL5rBRDnt/otb6Nz8CzU0L/igdcaQBDLWc4t9gvg==}
'@egjs/vue3-infinitegrid@4.13.0':
resolution: {integrity: sha512-EK+/ODiIGa3kiUIQCAcq6vIsQLVRHYdP5g50zLfPw/zYf6pddIRMGTXCwdK7ciZuPDK9874wdkvKFrRJert5oA==}
'@electric-sql/pglite-socket@0.1.1':
resolution: {integrity: sha512-p2hoXw3Z3LQHwTeikdZNsFBOvXGqKY2hk51BBw+8NKND8eoH+8LFOtW9Z8CQKmTJ2qqGYu82ipqiyFZOTTXNfw==}
hasBin: true
@@ -682,6 +715,11 @@ packages:
'@floating-ui/vue@1.1.11':
resolution: {integrity: sha512-HzHKCNVxnGS35r9fCHBc3+uCnjw9IWIlCPL683cGgM9Kgj2BiAl8x1mS7vtvP6F9S/e/q4O6MApwSHj8hNLGfw==}
'@heroicons/vue@2.2.0':
resolution: {integrity: sha512-G3dbSxoeEKqbi/DFalhRxJU4mTXJn7GwZ7ae8NuEQzd1bqdd0jAbdaBZlHPcvPD2xI1iGzNVB4k20Un2AguYPw==}
peerDependencies:
vue: '>= 3'
'@hono/node-server@1.19.11':
resolution: {integrity: sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==}
engines: {node: '>=18.14.1'}
@@ -7488,6 +7526,14 @@ snapshots:
dependencies:
fontkitten: 1.0.3
'@cfcs/core@0.0.24':
dependencies:
'@egjs/component': 3.0.5
'@cfcs/core@0.0.5':
dependencies:
'@egjs/component': 3.0.5
'@clack/core@1.2.0':
dependencies:
fast-wrap-ansi: 0.1.6
@@ -7528,6 +7574,37 @@ snapshots:
'@dxup/unimport@0.1.2': {}
'@egjs/children-differ@1.0.1':
dependencies:
'@egjs/list-differ': 1.0.1
'@egjs/component@3.0.5': {}
'@egjs/grid@1.18.0':
dependencies:
'@egjs/children-differ': 1.0.1
'@egjs/component': 3.0.5
'@egjs/imready': 1.4.2
'@egjs/imready@1.4.2':
dependencies:
'@cfcs/core': 0.0.24
'@egjs/component': 3.0.5
'@egjs/infinitegrid@4.13.0':
dependencies:
'@cfcs/core': 0.0.5
'@egjs/children-differ': 1.0.1
'@egjs/component': 3.0.5
'@egjs/grid': 1.18.0
'@egjs/list-differ': 1.0.1
'@egjs/list-differ@1.0.1': {}
'@egjs/vue3-infinitegrid@4.13.0':
dependencies:
'@egjs/infinitegrid': 4.13.0
'@electric-sql/pglite-socket@0.1.1(@electric-sql/pglite@0.4.1)':
dependencies:
'@electric-sql/pglite': 0.4.1
@@ -7820,6 +7897,10 @@ snapshots:
- '@vue/composition-api'
- vue
'@heroicons/vue@2.2.0(vue@3.5.33(typescript@6.0.3))':
dependencies:
vue: 3.5.33(typescript@6.0.3)
'@hono/node-server@1.19.11(hono@4.12.15)':
dependencies:
hono: 4.12.15
+75
View File
@@ -0,0 +1,75 @@
// server/api/images/presets.get.ts - 预设提示词列表接口:分页返回已启用的生图预设。
import type { IPromptPresetListData } from "#shared/types/openai";
import {
createApiLogger,
createSuccessResponse,
createUpstreamErrorResponse,
listPromptPresets,
toSafeLogError
} from "~~/server/utils";
const DEFAULT_PAGE = 1;
const DEFAULT_PAGE_SIZE = 20;
const MAX_PAGE_SIZE = 50;
/**
* GET /api/images/presets
*
* 流程:
* 1. 读取 page/size 查询参数,并限制最大 pageSize。
* 2. 只返回已启用的预设提示词,按导入顺序展示。
* 3. 返回前端展示和填充输入所需字段,不包含内部排查信息。
*/
export default defineEventHandler(async (event) => {
const logger = createApiLogger("images.presets.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 listPromptPresets({
page,
pageSize
});
logger.done("成功", {
page,
pageSize,
total: result.total,
itemCount: result.items.length
});
return createSuccessResponse<IPromptPresetListData>(
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;
};
+1
View File
@@ -10,3 +10,4 @@ export * from "./newApiTokens";
export * from "./openai";
export * from "./plazaPosts";
export * from "./prisma";
export * from "./promptPresets";
+74
View File
@@ -0,0 +1,74 @@
// server/utils/promptPresets.ts - 预设提示词数据库查询:为前端提供可分页展示的预设列表。
import type {
IPromptPresetItem,
IPromptPresetListData
} from "#shared/types/openai";
import { prisma } from "~~/server/utils/prisma";
export const listPromptPresets = async ({
page,
pageSize
}: {
page: number;
pageSize: number;
}): Promise<IPromptPresetListData> => {
const where = {
enabled: true
};
const [total, records] = await Promise.all([
prisma.promptPreset.count({ where }),
prisma.promptPreset.findMany({
where,
orderBy: [
{
sortOrder: "asc"
},
{
id: "asc"
}
],
skip: (page - 1) * pageSize,
take: pageSize
})
]);
return {
page,
pageSize,
total,
items: records.map(mapPromptPresetItem)
};
};
const mapPromptPresetItem = (record: {
id: bigint;
sourceId: string;
name: string;
promptRaw: string;
prompt: string;
negativePrompt: string;
category: string;
tags: unknown;
aspectRatio: string;
previewUrl: string;
compressedPreviewUrl: string;
sortOrder: number;
}): IPromptPresetItem => {
return {
id: record.id.toString(),
sourceId: record.sourceId,
name: record.name,
promptRaw: record.promptRaw,
prompt: record.prompt,
negativePrompt: record.negativePrompt,
category: record.category,
tags: Array.isArray(record.tags)
? record.tags.filter((tag): tag is string => typeof tag === "string")
: [],
aspectRatio: record.aspectRatio,
previewUrl: record.previewUrl,
compressedPreviewUrl: record.compressedPreviewUrl,
sortOrder: record.sortOrder
};
};
+44
View File
@@ -125,6 +125,50 @@ export interface IImagePublicStateData {
isPublic: boolean;
}
/**
* 预设提示词列表项。
*/
export interface IPromptPresetItem {
/** 预设记录 IDBigInt 会以字符串返回 */
id: string;
/** 来源 JSON 中的稳定 ID */
sourceId: string;
/** 预设名称 */
name: string;
/** 原始提示词 */
promptRaw: string;
/** 清洗后的提示词 */
prompt: string;
/** 反向提示词 */
negativePrompt: string;
/** 分类名称 */
category: string;
/** 标签数组 */
tags: string[];
/** 建议图片比例 */
aspectRatio: string;
/** 预览图地址 */
previewUrl: string;
/** 压缩预览图地址 */
compressedPreviewUrl: string;
/** 排序值 */
sortOrder: number;
}
/**
* 预设提示词分页响应。
*/
export interface IPromptPresetListData {
/** 当前页码 */
page: number;
/** 每页数量 */
pageSize: number;
/** 已启用的预设总数 */
total: number;
/** 当前页预设提示词 */
items: IPromptPresetItem[];
}
/**
* 全局生图统计响应。
*/
+4
View File
@@ -0,0 +1,4 @@
> aiartstudio@ typecheck D:\aiartstudio
> vue-tsc --noEmit