feat(init): 初始化远程组件项目

This commit is contained in:
chengcheng 2025-08-19 18:15:28 +08:00
commit 611cc5c6dd
32 changed files with 13612 additions and 0 deletions

24
.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Nuxt dev/build outputs
.output
.data
.nuxt
.nitro
.cache
dist
# Node dependencies
node_modules
# Logs
logs
*.log
# Misc
.DS_Store
.fleet
.idea
# Local env files
.env
.env.*
!.env.example

123
README.md Normal file
View File

@ -0,0 +1,123 @@
# EasyAI 远程组件项目
## 项目介绍
本项目支持以插件的方式为EasyAI平台提供远程组件服务以实现对EasyAI平台前端组件库进行扩展实现自定义组件以满足业务需求。
## 功能描述
- 支持扩展绘画组件库
- 支持扩展首页组件库(即将上线)
## 组件开发
### 项目启动
1. 安装依赖
```bash
pnpm install
```
2. 启动项目
```bash
pnpm dev
```
3. 访问地址http://localhost:3201
4. 组件目录components
5. 维护组件信息目录manifest
6. 组件调试入口: app.vue
7. EasyAI平台全局注入的数据和方法: types/common.ts
1. 注入的素材库数据GlobalInjectMaterials
- mock数据composables/mock/material.data.ts
- 在组件中使用components/RecommendedImages.vue
```ts
const { materials: materialList } =
inject<GlobalInjectMaterials>(GlobalInjectKeyConst.AllMaterials, {
materials: ref<IImageSource>([]),
}) || {};
```
2. 注入的上传文件方法GlobalInjectUploadFileToOSS
- 在组件中使用components/drawPanne/ImageUpload.vue
```ts
const { useUtilsUploadFileToOSS } = inject<GlobalInjectUploadFileToOSS>(
GlobalInjectKeyConst.UploadFileToOSS,
{
useUtilsUploadFileToOSS: async (file: File | Blob, filename?: string) => "",
},
);
```
## 示例组件 (图片上传组件)
1. 组件位置components/drawPanne/ImageUpload.vue
正常的nuxt(vue3)项目组件开发直接在components文件夹下创建即可
2. 维护组件信息文件manifest/ImageUpload.ts
需要参照IComponentMateInfo类型约束文件进行定义
```typescript
export default {
name: "DrawImageUpload", // 组件名称
path: "./components/drawPanne/ImageUpload.vue", // 组件路径
scenes: ComponentSceneConst.DrawPanne, // 组件场景
description: "A image upload component", // 组件描述
data: { // 组件数据
paramName: "upload_image_path", // 参数名称
label: "图片上传", // 组件标签
icon: "icon-park:upload-picture", // 组件图标
group: ComponentGroupConst.IMAGE, // 组件分组
isRefComponent: true, // 是否为引用组件 (引用组件,在执行绘画的时候会执行参数赋值操作)
},
} satisfies IComponentMateInfo;
```
3. 组件调试入口app.vue
```text
<template>
<div class="remote-ui">
<!-- 调试对象-->
<DrawPanneImageUpload />
</div>
</template>
<script lang="ts" setup>
import {
GlobalInjectKeyConst,
type GlobalInjectMaterials,
} from "~/types/common";
import { MockMaterials } from "~/composables/mock/material.data";
const materialData = ref(MockMaterials);
/**
* EasyAI平台已全局注入素材库库信息这里使用mock数据用于调试
*/
provide<GlobalInjectMaterials>(GlobalInjectKeyConst.AllMaterials, {
materials: materialData,
refreshMaterials: async () => [],
});
</script>
```
## 部署
1. 打包
```bash
pnpm build
```
2. 启动项目
```bash
pnpm serve
```
3. 访问
```bash
http://localhost:3200
```
## 使用

22
app.vue Normal file
View File

@ -0,0 +1,22 @@
<template>
<div class="remote-ui">
<!-- 调试对象-->
<DrawPanneImageUpload />
</div>
</template>
<script lang="ts" setup>
import {
GlobalInjectKeyConst,
type GlobalInjectMaterials,
} from "~/types/common";
import { MockMaterials } from "~/composables/mock/material.data";
const materialData = ref(MockMaterials);
/**
* EasyAI平台已全局注入素材库库信息这里使用mock数据用于调试
*/
provide<GlobalInjectMaterials>(GlobalInjectKeyConst.AllMaterials, {
materials: materialData,
refreshMaterials: async () => [],
});
</script>

3
assets/css/tailwind.css Normal file
View File

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@ -0,0 +1,54 @@
<script setup lang="ts">
import { useUploadImage } from "~/composables/draw/useUploadImage";
const props = withDefaults(
defineProps<{
maxSizeMB?: number;
}>(),
{ maxSizeMB: 5 },
);
const image = defineModel<string>();
const updateImage = (file: File) => {
image.value = URL.createObjectURL(file);
};
const { open } = useUploadImage(updateImage, props.maxSizeMB);
</script>
<template>
<div class="h-full w-full relative group">
<img
class="h-full w-full object-contain image-bg rounded"
:src="image"
alt="show image"
/>
<div
class="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2"
>
<div
class="flex items-center p-2 px-3 gap-3 invisible group-hover:visible rounded-lg bg-[rgba(var(--v-theme-background),0.4)] backdrop-blur-md cursor-pointer"
>
<a-tooltip :title="1">
<icon name="mage:image-upload" size="18" @click="open" />
</a-tooltip>
<a-tooltip :title="2">
<icon
name="material-symbols:delete-outline"
size="18"
@click.stop="image = undefined"
/>
</a-tooltip>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.image-bg {
@apply backdrop-blur-md bg-gradient-to-r
from-gray-300 via-gray-50 to-gray-300
dark:from-gray-600 dark:via-gray-300 dark:to-gray-600;
}
</style>

