mirror of
https://github.com/Megghy/vtsuru.live.git
synced 2025-12-07 02:46:55 +08:00
feat: 更新依赖和增强动态表单功能
- 在 package.json 中添加 hammerjs 和 tui-image-editor 依赖 - 在 DynamicForm.vue 中引入并实现装饰性图片功能,支持图片上传、删除和属性调整 - 优化颜色处理逻辑,支持 RGBA 格式 - 更新常量和类型定义,增强代码可读性和可维护性
This commit is contained in:
@@ -1,11 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { getImageUploadModel } from '@/Utils';
|
||||
import { QueryPostAPI } from '@/api/query';
|
||||
import { ConfigItemDefinition, TemplateConfigImageItem } from '@/data/VTsuruTypes';
|
||||
import { ConfigItemDefinition, DecorativeImageProperties, TemplateConfigImageItem, RGBAColor, rgbaToString } from '@/data/VTsuruTypes';
|
||||
import { FILE_BASE_URL, VTSURU_API_URL } from '@/data/constants';
|
||||
import { ArrowDown20Filled, ArrowUp20Filled, Delete20Filled } from '@vicons/fluent';
|
||||
import { Info24Filled } from '@vicons/fluent';
|
||||
import { NButton, NCheckbox, NColorPicker, NEmpty, NForm, NGrid, NInput, NInputNumber, NSlider, NTooltip, NUpload, UploadFileInfo, useMessage } from 'naive-ui';
|
||||
import { NButton, NCard, NCheckbox, NColorPicker, NEmpty, NFlex, NForm, NGrid, NIcon, NInput, NInputNumber, NScrollbar, NSlider, NSpace, NTooltip, NUpload, UploadFileInfo, useMessage } from 'naive-ui';
|
||||
import { h } from 'vue';
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
const message = useMessage();
|
||||
|
||||
@@ -16,6 +19,7 @@ import { onMounted, ref } from 'vue';
|
||||
}>();
|
||||
|
||||
const fileList = ref<{ [key: string]: UploadFileInfo[]; }>({});
|
||||
const selectedImageId = ref<string | null>(null);
|
||||
|
||||
const isUploading = ref(false);
|
||||
|
||||
@@ -70,6 +74,183 @@ import { onMounted, ref } from 'vue';
|
||||
}
|
||||
}
|
||||
|
||||
// --- 颜色转换辅助函数 ---
|
||||
function stringToRgba(colorString: string | null | undefined): RGBAColor {
|
||||
const defaultColor: RGBAColor = { r: 0, g: 0, b: 0, a: 1 }; // 默认黑色不透明
|
||||
if (!colorString) return defaultColor;
|
||||
|
||||
try {
|
||||
// 尝试匹配 rgba(r, g, b, a)
|
||||
const rgbaMatch = colorString.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
|
||||
if (rgbaMatch) {
|
||||
return {
|
||||
r: parseInt(rgbaMatch[1], 10),
|
||||
g: parseInt(rgbaMatch[2], 10),
|
||||
b: parseInt(rgbaMatch[3], 10),
|
||||
a: rgbaMatch[4] !== undefined ? parseFloat(rgbaMatch[4]) : 1,
|
||||
};
|
||||
}
|
||||
|
||||
// 尝试匹配 #RRGGBBAA
|
||||
const hex8Match = colorString.match(/^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i);
|
||||
if (hex8Match) {
|
||||
return {
|
||||
r: parseInt(hex8Match[1], 16),
|
||||
g: parseInt(hex8Match[2], 16),
|
||||
b: parseInt(hex8Match[3], 16),
|
||||
a: parseInt(hex8Match[4], 16) / 255,
|
||||
};
|
||||
}
|
||||
|
||||
// 尝试匹配 #RRGGBB
|
||||
const hex6Match = colorString.match(/^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i);
|
||||
if (hex6Match) {
|
||||
return {
|
||||
r: parseInt(hex6Match[1], 16),
|
||||
g: parseInt(hex6Match[2], 16),
|
||||
b: parseInt(hex6Match[3], 16),
|
||||
a: 1, // Hex6 doesn't have alpha, assume 1
|
||||
};
|
||||
}
|
||||
|
||||
// 尝试匹配 #RGBA
|
||||
const shortHex4Match = colorString.match(/^#?([a-f\d])([a-f\d])([a-f\d])([a-f\d])$/i);
|
||||
if (shortHex4Match) {
|
||||
return {
|
||||
r: parseInt(shortHex4Match[1] + shortHex4Match[1], 16),
|
||||
g: parseInt(shortHex4Match[2] + shortHex4Match[2], 16),
|
||||
b: parseInt(shortHex4Match[3] + shortHex4Match[3], 16),
|
||||
a: parseInt(shortHex4Match[4] + shortHex4Match[4], 16) / 255,
|
||||
};
|
||||
}
|
||||
|
||||
// 尝试匹配 #RGB
|
||||
const shortHex3Match = colorString.match(/^#?([a-f\d])([a-f\d])([a-f\d])$/i);
|
||||
if (shortHex3Match) {
|
||||
return {
|
||||
r: parseInt(shortHex3Match[1] + shortHex3Match[1], 16),
|
||||
g: parseInt(shortHex3Match[2] + shortHex3Match[2], 16),
|
||||
b: parseInt(shortHex3Match[3] + shortHex3Match[3], 16),
|
||||
a: 1, // Hex3 doesn't have alpha, assume 1
|
||||
};
|
||||
}
|
||||
|
||||
console.warn(`无法解析颜色字符串: "${colorString}", 已返回默认颜色`);
|
||||
return defaultColor;
|
||||
} catch (e) {
|
||||
console.error(`解析颜色字符串 "${colorString}" 时出错:`, e);
|
||||
return defaultColor;
|
||||
}
|
||||
}
|
||||
|
||||
// 确保 rgbaToString 也能处理 null/undefined
|
||||
const safeRgbaToString = (color: RGBAColor | null | undefined): string => {
|
||||
return rgbaToString(color ?? { r: 0, g: 0, b: 0, a: 1 }); // 提供默认值以防万一
|
||||
}
|
||||
|
||||
// 装饰图片功能
|
||||
const updateImageProp = (id: string, prop: keyof DecorativeImageProperties, value: any, key: string) => {
|
||||
const images = props.configData[key] as DecorativeImageProperties[];
|
||||
const index = images.findIndex(img => img.id === id);
|
||||
if (index !== -1) {
|
||||
const updatedImages = [...images];
|
||||
updatedImages[index] = { ...updatedImages[index], [prop]: value };
|
||||
props.configData[key] = updatedImages;
|
||||
}
|
||||
};
|
||||
|
||||
const removeImage = (id: string, key: string) => {
|
||||
const images = props.configData[key] as DecorativeImageProperties[];
|
||||
props.configData[key] = images.filter(img => img.id !== id);
|
||||
if (selectedImageId.value === id) {
|
||||
selectedImageId.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const changeZIndex = (id: string, direction: 'up' | 'down', key: string) => {
|
||||
const images = props.configData[key] as DecorativeImageProperties[];
|
||||
const index = images.findIndex(img => img.id === id);
|
||||
if (index === -1) return;
|
||||
const newImages = [...images];
|
||||
if (direction === 'up' && index < newImages.length - 1) {
|
||||
[newImages[index], newImages[index + 1]] = [newImages[index + 1], newImages[index]];
|
||||
} else if (direction === 'down' && index > 0) {
|
||||
[newImages[index], newImages[index - 1]] = [newImages[index - 1], newImages[index]];
|
||||
}
|
||||
newImages.forEach((img, i) => img.zIndex = i + 1);
|
||||
props.configData[key] = newImages;
|
||||
};
|
||||
|
||||
const renderDecorativeImages = (key: string) => {
|
||||
// 获取全局处理器
|
||||
const uploadHandler = (window as any).$upload;
|
||||
const messageHandler = (window as any).$message ?? message;
|
||||
|
||||
return h(NFlex, { vertical: true, size: 'large' }, () => [
|
||||
// 上传按钮
|
||||
h(NUpload, {
|
||||
multiple: true, accept: 'image/*', showFileList: false,
|
||||
'onUpdate:fileList': (fileList: UploadFileInfo[]) => {
|
||||
if (uploadHandler?.upload && fileList.length > 0) {
|
||||
const filesToUpload = fileList.map(f => f.file).filter((f): f is File => f instanceof File);
|
||||
if (filesToUpload.length > 0) {
|
||||
uploadHandler.upload(filesToUpload, '/api/file/upload')
|
||||
.then((results: any[]) => {
|
||||
const newImages: DecorativeImageProperties[] = results.map((result: any, index: number) => ({
|
||||
id: uuidv4(), src: result.url, x: 10 + index * 5, y: 10 + index * 5,
|
||||
width: 20, rotation: 0, opacity: 1,
|
||||
zIndex: (props.configData[key]?.length ?? 0) + index + 1,
|
||||
}));
|
||||
const currentImages = props.configData[key] as DecorativeImageProperties[] || [];
|
||||
props.configData[key] = [...currentImages, ...newImages];
|
||||
})
|
||||
.catch((error: any) => {
|
||||
console.error("图片上传失败:", error);
|
||||
messageHandler?.error("图片上传失败: " + (error?.message ?? error));
|
||||
});
|
||||
}
|
||||
}
|
||||
return [];
|
||||
},
|
||||
}, { default: () => h(NButton, null, () => '添加装饰图片') }),
|
||||
|
||||
// 图片列表
|
||||
h(NScrollbar, { style: { maxHeight: '300px', marginTop: '10px' } }, () => {
|
||||
const images = props.configData[key] as DecorativeImageProperties[] || [];
|
||||
return images.length > 0
|
||||
? images.map((img: DecorativeImageProperties) => {
|
||||
const isSelected = selectedImageId.value === img.id;
|
||||
return h(NCard, {
|
||||
key: img.id, size: 'small', hoverable: true,
|
||||
style: { marginBottom: '10px', cursor: 'pointer', border: isSelected ? '2px solid var(--primary-color)' : '1px solid #eee' },
|
||||
onClick: () => selectedImageId.value = img.id
|
||||
}, {
|
||||
default: () => h(NFlex, { justify: 'space-between', align: 'center' }, () => [
|
||||
h(NFlex, { align: 'center', size: 'small' }, () => [
|
||||
h('img', { src: FILE_BASE_URL + img.src, style: { width: '40px', height: '40px', objectFit: 'contain', marginRight: '10px', backgroundColor: '#f0f0f0' } }),
|
||||
h('span', `ID: ...${img.id.slice(-4)}`)
|
||||
]),
|
||||
h(NSpace, null, () => [
|
||||
h(NButton, { size: 'tiny', circle: true, secondary: true, title: '上移一层', onClick: (e: Event) => { e.stopPropagation(); changeZIndex(img.id, 'up', key); } }, { icon: () => h(NIcon, { component: ArrowUp20Filled }) }),
|
||||
h(NButton, { size: 'tiny', circle: true, secondary: true, title: '下移一层', onClick: (e: Event) => { e.stopPropagation(); changeZIndex(img.id, 'down', key); } }, { icon: () => h(NIcon, { component: ArrowDown20Filled }) }),
|
||||
h(NButton, { size: 'tiny', circle: true, type: 'error', ghost: true, title: '删除', onClick: (e: Event) => { e.stopPropagation(); removeImage(img.id, key); } }, { icon: () => h(NIcon, { component: Delete20Filled }) })
|
||||
])
|
||||
]),
|
||||
footer: () => isSelected ? h(NFlex, { vertical: true, size: 'small', style: { marginTop: '10px' } }, () => [
|
||||
h(NFlex, { align: 'center' }, () => [h('span', { style: { width: '50px' } }, 'X (%):'), h(NInputNumber, { value: img.x, size: 'small', 'onUpdate:value': (v: number | null) => updateImageProp(img.id, 'x', v ?? 0, key), min: 0, max: 100, step: 1 })]),
|
||||
h(NFlex, { align: 'center' }, () => [h('span', { style: { width: '50px' } }, 'Y (%):'), h(NInputNumber, { value: img.y, size: 'small', 'onUpdate:value': (v: number | null) => updateImageProp(img.id, 'y', v ?? 0, key), min: 0, max: 100, step: 1 })]),
|
||||
h(NFlex, { align: 'center' }, () => [h('span', { style: { width: '50px' } }, '宽度(%):'), h(NInputNumber, { value: img.width, size: 'small', 'onUpdate:value': (v: number | null) => updateImageProp(img.id, 'width', v ?? 1, key), min: 1, step: 1 })]),
|
||||
h(NFlex, { align: 'center' }, () => [h('span', { style: { width: '50px' } }, '旋转(°):'), h(NInputNumber, { value: img.rotation, size: 'small', 'onUpdate:value': (v: number | null) => updateImageProp(img.id, 'rotation', v ?? 0, key), min: -360, max: 360, step: 1 }), h(NSlider, { value: img.rotation, 'onUpdate:value': (v: number | number[]) => updateImageProp(img.id, 'rotation', Array.isArray(v) ? v[0] : v ?? 0, key), min: -180, max: 180, step: 1, style: { marginLeft: '10px', flexGrow: 1 } })]),
|
||||
h(NFlex, { align: 'center' }, () => [h('span', { style: { width: '50px' } }, '透明度:'), h(NInputNumber, { value: img.opacity, size: 'small', 'onUpdate:value': (v: number | null) => updateImageProp(img.id, 'opacity', v ?? 0, key), min: 0, max: 1, step: 0.01 }), h(NSlider, { value: img.opacity, 'onUpdate:value': (v: number | number[]) => updateImageProp(img.id, 'opacity', Array.isArray(v) ? v[0] : v ?? 0, key), min: 0, max: 1, step: 0.01, style: { marginLeft: '10px', flexGrow: 1 } })]),
|
||||
h(NFlex, { align: 'center' }, () => [h('span', { style: { width: '50px' } }, '层级:'), h(NInputNumber, { value: img.zIndex, size: 'small', readonly: true })]),
|
||||
]) : null
|
||||
})
|
||||
})
|
||||
: h(NEmpty, { description: '暂无装饰图片' })
|
||||
}),
|
||||
]);
|
||||
};
|
||||
|
||||
function getItems() { }
|
||||
onMounted(() => {
|
||||
props.config?.forEach(item => {
|
||||
@@ -115,6 +296,10 @@ import { onMounted, ref } from 'vue';
|
||||
:is="item.render(configData)"
|
||||
v-if="item.type == 'render'"
|
||||
/>
|
||||
<component
|
||||
:is="renderDecorativeImages(item.key)"
|
||||
v-else-if="item.type == 'decorativeImages'"
|
||||
/>
|
||||
<template v-else-if="item.type == 'string'">
|
||||
<NInput
|
||||
:value="configData[item.key]"
|
||||
@@ -125,9 +310,9 @@ import { onMounted, ref } from 'vue';
|
||||
</template>
|
||||
<NColorPicker
|
||||
v-else-if="item.type == 'color'"
|
||||
:value="configData[item.key]"
|
||||
:value="safeRgbaToString(configData[item.key])"
|
||||
:show-alpha="item.showAlpha ?? false"
|
||||
@update:value="configData[item.key] = $event"
|
||||
@update:value="configData[item.key] = stringToRgba($event)"
|
||||
/>
|
||||
<NInputNumber
|
||||
v-else-if="item.type == 'number'"
|
||||
@@ -146,7 +331,8 @@ import { onMounted, ref } from 'vue';
|
||||
<template v-else-if="item.type == 'boolean'">
|
||||
<NCheckbox
|
||||
:checked="configData[item.key]"
|
||||
@update:checked="configData[item.key] = $event">
|
||||
@update:checked="configData[item.key] = $event"
|
||||
>
|
||||
启用
|
||||
</NCheckbox>
|
||||
<NTooltip
|
||||
|
||||
Reference in New Issue
Block a user