Binary file not shown.
|
After Width: | Height: | Size: 158 KiB |
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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,3 +1,4 @@
|
||||
<!-- 图片生成组件 -->
|
||||
<script setup lang="ts">
|
||||
import { ImageService } from "~/services";
|
||||
import { useUserStore } from "~/stores";
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,3 +1,4 @@
|
||||
<!-- 登录组件 -->
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
ICommonResponse,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
<!-- 注册组件 -->
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
ICommonResponse,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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,3 +1,4 @@
|
||||
<!-- 登录页 -->
|
||||
<script setup lang="ts">
|
||||
import LogInCom from "~/components/login/LogInCom.vue";
|
||||
import SignUpCom from "~/components/login/SignUpCom.vue";
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+81
@@ -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
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -10,3 +10,4 @@ export * from "./newApiTokens";
|
||||
export * from "./openai";
|
||||
export * from "./plazaPosts";
|
||||
export * from "./prisma";
|
||||
export * from "./promptPresets";
|
||||
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -125,6 +125,50 @@ export interface IImagePublicStateData {
|
||||
isPublic: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 预设提示词列表项。
|
||||
*/
|
||||
export interface IPromptPresetItem {
|
||||
/** 预设记录 ID,BigInt 会以字符串返回 */
|
||||
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[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 全局生图统计响应。
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
|
||||
> aiartstudio@ typecheck D:\aiartstudio
|
||||
> vue-tsc --noEmit
|
||||
|
||||
Reference in New Issue
Block a user