View File

@ -0,0 +1,52 @@
<script setup lang="ts">
import { useUploadImage } from "~/composables/draw/useUploadImage";
const props = withDefaults(
defineProps<{
maxSizeMB?: number;
}>(),
{ maxSizeMB: 5 },
);
const image = defineModel<string>();
const updateImage = (file: File) => {
image.value = URL.createObjectURL(file);
};
const { open, handleDrop, handlePaste } = useUploadImage(
updateImage,
props.maxSizeMB,
);
onMounted(() => {
window.addEventListener("paste", handlePaste);
});
onBeforeUnmount(() => {
window.removeEventListener("paste", handlePaste);
});
</script>
<template>
<div
class="aspect-[5/2] flex justify-center items-center cursor-pointer"
@click="open"
>
<div
class="flex flex-col gap-2 justify-center items-center rounded-lg"
@dragover.prevent
@drop.prevent="handleDrop"
>
<slot name="description" :max-size-m-b="maxSizeMB">
<icon name="mdi:image-plus-outline" size="24" />
<p class="text-md">点击/拖拽/粘贴</p>
<p class="text-xs text-gray-500">
请上传图片文件文件大小不超过{{ maxSizeMB }}MB
</p>
</slot>
</div>
</div>
</template>
<style scoped lang="scss"></style>

View File

@ -0,0 +1,170 @@
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import {
GlobalInjectKeyConst,
type GlobalInjectMaterials,
} from "~/types/common";
interface IImageSource {
_id: string;
url: string;
tags: string[];
}
const { materials: materialList } =
inject<GlobalInjectMaterials>(GlobalInjectKeyConst.AllMaterials, {
materials: ref<IImageSource>([]),
}) || {};
const emit = defineEmits(["update-image"]);
const pageSize = defineModel<number>({ default: 10 });
const showRecommendedImage = computed(() => {
return materialList.value?.length > 0;
});
//
const visibleMaterials = ref<IImageSource[]>([]);
// pageSize
const showRefreshButton = computed(() => {
return materialList.value?.length > pageSize.value;
});
// pageSize
const initVisibleMaterials = () => {
if (!materialList.value?.length) return;
visibleMaterials.value = getRandomMaterials(
materialList.value,
pageSize.value,
);
};
// count
const getRandomMaterials = (
list: IImageSource[],
count: number,
): IImageSource[] => {
if (!list.length) {
return [];
}
const shuffled = [...list].sort(() => Math.random() - 0.5);
if (shuffled.length >= count) {
return shuffled.slice(0, count);
} else {
//
const times = Math.ceil(count / shuffled.length);
const extended = Array(times).fill(shuffled).flat();
return extended.slice(0, count);
}
};
const isRotating = ref(false);
const refresh = () => {
if (isRotating.value) return; //
isRotating.value = true;
//
visibleMaterials.value = getRandomMaterials(
materialList.value,
pageSize.value,
);
// 0.5s
setTimeout(() => {
isRotating.value = false;
}, 500);
};
const handleSetImage = (url: string) => {
emit("update-image", url);
};
const visibleMap = reactive<Record<string, boolean>>({});
const hoveringMap = reactive<Record<string, boolean>>({});
const timerMap: Record<string, ReturnType<typeof setTimeout>> = {};
const startDelay = (id: string) => {
hoveringMap[id] = true;
if (timerMap[id]) clearTimeout(timerMap[id]);
timerMap[id] = setTimeout(() => {
if (hoveringMap[id]) {
visibleMap[id] = true;
}
}, 500);
};
const cancelDelay = (id: string) => {
hoveringMap[id] = false;
if (timerMap[id]) clearTimeout(timerMap[id]);
visibleMap[id] = false;
};
onMounted(async () => {
initVisibleMaterials();
watch(pageSize, () => {
initVisibleMaterials();
});
});
</script>
<template>
<div
v-if="showRecommendedImage"
class="px-2 py-1 flex flex-row items-center gap-2 rounded"
>
<div class="text-sm mb-2 flex-shrink-0">推荐:</div>
<div
class="grid gap-2"
:style="`grid-template-columns: repeat(${pageSize}, minmax(0, 1fr))`"
>
<div
v-for="material in visibleMaterials"
:key="material._id"
class="cursor-pointer"
@click="handleSetImage(material.url)"
>
<div
class="inline-block"
@mouseenter="startDelay(material._id)"
@mouseleave="cancelDelay(material._id)"
@dragstart="cancelDelay(material._id)"
>
<a-popover :open="!!visibleMap[material._id]">
<img
:src="material.url"
alt="img"
class="object-cover w-full rounded aspect-[1/1]"
/>
<template #content>
<img
:src="material.url"
alt="img"
class="w-48 h-auto object-contain border cursor-pointer"
/>
</template>
</a-popover>
</div>
</div>
</div>
<div
v-if="showRefreshButton"
class="cursor-pointer text-sm flex-shrink-0"
@click="refresh"
>
<a-popover>
<icon
name="hugeicons:exchange-01"
size="20"
:class="{ 'animate-spin': isRotating }"
/>
<template #content>
<div class="text-xs">刷新</div>
</template>
</a-popover>
</div>
</div>
</template>
<style scoped lang="scss"></style>

