mirror of
https://github.com/Megghy/vtsuru.live.git
synced 2025-12-06 18:36:55 +08:00
feat: 更新OBS组件和路由配置,添加动态九图生成器功能, 修复礼物描述不换行的问题
- 在package.json中添加vue-cropperjs和相关类型定义 - 在obsConstants.ts中新增示例组件和控制器组件定义 - 更新manage.ts路由配置,添加OBS组件库和直播工具箱路由 - 在DynamicForm.vue中移除调试信息输出 - 在PointGoodsItem.vue中优化商品描述的显示逻辑 - 删除不再使用的OBS组件视图文件
This commit is contained in:
@@ -29,6 +29,7 @@
|
||||
"@tauri-apps/plugin-updater": "^2.7.1",
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
"@types/md5": "^2.3.5",
|
||||
"@types/vue-cropperjs": "^4.1.6",
|
||||
"@vicons/fluent": "^0.13.0",
|
||||
"@vitejs/plugin-vue": "^5.2.3",
|
||||
"@vueuse/core": "^13.1.0",
|
||||
@@ -37,6 +38,7 @@
|
||||
"@wangeditor/editor": "^5.1.23",
|
||||
"@wangeditor/editor-for-vue": "^5.1.12",
|
||||
"bilibili-live-ws": "^6.3.1",
|
||||
"cropperjs": "^2.0.0",
|
||||
"crypto-js": "^4.2.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"easy-speech": "^2.4.0",
|
||||
@@ -65,6 +67,7 @@
|
||||
"vite-plugin-oxlint": "^1.3.1",
|
||||
"vite-svg-loader": "^5.1.0",
|
||||
"vue": "3.5.13",
|
||||
"vue-cropperjs": "^5.0.0",
|
||||
"vue-echarts": "^7.0.3",
|
||||
"vue-request": "^2.0.4",
|
||||
"vue-router": "^4.5.1",
|
||||
|
||||
1
src/components.d.ts
vendored
1
src/components.d.ts
vendored
@@ -53,6 +53,7 @@ declare module 'vue' {
|
||||
SongList: typeof import('./components/SongList.vue')['default']
|
||||
SongPlayer: typeof import('./components/SongPlayer.vue')['default']
|
||||
TempComponent: typeof import('./components/TempComponent.vue')['default']
|
||||
ToolDynamicNineGrid: typeof import('./components/manage/tools/ToolDynamicNineGrid.vue')['default']
|
||||
TurnstileVerify: typeof import('./components/TurnstileVerify.vue')['default']
|
||||
UpdateNoteContainer: typeof import('./components/UpdateNoteContainer.vue')['default']
|
||||
UserBasicInfoCard: typeof import('./components/UserBasicInfoCard.vue')['default']
|
||||
|
||||
@@ -414,7 +414,6 @@ import { computed, h, onMounted, ref } from 'vue';
|
||||
function getItems() { }
|
||||
onMounted(() => {
|
||||
props.config?.forEach(item => {
|
||||
console.log(props.configData)
|
||||
if (item.default && !(item.key in props.configData)) {
|
||||
props.configData[item.key] = item.default;
|
||||
}
|
||||
|
||||
@@ -160,6 +160,11 @@ import { NCard, NEllipsis, NEmpty, NFlex, NIcon, NImage, NTag, NText } from 'nai
|
||||
:line-clamp="2"
|
||||
class="description-text"
|
||||
>
|
||||
<template #tooltip>
|
||||
<div style="white-space: pre-wrap;">
|
||||
{{ goods.description ? goods.description : '暂无描述' }}
|
||||
</div>
|
||||
</template>
|
||||
<NText
|
||||
:depth="goods.description ? 1 : 3"
|
||||
:italic="!goods.description"
|
||||
@@ -284,6 +289,7 @@ import { NCard, NEllipsis, NEmpty, NFlex, NIcon, NImage, NTag, NText } from 'nai
|
||||
|
||||
.description-text {
|
||||
margin-bottom: 4px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.tags-container {
|
||||
|
||||
608
src/components/manage/tools/ToolDynamicNineGrid.vue
Normal file
608
src/components/manage/tools/ToolDynamicNineGrid.vue
Normal file
@@ -0,0 +1,608 @@
|
||||
<template>
|
||||
<div class="dynamic-nine-grid-tool">
|
||||
<n-card title="动态九图生成器">
|
||||
<n-space vertical size="large">
|
||||
<div class="upload-section">
|
||||
<n-upload
|
||||
action="#"
|
||||
:show-file-list="false"
|
||||
@change="handleFileChange"
|
||||
accept="image/*"
|
||||
>
|
||||
<n-button type="primary">上传原始图片</n-button>
|
||||
</n-upload>
|
||||
<n-text depth="3" class="mt-1">
|
||||
请上传一张方形或长方形图片,将会自动分割成3x3的九宫格图片
|
||||
</n-text>
|
||||
</div> <div v-if="originalImage" class="cropper-container">
|
||||
<div>
|
||||
<n-text>请确保您上传的图片是方形或近似方形,以获得最佳效果</n-text>
|
||||
</div>
|
||||
<div class="image-preview">
|
||||
<img :src="originalImage" alt="原始图片" class="original-image-preview" />
|
||||
</div> <div class="cropper-controls">
|
||||
<n-button @click="generatePreview" type="primary">生成预览</n-button>
|
||||
<n-upload
|
||||
action="#"
|
||||
:show-file-list="false"
|
||||
@change="handleFileChange"
|
||||
accept="image/*"
|
||||
>
|
||||
<n-button type="warning">重新上传</n-button>
|
||||
</n-upload>
|
||||
</div>
|
||||
</div> <div v-if="croppedSquareImage" class="preview-section">
|
||||
<n-h4>九宫格预览</n-h4>
|
||||
<n-text depth="3">
|
||||
九宫格预览显示了图片分割后的效果,每个格子都可以添加下方图片
|
||||
</n-text>
|
||||
|
||||
<div class="whole-image-preview">
|
||||
<img :src="croppedSquareImage" alt="完整预览" class="whole-preview-img" />
|
||||
<div class="grid-overlay">
|
||||
<div v-for="i in 9" :key="`overlay-${i}`" class="grid-overlay-cell">
|
||||
<div class="cell-number">{{ i }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="nine-grid-preview">
|
||||
<div
|
||||
v-for="i in 9"
|
||||
:key="`grid-${i}`"
|
||||
class="grid-item-container"
|
||||
>
|
||||
<div class="grid-image-container">
|
||||
<div class="grid-position-indicator">第{{ i }}格</div>
|
||||
<div class="grid-image-wrapper">
|
||||
<img
|
||||
:src="croppedSquareImage"
|
||||
alt="九宫格预览"
|
||||
class="grid-image-base"
|
||||
:style="{
|
||||
clipPath: generateClipPath(i-1),
|
||||
transform: 'scale(3)',
|
||||
transformOrigin: calculateTransformOrigin(i-1)
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="additionalImages[i-1]" class="additional-image-preview">
|
||||
<img :src="additionalImages[i-1] || ''" alt="附加图片" />
|
||||
</div>
|
||||
<div class="grid-controls">
|
||||
<n-upload
|
||||
action="#"
|
||||
:show-file-list="false"
|
||||
@change="(data) => handleAdditionalImageChange(data, i - 1)"
|
||||
accept="image/*"
|
||||
>
|
||||
<n-button size="tiny">添加下方图片</n-button>
|
||||
</n-upload>
|
||||
<n-button
|
||||
v-if="additionalImages[i-1]"
|
||||
size="tiny"
|
||||
type="error"
|
||||
@click="() => removeAdditionalImage(i-1)"
|
||||
>
|
||||
删除下方图片
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="action-buttons">
|
||||
<n-button @click="generateFinalImages" type="success" class="mt-2">生成九张图片</n-button>
|
||||
<n-button @click="downloadAllImages" type="info" class="mt-2" :disabled="finalImages.length === 0">
|
||||
打包下载全部
|
||||
</n-button>
|
||||
</div>
|
||||
</div><div v-if="finalImages.length > 0" class="final-images-section">
|
||||
<n-h4>最终图片</n-h4>
|
||||
<n-text depth="3">
|
||||
以下是生成的九宫格图片,您可以单独下载每张图片
|
||||
</n-text>
|
||||
<div class="final-images-grid">
|
||||
<div v-for="(imgDataUrl, index) in finalImages" :key="`final-${index}`" class="final-image-item">
|
||||
<img :src="imgDataUrl" :alt="`最终图片 ${index + 1}`" />
|
||||
<div class="image-number">{{ index + 1 }}</div>
|
||||
<n-button size="small" @click="() => downloadImage(imgDataUrl, `grid_image_${index + 1}.png`)" class="download-button">
|
||||
下载
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</n-space>
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { NCard, NButton, NUpload, NSpace, NH4, NText, useMessage } from 'naive-ui';
|
||||
import type { UploadFileInfo } from 'naive-ui';
|
||||
// 直接引入 vue-cropperjs,它应该会自动包含所需的 CSS
|
||||
import VueCropper from 'vue-cropperjs';
|
||||
import { useFileDialog } from '@vueuse/core';
|
||||
|
||||
const message = useMessage();
|
||||
|
||||
const originalImage = ref<string | null>(null);
|
||||
const croppedSquareImage = ref<string | null>(null);
|
||||
const cropperRef = ref<any>(null); // VueCropper Instance
|
||||
|
||||
const additionalImages = ref<(string | null)[]>(Array(9).fill(null));
|
||||
const finalImages = ref<string[]>([]);
|
||||
|
||||
// 添加九宫格位置控制变量
|
||||
const gridPositions = ref<{ x: number, y: number }[]>(
|
||||
Array(9).fill(null).map(() => ({ x: 0, y: 0 }))
|
||||
);
|
||||
|
||||
const { files, open, reset } = useFileDialog({
|
||||
accept: 'image/*',
|
||||
multiple: false,
|
||||
});
|
||||
|
||||
const handleFileChange = (data: { file: UploadFileInfo }) => {
|
||||
const file = data.file.file;
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
originalImage.value = e.target?.result as string;
|
||||
generatePreview();
|
||||
finalImages.value = []; // Reset final images
|
||||
additionalImages.value = Array(9).fill(null); // Reset additional images
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
const generatePreview = async () => {
|
||||
if (!originalImage.value) return;
|
||||
|
||||
const img = new Image();
|
||||
img.src = originalImage.value;
|
||||
await new Promise(resolve => img.onload = resolve);
|
||||
|
||||
// 创建方形预览图
|
||||
const size = Math.min(img.width, img.height);
|
||||
const tempCanvas = document.createElement('canvas');
|
||||
const tempCtx = tempCanvas.getContext('2d');
|
||||
|
||||
if (!tempCtx) return;
|
||||
|
||||
const offsetX = (img.width - size) / 2;
|
||||
const offsetY = (img.height - size) / 2;
|
||||
|
||||
tempCanvas.width = size;
|
||||
tempCanvas.height = size;
|
||||
|
||||
tempCtx.drawImage(img, offsetX, offsetY, size, size, 0, 0, size, size);
|
||||
croppedSquareImage.value = tempCanvas.toDataURL();
|
||||
|
||||
message.success('图片已准备就绪,可以查看九宫格预览');
|
||||
};
|
||||
|
||||
const onCropperReady = () => {
|
||||
if (cropperRef.value) {
|
||||
// 设置裁剪区域为方形
|
||||
const containerData = cropperRef.value.cropper.getContainerData();
|
||||
const size = Math.min(containerData.width, containerData.height) * 0.8;
|
||||
|
||||
cropperRef.value.cropper.setCropBoxData({
|
||||
left: (containerData.width - size) / 2,
|
||||
top: (containerData.height - size) / 2,
|
||||
width: size,
|
||||
height: size
|
||||
});
|
||||
|
||||
message.success('可以调整裁剪区域');
|
||||
}
|
||||
};
|
||||
|
||||
const cropImage = () => {
|
||||
if (cropperRef.value) {
|
||||
croppedSquareImage.value = cropperRef.value.getCroppedCanvas({
|
||||
imageSmoothingQuality: 'high',
|
||||
}).toDataURL();
|
||||
|
||||
message.success('图片裁剪成功!');
|
||||
}
|
||||
};
|
||||
|
||||
const handleAdditionalImageChange = (data: { file: UploadFileInfo }, index: number) => {
|
||||
const file = data.file.file;
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
additionalImages.value[index] = e.target?.result as string;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
const generateFinalImages = async () => {
|
||||
if (!originalImage.value) {
|
||||
message.error('请先上传一张图片');
|
||||
return;
|
||||
}
|
||||
finalImages.value = [];
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
if (!ctx) {
|
||||
message.error('无法创建画布');
|
||||
return;
|
||||
}
|
||||
|
||||
// 加载原始图片
|
||||
const originalImg = new Image();
|
||||
originalImg.src = originalImage.value;
|
||||
await new Promise(resolve => originalImg.onload = resolve);
|
||||
|
||||
// 确保图片是正方形的
|
||||
const size = Math.min(originalImg.width, originalImg.height);
|
||||
const offsetX = (originalImg.width - size) / 2;
|
||||
const offsetY = (originalImg.height - size) / 2;
|
||||
|
||||
// 每个格子的尺寸
|
||||
const gridSize = size / 3;
|
||||
|
||||
// 为每个格子生成图片
|
||||
for (let i = 0; i < 9; i++) {
|
||||
// 计算当前格子在原图中的位置
|
||||
const row = Math.floor(i / 3);
|
||||
const col = i % 3;
|
||||
const srcX = offsetX + col * gridSize;
|
||||
const srcY = offsetY + row * gridSize;
|
||||
|
||||
// 加载额外图片(如果有)
|
||||
let additionalImg: HTMLImageElement | null = null;
|
||||
let additionalHeight = 0;
|
||||
|
||||
if (additionalImages.value[i]) {
|
||||
additionalImg = new Image();
|
||||
additionalImg.src = additionalImages.value[i] as string;
|
||||
await new Promise(resolve => additionalImg!.onload = resolve);
|
||||
|
||||
// 计算额外图片等比例缩放后的高度
|
||||
additionalHeight = (gridSize / additionalImg.width) * additionalImg.height;
|
||||
}
|
||||
|
||||
// 设置画布尺寸
|
||||
canvas.width = gridSize;
|
||||
canvas.height = gridSize + additionalHeight;
|
||||
|
||||
// 清空画布
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// 绘制九宫格中的一格
|
||||
ctx.drawImage(
|
||||
originalImg,
|
||||
srcX, srcY, gridSize, gridSize, // 从原图截取的区域
|
||||
0, 0, gridSize, gridSize // 绘制到画布的位置和大小
|
||||
);
|
||||
|
||||
// 如果有额外图片,绘制在下方
|
||||
if (additionalImg) {
|
||||
ctx.drawImage(
|
||||
additionalImg,
|
||||
0, gridSize, gridSize, additionalHeight
|
||||
);
|
||||
}
|
||||
|
||||
// 保存生成的图片
|
||||
finalImages.value.push(canvas.toDataURL('image/png'));
|
||||
}
|
||||
|
||||
message.success('九宫格图片已生成!可以单独下载每张图片');
|
||||
};
|
||||
|
||||
const downloadImage = (dataUrl: string, filename: string) => {
|
||||
const link = document.createElement('a');
|
||||
link.href = dataUrl;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
const resetCropper = () => {
|
||||
if (cropperRef.value) {
|
||||
cropperRef.value.cropper.reset();
|
||||
message.info('已重置裁剪区域');
|
||||
}
|
||||
};
|
||||
|
||||
const removeAdditionalImage = (index: number) => {
|
||||
additionalImages.value[index] = null;
|
||||
message.success('已删除附加图片');
|
||||
};
|
||||
|
||||
const downloadAllImages = () => {
|
||||
if (finalImages.value.length === 0) {
|
||||
message.error('请先生成九宫格图片');
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建一个 zip 文件的替代方案
|
||||
// 这里我们简单地连续下载所有图片
|
||||
finalImages.value.forEach((dataUrl, index) => {
|
||||
setTimeout(() => {
|
||||
downloadImage(dataUrl, `grid_image_${index + 1}.png`);
|
||||
}, index * 300); // 避免浏览器阻止多次下载,添加延迟
|
||||
});
|
||||
|
||||
message.success('正在下载所有图片...');
|
||||
};
|
||||
|
||||
// 生成CSS clip-path以显示图片的特定部分
|
||||
const generateClipPath = (index: number) => {
|
||||
const row = Math.floor(index / 3);
|
||||
const col = index % 3;
|
||||
const startX = col * 33.33;
|
||||
const startY = row * 33.33;
|
||||
const endX = startX + 33.33;
|
||||
const endY = startY + 33.33;
|
||||
|
||||
return `polygon(${startX}% ${startY}%, ${endX}% ${startY}%, ${endX}% ${endY}%, ${startX}% ${endY}%)`;
|
||||
};
|
||||
|
||||
// 计算图片的transform-origin,确保正确缩放
|
||||
const calculateTransformOrigin = (index: number) => {
|
||||
const row = Math.floor(index / 3);
|
||||
const col = index % 3;
|
||||
const originX = col * 50; // 使用百分比
|
||||
const originY = row * 50;
|
||||
|
||||
return `${originX}% ${originY}%`;
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dynamic-nine-grid-tool {
|
||||
max-width: 900px;
|
||||
margin: auto;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.upload-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
border: 1px dashed #ccc;
|
||||
border-radius: 8px;
|
||||
background-color: rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
.cropper-container {
|
||||
margin: 1rem 0;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
background-color: #fafafa;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.cropper-controls {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 15px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.preview-section {
|
||||
margin: 1.5rem 0;
|
||||
padding: 15px;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
background-color: #fafafa;
|
||||
}
|
||||
|
||||
.nine-grid-preview {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 4px;
|
||||
padding: 10px;
|
||||
background-color: #f0f0f0;
|
||||
border-radius: 8px;
|
||||
margin: 1rem auto;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.grid-item-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: white;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.grid-image-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.grid-position-indicator {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
color: white;
|
||||
font-size: 10px;
|
||||
padding: 2px 4px;
|
||||
border-radius: 2px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.grid-image-base {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transform-origin: 0 0;
|
||||
}
|
||||
|
||||
.additional-image-preview {
|
||||
width: 100%;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.additional-image-preview img {
|
||||
width: 100%;
|
||||
display: block;
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
|
||||
.grid-controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
padding: 5px;
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.final-images-section {
|
||||
margin-top: 1.5rem;
|
||||
padding: 15px;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
background-color: #fafafa;
|
||||
}
|
||||
|
||||
.final-images-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 15px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.final-image-item {
|
||||
position: relative;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background-color: white;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.final-image-item img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.image-number {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
left: 5px;
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
color: white;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.whole-image-preview {
|
||||
position: relative;
|
||||
margin: 20px auto;
|
||||
max-width: 300px;
|
||||
border: 2px solid #333;
|
||||
}
|
||||
|
||||
.whole-preview-img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.grid-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
grid-template-rows: repeat(3, 1fr);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.grid-overlay-cell {
|
||||
border: 1px dashed rgba(255, 255, 255, 0.7);
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.cell-number {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
left: 5px;
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
color: white;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.grid-image-wrapper {
|
||||
width: 100%;
|
||||
height: 0;
|
||||
padding-bottom: 100%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.grid-image-base {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.original-image-preview {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.download-button {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.mt-1 {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.mt-2 {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
</style>
|
||||
@@ -36,4 +36,18 @@ export const OBSComponentMap: Record<string, OBSComponentDefinition> = {
|
||||
// settingName: 'obsExampleComponentSettings',
|
||||
// version: '1.0.0',
|
||||
// },
|
||||
Example: {
|
||||
id: 'Example',
|
||||
name: '示例组件',
|
||||
description: '一个基础的OBS组件,用于演示和测试功能。',
|
||||
component: defineAsyncComponent(() => import('@/views/obs_store/components/ExampleOBSComponent.vue')),
|
||||
version: '1.0.0',
|
||||
},
|
||||
Controller: {
|
||||
id: 'Controller',
|
||||
name: '控制器',
|
||||
description: '将用户手柄操作映射到OBS的场景中',
|
||||
component: defineAsyncComponent(() => import('@/views/obs_store/components/gamepads/GamepadViewer.vue')),
|
||||
version: '1.0.0',
|
||||
},
|
||||
};
|
||||
@@ -93,6 +93,16 @@ export default //管理页面
|
||||
danmaku: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'obs-store',
|
||||
name: 'manage-obsStore',
|
||||
component: () => import('@/views/obs_store/OBSComponentStoreView.vue'),
|
||||
meta: {
|
||||
title: 'OBS组件库',
|
||||
keepAlive: true,
|
||||
danmaku: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'queue',
|
||||
name: 'manage-liveQueue',
|
||||
@@ -202,6 +212,24 @@ export default //管理页面
|
||||
meta: {
|
||||
title: '数据分析'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'tools',
|
||||
name: 'manage-tools-dashboard',
|
||||
component: () => import('@/views/manage/ToolsDashboardView.vue'),
|
||||
meta: {
|
||||
title: '直播工具箱',
|
||||
keepAlive: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'tools/dynamic-nine-grid',
|
||||
name: 'ManageToolDynamicNineGrid',
|
||||
component: () => import('@/components/manage/tools/ToolDynamicNineGrid.vue'),
|
||||
meta: {
|
||||
title: '动态九图生成器',
|
||||
parent: 'manage-tools-dashboard' // 指向工具箱仪表盘
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ export default {
|
||||
{
|
||||
path: 'gamepad-manage',
|
||||
name: 'obs-store-gamepad-manage',
|
||||
component: () => import('@/views/manage/obs_store/components/gamepads/GamepadViewer.vue'),
|
||||
component: () => import('@/views/obs_store/components/gamepads/GamepadViewer.vue'),
|
||||
meta: {
|
||||
title: '游戏手柄',
|
||||
forceReload: true,
|
||||
@@ -14,7 +14,7 @@ export default {
|
||||
{
|
||||
path: 'gamepad',
|
||||
name: 'obs-store-gamepad-display',
|
||||
component: () => import('@/views/manage/obs_store/components/gamepads/GamepadDisplay.vue'),
|
||||
component: () => import('@/views/obs_store/components/gamepads/GamepadDisplay.vue'),
|
||||
meta: {
|
||||
title: '手柄显示',
|
||||
forceReload: true,
|
||||
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
NAlert,
|
||||
NButton,
|
||||
NCheckbox,
|
||||
NCollapse,
|
||||
NCollapseItem,
|
||||
NDivider,
|
||||
NFlex,
|
||||
NForm,
|
||||
@@ -60,6 +62,18 @@ const showModal = ref(false)
|
||||
const showModalRenderKey = ref(0)
|
||||
const onlyResetNameOnAdded = ref(true)
|
||||
|
||||
// 文件导入的列头映射配置
|
||||
const useCustomColumnMapping = ref(false)
|
||||
const columnMappings = useStorage('song-list-column-mappings', {
|
||||
name: '名称,歌名,标题,title,name',
|
||||
translateName: '翻译名称,译名,translated,translate',
|
||||
author: '作者,歌手,演唱,singer,author,artist',
|
||||
description: '描述,备注,说明,description,note,remark',
|
||||
url: '链接,地址,url,link',
|
||||
language: '语言,language',
|
||||
tags: '标签,类别,分类,tag,tags,category'
|
||||
})
|
||||
|
||||
// 歌曲列表数据
|
||||
const songs = ref<SongsInfo[]>([])
|
||||
|
||||
@@ -550,63 +564,81 @@ function parseExcelFile() {
|
||||
|
||||
// 解析每一行数据
|
||||
const parsedSongs = rows.map((row) => {
|
||||
const song = {} as SongsInfo
|
||||
const song = {} as SongsInfo;
|
||||
|
||||
for (let i = 0; i < headers.length; i++) {
|
||||
const key = headers[i] as string
|
||||
const value = row[i] as string
|
||||
const headerFromFile = (headers[i] as string)?.toLowerCase().trim();
|
||||
if (!headerFromFile) continue;
|
||||
|
||||
if (!key) continue
|
||||
const value = row[i];
|
||||
|
||||
// 根据列头映射到歌曲属性
|
||||
switch (key.toLowerCase().trim()) {
|
||||
case 'id':
|
||||
case 'name':
|
||||
case '名称':
|
||||
case '曲名':
|
||||
case '歌名':
|
||||
if (!value) {
|
||||
console.log('忽略空歌名: ' + row)
|
||||
continue
|
||||
}
|
||||
song.name = value
|
||||
break
|
||||
case 'author':
|
||||
case 'singer':
|
||||
case '作者':
|
||||
case '歌手':
|
||||
if (!value) break
|
||||
song.author = parseMultipleValues(value)
|
||||
break
|
||||
case 'description':
|
||||
case 'desc':
|
||||
case '说明':
|
||||
case '描述':
|
||||
song.description = value
|
||||
break
|
||||
case 'url':
|
||||
case '链接':
|
||||
song.url = value
|
||||
break
|
||||
case 'language':
|
||||
case '语言':
|
||||
if (!value) break
|
||||
song.language = parseMultipleValues(value)
|
||||
break
|
||||
case 'tags':
|
||||
case 'tag':
|
||||
case '标签':
|
||||
if (!value) break
|
||||
song.tags = parseMultipleValues(value)
|
||||
break
|
||||
// 歌曲名称 (必填)
|
||||
const nameHeaders = columnMappings.value.name.split(/,|,/).map(h => h.trim().toLowerCase());
|
||||
if (nameHeaders.includes(headerFromFile)) {
|
||||
if (value) song.name = value;
|
||||
// 注意:即使找到歌名,也不立即continue,因为一个列可能对应多个信息(虽然不推荐)
|
||||
// 但标准做法是每个信息有独立列
|
||||
}
|
||||
|
||||
// 翻译名称
|
||||
if (columnMappings.value.translateName) {
|
||||
const translateNameHeaders = columnMappings.value.translateName.split(/,|,/).map(h => h.trim().toLowerCase());
|
||||
if (translateNameHeaders.includes(headerFromFile)) {
|
||||
if (value) song.translateName = value;
|
||||
}
|
||||
}
|
||||
|
||||
// 作者
|
||||
if (columnMappings.value.author) {
|
||||
const authorHeaders = columnMappings.value.author.split(/,|,/).map(h => h.trim().toLowerCase());
|
||||
if (authorHeaders.includes(headerFromFile)) {
|
||||
if (value) song.author = parseMultipleValues(value as string);
|
||||
}
|
||||
}
|
||||
|
||||
// 描述
|
||||
if (columnMappings.value.description) {
|
||||
const descriptionHeaders = columnMappings.value.description.split(/,|,/).map(h => h.trim().toLowerCase());
|
||||
if (descriptionHeaders.includes(headerFromFile)) {
|
||||
song.description = value;
|
||||
}
|
||||
}
|
||||
|
||||
// 链接
|
||||
if (columnMappings.value.url) {
|
||||
const urlHeaders = columnMappings.value.url.split(/,|,/).map(h => h.trim().toLowerCase());
|
||||
if (urlHeaders.includes(headerFromFile)) {
|
||||
song.url = value;
|
||||
}
|
||||
}
|
||||
|
||||
// 语言
|
||||
if (columnMappings.value.language) {
|
||||
const languageHeaders = columnMappings.value.language.split(/,|,/).map(h => h.trim().toLowerCase());
|
||||
if (languageHeaders.includes(headerFromFile)) {
|
||||
if (value) song.language = parseMultipleValues(value as string);
|
||||
}
|
||||
}
|
||||
|
||||
// 标签
|
||||
if (columnMappings.value.tags) {
|
||||
const tagsHeaders = columnMappings.value.tags.split(/,|,/).map(h => h.trim().toLowerCase());
|
||||
if (tagsHeaders.includes(headerFromFile)) {
|
||||
if (value) song.tags = parseMultipleValues(value as string);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return song
|
||||
})
|
||||
// 如果没有解析到歌名,则这条记录无效
|
||||
if (!song.name) {
|
||||
console.log('忽略无效记录(未找到歌名或歌名为空): ' + row.join(','));
|
||||
return null;
|
||||
}
|
||||
|
||||
// 过滤掉没有名称的歌曲
|
||||
uploadSongsFromFile.value = parsedSongs.filter((s) => s.name)
|
||||
return song;
|
||||
}).filter(s => s !== null) as SongsInfo[];
|
||||
|
||||
uploadSongsFromFile.value = parsedSongs;
|
||||
message.success('解析完成, 共获取 ' + uploadSongsFromFile.value.length + ' 首曲目')
|
||||
}
|
||||
}
|
||||
@@ -615,10 +647,9 @@ function parseExcelFile() {
|
||||
* 解析多值字段(如作者、标签等)
|
||||
*/
|
||||
function parseMultipleValues(value: string): string[] {
|
||||
console.log(value)
|
||||
if (!value) return []
|
||||
// @ts-ignore
|
||||
if (value instanceof Boolean) {
|
||||
if (typeof value !== 'string') {
|
||||
// @ts-ignore
|
||||
value = value.toString()
|
||||
}
|
||||
return value
|
||||
@@ -680,6 +711,31 @@ function resetAddingSong(onlyName = false) {
|
||||
message.success('已重置')
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置自定义列头映射
|
||||
*/
|
||||
function resetColumnMappings() {
|
||||
columnMappings.value = {
|
||||
name: '名称,歌名,标题,title,name',
|
||||
translateName: '翻译名称,译名,translated,translate',
|
||||
author: '作者,歌手,演唱,singer,author,artist',
|
||||
description: '描述,备注,说明,description,note,remark',
|
||||
url: '链接,地址,url,link',
|
||||
language: '语言,language',
|
||||
tags: '标签,类别,分类,tag,tags,category'
|
||||
}
|
||||
message.success('已重置为默认映射')
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存自定义列头映射
|
||||
*/
|
||||
function saveColumnMappings() {
|
||||
// 由于使用了useStorage,映射内容会自动保存
|
||||
// 这里只需要提示用户保存成功
|
||||
message.success('映射已保存,下次导入将使用当前设置')
|
||||
}
|
||||
|
||||
// 组件挂载时加载歌曲列表
|
||||
onMounted(async () => {
|
||||
await getSongs()
|
||||
@@ -1144,6 +1200,99 @@ onMounted(async () => {
|
||||
此页面
|
||||
</NButton>
|
||||
</NAlert>
|
||||
|
||||
<NDivider>
|
||||
导入设置
|
||||
</NDivider>
|
||||
|
||||
<NSpace vertical>
|
||||
<NCheckbox v-model:checked="useCustomColumnMapping">
|
||||
自定义列头映射
|
||||
<NTooltip>
|
||||
<template #trigger>
|
||||
<NIcon :component="Info24Filled" />
|
||||
</template>
|
||||
启用后可以自定义Excel文件中列头与歌曲信息的对应关系
|
||||
</NTooltip>
|
||||
</NCheckbox>
|
||||
|
||||
<NCollapse v-if="useCustomColumnMapping">
|
||||
<NCollapseItem
|
||||
title="自定义列头映射"
|
||||
name="custom-mapping"
|
||||
>
|
||||
<NSpace vertical>
|
||||
<NAlert type="info">
|
||||
请输入各字段对应的Excel列头名称,多个名称用逗号分隔。导入时会自动匹配这些名称,不区分大小写。
|
||||
</NAlert>
|
||||
<NFormItem label="歌曲名称 (必填)">
|
||||
<NInput
|
||||
v-model:value="columnMappings.name"
|
||||
placeholder="使用逗号分隔多个可能的列头名称"
|
||||
/>
|
||||
</NFormItem>
|
||||
<NFormItem label="翻译名称">
|
||||
<NInput
|
||||
v-model:value="columnMappings.translateName"
|
||||
placeholder="使用逗号分隔多个可能的列头名称"
|
||||
/>
|
||||
</NFormItem>
|
||||
<NFormItem label="作者">
|
||||
<NInput
|
||||
v-model:value="columnMappings.author"
|
||||
placeholder="使用逗号分隔多个可能的列头名称"
|
||||
/>
|
||||
</NFormItem>
|
||||
<NFormItem label="描述">
|
||||
<NInput
|
||||
v-model:value="columnMappings.description"
|
||||
placeholder="使用逗号分隔多个可能的列头名称"
|
||||
/>
|
||||
</NFormItem>
|
||||
<NFormItem label="链接">
|
||||
<NInput
|
||||
v-model:value="columnMappings.url"
|
||||
placeholder="使用逗号分隔多个可能的列头名称"
|
||||
/>
|
||||
</NFormItem>
|
||||
<NFormItem label="语言">
|
||||
<NInput
|
||||
v-model:value="columnMappings.language"
|
||||
placeholder="使用逗号分隔多个可能的列头名称"
|
||||
/>
|
||||
</NFormItem>
|
||||
<NFormItem label="标签">
|
||||
<NInput
|
||||
v-model:value="columnMappings.tags"
|
||||
placeholder="使用逗号分隔多个可能的列头名称"
|
||||
/>
|
||||
</NFormItem>
|
||||
<NSpace>
|
||||
<NButton
|
||||
type="primary"
|
||||
@click="saveColumnMappings"
|
||||
>
|
||||
保存映射
|
||||
</NButton>
|
||||
<NButton
|
||||
type="warning"
|
||||
@click="resetColumnMappings"
|
||||
>
|
||||
重置为默认映射
|
||||
</NButton>
|
||||
</NSpace>
|
||||
<NAlert type="info">
|
||||
设置完成后请点击"保存映射",设置将自动保存到本地浏览器,下次访问时仍会使用
|
||||
</NAlert>
|
||||
</NSpace>
|
||||
</NCollapseItem>
|
||||
</NCollapse>
|
||||
</NSpace>
|
||||
|
||||
<NDivider>
|
||||
文件上传
|
||||
</NDivider>
|
||||
|
||||
<NUpload
|
||||
v-model:file-list="uploadFiles"
|
||||
:default-upload="false"
|
||||
|
||||
83
src/views/manage/ToolsDashboardView.vue
Normal file
83
src/views/manage/ToolsDashboardView.vue
Normal file
@@ -0,0 +1,83 @@
|
||||
<template>
|
||||
<n-layout class="tools-dashboard">
|
||||
<n-layout-header bordered class="header">
|
||||
<n-h1 style="margin: 0; padding: 16px;">直播工具箱</n-h1>
|
||||
</n-layout-header>
|
||||
<n-layout-content style="padding: 24px;">
|
||||
<n-grid cols="1 s:2 m:3 l:4 xl:4 xxl:5" responsive="screen" :x-gap="16" :y-gap="16">
|
||||
<n-grid-item v-for="tool in availableTools" :key="tool.name">
|
||||
<n-card :title="tool.displayName" hoverable @click="navigateToTool(tool.routeName)">
|
||||
<template #cover v-if="tool.icon">
|
||||
<!-- Placeholder for an icon or image -->
|
||||
<div style="font-size: 48px; text-align: center; padding: 20px 0;">
|
||||
<n-icon :component="tool.icon" />
|
||||
</div>
|
||||
</template>
|
||||
{{ tool.description }}
|
||||
<template #action>
|
||||
<n-button type="primary" block @click.stop="navigateToTool(tool.routeName)">
|
||||
打开工具
|
||||
</n-button>
|
||||
</template>
|
||||
</n-card>
|
||||
</n-grid-item>
|
||||
</n-grid>
|
||||
</n-layout-content>
|
||||
</n-layout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { shallowRef } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { NLayout, NLayoutHeader, NLayoutContent, NGrid, NGridItem, NCard, NH1, NIcon, NButton } from 'naive-ui';
|
||||
import { ImagesOutline as NineGridIcon } from '@vicons/ionicons5'; // Example Icon
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
interface ToolDefinition {
|
||||
name: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
routeName: string;
|
||||
icon?: any; // Using 'any' for icon component type for simplicity
|
||||
}
|
||||
|
||||
const availableTools = shallowRef<ToolDefinition[]>([
|
||||
{
|
||||
name: 'DynamicNineGrid',
|
||||
displayName: '动态九图生成器',
|
||||
description: '快速创建用于B站动态的九宫格图片,支持自定义拼接。',
|
||||
routeName: 'ManageToolDynamicNineGrid',
|
||||
icon: NineGridIcon,
|
||||
},
|
||||
// Add more tools here as they are created
|
||||
// {
|
||||
// name: 'AnotherTool',
|
||||
// displayName: '另一个工具',
|
||||
// description: '这是另一个很棒的工具。',
|
||||
// routeName: 'ManageToolAnotherTool',
|
||||
// icon: AnotherIconComponent,
|
||||
// },
|
||||
]);
|
||||
|
||||
const navigateToTool = (routeName: string) => {
|
||||
router.push({ name: routeName });
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tools-dashboard {
|
||||
min-height: calc(100vh - 64px); /* Adjust based on your header/footer height */
|
||||
}
|
||||
.header {
|
||||
background-color: var(--card-color); /* Or your preferred header background */
|
||||
}
|
||||
.n-card {
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
|
||||
}
|
||||
.n-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: var(--card-box-shadow-hover);
|
||||
}
|
||||
</style>
|
||||
25
src/views/manage/ToolsManageView.vue
Normal file
25
src/views/manage/ToolsManageView.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<div class="tools-manage-view p-4">
|
||||
<n-h1>直播工具箱</n-h1>
|
||||
<n-tabs type="line" animated>
|
||||
<n-tab-pane name="nine-grid" tab="动态九宫格生成器">
|
||||
<DynamicNineGridGenerator />
|
||||
</n-tab-pane>
|
||||
<!-- 更多工具可以在这里添加 -->
|
||||
</n-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { NH1, NTabs, NTabPane } from 'naive-ui'
|
||||
import DynamicNineGridGenerator from './tools/DynamicNineGridGenerator.vue'
|
||||
|
||||
// 后续可能需要的逻辑
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tools-manage-view {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
</style>
|
||||
@@ -1,376 +0,0 @@
|
||||
<template>
|
||||
<div class="obs-component-store-view">
|
||||
<NPageHeader :title="currentSelectedComponent ? currentSelectedComponent.name : 'OBS 组件商店'">
|
||||
<template #subtitle>
|
||||
{{ currentSelectedComponent ? currentSelectedComponent.description : '选择一个组件进行预览和配置' }}
|
||||
</template>
|
||||
<template #extra>
|
||||
<NSpace>
|
||||
<NButton
|
||||
v-if="currentSelectedComponent?.settingName && userInfo?.id === accountInfo.id"
|
||||
type="primary"
|
||||
@click="showSettingModal = true"
|
||||
>
|
||||
配置组件
|
||||
</NButton>
|
||||
<NButton
|
||||
v-if="currentSelectedComponent"
|
||||
@click="refreshSelectedComponent"
|
||||
>
|
||||
刷新组件
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</template>
|
||||
</NPageHeader>
|
||||
|
||||
<NGrid
|
||||
v-if="!currentSelectedComponent"
|
||||
cols="1 s:2 m:3 l:4 xl:4 xxl:5"
|
||||
responsive="screen"
|
||||
:x-gap="12"
|
||||
:y-gap="12"
|
||||
style="padding: 16px;"
|
||||
>
|
||||
<NGridItem
|
||||
v-for="compDef in availableComponents"
|
||||
:key="compDef.id"
|
||||
>
|
||||
<NCard
|
||||
:title="compDef.name"
|
||||
hoverable
|
||||
class="component-card"
|
||||
@click="selectComponent(compDef.id)"
|
||||
>
|
||||
<template
|
||||
v-if="compDef.icon"
|
||||
#cover
|
||||
>
|
||||
<!-- <img :src="compDef.icon" alt="compDef.name" /> -->
|
||||
</template>
|
||||
<p>{{ compDef.description }}</p>
|
||||
<template
|
||||
v-if="compDef.version"
|
||||
#footer
|
||||
>
|
||||
<NTag
|
||||
size="small"
|
||||
type="info"
|
||||
>
|
||||
v{{ compDef.version }}
|
||||
</NTag>
|
||||
</template>
|
||||
</NCard>
|
||||
</NGridItem>
|
||||
</NGrid>
|
||||
|
||||
<div
|
||||
v-if="currentSelectedComponent"
|
||||
class="component-preview-area"
|
||||
>
|
||||
<NAlert
|
||||
v-if="isLoading"
|
||||
title="加载中..."
|
||||
type="info"
|
||||
style="margin-bottom: 16px;"
|
||||
>
|
||||
正在加载组件配置和资源...
|
||||
</NAlert>
|
||||
<NSpin :show="isLoading">
|
||||
<component
|
||||
:is="currentSelectedComponent.component"
|
||||
ref="dynamicComponentRef"
|
||||
:config="componentConfig"
|
||||
:user-info="userInfo"
|
||||
:bili-info="biliInfo"
|
||||
:refresh-signal="refreshSignal"
|
||||
v-bind="currentSelectedComponent.props || {}"
|
||||
@update:config="handleConfigUpdateFromChild"
|
||||
/>
|
||||
</NSpin>
|
||||
</div>
|
||||
|
||||
<NModal
|
||||
v-model:show="showSettingModal"
|
||||
style="max-width: 90vw; width: 800px;"
|
||||
preset="card"
|
||||
title="组件配置"
|
||||
:mask-closable="false"
|
||||
>
|
||||
<DynamicForm
|
||||
v-if="selectedComponentDefinitionForModal?.settingName && selectedComponentDefinitionForModal?.componentRef?.Config"
|
||||
:name="selectedComponentDefinitionForModal.settingName"
|
||||
:config-data="componentConfigForEditing"
|
||||
:config="selectedComponentDefinitionForModal.componentRef.Config"
|
||||
@update:config-data="onDynamicFormUpdate"
|
||||
/>
|
||||
<template #footer>
|
||||
<NSpace justify="end">
|
||||
<NButton @click="showSettingModal = false">
|
||||
取消
|
||||
</NButton>
|
||||
<NButton
|
||||
type="primary"
|
||||
@click="saveComponentConfig"
|
||||
>
|
||||
保存配置
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</template>
|
||||
</NModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { DownloadConfig, UploadConfig, useAccount } from '@/api/account';
|
||||
import { UserInfo } from '@/api/api-models';
|
||||
import { OBSComponentMap } from '@/data/obsConstants';
|
||||
import { OBSComponentDefinition } from '@/data/obsConstants';
|
||||
import { ConfigItemDefinition } from '@/data/VTsuruConfigTypes';
|
||||
import { useBiliAuth } from '@/store/useBiliAuth';
|
||||
import { NAlert, NButton, NCard, NGrid, NGridItem, NModal, NPageHeader, NSpace, NSpin, NTag, useMessage } from 'naive-ui';
|
||||
import { ComponentPublicInstance, computed, defineAsyncComponent, onMounted, ref, watch } from 'vue';
|
||||
|
||||
// --- 静态导入所有可能的组件,以便 DynamicForm 能获取到 Config 定义 ---
|
||||
// 如果组件过多,考虑更动态的注册方式,但 DynamicForm 需要直接访问 Config
|
||||
// 修正:直接从子组件实例获取 Config,而不是静态导入模块本身
|
||||
// import * as ExampleOBSComponent from './components/ExampleOBSComponent.vue';
|
||||
|
||||
|
||||
// --- 模拟父组件传入的信息 ---
|
||||
const props = defineProps<{
|
||||
// 如果此视图作为路由组件,可能从路由参数获取信息
|
||||
// userId?: string; // 示例:如果配置与特定用户关联
|
||||
biliInfo?: any; // B站信息 (可选)
|
||||
}>();
|
||||
|
||||
const accountInfo = useAccount();
|
||||
const biliAuth = useBiliAuth(); // 若需要B站授权信息
|
||||
const message = useMessage();
|
||||
|
||||
const userInfo = ref<UserInfo | undefined>(accountInfo.value.id ? { id: accountInfo.value.id, name: accountInfo.value.name } as UserInfo : undefined); // 模拟
|
||||
|
||||
const availableComponents = ref<OBSComponentDefinition[]>([]);
|
||||
const currentSelectedComponentId = ref<string | null>(null);
|
||||
const dynamicComponentRef = ref<ComponentPublicInstance & { Config?: ConfigItemDefinition[], DefaultConfig?: any } | null>(null);
|
||||
|
||||
|
||||
const componentConfig = ref<any>({}); // 当前选中组件的运行时配置
|
||||
const componentConfigForEditing = ref<any>({}); // 模态框中编辑的配置副本
|
||||
|
||||
const isLoading = ref(false);
|
||||
const showSettingModal = ref(false);
|
||||
const refreshSignal = ref(0); // 用于手动触发子组件刷新
|
||||
|
||||
// 初始化可用组件列表
|
||||
function initializeComponents() {
|
||||
// 清空并重新从 OBSComponentMap 构建,避免重复添加
|
||||
availableComponents.value = [];
|
||||
// 示例组件定义(实际项目中可能从 obsConstants.ts 导入并处理)
|
||||
if (!OBSComponentMap['example']) {
|
||||
const exampleCompDef: OBSComponentDefinition = {
|
||||
id: 'example',
|
||||
name: '示例 OBS 组件',
|
||||
description: '这是一个基础的OBS组件,用于演示和测试功能。',
|
||||
component: defineAsyncComponent(() => import('./components/ExampleOBSComponent.vue')),
|
||||
settingName: 'obsExampleComponentSettings', // 用于配置存储的键
|
||||
version: '1.0.0',
|
||||
// icon: 'path/to/icon.png'
|
||||
};
|
||||
OBSComponentMap['example'] = exampleCompDef;
|
||||
}
|
||||
availableComponents.value = Object.values(OBSComponentMap);
|
||||
}
|
||||
|
||||
const currentSelectedComponent = computed<OBSComponentDefinition | undefined>(() => {
|
||||
if (!currentSelectedComponentId.value) return undefined;
|
||||
return OBSComponentMap[currentSelectedComponentId.value];
|
||||
});
|
||||
|
||||
// 用于模态框的计算属性,确保组件引用已加载并且 Config 存在
|
||||
const selectedComponentDefinitionForModal = computed(() => {
|
||||
if (!currentSelectedComponent.value || !dynamicComponentRef.value?.Config) return undefined;
|
||||
return {
|
||||
...currentSelectedComponent.value,
|
||||
componentRef: dynamicComponentRef.value, // 直接使用 ref
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
async function selectComponent(componentId: string) {
|
||||
if (currentSelectedComponentId.value === componentId) { // 如果已经是当前选中的,则尝试刷新
|
||||
refreshSelectedComponent();
|
||||
return;
|
||||
}
|
||||
|
||||
currentSelectedComponentId.value = componentId;
|
||||
isLoading.value = true;
|
||||
componentConfig.value = {}; // 重置配置
|
||||
|
||||
// 等待下一个 tick 确保 dynamicComponentRef 更新
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
|
||||
if (currentSelectedComponent.value?.settingName) {
|
||||
await loadComponentConfig(currentSelectedComponent.value.settingName);
|
||||
} else {
|
||||
// 如果组件没有 settingName,则它可能不使用持久化配置,或者使用默认配置
|
||||
if (dynamicComponentRef.value && dynamicComponentRef.value.DefaultConfig) {
|
||||
componentConfig.value = { ...dynamicComponentRef.value.DefaultConfig };
|
||||
componentConfigForEditing.value = JSON.parse(JSON.stringify(componentConfig.value));
|
||||
}
|
||||
}
|
||||
isLoading.value = false;
|
||||
}
|
||||
|
||||
async function loadComponentConfig(settingName: string) {
|
||||
if (!userInfo.value?.id) {
|
||||
message.error('无法加载组件配置:未找到用户信息。');
|
||||
if (dynamicComponentRef.value && dynamicComponentRef.value.DefaultConfig) {
|
||||
componentConfig.value = { ...dynamicComponentRef.value.DefaultConfig };
|
||||
componentConfigForEditing.value = JSON.parse(JSON.stringify(componentConfig.value));
|
||||
}
|
||||
isLoading.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading.value = true;
|
||||
try {
|
||||
const configData = await DownloadConfig<any>(settingName, userInfo.value.id);
|
||||
const defaultConfig = dynamicComponentRef.value?.DefaultConfig || {};
|
||||
|
||||
if (configData.msg || Object.keys(configData.data || {}).length === 0) {
|
||||
componentConfig.value = { ...defaultConfig };
|
||||
message.info('未找到在线配置,已加载默认配置。');
|
||||
} else {
|
||||
// 合并远程配置和默认配置,确保所有键都存在
|
||||
componentConfig.value = configData.data;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载组件配置失败:', error);
|
||||
message.error(`加载组件配置失败: ${error instanceof Error ? error.message : String(error)}`);
|
||||
componentConfig.value = { ...(dynamicComponentRef.value?.DefaultConfig || {}) };
|
||||
} finally {
|
||||
componentConfigForEditing.value = JSON.parse(JSON.stringify(componentConfig.value)); // 深拷贝用于编辑
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveComponentConfig() {
|
||||
if (!currentSelectedComponent.value?.settingName || !userInfo.value?.id) {
|
||||
message.error('无法保存配置:组件配置名称或用户信息丢失。');
|
||||
return;
|
||||
}
|
||||
isLoading.value = true;
|
||||
try {
|
||||
await UploadConfig(currentSelectedComponent.value.settingName,
|
||||
JSON.stringify(componentConfigForEditing.value), // 保存编辑后的配置
|
||||
false) // 或根据需要设置为 true);
|
||||
message.success('配置保存成功!');
|
||||
componentConfig.value = JSON.parse(JSON.stringify(componentConfigForEditing.value)); // 更新运行时配置
|
||||
showSettingModal.value = false;
|
||||
refreshSignal.value++; // 触发子组件刷新
|
||||
} catch (error) {
|
||||
console.error('保存组件配置失败:', error);
|
||||
message.error(`保存组件配置失败: ${error instanceof Error ? error.message : String(error)}`);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onDynamicFormUpdate(updatedConfig: any) {
|
||||
componentConfigForEditing.value = updatedConfig;
|
||||
}
|
||||
|
||||
function handleConfigUpdateFromChild(newConfig: any) {
|
||||
// 如果子组件能直接修改配置并冒泡事件,可以在此处理
|
||||
// componentConfig.value = newConfig;
|
||||
// componentConfigForEditing.value = JSON.parse(JSON.stringify(newConfig));
|
||||
// console.log('Config updated from child:', newConfig);
|
||||
}
|
||||
|
||||
function refreshSelectedComponent() {
|
||||
if (!currentSelectedComponent.value) return;
|
||||
message.info(`正在刷新 ${currentSelectedComponent.value.name}...`);
|
||||
|
||||
// 方式1: 增加 refreshSignal 以触发子组件 watch
|
||||
refreshSignal.value++;
|
||||
|
||||
// 方式2: 如果子组件有暴露的刷新方法,可以调用
|
||||
// if (typeof dynamicComponentRef.value?.refresh === 'function') {
|
||||
// dynamicComponentRef.value.refresh();
|
||||
// }
|
||||
|
||||
// 可选: 重新加载配置
|
||||
if (currentSelectedComponent.value.settingName) {
|
||||
loadComponentConfig(currentSelectedComponent.value.settingName);
|
||||
}
|
||||
}
|
||||
|
||||
// 当 dynamicComponentRef 变化时 (组件加载完成),尝试加载/设置配置
|
||||
watch(dynamicComponentRef, (newRef) => {
|
||||
if (newRef) { // 组件已挂载
|
||||
const compDef = currentSelectedComponent.value;
|
||||
if (compDef) {
|
||||
if (compDef.settingName) {
|
||||
// 如果有 settingName,则 loadComponentConfig 会处理(包括默认配置)
|
||||
// 这里确保在 selectComponent 中已经调用了 loadComponentConfig
|
||||
} else if (newRef.DefaultConfig) {
|
||||
// 没有 settingName,但子组件有 DefaultConfig
|
||||
componentConfig.value = { ...newRef.DefaultConfig };
|
||||
componentConfigForEditing.value = JSON.parse(JSON.stringify(componentConfig.value));
|
||||
}
|
||||
}
|
||||
}
|
||||
}, { immediate: false }); // immediate: false 因为 selectComponent 会处理首次加载
|
||||
|
||||
watch(showSettingModal, (isShown) => {
|
||||
if (isShown && currentSelectedComponent.value) {
|
||||
// 打开模态框时,确保编辑的是当前运行时配置的深拷贝
|
||||
// 同时,确保 DefaultConfig 能够正确合并,以防远程配置不完整
|
||||
const defaultConfig = dynamicComponentRef.value?.DefaultConfig || {};
|
||||
componentConfigForEditing.value = JSON.parse(JSON.stringify({
|
||||
...defaultConfig,
|
||||
...componentConfig.value // 当前运行时配置优先
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
initializeComponents();
|
||||
// 可以在这里根据路由参数或其他逻辑自动选择一个组件
|
||||
// if (props.initialComponentId) {
|
||||
// selectComponent(props.initialComponentId);
|
||||
// }
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.obs-component-store-view {
|
||||
padding: 0px; /* 改为0,由 PageHeader 控制内边距 */
|
||||
}
|
||||
|
||||
.component-card {
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.component-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: var(--n-box-shadow-active);
|
||||
}
|
||||
|
||||
.component-card p {
|
||||
min-height: 40px; /* 防止描述为空时卡片高度不一致 */
|
||||
font-size: 0.9em;
|
||||
color: var(--n-text-color-disabled);
|
||||
}
|
||||
|
||||
.component-preview-area {
|
||||
padding: 16px;
|
||||
margin-top: 16px;
|
||||
border: 1px solid var(--n-border-color);
|
||||
border-radius: var(--n-border-radius);
|
||||
background-color: var(--n-card-color);
|
||||
min-height: 300px; /* 预览区域最小高度 */
|
||||
}
|
||||
</style>
|
||||
@@ -1,176 +0,0 @@
|
||||
<template>
|
||||
<NCard
|
||||
:title="localConfig.title || '示例 OBS 组件'"
|
||||
class="example-obs-component"
|
||||
>
|
||||
<NAlert
|
||||
:type="localConfig.alertType as any || 'info'"
|
||||
:title="localConfig.alertTitle || '组件信息'"
|
||||
>
|
||||
<p>{{ localConfig.contentText || '这是示例 OBS 组件的内容。' }}</p>
|
||||
<p v-if="userInfo">
|
||||
当前用户: {{ userInfo.name }}
|
||||
</p>
|
||||
<p>刷新次数: {{ refreshCount }}</p>
|
||||
<p>当前配置: <pre>{{ JSON.stringify(localConfig, null, 2) }}</pre></p>
|
||||
</NAlert>
|
||||
|
||||
<NForm style="margin-top: 20px;">
|
||||
<NFormItem label="动态修改组件标题 (仅限本地,不保存)">
|
||||
<NInput
|
||||
v-model:value="dynamicTitle"
|
||||
placeholder="输入新标题"
|
||||
/>
|
||||
</NFormItem>
|
||||
<NButton @click="updateTitle">
|
||||
更新标题
|
||||
</NButton>
|
||||
</NForm>
|
||||
</NCard>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
// Moved Config and DefaultConfig here to avoid linter errors with <script setup>
|
||||
import { defineTemplateConfig, ExtractConfigData } from '@/data/VTsuruConfigTypes';
|
||||
|
||||
export const Config = defineTemplateConfig([
|
||||
{
|
||||
name: '组件标题',
|
||||
key: 'title',
|
||||
type: 'string',
|
||||
default: '我的示例 OBS 组件',
|
||||
description: '显示在组件顶部的标题文字。'
|
||||
},
|
||||
{
|
||||
name: '提示类型',
|
||||
key: 'alertType',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: '信息 (Info)', value: 'info' },
|
||||
{ label: '成功 (Success)', value: 'success' },
|
||||
{ label: '警告 (Warning)', value: 'warning' },
|
||||
{ label: '错误 (Error)', value: 'error' },
|
||||
],
|
||||
default: 'info',
|
||||
description: '组件内 NAlert 提示框的样式类型。'
|
||||
},
|
||||
{
|
||||
name: '提示标题',
|
||||
key: 'alertTitle',
|
||||
type: 'string',
|
||||
default: '组件信息',
|
||||
},
|
||||
{
|
||||
name: '主要内容文本',
|
||||
key: 'contentText',
|
||||
type: 'string',
|
||||
inputType: 'textarea',
|
||||
default: '这是示例 OBS 组件的默认内容。您可以在此输入多行文本。',
|
||||
description: '组件内显示的主要文本信息。'
|
||||
},
|
||||
{
|
||||
name: '启用高级特性',
|
||||
key: 'enableAdvanced',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
},
|
||||
]);
|
||||
|
||||
export type ExampleConfigType = ExtractConfigData<typeof Config>;
|
||||
|
||||
export const DefaultConfig: ExampleConfigType = {
|
||||
title: '示例组件默认标题',
|
||||
alertType: 'success',
|
||||
alertTitle: '默认提示',
|
||||
contentText: '来自 DefaultConfig 的内容。点歌点歌点歌。关注vtsuru喵!',
|
||||
enableAdvanced: false,
|
||||
};
|
||||
</script>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { UserInfo } from '@/api/api-models';
|
||||
// ConfigItemType is imported in the script block above
|
||||
// import { ConfigItemDefinition, ConfigItemType, ExtractConfigData, defineTemplateConfig } from '@/data/VTsuruConfigTypes';
|
||||
import { NAlert, NButton, NCard, NForm, NFormItem, NInput, useMessage } from 'naive-ui';
|
||||
import { computed, ref, watch, onMounted } from 'vue';
|
||||
|
||||
// --- Props ---
|
||||
const props = defineProps<{
|
||||
config: ExampleConfigType; // 从父组件接收的配置
|
||||
userInfo?: UserInfo;
|
||||
biliInfo?: any;
|
||||
refreshSignal?: number; // 接收刷新信号
|
||||
}>();
|
||||
|
||||
// --- Emits (可选,如果子组件需要通知父组件配置更改) ---
|
||||
// const emits = defineEmits(['update:config']);
|
||||
|
||||
const message = useMessage();
|
||||
|
||||
// --- 本地状态 ---
|
||||
const refreshCount = ref(0);
|
||||
const dynamicTitle = ref(props.config?.title || '默认标题');
|
||||
|
||||
// --- 计算属性,合并传入的config和默认值,确保所有字段都存在 ---
|
||||
const localConfig = computed<ExampleConfigType>(() => {
|
||||
return {
|
||||
...DefaultConfig, // 先使用默认值
|
||||
...(props.config || {}), // 然后用传入的配置覆盖
|
||||
};
|
||||
});
|
||||
|
||||
// --- 监听刷新信号 ---
|
||||
watch(() => props.refreshSignal, (newValue, oldValue) => {
|
||||
if (newValue !== undefined && newValue !== oldValue) {
|
||||
refreshCount.value++;
|
||||
message.success(`'示例 OBS 组件' 已刷新 (信号: ${newValue})`);
|
||||
// 在这里执行组件的刷新逻辑,例如重新获取数据、重置状态等
|
||||
// fetchData();
|
||||
}
|
||||
});
|
||||
|
||||
// --- 方法 ---
|
||||
function updateTitle() {
|
||||
if (props.config) {
|
||||
// 这是直接修改 prop,Vue 会发出警告。在实际应用中,应该通过 emit 更新父组件的配置
|
||||
// (props.config as any).title = dynamicTitle.value;
|
||||
message.info('标题已在本地临时更改。若要保存,请通过父组件的配置面板。');
|
||||
// 要正确更新,应该 emit事件,例如:
|
||||
// emits('update:config', { ...localConfig.value, title: dynamicTitle.value });
|
||||
}
|
||||
}
|
||||
|
||||
// --- Expose (使得父组件可以通过 ref 访问 Config 和 DefaultConfig) ---
|
||||
// Vue 3 <script setup> 默认关闭,需要显式 defineExpose
|
||||
// 但对于 DynamicForm,它似乎能够通过某种方式访问导出的 Config 和 DefaultConfig
|
||||
// 如果父组件需要通过 ref 主动调用方法或访问属性,则需要 defineExpose
|
||||
// defineExpose({ Config, DefaultConfig, /* refreshMethod */ });
|
||||
|
||||
onMounted(() => {
|
||||
// console.log('ExampleOBSComponent mounted with config:', props.config);
|
||||
// console.log('Effective localConfig:', localConfig.value);
|
||||
// console.log('Exposed Config definition:', Config);
|
||||
// console.log('Exposed DefaultConfig:', DefaultConfig);
|
||||
dynamicTitle.value = localConfig.value.title;
|
||||
});
|
||||
|
||||
watch(() => props.config, (newConfig) => {
|
||||
dynamicTitle.value = newConfig?.title || DefaultConfig.title;
|
||||
}, { deep: true });
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.example-obs-component {
|
||||
border: 1px dashed var(--n-border-color);
|
||||
padding: 16px;
|
||||
}
|
||||
pre {
|
||||
background-color: var(--n-code-block-color);
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85em;
|
||||
white-space: pre-wrap; /* 确保长内容能换行 */
|
||||
word-break: break-all; /* 强制断词,防止溢出 */
|
||||
}
|
||||
</style>
|
||||
499
src/views/manage/tools/DynamicNineGridGenerator.vue
Normal file
499
src/views/manage/tools/DynamicNineGridGenerator.vue
Normal file
@@ -0,0 +1,499 @@
|
||||
<template>
|
||||
<div class="dynamic-nine-grid-generator p-4">
|
||||
<n-h2>动态九宫格图片生成器</n-h2>
|
||||
<n-space vertical :size="20">
|
||||
<n-upload
|
||||
action="#"
|
||||
:show-file-list="false"
|
||||
@change="handleImageUpload"
|
||||
accept="image/*"
|
||||
>
|
||||
<n-button type="primary">上传图片</n-button>
|
||||
</n-upload>
|
||||
|
||||
<div v-if="originalImageSrc" class="image-preview-area">
|
||||
<n-h3>原图预览</n-h3>
|
||||
<img ref="originalImageRef" :src="originalImageSrc" alt="Original Image" class="original-image-preview" @load="onImageLoad"/>
|
||||
</div>
|
||||
|
||||
<div v-if="cropper" class="cropper-controls">
|
||||
<n-h3>裁剪区域 (选择一个正方形区域作为小图)</n-h3>
|
||||
<div ref="cropperContainerRef" class="cropper-container"></div>
|
||||
<n-button @click="cropImage" type="info" class="mt-2">确认裁剪区域</n-button>
|
||||
</div>
|
||||
|
||||
|
||||
<div v-if="croppedImageSrc" class="cropped-image-preview-area">
|
||||
<n-h3>单张小图预览</n-h3>
|
||||
<img :src="croppedImageSrc" alt="Cropped Tile" class="cropped-tile-preview" />
|
||||
</div>
|
||||
|
||||
<div v-if="croppedImageSrc" class="grid-customization">
|
||||
<n-h3>九宫格生成与自定义</n-h3>
|
||||
<n-space vertical>
|
||||
<n-text>点击下方小图进行自定义内容添加。</n-text>
|
||||
<div class="nine-grid-preview-container">
|
||||
<div
|
||||
v-for="index in 9"
|
||||
:key="index"
|
||||
class="grid-cell"
|
||||
@click="selectCellForCustomization(index -1)"
|
||||
:class="{ 'selected-cell': selectedCellIndex === index -1 }"
|
||||
>
|
||||
<img :src="gridImages[index-1]?.finalSrc || croppedImageSrc" :alt="`Grid ${index}`" />
|
||||
<div v-if="gridImages[index-1]?.customContent" class="custom-content-indicator">有自定义</div>
|
||||
</div>
|
||||
</div>
|
||||
</n-space>
|
||||
</div>
|
||||
|
||||
<n-modal v-model:show="showCustomizationModal" preset="card" title="自定义小图内容" style="width: 600px;">
|
||||
<n-space vertical v-if="selectedCellIndex !== null && gridImages[selectedCellIndex]">
|
||||
<n-h4>当前编辑: 第 {{ selectedCellIndex + 1 }} 张小图</n-h4>
|
||||
<img :src="gridImages[selectedCellIndex].baseSrc" alt="Base for customization" class="customization-base-preview"/>
|
||||
<n-upload
|
||||
action="#"
|
||||
:show-file-list="false"
|
||||
@change="handleAddCustomImage"
|
||||
accept="image/*"
|
||||
list-type="image-card"
|
||||
>
|
||||
<n-button>添加自定义图片</n-button>
|
||||
</n-upload>
|
||||
<div v-if="gridImages[selectedCellIndex].customImages.length > 0" class="custom-images-preview">
|
||||
<n-h5>已添加的自定义图片:</n-h5>
|
||||
<n-image-group>
|
||||
<n-space>
|
||||
<n-image
|
||||
v-for="(img, idx) in gridImages[selectedCellIndex].customImages"
|
||||
:key="idx"
|
||||
width="100"
|
||||
:src="img.src"
|
||||
:alt="`Custom ${idx + 1}`"
|
||||
/>
|
||||
</n-space>
|
||||
</n-image-group>
|
||||
<n-button @click="clearCustomImages(selectedCellIndex)" type="warning" size="small" class="mt-2">清空自定义图片</n-button>
|
||||
</div>
|
||||
<n-button @click="applyCellCustomization" type="primary">应用自定义</n-button>
|
||||
</n-space>
|
||||
</n-modal>
|
||||
|
||||
<n-button v-if="croppedImageSrc" @click="generateAndDownloadNineGrid" type="success" :loading="isGenerating">
|
||||
生成并下载九宫格图片
|
||||
</n-button>
|
||||
<n-text v-if="isGenerating">正在生成图片,请稍候...</n-text>
|
||||
</n-space>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, nextTick, watch } from 'vue';
|
||||
import {
|
||||
NButton,
|
||||
NSpace,
|
||||
NUpload,
|
||||
NH2,
|
||||
NH3,
|
||||
NText,
|
||||
NModal,
|
||||
NImageGroup,
|
||||
NImage,
|
||||
NH4,
|
||||
NH5,
|
||||
useMessage
|
||||
} from 'naive-ui';
|
||||
import Cropper from 'cropperjs';
|
||||
import 'cropperjs/dist/cropper.css';
|
||||
import { useFileDialog, useBase64, useLocalStorage } from '@vueuse/core';
|
||||
import html2canvas from 'html2canvas'; // 用于将DOM元素转为图片
|
||||
|
||||
const message = useMessage();
|
||||
|
||||
interface GridImageData {
|
||||
baseSrc: string; // 裁剪后的基础图片
|
||||
customImages: { src: string, file: File }[]; // 用户为这个格子添加的自定义图片
|
||||
finalSrc: string | null; // 最终合成的图片 (base + custom)
|
||||
}
|
||||
|
||||
const originalImageSrc = ref<string | null>(null);
|
||||
const originalImageRef = ref<HTMLImageElement | null>(null);
|
||||
const cropperContainerRef = ref<HTMLDivElement | null>(null);
|
||||
const cropper = ref<Cropper | null>(null);
|
||||
const croppedImageSrc = ref<string | null>(null); // 存储裁剪后的单张小图数据URL
|
||||
|
||||
const gridImages = ref<GridImageData[]>([]); // 存储9张小图的数据,包括自定义内容
|
||||
const showCustomizationModal = ref(false);
|
||||
const selectedCellIndex = ref<number | null>(null);
|
||||
const isGenerating = ref(false);
|
||||
|
||||
// 使用localStorage存储一些可复用设置,例如裁剪比例等,如果需要的话
|
||||
// const cropperAspectRatio = useLocalStorage('Setting.Tools.DynamicNineGridGenerator.cropperAspectRatio', 1); // 强制1:1
|
||||
|
||||
const handleImageUpload = (options: { file: { file: File } }) => {
|
||||
const file = options.file.file;
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
originalImageSrc.value = e.target?.result as string;
|
||||
croppedImageSrc.value = null; // 清空之前的裁剪
|
||||
gridImages.value = [];
|
||||
if (cropper.value) {
|
||||
cropper.value.destroy();
|
||||
cropper.value = null;
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
const onImageLoad = () => {
|
||||
nextTick(() => {
|
||||
if (originalImageRef.value && cropperContainerRef.value) {
|
||||
if (cropper.value) {
|
||||
cropper.value.destroy();
|
||||
}
|
||||
// 先清空容器再初始化,避免重复
|
||||
cropperContainerRef.value.innerHTML = '';
|
||||
const imgElement = originalImageRef.value.cloneNode() as HTMLImageElement;
|
||||
cropperContainerRef.value.appendChild(imgElement);
|
||||
|
||||
cropper.value = new Cropper(imgElement, {
|
||||
aspectRatio: 1, // 固定为正方形裁剪
|
||||
viewMode: 1,
|
||||
dragMode: 'move',
|
||||
background: false,
|
||||
autoCropArea: 0.8,
|
||||
responsive: true,
|
||||
crop(event) {
|
||||
// console.log(event.detail.x);
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const cropImage = () => {
|
||||
if (cropper.value) {
|
||||
const croppedCanvas = cropper.value.getCroppedCanvas();
|
||||
if (croppedCanvas) {
|
||||
croppedImageSrc.value = croppedCanvas.toDataURL();
|
||||
// 初始化九宫格图片数据
|
||||
gridImages.value = Array(9).fill(null).map(() => ({
|
||||
baseSrc: croppedImageSrc.value!,
|
||||
customImages: [],
|
||||
finalSrc: croppedImageSrc.value!, // 初始时finalSrc与baseSrc相同
|
||||
}));
|
||||
message.success('图片裁剪成功!');
|
||||
} else {
|
||||
message.error('无法裁剪图片,请重试');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const selectCellForCustomization = (index: number) => {
|
||||
selectedCellIndex.value = index;
|
||||
if (!gridImages.value[index]) { // 以防万一
|
||||
gridImages.value[index] = {
|
||||
baseSrc: croppedImageSrc.value!,
|
||||
customImages: [],
|
||||
finalSrc: croppedImageSrc.value!,
|
||||
};
|
||||
}
|
||||
showCustomizationModal.value = true;
|
||||
};
|
||||
|
||||
const handleAddCustomImage = (options: { file: { file: File } }) => {
|
||||
const file = options.file.file;
|
||||
if (file && selectedCellIndex.value !== null) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
gridImages.value[selectedCellIndex.value!]?.customImages.push({ src: e.target?.result as string, file });
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
const clearCustomImages = (index: number) => {
|
||||
if (gridImages.value[index]) {
|
||||
gridImages.value[index].customImages = [];
|
||||
// 需要重新合成或标记为待合成
|
||||
applyCellCustomization();
|
||||
}
|
||||
};
|
||||
|
||||
const applyCellCustomization = async () => {
|
||||
if (selectedCellIndex.value === null || !gridImages.value[selectedCellIndex.value]) {
|
||||
showCustomizationModal.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const cell = gridImages.value[selectedCellIndex.value];
|
||||
const baseImg = new Image();
|
||||
baseImg.src = cell.baseSrc;
|
||||
|
||||
await new Promise(resolve => baseImg.onload = resolve);
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
message.error('无法获取Canvas绘图上下文');
|
||||
showCustomizationModal.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
let totalHeight = baseImg.height;
|
||||
const customImageElements: HTMLImageElement[] = [];
|
||||
|
||||
for (const customImgData of cell.customImages) {
|
||||
const img = new Image();
|
||||
img.src = customImgData.src;
|
||||
await new Promise(resolve => img.onload = resolve);
|
||||
totalHeight += img.height;
|
||||
customImageElements.push(img);
|
||||
}
|
||||
|
||||
canvas.width = baseImg.width; // 宽度以基础裁剪图为准
|
||||
canvas.height = totalHeight;
|
||||
|
||||
ctx.drawImage(baseImg, 0, 0);
|
||||
|
||||
let currentY = baseImg.height;
|
||||
for (const img of customImageElements) {
|
||||
ctx.drawImage(img, 0, currentY);
|
||||
currentY += img.height;
|
||||
}
|
||||
|
||||
cell.finalSrc = canvas.toDataURL();
|
||||
message.success(`第 ${selectedCellIndex.value + 1} 张小图自定义已应用`);
|
||||
showCustomizationModal.value = false;
|
||||
selectedCellIndex.value = null;
|
||||
};
|
||||
|
||||
|
||||
const generateAndDownloadNineGrid = async () => {
|
||||
if (!croppedImageSrc.value || gridImages.value.some(img => !img.finalSrc)) {
|
||||
message.error('请先上传并裁剪图片,并确保所有小图都已处理。');
|
||||
return;
|
||||
}
|
||||
isGenerating.value = true;
|
||||
message.info("开始生成九宫格大图,请稍候...");
|
||||
|
||||
// 确保所有finalSrc都是最新的
|
||||
for (let i = 0; i < gridImages.value.length; i++) {
|
||||
if (!gridImages.value[i].finalSrc) { // 如果有未处理的(理论上不应该,因为applyCellCustomization会处理)
|
||||
await applyCellCustomizationInternal(i); // 复用一个内部的合成逻辑
|
||||
}
|
||||
}
|
||||
|
||||
// 创建一个临时的容器来渲染九宫格
|
||||
const tempGridContainer = document.createElement('div');
|
||||
tempGridContainer.style.display = 'grid';
|
||||
tempGridContainer.style.gridTemplateColumns = 'repeat(3, 1fr)';
|
||||
tempGridContainer.style.gridTemplateRows = 'repeat(3, 1fr)';
|
||||
tempGridContainer.style.gap = '4px'; // B站动态图片间隔
|
||||
tempGridContainer.style.position = 'absolute'; // 防止影响当前页面布局
|
||||
tempGridContainer.style.left = '-9999px'; // 移出视口
|
||||
|
||||
const imageLoadPromises = gridImages.value.map((imgData, index) => {
|
||||
return new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.src = imgData.finalSrc!;
|
||||
img.onload = () => {
|
||||
// 为了确保html2canvas能正确处理,我们获取图片的原始宽高
|
||||
// 但在网格布局中,它们会被调整。这里主要是为了加载。
|
||||
resolve(img);
|
||||
};
|
||||
img.onerror = reject;
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
const loadedImages = await Promise.all(imageLoadPromises);
|
||||
let maxWidth = 0;
|
||||
let maxHeight = 0;
|
||||
|
||||
loadedImages.forEach(img => {
|
||||
const cellDiv = document.createElement('div');
|
||||
cellDiv.style.width = `${img.naturalWidth}px`; // 使用图片原始宽度
|
||||
cellDiv.style.height = `${img.naturalHeight}px`; // 使用图片原始高度
|
||||
const cellImg = img.cloneNode() as HTMLImageElement;
|
||||
cellDiv.appendChild(cellImg);
|
||||
tempGridContainer.appendChild(cellDiv);
|
||||
maxWidth = Math.max(maxWidth, img.naturalWidth);
|
||||
maxHeight = Math.max(maxHeight, img.naturalHeight);
|
||||
});
|
||||
|
||||
// 设置容器的总宽高,基于最大单图尺寸乘以3,再加上gap
|
||||
tempGridContainer.style.width = `${maxWidth * 3 + 4 * 2}px`;
|
||||
tempGridContainer.style.height = `${maxHeight * 3 + 4 * 2}px`;
|
||||
|
||||
|
||||
document.body.appendChild(tempGridContainer);
|
||||
|
||||
// 等待一小段时间确保DOM更新和图片渲染
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
const canvas = await html2canvas(tempGridContainer, {
|
||||
useCORS: true,
|
||||
backgroundColor: null, // 透明背景
|
||||
logging: true,
|
||||
scale: window.devicePixelRatio, // 提高清晰度
|
||||
onclone: (clonedDoc) => { // 确保克隆的文档中的图片也使用原始尺寸
|
||||
const clonedImgs = clonedDoc.querySelectorAll('.nine-grid-preview-container img');
|
||||
clonedImgs.forEach((clonedImgElem ) => {
|
||||
const originalImg = Array.from(tempGridContainer.querySelectorAll('img')).find(orig => orig.src === (clonedImgElem as HTMLImageElement).src);
|
||||
if (originalImg) {
|
||||
(clonedImgElem as HTMLImageElement).style.width = `${originalImg.naturalWidth}px`;
|
||||
(clonedImgElem as HTMLImageElement).style.height = `${originalImg.naturalHeight}px`;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
document.body.removeChild(tempGridContainer);
|
||||
|
||||
const dataUrl = canvas.toDataURL('image/png');
|
||||
const link = document.createElement('a');
|
||||
link.download = 'nine-grid-combined.png';
|
||||
link.href = dataUrl;
|
||||
link.click();
|
||||
message.success('九宫格图片已生成并开始下载!');
|
||||
} catch (error) {
|
||||
console.error("Error generating nine grid image:", error);
|
||||
message.error('生成九宫格图片失败,详情请查看控制台。');
|
||||
} finally {
|
||||
isGenerating.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 内部合成逻辑,避免重复代码
|
||||
async function applyCellCustomizationInternal(cellIndex: number) {
|
||||
const cell = gridImages.value[cellIndex];
|
||||
if (!cell) return;
|
||||
|
||||
const baseImg = new Image();
|
||||
baseImg.src = cell.baseSrc;
|
||||
await new Promise(resolve => baseImg.onload = resolve);
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
let totalHeight = baseImg.height;
|
||||
const customImageElements: HTMLImageElement[] = [];
|
||||
|
||||
for (const customImgData of cell.customImages) {
|
||||
const img = new Image();
|
||||
img.src = customImgData.src;
|
||||
await new Promise(resolve => img.onload = resolve);
|
||||
totalHeight += img.height;
|
||||
customImageElements.push(img);
|
||||
}
|
||||
|
||||
canvas.width = baseImg.width;
|
||||
canvas.height = totalHeight;
|
||||
ctx.drawImage(baseImg, 0, 0);
|
||||
let currentY = baseImg.height;
|
||||
for (const img of customImageElements) {
|
||||
ctx.drawImage(img, 0, currentY);
|
||||
currentY += img.height;
|
||||
}
|
||||
cell.finalSrc = canvas.toDataURL();
|
||||
}
|
||||
|
||||
|
||||
watch(originalImageSrc, (newSrc) => {
|
||||
if (newSrc) {
|
||||
// 图片加载后初始化Cropper
|
||||
} else {
|
||||
if (cropper.value) {
|
||||
cropper.value.destroy();
|
||||
cropper.value = null;
|
||||
}
|
||||
croppedImageSrc.value = null;
|
||||
gridImages.value = [];
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dynamic-nine-grid-generator {
|
||||
max-width: 800px;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.original-image-preview, .cropped-tile-preview, .customization-base-preview {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border: 1px solid #eee;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.cropper-container {
|
||||
width: 100%;
|
||||
max-height: 500px; /* 限制cropper的高度 */
|
||||
margin-top: 10px;
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
|
||||
/* cropperjs 的默认样式可能需要调整,确保它在 naive-ui 环境下正确显示 */
|
||||
:deep(.cropper-view-box),
|
||||
:deep(.cropper-face) {
|
||||
border-radius: 0; /* 如果需要直角 */
|
||||
}
|
||||
|
||||
.nine-grid-preview-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
grid-template-rows: repeat(3, 1fr);
|
||||
gap: 4px; /* B站动态图片间隔 */
|
||||
border: 1px solid #ddd;
|
||||
padding: 4px;
|
||||
background-color: #f0f0f0;
|
||||
max-width: 400px; /* 控制预览区域大小 */
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.grid-cell {
|
||||
border: 1px solid #ccc;
|
||||
background-color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
aspect-ratio: 1 / 1; /* 保持小格子为正方形,根据内容可能会被撑开 */
|
||||
}
|
||||
|
||||
.grid-cell img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: cover; /* 确保图片填满单元格,可能会裁剪 */
|
||||
}
|
||||
.grid-cell.selected-cell {
|
||||
border: 2px solid #18a058; /* Naive UI 主题色 */
|
||||
box-shadow: 0 0 5px #18a058;
|
||||
}
|
||||
|
||||
.custom-content-indicator {
|
||||
position: absolute;
|
||||
bottom: 2px;
|
||||
right: 2px;
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
color: white;
|
||||
font-size: 10px;
|
||||
padding: 1px 3px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.mt-2 {
|
||||
margin-top: 8px;
|
||||
}
|
||||
.custom-images-preview .n-image {
|
||||
border: 1px solid #eee;
|
||||
margin: 2px;
|
||||
}
|
||||
</style>
|
||||
15
src/views/manage/tools/DynamicNineGridView.vue
Normal file
15
src/views/manage/tools/DynamicNineGridView.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<tool-dynamic-nine-grid />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ToolDynamicNineGrid from '@/components/manage/tools/ToolDynamicNineGrid.vue';
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-container {
|
||||
padding: 20px;
|
||||
}
|
||||
</style>
|
||||
608
src/views/manage/tools/ToolDynamicNineGrid.vue
Normal file
608
src/views/manage/tools/ToolDynamicNineGrid.vue
Normal file
@@ -0,0 +1,608 @@
|
||||
<template>
|
||||
<div class="dynamic-nine-grid-tool">
|
||||
<n-card title="动态九图生成器">
|
||||
<n-space vertical size="large">
|
||||
<div class="upload-section">
|
||||
<n-upload
|
||||
action="#"
|
||||
:show-file-list="false"
|
||||
@change="handleFileChange"
|
||||
accept="image/*"
|
||||
>
|
||||
<n-button type="primary">上传原始图片</n-button>
|
||||
</n-upload>
|
||||
<n-text depth="3" class="mt-1">
|
||||
请上传一张方形或长方形图片,将会自动分割成3x3的九宫格图片
|
||||
</n-text>
|
||||
</div> <div v-if="originalImage" class="cropper-container">
|
||||
<div>
|
||||
<n-text>请确保您上传的图片是方形或近似方形,以获得最佳效果</n-text>
|
||||
</div>
|
||||
<div class="image-preview">
|
||||
<img :src="originalImage" alt="原始图片" class="original-image-preview" />
|
||||
</div> <div class="cropper-controls">
|
||||
<n-button @click="generatePreview" type="primary">生成预览</n-button>
|
||||
<n-upload
|
||||
action="#"
|
||||
:show-file-list="false"
|
||||
@change="handleFileChange"
|
||||
accept="image/*"
|
||||
>
|
||||
<n-button type="warning">重新上传</n-button>
|
||||
</n-upload>
|
||||
</div>
|
||||
</div> <div v-if="croppedSquareImage" class="preview-section">
|
||||
<n-h4>九宫格预览</n-h4>
|
||||
<n-text depth="3">
|
||||
九宫格预览显示了图片分割后的效果,每个格子都可以添加下方图片
|
||||
</n-text>
|
||||
|
||||
<div class="whole-image-preview">
|
||||
<img :src="croppedSquareImage" alt="完整预览" class="whole-preview-img" />
|
||||
<div class="grid-overlay">
|
||||
<div v-for="i in 9" :key="`overlay-${i}`" class="grid-overlay-cell">
|
||||
<div class="cell-number">{{ i }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="nine-grid-preview">
|
||||
<div
|
||||
v-for="i in 9"
|
||||
:key="`grid-${i}`"
|
||||
class="grid-item-container"
|
||||
>
|
||||
<div class="grid-image-container">
|
||||
<div class="grid-position-indicator">第{{ i }}格</div>
|
||||
<div class="grid-image-wrapper">
|
||||
<img
|
||||
:src="croppedSquareImage"
|
||||
alt="九宫格预览"
|
||||
class="grid-image-base"
|
||||
:style="{
|
||||
clipPath: generateClipPath(i-1),
|
||||
transform: 'scale(3)',
|
||||
transformOrigin: calculateTransformOrigin(i-1)
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="additionalImages[i-1]" class="additional-image-preview">
|
||||
<img :src="additionalImages[i-1] || ''" alt="附加图片" />
|
||||
</div>
|
||||
<div class="grid-controls">
|
||||
<n-upload
|
||||
action="#"
|
||||
:show-file-list="false"
|
||||
@change="(data) => handleAdditionalImageChange(data, i - 1)"
|
||||
accept="image/*"
|
||||
>
|
||||
<n-button size="tiny">添加下方图片</n-button>
|
||||
</n-upload>
|
||||
<n-button
|
||||
v-if="additionalImages[i-1]"
|
||||
size="tiny"
|
||||
type="error"
|
||||
@click="() => removeAdditionalImage(i-1)"
|
||||
>
|
||||
删除下方图片
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="action-buttons">
|
||||
<n-button @click="generateFinalImages" type="success" class="mt-2">生成九张图片</n-button>
|
||||
<n-button @click="downloadAllImages" type="info" class="mt-2" :disabled="finalImages.length === 0">
|
||||
打包下载全部
|
||||
</n-button>
|
||||
</div>
|
||||
</div><div v-if="finalImages.length > 0" class="final-images-section">
|
||||
<n-h4>最终图片</n-h4>
|
||||
<n-text depth="3">
|
||||
以下是生成的九宫格图片,您可以单独下载每张图片
|
||||
</n-text>
|
||||
<div class="final-images-grid">
|
||||
<div v-for="(imgDataUrl, index) in finalImages" :key="`final-${index}`" class="final-image-item">
|
||||
<img :src="imgDataUrl" :alt="`最终图片 ${index + 1}`" />
|
||||
<div class="image-number">{{ index + 1 }}</div>
|
||||
<n-button size="small" @click="() => downloadImage(imgDataUrl, `grid_image_${index + 1}.png`)" class="download-button">
|
||||
下载
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</n-space>
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { NCard, NButton, NUpload, NSpace, NH4, NText, useMessage } from 'naive-ui';
|
||||
import type { UploadFileInfo } from 'naive-ui';
|
||||
// 直接引入 vue-cropperjs,它应该会自动包含所需的 CSS
|
||||
import VueCropper from 'vue-cropperjs';
|
||||
import { useFileDialog } from '@vueuse/core';
|
||||
|
||||
const message = useMessage();
|
||||
|
||||
const originalImage = ref<string | null>(null);
|
||||
const croppedSquareImage = ref<string | null>(null);
|
||||
const cropperRef = ref<any>(null); // VueCropper Instance
|
||||
|
||||
const additionalImages = ref<(string | null)[]>(Array(9).fill(null));
|
||||
const finalImages = ref<string[]>([]);
|
||||
|
||||
// 添加九宫格位置控制变量
|
||||
const gridPositions = ref<{ x: number, y: number }[]>(
|
||||
Array(9).fill(null).map(() => ({ x: 0, y: 0 }))
|
||||
);
|
||||
|
||||
const { files, open, reset } = useFileDialog({
|
||||
accept: 'image/*',
|
||||
multiple: false,
|
||||
});
|
||||
|
||||
const handleFileChange = (data: { file: UploadFileInfo }) => {
|
||||
const file = data.file.file;
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
originalImage.value = e.target?.result as string;
|
||||
generatePreview();
|
||||
finalImages.value = []; // Reset final images
|
||||
additionalImages.value = Array(9).fill(null); // Reset additional images
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
const generatePreview = async () => {
|
||||
if (!originalImage.value) return;
|
||||
|
||||
const img = new Image();
|
||||
img.src = originalImage.value;
|
||||
await new Promise(resolve => img.onload = resolve);
|
||||
|
||||
// 创建方形预览图
|
||||
const size = Math.min(img.width, img.height);
|
||||
const tempCanvas = document.createElement('canvas');
|
||||
const tempCtx = tempCanvas.getContext('2d');
|
||||
|
||||
if (!tempCtx) return;
|
||||
|
||||
const offsetX = (img.width - size) / 2;
|
||||
const offsetY = (img.height - size) / 2;
|
||||
|
||||
tempCanvas.width = size;
|
||||
tempCanvas.height = size;
|
||||
|
||||
tempCtx.drawImage(img, offsetX, offsetY, size, size, 0, 0, size, size);
|
||||
croppedSquareImage.value = tempCanvas.toDataURL();
|
||||
|
||||
message.success('图片已准备就绪,可以查看九宫格预览');
|
||||
};
|
||||
|
||||
const onCropperReady = () => {
|
||||
if (cropperRef.value) {
|
||||
// 设置裁剪区域为方形
|
||||
const containerData = cropperRef.value.cropper.getContainerData();
|
||||
const size = Math.min(containerData.width, containerData.height) * 0.8;
|
||||
|
||||
cropperRef.value.cropper.setCropBoxData({
|
||||
left: (containerData.width - size) / 2,
|
||||
top: (containerData.height - size) / 2,
|
||||
width: size,
|
||||
height: size
|
||||
});
|
||||
|
||||
message.success('可以调整裁剪区域');
|
||||
}
|
||||
};
|
||||
|
||||
const cropImage = () => {
|
||||
if (cropperRef.value) {
|
||||
croppedSquareImage.value = cropperRef.value.getCroppedCanvas({
|
||||
imageSmoothingQuality: 'high',
|
||||
}).toDataURL();
|
||||
|
||||
message.success('图片裁剪成功!');
|
||||
}
|
||||
};
|
||||
|
||||
const handleAdditionalImageChange = (data: { file: UploadFileInfo }, index: number) => {
|
||||
const file = data.file.file;
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
additionalImages.value[index] = e.target?.result as string;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
const generateFinalImages = async () => {
|
||||
if (!originalImage.value) {
|
||||
message.error('请先上传一张图片');
|
||||
return;
|
||||
}
|
||||
finalImages.value = [];
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
if (!ctx) {
|
||||
message.error('无法创建画布');
|
||||
return;
|
||||
}
|
||||
|
||||
// 加载原始图片
|
||||
const originalImg = new Image();
|
||||
originalImg.src = originalImage.value;
|
||||
await new Promise(resolve => originalImg.onload = resolve);
|
||||
|
||||
// 确保图片是正方形的
|
||||
const size = Math.min(originalImg.width, originalImg.height);
|
||||
const offsetX = (originalImg.width - size) / 2;
|
||||
const offsetY = (originalImg.height - size) / 2;
|
||||
|
||||
// 每个格子的尺寸
|
||||
const gridSize = size / 3;
|
||||
|
||||
// 为每个格子生成图片
|
||||
for (let i = 0; i < 9; i++) {
|
||||
// 计算当前格子在原图中的位置
|
||||
const row = Math.floor(i / 3);
|
||||
const col = i % 3;
|
||||
const srcX = offsetX + col * gridSize;
|
||||
const srcY = offsetY + row * gridSize;
|
||||
|
||||
// 加载额外图片(如果有)
|
||||
let additionalImg: HTMLImageElement | null = null;
|
||||
let additionalHeight = 0;
|
||||
|
||||
if (additionalImages.value[i]) {
|
||||
additionalImg = new Image();
|
||||
additionalImg.src = additionalImages.value[i] as string;
|
||||
await new Promise(resolve => additionalImg!.onload = resolve);
|
||||
|
||||
// 计算额外图片等比例缩放后的高度
|
||||
additionalHeight = (gridSize / additionalImg.width) * additionalImg.height;
|
||||
}
|
||||
|
||||
// 设置画布尺寸
|
||||
canvas.width = gridSize;
|
||||
canvas.height = gridSize + additionalHeight;
|
||||
|
||||
// 清空画布
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// 绘制九宫格中的一格
|
||||
ctx.drawImage(
|
||||
originalImg,
|
||||
srcX, srcY, gridSize, gridSize, // 从原图截取的区域
|
||||
0, 0, gridSize, gridSize // 绘制到画布的位置和大小
|
||||
);
|
||||
|
||||
// 如果有额外图片,绘制在下方
|
||||
if (additionalImg) {
|
||||
ctx.drawImage(
|
||||
additionalImg,
|
||||
0, gridSize, gridSize, additionalHeight
|
||||
);
|
||||
}
|
||||
|
||||
// 保存生成的图片
|
||||
finalImages.value.push(canvas.toDataURL('image/png'));
|
||||
}
|
||||
|
||||
message.success('九宫格图片已生成!可以单独下载每张图片');
|
||||
};
|
||||
|
||||
const downloadImage = (dataUrl: string, filename: string) => {
|
||||
const link = document.createElement('a');
|
||||
link.href = dataUrl;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
const resetCropper = () => {
|
||||
if (cropperRef.value) {
|
||||
cropperRef.value.cropper.reset();
|
||||
message.info('已重置裁剪区域');
|
||||
}
|
||||
};
|
||||
|
||||
const removeAdditionalImage = (index: number) => {
|
||||
additionalImages.value[index] = null;
|
||||
message.success('已删除附加图片');
|
||||
};
|
||||
|
||||
const downloadAllImages = () => {
|
||||
if (finalImages.value.length === 0) {
|
||||
message.error('请先生成九宫格图片');
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建一个 zip 文件的替代方案
|
||||
// 这里我们简单地连续下载所有图片
|
||||
finalImages.value.forEach((dataUrl, index) => {
|
||||
setTimeout(() => {
|
||||
downloadImage(dataUrl, `grid_image_${index + 1}.png`);
|
||||
}, index * 300); // 避免浏览器阻止多次下载,添加延迟
|
||||
});
|
||||
|
||||
message.success('正在下载所有图片...');
|
||||
};
|
||||
|
||||
// 生成CSS clip-path以显示图片的特定部分
|
||||
const generateClipPath = (index: number) => {
|
||||
const row = Math.floor(index / 3);
|
||||
const col = index % 3;
|
||||
const startX = col * 33.33;
|
||||
const startY = row * 33.33;
|
||||
const endX = startX + 33.33;
|
||||
const endY = startY + 33.33;
|
||||
|
||||
return `polygon(${startX}% ${startY}%, ${endX}% ${startY}%, ${endX}% ${endY}%, ${startX}% ${endY}%)`;
|
||||
};
|
||||
|
||||
// 计算图片的transform-origin,确保正确缩放
|
||||
const calculateTransformOrigin = (index: number) => {
|
||||
const row = Math.floor(index / 3);
|
||||
const col = index % 3;
|
||||
const originX = col * 50; // 使用百分比
|
||||
const originY = row * 50;
|
||||
|
||||
return `${originX}% ${originY}%`;
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dynamic-nine-grid-tool {
|
||||
max-width: 900px;
|
||||
margin: auto;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.upload-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
border: 1px dashed #ccc;
|
||||
border-radius: 8px;
|
||||
background-color: rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
.cropper-container {
|
||||
margin: 1rem 0;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
background-color: #fafafa;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.cropper-controls {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 15px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.preview-section {
|
||||
margin: 1.5rem 0;
|
||||
padding: 15px;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
background-color: #fafafa;
|
||||
}
|
||||
|
||||
.nine-grid-preview {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 4px;
|
||||
padding: 10px;
|
||||
background-color: #f0f0f0;
|
||||
border-radius: 8px;
|
||||
margin: 1rem auto;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.grid-item-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: white;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.grid-image-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.grid-position-indicator {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
color: white;
|
||||
font-size: 10px;
|
||||
padding: 2px 4px;
|
||||
border-radius: 2px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.grid-image-base {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transform-origin: 0 0;
|
||||
}
|
||||
|
||||
.additional-image-preview {
|
||||
width: 100%;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.additional-image-preview img {
|
||||
width: 100%;
|
||||
display: block;
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
|
||||
.grid-controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
padding: 5px;
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.final-images-section {
|
||||
margin-top: 1.5rem;
|
||||
padding: 15px;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
background-color: #fafafa;
|
||||
}
|
||||
|
||||
.final-images-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 15px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.final-image-item {
|
||||
position: relative;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background-color: white;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.final-image-item img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.image-number {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
left: 5px;
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
color: white;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.whole-image-preview {
|
||||
position: relative;
|
||||
margin: 20px auto;
|
||||
max-width: 300px;
|
||||
border: 2px solid #333;
|
||||
}
|
||||
|
||||
.whole-preview-img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.grid-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
grid-template-rows: repeat(3, 1fr);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.grid-overlay-cell {
|
||||
border: 1px dashed rgba(255, 255, 255, 0.7);
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.cell-number {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
left: 5px;
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
color: white;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.grid-image-wrapper {
|
||||
width: 100%;
|
||||
height: 0;
|
||||
padding-bottom: 100%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.grid-image-base {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.original-image-preview {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.download-button {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.mt-1 {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.mt-2 {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
</style>
|
||||
421
src/views/obs_store/OBSComponentStoreView.vue
Normal file
421
src/views/obs_store/OBSComponentStoreView.vue
Normal file
@@ -0,0 +1,421 @@
|
||||
<template>
|
||||
<div class="obs-component-store-view">
|
||||
<NPageHeader :title="currentSelectedComponent ? currentSelectedComponent.name : 'OBS 组件商店'">
|
||||
<template #subtitle>
|
||||
{{ currentSelectedComponent ? currentSelectedComponent.description : '选择一个组件进行预览和配置' }}
|
||||
</template>
|
||||
</NPageHeader>
|
||||
|
||||
<NGrid
|
||||
cols="1 s:2 m:3 l:4 xl:4 xxl:5"
|
||||
responsive="screen"
|
||||
:x-gap="12"
|
||||
:y-gap="12"
|
||||
style="padding: 16px;"
|
||||
>
|
||||
<NGridItem
|
||||
v-for="compDef in availableComponents"
|
||||
:key="compDef.id"
|
||||
>
|
||||
<NCard
|
||||
:title="compDef.name"
|
||||
hoverable
|
||||
class="component-card"
|
||||
@click="selectComponent(compDef.id)"
|
||||
>
|
||||
<template
|
||||
v-if="compDef.icon"
|
||||
#cover
|
||||
>
|
||||
<!-- <img :src="compDef.icon" alt="compDef.name" /> -->
|
||||
</template>
|
||||
<p>{{ compDef.description }}</p>
|
||||
<template
|
||||
v-if="compDef.version"
|
||||
#footer
|
||||
>
|
||||
<NTag
|
||||
size="small"
|
||||
type="info"
|
||||
>
|
||||
v{{ compDef.version }}
|
||||
</NTag>
|
||||
</template>
|
||||
</NCard>
|
||||
</NGridItem>
|
||||
</NGrid>
|
||||
|
||||
<!-- 组件预览 Modal -->
|
||||
<NModal
|
||||
v-model:show="showPreviewModal"
|
||||
preset="card"
|
||||
:title="'组件预览:' + (currentSelectedComponent?.name || '')"
|
||||
style="max-width: 95vw; width: 1000px; max-height: 95vh; height: 1000px;"
|
||||
@update:show="handlePreviewModalUpdateShow"
|
||||
>
|
||||
<template #header-extra>
|
||||
<NSpace>
|
||||
<NButton
|
||||
v-if="dynamicComponentRef?.Config"
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="openSettingsForCurrentComponent"
|
||||
>
|
||||
配置组件
|
||||
</NButton>
|
||||
<NButton
|
||||
v-if="currentSelectedComponent"
|
||||
size="small"
|
||||
@click="refreshSelectedComponent"
|
||||
>
|
||||
刷新组件
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</template>
|
||||
<div class="component-preview-area">
|
||||
<NAlert
|
||||
v-if="isLoading"
|
||||
title="加载中..."
|
||||
type="info"
|
||||
style="margin-bottom: 16px;"
|
||||
>
|
||||
正在加载组件配置和资源...
|
||||
</NAlert>
|
||||
<NSpin :show="isLoading">
|
||||
<NFlex vertical>
|
||||
<NFlex>
|
||||
<NButton style="display: none;">
|
||||
占位
|
||||
</NButton>
|
||||
</NFlex>
|
||||
<component
|
||||
:is="currentSelectedComponent!.component"
|
||||
v-if="currentSelectedComponent"
|
||||
ref="dynamicComponentRef"
|
||||
:config="componentConfig"
|
||||
:user-info="userInfo"
|
||||
:bili-info="biliInfo"
|
||||
:refresh-signal="refreshSignal"
|
||||
v-bind="currentSelectedComponent.props || {}"
|
||||
@update:config="handleConfigUpdateFromChild"
|
||||
/>
|
||||
</NFlex>
|
||||
</NSpin>
|
||||
</div>
|
||||
<template #footer>
|
||||
<NSpace justify="end">
|
||||
<NButton @click="showPreviewModal = false">
|
||||
关闭
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</template>
|
||||
</NModal>
|
||||
|
||||
<!-- 组件配置 Modal -->
|
||||
<NModal
|
||||
v-model:show="showSettingModal"
|
||||
style="max-width: 90vw; width: 800px;"
|
||||
preset="card"
|
||||
title="组件配置"
|
||||
:mask-closable="false"
|
||||
>
|
||||
<DynamicForm
|
||||
v-if="selectedComponentDefinitionForModal?.settingName && selectedComponentDefinitionForModal?.componentRef?.Config"
|
||||
:name="selectedComponentDefinitionForModal.settingName"
|
||||
:config-data="componentConfigForEditing"
|
||||
:config="selectedComponentDefinitionForModal.componentRef.Config"
|
||||
@update:config-data="onDynamicFormUpdate"
|
||||
/>
|
||||
<template #footer>
|
||||
<NSpace justify="end">
|
||||
<NButton @click="showSettingModal = false">
|
||||
取消
|
||||
</NButton>
|
||||
<NButton
|
||||
type="primary"
|
||||
@click="saveComponentConfig"
|
||||
>
|
||||
保存配置
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</template>
|
||||
</NModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { DownloadConfig, UploadConfig, useAccount } from '@/api/account';
|
||||
import { UserInfo } from '@/api/api-models';
|
||||
import { OBSComponentMap } from '@/data/obsConstants';
|
||||
import { OBSComponentDefinition } from '@/data/obsConstants';
|
||||
import { ConfigItemDefinition } from '@/data/VTsuruConfigTypes';
|
||||
import { useBiliAuth } from '@/store/useBiliAuth';
|
||||
import { NAlert, NButton, NCard, NGrid, NGridItem, NModal, NPageHeader, NSpace, NSpin, NTag, useMessage } from 'naive-ui';
|
||||
import { ComponentPublicInstance, computed, defineAsyncComponent, onMounted, ref, watch } from 'vue';
|
||||
|
||||
// --- 静态导入所有可能的组件,以便 DynamicForm 能获取到 Config 定义 ---
|
||||
// 如果组件过多,考虑更动态的注册方式,但 DynamicForm 需要直接访问 Config
|
||||
// 修正:直接从子组件实例获取 Config,而不是静态导入模块本身
|
||||
// import * as ExampleOBSComponent from './components/ExampleOBSComponent.vue';
|
||||
|
||||
|
||||
// --- 模拟父组件传入的信息 ---
|
||||
const props = defineProps<{
|
||||
// 如果此视图作为路由组件,可能从路由参数获取信息
|
||||
// userId?: string; // 示例:如果配置与特定用户关联
|
||||
biliInfo?: any; // B站信息 (可选)
|
||||
}>();
|
||||
|
||||
const accountInfo = useAccount();
|
||||
const biliAuth = useBiliAuth(); // 若需要B站授权信息
|
||||
const message = useMessage();
|
||||
|
||||
const userInfo = ref<UserInfo | undefined>(accountInfo.value.id ? { id: accountInfo.value.id, name: accountInfo.value.name } as UserInfo : undefined); // 模拟
|
||||
|
||||
const availableComponents = ref<OBSComponentDefinition[]>([]);
|
||||
const currentSelectedComponentId = ref<string | null>(null);
|
||||
const dynamicComponentRef = ref<ComponentPublicInstance & { Config?: ConfigItemDefinition[], DefaultConfig?: any; } | null>(null);
|
||||
|
||||
|
||||
const componentConfig = ref<any>({}); // 当前选中组件的运行时配置
|
||||
const componentConfigForEditing = ref<any>({}); // 模态框中编辑的配置副本
|
||||
|
||||
const isLoading = ref(false);
|
||||
const showSettingModal = ref(false);
|
||||
const refreshSignal = ref(0); // 用于手动触发子组件刷新
|
||||
const showPreviewModal = ref(false); // 新增:控制预览 Modal 的显示
|
||||
|
||||
// 初始化可用组件列表
|
||||
function initializeComponents() {
|
||||
availableComponents.value = Object.values(OBSComponentMap);
|
||||
}
|
||||
|
||||
const currentSelectedComponent = computed<OBSComponentDefinition | undefined>(() => {
|
||||
if (!currentSelectedComponentId.value) return undefined;
|
||||
return OBSComponentMap[currentSelectedComponentId.value];
|
||||
});
|
||||
|
||||
// 用于模态框的计算属性,确保组件引用已加载并且 Config 存在
|
||||
const selectedComponentDefinitionForModal = computed(() => {
|
||||
if (!currentSelectedComponent.value || !dynamicComponentRef.value?.Config) return undefined;
|
||||
// 确保 dynamicComponentRef.value 存在并且具有 Config 属性
|
||||
if (dynamicComponentRef.value && typeof dynamicComponentRef.value.Config !== 'undefined') {
|
||||
return {
|
||||
...currentSelectedComponent.value,
|
||||
componentRef: dynamicComponentRef.value, // 直接使用 ref
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
|
||||
async function selectComponent(componentId: string) {
|
||||
if (currentSelectedComponentId.value === componentId) { // 如果已经是当前选中的,则尝试刷新
|
||||
refreshSelectedComponent();
|
||||
return;
|
||||
}
|
||||
|
||||
currentSelectedComponentId.value = componentId;
|
||||
isLoading.value = true;
|
||||
componentConfig.value = {}; // 重置配置
|
||||
|
||||
// 等待下一个 tick 确保 dynamicComponentRef 更新
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
currentSelectedComponent.value!.settingName = dynamicComponentRef.value?.Config ? `OBSStore.Config.${currentSelectedComponent.value!.id}` : undefined;
|
||||
|
||||
if (currentSelectedComponent.value?.settingName) {
|
||||
await loadComponentConfig(currentSelectedComponent.value.settingName);
|
||||
} else {
|
||||
// 如果组件没有 settingName,则它可能不使用持久化配置,或者使用默认配置
|
||||
if (dynamicComponentRef.value && dynamicComponentRef.value.DefaultConfig) {
|
||||
componentConfig.value = { ...dynamicComponentRef.value.DefaultConfig };
|
||||
componentConfigForEditing.value = JSON.parse(JSON.stringify(componentConfig.value));
|
||||
}
|
||||
}
|
||||
isLoading.value = false;
|
||||
showPreviewModal.value = true; // 新增:显示预览 Modal
|
||||
}
|
||||
|
||||
async function loadComponentConfig(settingName: string) {
|
||||
if (!userInfo.value?.id) {
|
||||
message.error('无法加载组件配置:未找到用户信息。');
|
||||
if (dynamicComponentRef.value && dynamicComponentRef.value.DefaultConfig) {
|
||||
componentConfig.value = { ...dynamicComponentRef.value.DefaultConfig };
|
||||
componentConfigForEditing.value = JSON.parse(JSON.stringify(componentConfig.value));
|
||||
}
|
||||
isLoading.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading.value = true;
|
||||
try {
|
||||
const configData = await DownloadConfig<any>(settingName, userInfo.value.id);
|
||||
const defaultConfig = dynamicComponentRef.value?.DefaultConfig || {};
|
||||
|
||||
if (configData.msg || Object.keys(configData.data || {}).length === 0) {
|
||||
componentConfig.value = { ...defaultConfig };
|
||||
message.info('未找到在线配置,已加载默认配置。');
|
||||
} else {
|
||||
// 合并远程配置和默认配置,确保所有键都存在
|
||||
componentConfig.value = configData.data;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载组件配置失败:', error);
|
||||
message.error(`加载组件配置失败: ${error instanceof Error ? error.message : String(error)}`);
|
||||
componentConfig.value = { ...(dynamicComponentRef.value?.DefaultConfig || {}) };
|
||||
} finally {
|
||||
componentConfigForEditing.value = JSON.parse(JSON.stringify(componentConfig.value)); // 深拷贝用于编辑
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveComponentConfig() {
|
||||
if (!currentSelectedComponent.value?.settingName || !userInfo.value?.id) {
|
||||
message.error('无法保存配置:组件配置名称或用户信息丢失。');
|
||||
return;
|
||||
}
|
||||
isLoading.value = true;
|
||||
try {
|
||||
await UploadConfig(currentSelectedComponent.value.settingName,
|
||||
JSON.stringify(componentConfigForEditing.value), // 保存编辑后的配置
|
||||
false); // 或根据需要设置为 true);
|
||||
message.success('配置保存成功!');
|
||||
componentConfig.value = JSON.parse(JSON.stringify(componentConfigForEditing.value)); // 更新运行时配置
|
||||
showSettingModal.value = false;
|
||||
refreshSignal.value++; // 触发子组件刷新
|
||||
} catch (error) {
|
||||
console.error('保存组件配置失败:', error);
|
||||
message.error(`保存组件配置失败: ${error instanceof Error ? error.message : String(error)}`);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onDynamicFormUpdate(updatedConfig: any) {
|
||||
componentConfigForEditing.value = updatedConfig;
|
||||
}
|
||||
|
||||
function handleConfigUpdateFromChild(newConfig: any) {
|
||||
// 如果子组件能直接修改配置并冒泡事件,可以在此处理
|
||||
// componentConfig.value = newConfig;
|
||||
// componentConfigForEditing.value = JSON.parse(JSON.stringify(newConfig));
|
||||
// console.log('Config updated from child:', newConfig);
|
||||
}
|
||||
|
||||
function refreshSelectedComponent() {
|
||||
if (!currentSelectedComponent.value) return;
|
||||
message.info(`正在刷新 ${currentSelectedComponent.value.name}...`);
|
||||
|
||||
// 方式1: 增加 refreshSignal 以触发子组件 watch
|
||||
refreshSignal.value++;
|
||||
|
||||
// 方式2: 如果子组件有暴露的刷新方法,可以调用
|
||||
// if (typeof dynamicComponentRef.value?.refresh === 'function') {
|
||||
// dynamicComponentRef.value.refresh();
|
||||
// }
|
||||
|
||||
// 可选: 重新加载配置
|
||||
if (currentSelectedComponent.value.settingName) {
|
||||
loadComponentConfig(currentSelectedComponent.value.settingName);
|
||||
}
|
||||
}
|
||||
|
||||
// 当 dynamicComponentRef 变化时 (组件加载完成),尝试加载/设置配置
|
||||
watch(dynamicComponentRef, (newRef) => {
|
||||
if (newRef) { // 组件已挂载
|
||||
const compDef = currentSelectedComponent.value;
|
||||
if (compDef) {
|
||||
currentSelectedComponent.value!.settingName = newRef.Config ? `OBSStore.Config.${currentSelectedComponent.value!.id}` : undefined;
|
||||
if (compDef.settingName) {
|
||||
// 如果有 settingName,则 loadComponentConfig 会处理(包括默认配置)
|
||||
// 这里确保在 selectComponent 中已经调用了 loadComponentConfig
|
||||
} else if (newRef.DefaultConfig) {
|
||||
// 没有 settingName,但子组件有 DefaultConfig
|
||||
componentConfig.value = { ...newRef.DefaultConfig };
|
||||
componentConfigForEditing.value = JSON.parse(JSON.stringify(componentConfig.value));
|
||||
}
|
||||
}
|
||||
}
|
||||
}, { immediate: false }); // immediate: false 因为 selectComponent 会处理首次加载
|
||||
|
||||
watch(showSettingModal, (isShown) => {
|
||||
if (isShown && currentSelectedComponent.value) {
|
||||
// 打开模态框时,确保编辑的是当前运行时配置的深拷贝
|
||||
// 同时,确保 DefaultConfig 能够正确合并,以防远程配置不完整
|
||||
const defaultConfig = dynamicComponentRef.value?.DefaultConfig || {};
|
||||
componentConfigForEditing.value = JSON.parse(JSON.stringify({
|
||||
...defaultConfig,
|
||||
...componentConfig.value // 当前运行时配置优先
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
// 新增:用于从预览 Modal 中打开配置 Modal
|
||||
function openSettingsForCurrentComponent() {
|
||||
if (currentSelectedComponent.value?.settingName && userInfo.value?.id === accountInfo.value.id) {
|
||||
// 确保 componentConfigForEditing 是最新的,基于 componentConfig
|
||||
// watch(showSettingModal) 已经处理了更复杂的默认配置合并逻辑,这里仅确保基于当前运行时配置的深拷贝
|
||||
const defaultConfig = dynamicComponentRef.value?.DefaultConfig || {};
|
||||
componentConfigForEditing.value = JSON.parse(JSON.stringify({
|
||||
...defaultConfig,
|
||||
...componentConfig.value
|
||||
}));
|
||||
showSettingModal.value = true;
|
||||
} else {
|
||||
message.error('无法打开配置:组件无设置项或用户不匹配。');
|
||||
}
|
||||
}
|
||||
|
||||
function handlePreviewModalUpdateShow(show: boolean) {
|
||||
showPreviewModal.value = show; // 保持 v-model:show 的双向绑定
|
||||
if (!show) {
|
||||
// 清理预览 Modal 关闭后的状态
|
||||
currentSelectedComponentId.value = null;
|
||||
componentConfig.value = {};
|
||||
componentConfigForEditing.value = {};
|
||||
dynamicComponentRef.value = null; // 清除对组件实例的引用
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
initializeComponents();
|
||||
// 可以在这里根据路由参数或其他逻辑自动选择一个组件
|
||||
// if (props.initialComponentId) {
|
||||
// selectComponent(props.initialComponentId);
|
||||
// }
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.obs-component-store-view {
|
||||
padding: 0px;
|
||||
/* 改为0,由 PageHeader 控制内边距 */
|
||||
}
|
||||
|
||||
.component-card {
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.component-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: var(--n-box-shadow-active);
|
||||
}
|
||||
|
||||
.component-card p {
|
||||
min-height: 40px;
|
||||
/* 防止描述为空时卡片高度不一致 */
|
||||
font-size: 0.9em;
|
||||
color: var(--n-text-color-disabled);
|
||||
}
|
||||
|
||||
.component-preview-area {
|
||||
min-height: 300px; /* 预览区域最小高度 */
|
||||
/* padding: 16px; /* 由 NModal card preset 提供内边距 */
|
||||
/* margin-top: 16px; /* NModal 会处理间距 */
|
||||
/* border: 1px solid var(--n-border-color); /* NModal card preset 提供边框 */
|
||||
/* border-radius: var(--n-border-radius); /* NModal card preset 提供圆角 */
|
||||
/* background-color: var(--n-card-color); /* NModal card preset 提供背景 */
|
||||
}
|
||||
</style>
|
||||
182
src/views/obs_store/components/ExampleOBSComponent.vue
Normal file
182
src/views/obs_store/components/ExampleOBSComponent.vue
Normal file
@@ -0,0 +1,182 @@
|
||||
<template>
|
||||
<NCard
|
||||
:title="localConfig.title || '示例 OBS 组件'"
|
||||
class="example-obs-component"
|
||||
>
|
||||
<NAlert
|
||||
:type="localConfig.alertType as any || 'info'"
|
||||
:title="localConfig.alertTitle || '组件信息'"
|
||||
>
|
||||
<p>{{ localConfig.contentText || '这是示例 OBS 组件的内容。' }}</p>
|
||||
<p v-if="userInfo">
|
||||
当前用户: {{ userInfo.name }}
|
||||
</p>
|
||||
<p>刷新次数: {{ refreshCount }}</p>
|
||||
<p>
|
||||
当前配置:
|
||||
<pre>{{ JSON.stringify(localConfig, null, 2) }}</pre>
|
||||
</p>
|
||||
</NAlert>
|
||||
|
||||
<NForm style="margin-top: 20px;">
|
||||
<NFormItem label="动态修改组件标题 (仅限本地,不保存)">
|
||||
<NInput
|
||||
v-model:value="dynamicTitle"
|
||||
placeholder="输入新标题"
|
||||
/>
|
||||
</NFormItem>
|
||||
<NButton @click="updateTitle">
|
||||
更新标题
|
||||
</NButton>
|
||||
</NForm>
|
||||
</NCard>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { UserInfo } from '@/api/api-models';
|
||||
// ConfigItemType is imported in the script block above
|
||||
// import { ConfigItemDefinition, ConfigItemType, ExtractConfigData, defineTemplateConfig } from '@/data/VTsuruConfigTypes';
|
||||
import { NAlert, NButton, NCard, NForm, NFormItem, NInput, useMessage } from 'naive-ui';
|
||||
import { computed, ref, watch, onMounted } from 'vue'; import { defineTemplateConfig, ExtractConfigData } from '@/data/VTsuruConfigTypes';
|
||||
|
||||
const Config = defineTemplateConfig([
|
||||
{
|
||||
name: '组件标题',
|
||||
key: 'title',
|
||||
type: 'string',
|
||||
default: '我的示例 OBS 组件',
|
||||
description: '显示在组件顶部的标题文字。'
|
||||
},
|
||||
{
|
||||
name: '提示类型',
|
||||
key: 'alertType',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: '信息 (Info)', value: 'info' },
|
||||
{ label: '成功 (Success)', value: 'success' },
|
||||
{ label: '警告 (Warning)', value: 'warning' },
|
||||
{ label: '错误 (Error)', value: 'error' },
|
||||
],
|
||||
default: 'info',
|
||||
description: '组件内 NAlert 提示框的样式类型。'
|
||||
},
|
||||
{
|
||||
name: '提示标题',
|
||||
key: 'alertTitle',
|
||||
type: 'string',
|
||||
default: '组件信息',
|
||||
},
|
||||
{
|
||||
name: '主要内容文本',
|
||||
key: 'contentText',
|
||||
type: 'string',
|
||||
inputType: 'textarea',
|
||||
default: '这是示例 OBS 组件的默认内容。您可以在此输入多行文本。',
|
||||
description: '组件内显示的主要文本信息。'
|
||||
},
|
||||
{
|
||||
name: '启用高级特性',
|
||||
key: 'enableAdvanced',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
},
|
||||
]);
|
||||
|
||||
type ExampleConfigType = ExtractConfigData<typeof Config>;
|
||||
|
||||
const DefaultConfig: ExampleConfigType = {
|
||||
title: '示例组件默认标题',
|
||||
alertType: 'success',
|
||||
alertTitle: '默认提示',
|
||||
contentText: '来自 DefaultConfig 的内容。点歌点歌点歌。关注vtsuru喵!',
|
||||
enableAdvanced: false,
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
Config,
|
||||
DefaultConfig,
|
||||
});
|
||||
|
||||
// --- Props ---
|
||||
const props = defineProps<{
|
||||
config: ExampleConfigType; // 从父组件接收的配置
|
||||
userInfo?: UserInfo;
|
||||
biliInfo?: any;
|
||||
refreshSignal?: number; // 接收刷新信号
|
||||
}>();
|
||||
|
||||
// --- Emits (可选,如果子组件需要通知父组件配置更改) ---
|
||||
// const emits = defineEmits(['update:config']);
|
||||
|
||||
const message = useMessage();
|
||||
|
||||
// --- 本地状态 ---
|
||||
const refreshCount = ref(0);
|
||||
const dynamicTitle = ref(props.config?.title || '默认标题');
|
||||
|
||||
// --- 计算属性,合并传入的config和默认值,确保所有字段都存在 ---
|
||||
const localConfig = computed<ExampleConfigType>(() => {
|
||||
return {
|
||||
...DefaultConfig, // 先使用默认值
|
||||
...(props.config || {}), // 然后用传入的配置覆盖
|
||||
};
|
||||
});
|
||||
|
||||
// --- 监听刷新信号 ---
|
||||
watch(() => props.refreshSignal, (newValue, oldValue) => {
|
||||
if (newValue !== undefined && newValue !== oldValue) {
|
||||
refreshCount.value++;
|
||||
message.success(`'示例 OBS 组件' 已刷新 (信号: ${newValue})`);
|
||||
// 在这里执行组件的刷新逻辑,例如重新获取数据、重置状态等
|
||||
// fetchData();
|
||||
}
|
||||
});
|
||||
|
||||
// --- 方法 ---
|
||||
function updateTitle() {
|
||||
if (props.config) {
|
||||
// 这是直接修改 prop,Vue 会发出警告。在实际应用中,应该通过 emit 更新父组件的配置
|
||||
// (props.config as any).title = dynamicTitle.value;
|
||||
message.info('标题已在本地临时更改。若要保存,请通过父组件的配置面板。');
|
||||
// 要正确更新,应该 emit事件,例如:
|
||||
// emits('update:config', { ...localConfig.value, title: dynamicTitle.value });
|
||||
}
|
||||
}
|
||||
|
||||
// --- Expose (使得父组件可以通过 ref 访问 Config 和 DefaultConfig) ---
|
||||
// Vue 3 <script setup> 默认关闭,需要显式 defineExpose
|
||||
// 但对于 DynamicForm,它似乎能够通过某种方式访问导出的 Config 和 DefaultConfig
|
||||
// 如果父组件需要通过 ref 主动调用方法或访问属性,则需要 defineExpose
|
||||
// defineExpose({ Config, DefaultConfig, /* refreshMethod */ });
|
||||
|
||||
onMounted(() => {
|
||||
// console.log('ExampleOBSComponent mounted with config:', props.config);
|
||||
// console.log('Effective localConfig:', localConfig.value);
|
||||
// console.log('Exposed Config definition:', Config);
|
||||
// console.log('Exposed DefaultConfig:', DefaultConfig);
|
||||
dynamicTitle.value = localConfig.value.title;
|
||||
});
|
||||
|
||||
watch(() => props.config, (newConfig) => {
|
||||
dynamicTitle.value = newConfig?.title || DefaultConfig.title;
|
||||
}, { deep: true });
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.example-obs-component {
|
||||
border: 1px dashed var(--n-border-color);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
pre {
|
||||
background-color: var(--n-code-block-color);
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85em;
|
||||
white-space: pre-wrap;
|
||||
/* 确保长内容能换行 */
|
||||
word-break: break-all;
|
||||
/* 强制断词,防止溢出 */
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user