feat: 更新OBS组件和路由配置,添加动态九图生成器功能, 修复礼物描述不换行的问题

- 在package.json中添加vue-cropperjs和相关类型定义
- 在obsConstants.ts中新增示例组件和控制器组件定义
- 更新manage.ts路由配置,添加OBS组件库和直播工具箱路由
- 在DynamicForm.vue中移除调试信息输出
- 在PointGoodsItem.vue中优化商品描述的显示逻辑
- 删除不再使用的OBS组件视图文件
This commit is contained in:
Megghy
2025-06-03 18:03:49 +08:00
parent 0d5a657d5c
commit 8fd182acae
24 changed files with 2696 additions and 607 deletions

BIN
bun.lockb

Binary file not shown.

View File

@@ -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
View File

@@ -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']

View File

@@ -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;
}

View File

@@ -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 {

View 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>

View File

@@ -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',
},
};

View File

@@ -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' // 指向工具箱仪表盘
}
}
]
}

View File

@@ -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,

View File

@@ -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"

View 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>

View 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>

View File

@@ -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>

View File

@@ -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) {
// 这是直接修改 propVue 会发出警告。在实际应用中,应该通过 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>

View 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>

View 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>

View 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>

View 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>

View 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) {
// 这是直接修改 propVue 会发出警告。在实际应用中,应该通过 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>