View File

@ -0,0 +1,80 @@
<script setup lang="ts">
import {
GlobalInjectKeyConst,
type GlobalInjectUploadFileToOSS,
} from "~/types/common";
import { ref } from "vue";
interface Props {
title?: string; //
paramName?: string;
params?: { name: string; param?: string }[];
}
const props = withDefaults(defineProps<Props>(), {
title: "",
paramName: "upload_image_path",
});
const { useUtilsUploadFileToOSS } = inject<GlobalInjectUploadFileToOSS>(
GlobalInjectKeyConst.UploadFileToOSS,
{
useUtilsUploadFileToOSS: async (file: File | Blob, filename?: string) => "",
},
);
const image = ref<string | undefined>();
const handleSetImage = (url: string) => {
image.value = url;
};
const pageSize = ref<number>(8);
const handleUploadImg = async (image?: string) => {
if (!image) return;
if (image.startsWith("http")) {
return image;
}
const blob = await ImageUrlToBlob(image);
const fileName = `upload_image_${new Date().getTime()}.png`;
return await useUtilsUploadFileToOSS(blob, fileName);
};
const handleBindParams = async () => {
return {
[props.paramName]: await handleUploadImg(image.value),
};
};
defineExpose({
handleBindParams,
});
</script>
<template>
<div class="max-w-[460px] mt-1">
<div class="flex items-center py-1">
<icon name="icon-park:upload-picture" size="20" class="mr-1" />
{{ title || "图片上传" }}
</div>
<div class="flex flex-col items-center border rounded">
<!-- 图片上传预览区域 -->
<div class="h-48 w-full flex justify-center">
<!-- 显示选中图片 -->
<div v-if="image" class="p-2 w-full">
<ImagePreviewWithUpload v-model="image" />
</div>
<!-- 图片上传 -->
<ImageUploadDropPasteClick v-else v-model="image" />
</div>
<div>
<RecommendedImages v-model="pageSize" @update-image="handleSetImage" />
</div>
</div>
</div>
</template>
<style scoped></style>

View File

@ -0,0 +1,53 @@
import { useFileDialog } from "@vueuse/core";
export const useUploadImage = (
updateImageFn: (file: File) => void,
maxSizeMB: number = 5,
) => {
// 打开文件选择对话框
const { open, onChange } = useFileDialog({
accept: "image/*",
multiple: false,
});
const handleUploadFile = async (files: File[] | FileList) => {
const file = files?.[0];
if (!file) return;
const sizeMB = file.size / (1024 * 1024);
if (sizeMB > maxSizeMB) {
return;
}
updateImageFn(file);
};
// 监听选择的文件
onChange(async (files) => {
if (files) {
await handleUploadFile(files);
}
});
// 拖拽上传
const handleDrop = (event: DragEvent) => {
if (event.dataTransfer?.files) {
handleUploadFile(event.dataTransfer.files).then();
}
};
// 粘贴上传
const handlePaste = (event: ClipboardEvent) => {
const file = Array.from(event.clipboardData?.items || [])
.find((item) => item.type.startsWith("image/"))
?.getAsFile();
if (file) handleUploadFile([file]).then();
};
return {
open,
handleDrop,
handlePaste,
};
};

1
composables/index.ts Normal file
View File

@ -0,0 +1 @@
export * from "./meta";

View File

@ -0,0 +1,2 @@
export * from "./interface/meta.interface";
export * from "./interface/drawPanne.interface";

View File

@ -0,0 +1,204 @@
/**
*
*/
export const ComponentGroupConst = {
BASE: "base", // 基础组件
PROMPT: "prompt", // 提示词
HINT: "hint", // 提示类
SELECT: "select", // 下拉框
COLOR_SELECT: "color_select",
IMAGE: "image", // 图片组件
OUTPUT: "output", // 产出,无实际组件
BUSSINESS: "business",
} as const;
export type ComponentGroupType =
(typeof ComponentGroupConst)[keyof typeof ComponentGroupConst];
/** 动态参数 */
export interface IDynamicOptions {
min?: number;
max?: number;
step?: number;
hint?: string;
href?: string;
required?: boolean;
seedLength?: number; // 随机种子的长度
imageSelectItems?: IImageSelectItem[];
selectItems?: ISelectItem[];
enableLLM?: boolean; // 是否启用大模型微调功能
enableTranslate?: boolean; // 是否启用翻译功能
enableAutoTranslate?: boolean; // 是否启用自动翻译功能
presetPrompt?: string;
prePendPositive?: string; // 前置追加正向提示词
separator?: string; // 分割符号
limit?: number;
maskDomain?: number;
modelType?: PlatformModelType;
templateText?: string;
templateOptions?: Record<string, { label: string; value: string }[]>;
}
/** 预览选择组件参数 */
export interface IImageSelectItem {
src: string;
title: string;
value: string | number | boolean;
valueExtra: string | number | boolean;
valueExtra2?: string | number | boolean;
}
/** picker 选项 */
export interface ISelectItem {
label: string;
value: string | number | boolean;
}
/** 模型种类 */
export const PlatformModelTypeConst = {
IMAGE_GENERATE: "image_generate",
IMAGE_EDIT: "image_edit",
IMAGE_ANALYSIS: "image_analysis",
TEXT_GENERATE: "text_generate",
VIDEO_GENERATE: "video_generate",
IMAGE_TO_VIDEO: "image_to_video",
TOOLS_CALL: "tools_call",
AUDIO_GENERATE: "audio_generate",
TEXT_TO_MODEL: "text_to_model",
IMAGE_TO_MODEL: "image_to_model",
MULTIVIEW_TO_MODEL: "multiview_to_model",
} as const;
export type PlatformModelType =
(typeof PlatformModelTypeConst)[keyof typeof PlatformModelTypeConst];
// 用在外部接口的匹配
export interface ComfyUIProperties {
ckpt_name?: string;
lora_name?: string;
positive?: string;
positive_template?: string;
negative?: string;
width?: number;
height?: number;
seed?: number;
image_path?: string; // 整个工作流只有一张图片输入,一般使用这个参数
image_path_mask?: string; // 遮罩图片
image_path_face?: string; // 换脸场景,脸部参考图片
image_path_style?: string; // 参考图场景例如ipdapter
denoise?: number;
output?: string;
// 视频参数
video_frames?: number;
fps?: number;
motion_bucket_id?: number;
augmentation_level?: number;
filename_prefix?: string;
cfg?: number;
steps?: number;
min_cfg?: number;
upscale_by?: number; // 放大倍数
// SD3专属参数
sd3_aspect_ratio?: SD3AspectRatio;
// 自定义下拉列表
custom_dropselect?: string;
custom_dropselect_number?: number;
advance_select_image_preview?: string; // 高级选择组件,带图片样式预览
advance_select_single_chips?: string; // 高级选择组件,纸片组单选
advance_select_multi_chips?: string; // 高级选择组件,纸片组多选
// 颜色选择
advance_select_color: string;
// 颜色预览选择
advance_select_preview_color: string;
// 在线遮罩编辑
advance_onlineEdit_origin?: string; // 在线遮罩编辑,原图(图像图层部分)
advance_onlineEdit_mask?: string; // 在线遮罩编辑,遮罩部分 advance_onlineEdit_origin?: string //在线遮罩编辑,原图(图像图层部分)
advance_onlineEdit_origin_2?: string;
advance_onlineEdit_mask_2?: string;
// 生成的图像批次数量
batch_size?: number;
// 自定义数字滑块
custom_number_slider?: number;
// 自定义参数
custom_number?: number;
custom_string?: string;
// 自定义批量处理
custom_batch_image_path_origin?: string;
custom_batch_image_path_origin_2?: string;
// 自定义提示信息
custom_hint?: string;
// 第二版
// 模型选择
advance_ckpt_name?: string;
advance_lora_name?: string;
// 3d图片路径
advance_image_path_3d?: string;
// 正向提示词
advance_positive?: string;
// 负向提示词
advance_negative?: string;
// 细节增强提示词
advance_adetailer?: string;
// 自定义分组标签
custom_group_label?: string;
// 高级图片上传-参考图
advance_image_upload?: string;
// 高级图片上传-间单图片
advance_sample_image_upload?: string;
// 高级图片上传-背景图片
advance_background_upload?: string;
// 高级图片上传-画板图片
advance_canvas_image_upload?: string;
// 高级图片上传-遮罩图片
advance_canvas_mask_upload?: string;
// 高级图片上传-拆件原图
advance_splitter_image_upload?: string;
// 高级图片上传-拆件遮罩原图
advance_splitter_mask_upload?: string;
// 自定义单选框-参考图
custom_radio?: string;
// 高级自定义数字滑块
advance_custom_number_slider?: number;
// 高级自定义下拉列表
advance_custom_dropselect?: string;
// 高级随机种子
advance_seed?: number;
// 点集
coordinates?: string; // 正样本点集坐标
neg_coordinates?: string; // 负样本点集坐标
canvas_swap_background?: string; // 交换背景图片
canvas_swap_origin?: string; // 交换背景原图
multi_image_path?: string; // 多张图片
multi_source_image_path?: string; // 多源图片选择
canvas_image_group?: string; // 图片合并
image_first_frame?: string; // 首帧图片
image_last_frame?: string; // 尾帧图片
model_alias?: string; // 模型别名
multi_view_images?: string; // 多视图图片上传
upload_image_path?: string; // 图片上传(本地/素材库)
}
export type SD3AspectRatio =
| "1:1"
| "16:9"
| "21:9"
| "2:3"
| "3:2"
| "3:4"
| "4:3"
| "9:16";

View File

@ -0,0 +1,55 @@
import type {
ComfyUIProperties,
ComponentGroupType,
} from "~/composables/meta/interface/drawPanne.interface";
export const ComponentSceneConst = {
DynamicPage: "dynamicPage", // 自定义页面
Workflow: "workflow", // 工作流
DrawPanne: "drawPanne", // 绘画面板
} as const;
export type ComponentSceneType =
(typeof ComponentSceneConst)[keyof typeof ComponentSceneConst];
/**
*
* @property name
*/
export interface IComponentMateInfo<
T extends ComponentSceneType = ComponentSceneType,
> {
name: string; // 组件名称
path: string; // 组件路径
description: string; // 描述
scenes: T; // 所属场景
data?: ComponentDataMap[T]; // 组件数据
}
export type ComponentDataMap = {
[ComponentSceneConst.DrawPanne]: IDrawPanneData;
[ComponentSceneConst.DynamicPage]?: never;
[ComponentSceneConst.Workflow]?: never;
};
/**
*
* @property remoteEntry
*/
export interface IManifestMateInfo {
version: string; // 项目版本号
prefix: string; // 入口文件访问前缀
remoteEntry: string; // 远程入口文件
components: IComponentMateInfo<ComponentSceneType>[]; // 组件信息清单
}
export interface IDrawPanneData {
paramName: keyof ComfyUIProperties;
label?: string;
example?: IDynamicOptions;
icon?: string;
group: ComponentGroupType;
isRefComponent?: boolean;
type?: "number" | "string" | "boolean";
isUnique?: boolean;
}

View File

@ -0,0 +1,52 @@
export const MockMaterials = [
{
_id: "6850e2c2082fb640aaaced81",
url: "https://easyai-1253343986.cos.ap-shanghai.myqcloud.com/image/temps/663e19cd4fa9d8078385c7c9/49817b32-9608-40a8-b205-7670adaf385a%20%281%29.png",
tags: [],
},
{
_id: "6850b98f4755729482155fbf",
tags: [],
url: "https://easyai-1253343986.cos.ap-shanghai.myqcloud.com/image/temps/663e19cd4fa9d8078385c7c9/188g7b2m.png",
},
{
_id: "684ff8e823353e832d3d79cb",
tags: [],
url: "https://easyai-1253343986.cos.ap-shanghai.myqcloud.com/image/temps/663e19cd4fa9d8078385c7c9/4c8bccc1-8870-455f-add8-5086ea02a7d1.png",
},
{
_id: "684ff8e123353e832d3d79c4",
tags: [],
url: "https://easyai-1253343986.cos.ap-shanghai.myqcloud.com/image/temps/663e19cd4fa9d8078385c7c9/WechatIMG3.jpg",
},
{
_id: "684ff8db23353e832d3d79bd",
tags: [],
url: "https://easyai-1253343986.cos.ap-shanghai.myqcloud.com/image/temps/663e19cd4fa9d8078385c7c9/6df3fc3831556f32120fece8f58e91d7a4ef5d29f65675a697378d05bce889ab.png",
},
{
_id: "684ff8d423353e832d3d79b7",
tags: [],
url: "https://easyai-1253343986.cos.ap-shanghai.myqcloud.com/image/temps/663e19cd4fa9d8078385c7c9/YF1qBd-%25E6%2594%25B9%25E6%25AC%25BE%25E5%25A4%25A7%25E5%25B8%2588_00140_%20%282%29.png",
},
{
_id: "684ff8cc23353e832d3d79b0",
tags: [],
url: "https://easyai-1253343986.cos.ap-shanghai.myqcloud.com/image/temps/663e19cd4fa9d8078385c7c9/92e0df75-87ab-44d1-bb6a-f575d9fcb390.png",
},
{
_id: "684ff80323353e832d3d7973",
tags: [],
url: "https://easyai-1253343986.cos.ap-shanghai.myqcloud.com/image/temps/663e19cd4fa9d8078385c7c9/xCojEk-ComfyUI_00120_.png",
},
{
_id: "684ff7fd23353e832d3d796d",
tags: [],
url: "https://easyai-1253343986.cos.ap-shanghai.myqcloud.com/image/temps/663e19cd4fa9d8078385c7c9/lyZrMN-ComfyUI_01373_.png",
},
{
_id: "684ff7f523353e832d3d7966",
tags: [],
url: "https://easyai-1253343986.cos.ap-shanghai.myqcloud.com/image/temps/663e19cd4fa9d8078385c7c9/Vy_MP8-ps__00002_.png",
},
];

12
eslint.config.mjs Normal file
View File

@ -0,0 +1,12 @@
import nuxtConfig from "@nuxtjs/eslint-config";
export default [
nuxtConfig, // 直接包含继承的配置对象
{
languageOptions: {
globals: {
defineNuxtConfig: "readonly",
},
},
},
];

4
index.ts Normal file
View File

@ -0,0 +1,4 @@
import { loadRemoteStyle } from "./utils/loadStyle";
// 动态注入 Tailwind CSS
loadRemoteStyle("/_remote-ui-kit/style.css"); // 部署后对应 CSS URL

17
manifest/ImageUpload.ts Normal file
View File

@ -0,0 +1,17 @@
import { ComponentSceneConst, type IComponentMateInfo } from "~/composables";
import { ComponentGroupConst } from "~/composables";
export default {
name: "DrawImageUpload", // 组件名称
path: "./components/drawPanne/ImageUpload.vue", // 组件路径
scenes: ComponentSceneConst.DrawPanne, // 组件场景
description: "A image upload component", // 组件描述
data: {
// 组件数据
paramName: "upload_image_path", // 参数名称
label: "图片上传", // 组件标签
icon: "icon-park:upload-picture", // 组件图标
group: ComponentGroupConst.IMAGE, // 组件分组
isRefComponent: true, // 是否为引用组件 (引用组件,在执行绘画的时候会执行参数赋值操作)
},
} satisfies IComponentMateInfo;

View File

@ -0,0 +1,50 @@
import { defineNuxtModule } from "@nuxt/kit";
import fs from "fs";
import path from "path";
import pkg from "../package.json";
export default defineNuxtModule({
meta: { name: "generate-manifest" },
async setup(_, nuxt) {
const generateManifest = () => {
const manifestDir = path.resolve(nuxt.options.rootDir, "manifest");
if (!fs.existsSync(manifestDir)) {
return;
}
// 只读取 ts 文件
const files = fs
.readdirSync(manifestDir)
.filter((f) => f.endsWith(".ts"));
const components = files.map((f) => {
const mod = require(path.join(manifestDir, f));
return mod.default;
});
const remoteEntry = `remoteEntry_${pkg.version}.js`;
const outPath = path.join(process.cwd(), ".nuxt/generated/manifest.ts"); // 固定目录
fs.mkdirSync(path.dirname(outPath), { recursive: true });
fs.writeFileSync(
outPath,
`// ⚠️ 自动生成,请勿修改
export const manifestInfo = ${JSON.stringify(
{ version: pkg.version, prefix: "_nuxt", remoteEntry, components },
null,
2,
)};
`,
"utf-8",
);
return;
};
// 初始化就生成一次,保证 dev/build 第一阶段文件存在
generateManifest();
// build 前再生成一次
nuxt.hooks.hook("nitro:build:before", generateManifest);
},
});

147
nuxt.config.ts Normal file
View File

@ -0,0 +1,147 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
import federation from "@originjs/vite-plugin-federation";
import topLevelAwait from "vite-plugin-top-level-await";
import { join } from "path";
import { promises as fs } from "fs";
export default defineNuxtConfig({
compatibilityDate: "2025-05-15",
devtools: { enabled: false },
imports: {
dirs: ["composables", "composables/**"],
},
modules: [
"@nuxt/icon",
"@nuxt/eslint",
"@nuxtjs/tailwindcss",
"~/modules/generate-manifest",
],
// ssr: false,
css: ["~/assets/css/tailwind.css"],
postcss: {
plugins: {
tailwindcss: {},
autoprefixer: {},
"postcss-prefix-selector": {
prefix: ".remote-ui", // 远程组件根 class
transform(prefix: string, selector: string, prefixedSelector: string) {
// 保留全局基础样式
if (selector.startsWith("html") || selector.startsWith("body")) {
return selector;
}
return prefixedSelector;
},
},
},
},
eslint: {
config: {
stylistic: {
commaDangle: "never",
braceStyle: "1tbs",
},
},
},
vite: {
$client: {
base: process.env.NODE_ENV === "production" ? "/" : undefined,
},
plugins: [
topLevelAwait({
promiseExportName: "__tla",
promiseImportName: (i) => `__tla_${i}`,
}),
],
build: {
lib: {
entry: "./index.ts",
name: "RemoteUI",
fileName: "remote-ui-kit",
formats: ["es"],
},
},
},
hooks: {
"vite:extendConfig": async (config, { isClient }) => {
if (!isClient) return;
// 动态导入 manifestdev/build 都安全
let manifest;
try {
// Nuxt buildDir 是 .nuxt
const manifestPath = join(process.cwd(), ".nuxt/generated/manifest.ts");
// 检查文件是否存在
await fs.access(manifestPath);
// 动态导入
manifest = await import(manifestPath);
} catch (err) {
console.warn("manifest not found yet, using empty fallback.");
manifest = {
manifestInfo: { remoteEntry: "remoteEntry.js" },
exposes: {},
};
}
const components = manifest.manifestInfo?.components || [];
const exposes = components.map((c: { name: string; path: string }) => {
return { [c.name]: c.path } as const;
});
config.plugins = config.plugins || [];
config.plugins.push(
federation({
name: "dynamic-remote",
filename: manifest.manifestInfo?.remoteEntry || "remoteEntry.js",
exposes: exposes || {},
shared: { vue: { generate: false } },
}),
);
},
},
routeRules: {
"/_nuxt/**": {
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
},
},
},
icon: {
serverBundle: {
collections: [
"ant-design",
"carbon",
"duo-icons",
"fluent",
"healthicons",
"hugeicons",
"icon-park",
"line-md",
"lsicon",
"lucide",
"material-symbols",
"material-symbols-light",
"mdi",
"mingcute",
"ph",
"ri",
"si",
"solar",
"stash",
"streamline-plump",
"tabler",
"uil",
"ic",
"akar-icons",
], // 本地打包的内容
// remote: 'jsdelivr' // 'unpkg' or 'github-raw', or a custom function
},
localApiEndpoint: "/icon/_nuxt_icon",
},
});

68
package.json Normal file
View File

@ -0,0 +1,68 @@
{
"name": "easyai-remote-ui-kit",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"prebuild": "node scripts/updateVersion.js",
"build": "nuxt build",
"dev": "nuxt dev --port 3201",
"generate": "nuxt generate",
"preview": "nuxt preview PORT=3202",
"postinstall": "nuxt prepare",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"serve": "PORT=3200 node .output/server/index.mjs --cors --single"
},
"dependencies": {
"@ant-design-vue/nuxt": "1.4.6",
"@nuxt/icon": "^2.0.0",
"@nuxtjs/tailwindcss": "^6.14.0",
"@vueuse/core": "^13.6.0",
"ant-design-vue": "4.2.6",
"nuxt": "^4.0.3",
"vue": "^3.5.14",
"vue-router": "^4.5.1"
},
"devDependencies": {
"@iconify-json/akar-icons": "^1.2.7",
"@iconify-json/ant-design": "^1.2.5",
"@iconify-json/carbon": "^1.2.11",
"@iconify-json/duo-icons": "^1.2.2",
"@iconify-json/fluent": "^1.2.28",
"@iconify-json/healthicons": "^1.2.10",
"@iconify-json/hugeicons": "^1.2.6",
"@iconify-json/ic": "^1.2.4",
"@iconify-json/icon-park": "^1.2.2",
"@iconify-json/line-md": "^1.2.8",
"@iconify-json/lsicon": "^1.2.5",
"@iconify-json/lucide": "^1.2.62",
"@iconify-json/material-symbols": "^1.2.31",
"@iconify-json/material-symbols-light": "^1.2.31",
"@iconify-json/mdi": "^1.2.3",
"@iconify-json/mingcute": "^1.2.3",
"@iconify-json/ph": "^1.2.2",
"@iconify-json/ri": "^1.2.5",
"@iconify-json/si": "^1.2.3",
"@iconify-json/solar": "^1.2.2",
"@iconify-json/stash": "^1.2.4",
"@iconify-json/streamline-plump": "^1.2.0",
"@iconify-json/tabler": "^1.2.20",
"@iconify-json/uil": "^1.2.3",
"@nuxt/eslint": "^1.4.1",
"@nuxt/kit": "^4.0.3",
"@originjs/vite-plugin-federation": "^1.4.1",
"@tailwindcss/cli": "^4.1.12",
"@tailwindcss/postcss": "^4.1.12",
"@vitejs/plugin-vue": "^6.0.1",
"autoprefixer": "^10.4.21",
"eslint": "^9.27.0",
"eslint-config-prettier": "^10.1.5",
"eslint-plugin-prettier": "^5.4.0",
"postcss-prefix-selector": "^2.1.1",
"prettier": "^3.5.3",
"rollup-plugin-postcss": "^4.0.2",
"sass": "1.77.7",
"vite-plugin-top-level-await": "^1.6.0"
}
}

12201
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

2
public/robots.txt Normal file
View File

@ -0,0 +1,2 @@
User-Agent: *
Disallow:

14
scripts/updateVersion.js Normal file
View File

@ -0,0 +1,14 @@
import fs from "fs";
import path from "path";
const pkgPath = path.resolve(process.cwd(), "package.json");
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
// 自动 patch
const versionParts = pkg.version.split(".").map(Number);
versionParts[2] += 1; // 每次 build 自动 +1
pkg.version = versionParts.join(".");
// 写回 package.json
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2), "utf-8");
console.log(`Version updated to ${pkg.version}`);

View File

@ -0,0 +1,33 @@
import { join } from "path";
import { promises as fs } from "fs";
import { defineEventHandler } from "h3";
export default defineEventHandler(async (event) => {
// 跨域
event.node.res.setHeader("Access-Control-Allow-Origin", "*");
event.node.res.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS");
event.node.res.setHeader("Access-Control-Allow-Headers", "Content-Type");
// 支持 preflight OPTIONS 请求
if (event.node.req.method === "OPTIONS") {
event.node.res.statusCode = 204;
event.node.res.end();
return;
}
try {
// Nuxt buildDir 是 .nuxt
const manifestPath = join(process.cwd(), ".nuxt/generated/manifest.ts");
// 检查文件是否存在
await fs.access(manifestPath);
// 动态导入
const { manifestInfo } = await import(manifestPath);
return manifestInfo;
} catch (err) {
console.warn("manifest not found", err);
return { version: "0.0.0", components: [] };
}
});

3
server/tsconfig.json Normal file
View File

@ -0,0 +1,3 @@
{
"extends": "../.nuxt/tsconfig.server.json"
}

75
tailwind.config.js Normal file
View File

@ -0,0 +1,75 @@
// tailwind.config.js
/** @type {import('tailwindcss').Config} */
import { customTheme } from "./utils/customTheme";
function generateThemeColorScale(colorName) {
const rgbValue = hexToRgb(customTheme.colors[colorName]);
return {
[`theme-${colorName}`]: {
DEFAULT: `oklch(from rgb(${rgbValue}) l c h)`,
50: `oklch(from rgb(${rgbValue}) calc(l + 0.3) calc(c * 0.2) h)`,
100: `oklch(from rgb(${rgbValue}) calc(l + 0.25) calc(c * 0.3) h)`,
200: `oklch(from rgb(${rgbValue}) calc(l + 0.2) calc(c * 0.4) h)`,
300: `oklch(from rgb(${rgbValue}) calc(l + 0.1) calc(c * 0.6) h)`,
400: `oklch(from rgb(${rgbValue}) calc(l + 0.05) calc(c * 0.8) h)`,
500: `oklch(from rgb(${rgbValue}) l c h)`,
600: `oklch(from rgb(${rgbValue}) calc(l - 0.05) calc(c * 0.9) h)`,
700: `oklch(from rgb(${rgbValue}) calc(l - 0.1) calc(c * 0.8) h)`,
800: `oklch(from rgb(${rgbValue}) calc(l - 0.15) calc(c * 0.7) h)`,
900: `oklch(from rgb(${rgbValue}) calc(l - 0.2) calc(c * 0.6) h)`,
},
};
}
// 辅助函数:将十六进制颜色转换为 RGB 格式
function hexToRgb(hex) {
// 移除 # 号(如果存在)
hex = hex.replace(/^#/, "");
// 解析十六进制值
const bigint = parseInt(hex, 16);
const r = (bigint >> 16) & 255;
const g = (bigint >> 8) & 255;
const b = bigint & 255;
return `${r}, ${g}, ${b}`;
}
const colorTheme = {
...generateThemeColorScale("primary"),
...generateThemeColorScale("success"),
...generateThemeColorScale("warning"),
...generateThemeColorScale("error"),
...generateThemeColorScale("info"),
...generateThemeColorScale("background"),
...generateThemeColorScale("surface"),
};
module.exports = {
content: [
"./components/**/*.{vue,js,ts}",
"./layouts/**/*.vue",
"./pages/**/*.vue",
"./composables/**/*.{js,ts}",
"./app.vue",
"./plugins/**/*.{js,ts}",
],
theme: {
screens: {
sm: "640px",
md: "768px",
lg: "1024px",
xl: "1280px",
"2xl": "1536px",
"3xl": "1920px",
},
extend: {
colors: colorTheme,
},
},
corePlugins: {
// 可按需关闭某些插件,减少生成类
// preflight: false, // 如果你不想覆盖 base 样式
},
plugins: [],
};

4
tsconfig.json Normal file
View File

@ -0,0 +1,4 @@
{
// https://nuxt.com/docs/guide/concepts/typescript
"extends": "./.nuxt/tsconfig.json"
}

51
types/common.ts Normal file
View File

@ -0,0 +1,51 @@
export enum MaterialTypeEnum {
common = "common", // 公共级别,用户素材
personal = "personal", // 个人级别,用户个人素材
}
export type FileType = "image" | "video" | "audio" | "3d" | "text";
export interface IMaterial {
_id: string;
name: string; // 素材名称
ext: string; // 素材后缀
type: MaterialTypeEnum; // 素材类型
fileType: FileType; // 素材文件类型
description: string; // 素材描述
tags: string[]; // 素材标签
width: number; // 素材宽度
height: number; // 素材高度
size: number; // 素材大小
url: string; // 素材链接
mtl?: string; // 3d材质链接
user_id: string; // 上传者
organizations: string[]; // 组织
created_at: number; // 创建时间
updated_at: number; // 更新时间
preview: string; // 素材预览图
}
export const GlobalInjectKeyConst = {
AllMaterials: "allMaterials",
UploadFileToOSS: "useUtilsUploadFileToOSS",
} as const;
export type GlobalInjectKeyEnum =
(typeof GlobalInjectKeyConst)[keyof typeof GlobalInjectKeyConst];
/** 全局注入的素材数据 */
export interface GlobalInjectMaterials {
materials: Ref<IMaterial[], IMaterial[]>;
refreshMaterials: () => Promise<void>;
}
/** 全局注入函数 */
/**
* OSS函数
*/
export interface GlobalInjectUploadFileToOSS {
useUtilsUploadFileToOSS: (
file: File | Blob,
filename?: string,
) => Promise<string>;
}

12
utils/customTheme.ts Normal file
View File

@ -0,0 +1,12 @@
export const customTheme = {
colors: {
primary: '#4338CA',
secondary: '#03DAC6',
success: '#4CAF50',
info: '#2196F3',
warning: '#FB8C00',
error: '#FC3C56',
surface: '#FFFFFF',
background: '#F5F5F5'
}
}

15
utils/fileUtils.ts Normal file
View File

@ -0,0 +1,15 @@
/**
* URL转为blob
*
* @param url
* @constructor
*/
export const ImageUrlToBlob = async (url: string): Promise<Blob> => {
return new Promise((resolve, reject) => {
fetch(url)
.then((r) => r.blob())
.then((blob) => {
resolve(blob);
});
});
};

9
utils/loadStyle.ts Normal file
View File

@ -0,0 +1,9 @@
export function loadRemoteStyle(url: string) {
if (!document.getElementById(url)) {
const link = document.createElement("link");
link.id = url;
link.rel = "stylesheet";
link.href = url;
document.head.appendChild(link);
}
}