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

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

@@ -1,48 +0,0 @@
<template>
<img
v-if="svg"
:src="svg"
:class="['gamepad-component', 'gamepad-stick']"
:style="transformedStyle"
/>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import type { Position } from '@/types/gamepad';
interface Props {
svg?: string; // SVG URL
position?: Position;
axes?: { x: number; y: number };
}
const props = withDefaults(defineProps<Props>(), {
svg: undefined,
axes: () => ({ x: 0, y: 0 }),
position: () => ({ top: '0', left: '0', width: '5%' })
});
// 计算实际样式,加入摇杆位移
const transformedStyle = computed(() => {
const moveScale = 15; // 摇杆移动的最大距离(以像素为单位)
const translateX = props.axes.x * moveScale;
const translateY = props.axes.y * moveScale;
return {
top: props.position?.top,
left: props.position?.left,
width: props.position?.width,
height: props.position?.height || 'auto',
transform: `translate(${translateX}px, ${translateY}px)`
};
});
</script>
<style scoped>
.gamepad-component {
position: absolute;
user-select: none;
transition: transform 0.1s ease-out;
}
</style>

View File

@@ -1,47 +0,0 @@
<template>
<component
:is="svg"
v-if="svg"
:class="['gamepad-component', 'gamepad-button', { 'pressed': isPressed }]"
:style="positionStyle"
/>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import type { Component } from 'vue';
import type { Position } from '@/types/gamepad';
interface Props {
name?: string;
svg?: Component; // 改为接受组件而非URL字符串
position?: Position;
isPressed?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
name: 'Button',
svg: undefined,
isPressed: false,
position: () => ({ top: '0', left: '0', width: '5%' })
});
const positionStyle = computed(() => ({
top: props.position?.top,
left: props.position?.left,
width: props.position?.width,
height: props.position?.height || 'auto',
}));
</script>
<style scoped>
.gamepad-component {
position: absolute;
user-select: none;
transition: transform 0.05s ease-out, opacity 0.05s ease-out;
}
.gamepad-button.pressed {
transform: scale(0.92);
opacity: 0.7;
}
</style>

View File

@@ -1,688 +0,0 @@
<template>
<div :class="props.inlineMode ? 'gamepad-display-wrapper-inline' : 'gamepad-display-wrapper'">
<div
v-if="currentConfigFromQuery"
class="gamepad-viewer"
>
<div
ref="svgContainerRef"
class="svg-container"
>
<component
:is="currentBodySvgComponentFromQuery"
v-if="currentBodySvgComponentFromQuery"
ref="bodySvgRef"
class="gamepad-body"
:style="svgStyle"
:viewBox="effectiveViewBoxFromQuery"
preserveAspectRatio="xMidYMid meet"
/> <n-text v-else>
无法加载手柄SVG组件 (类型: {{ selectedType }}, 主体ID: {{ selectedBodyId }}).
</n-text>
</div>
<div
v-if="useOverlayButtons"
class="overlay-controls"
>
<template
v-for="(component, index) in currentConfigFromQuery.components"
:key="`${selectedType}-${index}`"
>
<GamepadButton
v-if="component.type === 'button'"
:name="component.name"
:svg="component.svg"
:position="component.position"
:is-pressed="gamepad.normalizedGamepadState.buttons[component.logicalButton]?.pressed ?? false"
/>
<GamepadStick
v-else-if="component.type === 'stick'"
:svg="component.svg"
:position="component.position"
:axes="gamepad.normalizedGamepadState.sticks[component.logicalButton] || { x: 0, y: 0 }"
/>
</template>
</div>
</div> <n-card
v-else
class="error-card"
>
无效的游戏手柄类型或配置: {{ selectedType }}
</n-card>
</div>
</template>
<script setup lang="ts">
import { computed, ref, onMounted, nextTick, shallowRef, watchEffect, watch } from 'vue';
import { useRouteQuery } from '@vueuse/router';
import type { GamepadType, GamepadConfig, LogicalButton } from '@/types/gamepad';
import { LogicalButtonsList } from '@/types/gamepad';
import GamepadButton from './GamepadButton.vue';
import GamepadStick from './GamepadStick.vue';
import { gamepadConfigs, controllerBodies, controllerStructures, ControllerComponentStructure, BodyOptionConfig } from '@/data/gamepadConfigs';
import { useGamepadStore } from '@/store/useGamepadStore';
import { NText, NCard } from 'naive-ui';
import type { ComponentPublicInstance } from 'vue';
interface Props {
fullscreenMode?: boolean; // 是否以全屏模式显示
type?: GamepadType; // 控制器类型
bodyId?: string; // 控制器主体ID
overlay?: boolean; // 是否使用叠加式按钮
pressedColor?: string | null; // 按下颜色
viewBox?: string; // 视图框
inlineMode?: boolean; // 是否为页内嵌入模式
stickSensitivity?: number; // 摇杆移动灵敏度
}
const props = withDefaults(defineProps<Props>(), {
fullscreenMode: true,
type: undefined,
bodyId: undefined,
overlay: undefined,
pressedColor: undefined,
viewBox: undefined,
inlineMode: false,
stickSensitivity: undefined, // 默认未定义将从query或配置中获取
});
// 字符串转布尔辅助函数
const stringToBoolean = (v: string | null | undefined): boolean => v === 'true';
// 路由查询参数
const querySelectedType = useRouteQuery<GamepadType>('type', 'xbox', { transform: String as (v: any) => GamepadType });
const querySelectedBodyId = useRouteQuery<string>('bodyId', '');
const queryUseOverlayButtons = useRouteQuery('overlay', 'true');
const queryCustomPressedColor = useRouteQuery<string | null>('pressedColor', null, { transform: v => v === 'null' ? null : String(v) });
const queryCustomViewBox = useRouteQuery<string>('viewBox', '');
const queryStickSensitivity = useRouteQuery<number>('stickSensitivity', 5, { transform: v => {
const num = Number(v);
return isNaN(num) ? 5 : num;
}});
// 优先使用 props没有则使用 query
const selectedType = computed<GamepadType>(() => props.type !== undefined ? props.type : querySelectedType.value);
const selectedBodyId = computed<string>(() => props.bodyId !== undefined ? props.bodyId : querySelectedBodyId.value);
const useOverlayButtonsValue = computed<boolean>(() => props.overlay !== undefined ? props.overlay : stringToBoolean(queryUseOverlayButtons.value));
const customPressedColorValue = computed<string | null>(() => props.pressedColor !== undefined ? props.pressedColor : queryCustomPressedColor.value);
const customViewBoxValue = computed<string>(() => props.viewBox !== undefined ? props.viewBox : queryCustomViewBox.value);
const stickSensitivityValue = computed<number>(() => props.stickSensitivity !== undefined ? props.stickSensitivity : queryStickSensitivity.value);
// 组件状态
const svgContainerRef = ref<HTMLElement | null>(null);
const bodySvgRef = ref<ComponentPublicInstance<{}, {}, SVGElement> | null>(null);
const originalElementFills = shallowRef<Map<string, string | null>>(new Map());
const originalElementTransforms = shallowRef<Map<string, string>>(new Map());
const defaultPressedHighlightColor = ref('#FFFFFF80');
const gamepad = useGamepadStore();
// 配置和可用主体
const currentConfigFromQuery = computed<GamepadConfig | undefined>(() => gamepadConfigs[selectedType.value]);
const availableBodiesFromQuery = computed<BodyOptionConfig[]>(() => controllerBodies[selectedType.value] || []);
const useOverlayButtons = useOverlayButtonsValue;
const currentBodySvgComponentFromQuery = shallowRef<any>(null);
// 当选择手柄类型或主体变化时更新SVG组件
watch(() => [selectedBodyId.value, selectedType.value, availableBodiesFromQuery.value], async () => {
const type = selectedType.value;
const bodyId = selectedBodyId.value;
const bodies = availableBodiesFromQuery.value;
const config = currentConfigFromQuery.value;
let componentToLoad = null;
if (bodyId && bodies.length > 0) {
const selectedBody = bodies.find(b => b.name === bodyId);
componentToLoad = selectedBody ? selectedBody.body : (config?.bodySvg || null);
} else if (config) {
componentToLoad = config.bodySvg || null;
}
currentBodySvgComponentFromQuery.value = componentToLoad;
// 当主体/类型变化时重置状态
originalElementFills.value.clear();
originalElementTransforms.value.clear();
if (bodySvgRef.value?.$el) {
resetAllElementStyles(bodySvgRef.value.$el);
}
nextTick(() => analyzeSvgStructure());
}, { immediate: true, deep: true });
// 视图框计算
const defaultViewBoxForCurrentBodyFromQuery = computed<string>(() => {
const selectedBodyConfig = availableBodiesFromQuery.value.find(b => b.name === selectedBodyId.value);
if (selectedBodyConfig?.defaultViewBox) {
return selectedBodyConfig.defaultViewBox;
}
return currentConfigFromQuery.value?.defaultViewBox || '0 0 1000 1000';
});
const effectiveViewBoxFromQuery = computed<string | undefined>(() => {
return customViewBoxValue.value || defaultViewBoxForCurrentBodyFromQuery.value;
});
const svgStyle = computed(() => ({
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'contain',
margin: 'auto',
}));
// SVG结构分析相关
interface SvgElementInfoForDisplay {
id: string | null;
label: string | null;
element?: Element | null;
componentName?: string;
}
const svgStructureForDisplay = ref<SvgElementInfoForDisplay[]>([]);
const controllerComponentsFromQuery = computed(() => controllerStructures[selectedType.value] || []);
// 分析SVG结构
function analyzeSvgStructure() {
if (!bodySvgRef.value || useOverlayButtons.value) return;
const svgElement = bodySvgRef.value.$el;
if (!svgElement) return;
svgStructureForDisplay.value = [];
originalElementFills.value.clear();
originalElementTransforms.value.clear();
function processComponent(component: ControllerComponentStructure) {
if (component.path) {
const element = findElementByPath(svgElement, component.path);
if (element) {
svgStructureForDisplay.value.push({
id: element.id || null,
label: element.getAttribute('inkscape:label') || null,
element,
componentName: component.name,
});
const targetElement = findTargetElement(element);
originalElementFills.value.set(component.path, targetElement.getAttribute('fill') || window.getComputedStyle(targetElement).fill || 'none');
if (component.type === 'stick') {
originalElementTransforms.value.set(component.path, element.getAttribute('transform') || '');
}
}
}
if (component.childComponents) {
component.childComponents.forEach(processComponent);
}
}
controllerComponentsFromQuery.value.forEach(processComponent);
}
// 当SVG组件加载完成后分析结构
watch(currentBodySvgComponentFromQuery, () => {
nextTick(() => {
if (bodySvgRef.value?.$el) {
analyzeSvgStructure();
}
});
}, { immediate: true });
// 监听手柄状态变化更新SVG样式
watchEffect(() => {
if (!gamepad.isGamepadConnected || !currentBodySvgComponentFromQuery.value || !bodySvgRef.value?.$el || useOverlayButtons.value) {
if (bodySvgRef.value?.$el && !useOverlayButtons.value) {
resetAllElementStyles(bodySvgRef.value.$el);
}
return;
}
const state = gamepad.normalizedGamepadState;
LogicalButtonsList.forEach(logicalButton => {
const buttonState = state.buttons[logicalButton];
if (buttonState) {
if (logicalButton === 'LEFT_SHOULDER_2' || logicalButton === 'RIGHT_SHOULDER_2') {
handleTriggerStateChange(logicalButton, buttonState.value);
} else {
handleButtonStateChange(logicalButton, buttonState.pressed);
}
}
});
const stickKeys: Array<keyof typeof state.sticks> = ['LEFT_STICK', 'RIGHT_STICK'];
stickKeys.forEach(stickKey => {
const stickAxes = state.sticks[stickKey];
if (stickAxes) {
handleStickStateChange(stickKey as LogicalButton, stickAxes);
}
});
});
// 重置所有元素样式
function resetAllElementStyles(svgElement: Element) {
controllerComponentsFromQuery.value.forEach(componentDef => {
if (componentDef.path) {
const element = findElementByPath(svgElement, componentDef.path);
if (element) {
if (componentDef.type === 'stick') {
const originalTransform = originalElementTransforms.value.get(componentDef.path);
if (originalTransform !== undefined) {
element.setAttribute('transform', originalTransform);
} else {
element.removeAttribute('transform');
}
} else {
resetElementStyle(element, componentDef.path);
}
}
}
});
}
// 通过路径查找SVG元素
function findElementByPath(svgElement: Element, path: string): Element | null {
if (!path || !svgElement) return null;
// 先尝试通过inkscape:label查找
let element = Array.from(svgElement.querySelectorAll('*')).find(
el => el.getAttribute('inkscape:label') === path
);
// 如果找不到尝试通过ID查找
if (!element) {
try {
if (/^[a-zA-Z][\w:.-]*$/.test(path)) {
const foundElement = svgElement.querySelector(`#${path.replace(/:/g, '\\:')}`);
if (foundElement) element = foundElement;
}
} catch (e) {}
}
// 部分匹配回退
if (!element) {
const pathWords = path.split(/\s+/);
for (const word of pathWords) {
if (word.length < 3) continue;
element = Array.from(svgElement.querySelectorAll('*')).find(
el => el.getAttribute('inkscape:label') &&
el.getAttribute('inkscape:label')!.includes(word)
);
if (element) break;
}
}
return element || null;
}
// 查找可应用样式的目标元素
function findTargetElement(element: Element): Element {
let colorElement = Array.from(element.querySelectorAll('*')).find(
el => el.getAttribute('inkscape:label') === 'Color' ||
el.getAttribute('inkscape:label')?.toLowerCase().includes('color')
);
colorElement ??= element.children[0];
return colorElement || element;
}
// 颜色反转
function invertColor(hex: string): string {
if (hex.indexOf('#') === 0) hex = hex.slice(1);
if (hex.length === 3) hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
if (hex.length !== 6) return defaultPressedHighlightColor.value;
const r = (255 - parseInt(hex.slice(0, 2), 16)).toString(16);
const g = (255 - parseInt(hex.slice(2, 4), 16)).toString(16);
const b = (255 - parseInt(hex.slice(4, 6), 16)).toString(16);
const padZero = (str: string) => ('00' + str).slice(-2);
return '#' + padZero(r) + padZero(g) + padZero(b);
}
// 应用按下样式
function applyPressedStyle(element: Element, pathId: string) {
if (!element) return;
const targetElement = findTargetElement(element);
const currentFill = targetElement.getAttribute('fill') || window.getComputedStyle(targetElement).fill;
if (!originalElementFills.value.has(pathId)) {
originalElementFills.value.set(pathId, currentFill === 'none' ? null : currentFill);
}
let pressedFill = customPressedColorValue.value;
if (!pressedFill) {
if (currentFill && currentFill !== 'none' && currentFill.startsWith('#')) {
pressedFill = invertColor(currentFill);
} else {
pressedFill = defaultPressedHighlightColor.value;
}
}
targetElement.setAttribute('fill', pressedFill);
}
// 重置元素样式
function resetElementStyle(element: Element, pathId: string) {
if (!element) return;
const targetElement = findTargetElement(element);
if (originalElementFills.value.has(pathId)) {
const originalFill = originalElementFills.value.get(pathId);
if (originalFill !== null && originalFill !== undefined) {
targetElement.setAttribute('fill', originalFill);
} else {
targetElement.removeAttribute('fill');
}
}
}
// 查找关联组件
function findAssociatedComponents(
components: ControllerComponentStructure[],
logicalButton: LogicalButton,
componentType?: string
): ControllerComponentStructure[] {
const result: ControllerComponentStructure[] = [];
function traverse(comps: ControllerComponentStructure[]) {
comps.forEach(comp => {
if (comp.logicalButton === logicalButton) {
if (componentType) {
if (comp.type === componentType) {
result.push(comp);
}
} else {
result.push(comp);
}
}
if (comp.childComponents) {
traverse(comp.childComponents);
}
});
}
traverse(components);
return result;
}
// 处理按钮状态变化
function handleButtonStateChange(logicalButton: LogicalButton, isPressed: boolean) {
if (useOverlayButtons.value || !bodySvgRef.value?.$el) return;
if (logicalButton === 'LEFT_SHOULDER_2' || logicalButton === 'RIGHT_SHOULDER_2') {
const value = gamepad.normalizedGamepadState.buttons[logicalButton]?.value || 0;
handleTriggerStateChange(logicalButton, value);
return;
}
const svgElement = bodySvgRef.value.$el;
const associatedComponents = findAssociatedComponents(controllerComponentsFromQuery.value, logicalButton);
associatedComponents.forEach(componentDef => {
if (componentDef.path) {
const targetElement = findElementByPath(svgElement, componentDef.path);
if (targetElement) {
if (isPressed) applyPressedStyle(targetElement, componentDef.path);
else resetElementStyle(targetElement, componentDef.path);
}
}
});
}
// 处理扳机键状态变化
function handleTriggerStateChange(logicalButton: LogicalButton, value: number) {
if (useOverlayButtons.value || !bodySvgRef.value?.$el) return;
const svgElement = bodySvgRef.value.$el;
const associatedComponents = findAssociatedComponents(
controllerComponentsFromQuery.value,
logicalButton,
'trigger'
);
associatedComponents.forEach(componentDef => {
if (componentDef.path) {
const targetElement = findElementByPath(svgElement, componentDef.path);
if (targetElement) {
applyTriggerStyle(targetElement, componentDef.path, value);
}
}
});
}
// 应用扳机键样式
function applyTriggerStyle(element: Element, pathId: string, value: number) {
if (!element) return;
const targetElement = findTargetElement(element);
const originalFillMap = originalElementFills.value;
const originalTransformMap = originalElementTransforms.value;
// 保存原始样式
if (!originalFillMap.has(pathId)) {
const currentFill = targetElement.getAttribute('fill') || window.getComputedStyle(targetElement).fill || 'none';
originalFillMap.set(pathId, currentFill);
}
if (!originalTransformMap.has(pathId)) {
const currentTransform = element.getAttribute('transform') || '';
originalTransformMap.set(pathId, currentTransform);
}
const originalFill = originalFillMap.get(pathId);
const originalTransform = originalTransformMap.get(pathId) || '';
// 力度非常小时恢复原始状态
if (value <= 0.01) {
if (originalFill !== null && originalFill !== undefined && originalFill !== 'none') {
targetElement.setAttribute('fill', originalFill);
} else {
targetElement.removeAttribute('fill');
}
element.setAttribute('transform', originalTransform);
return;
}
// 目标颜色(按下状态的颜色)
const targetColor = customPressedColorValue.value || '#FFFFFF';
// 解析颜色函数
function parseColor(color: string): { r: number; g: number; b: number; } {
if (color === 'none' || !color) {
return { r: 200, g: 200, b: 200 }; // 默认灰色
}
// 处理十六进制颜色
if (color.startsWith('#')) {
let hex = color.slice(1);
// 规范化颜色格式
if (hex.length === 3) {
hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
} else if (hex.length === 4) {
hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
} else if (hex.length === 8) {
hex = hex.slice(0, 6); // 移除alpha通道
}
return {
r: parseInt(hex.slice(0, 2), 16),
g: parseInt(hex.slice(2, 4), 16),
b: parseInt(hex.slice(4, 6), 16)
};
}
// 处理rgb/rgba颜色
const rgbMatch = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*[\d.]+)?\)/);
if (rgbMatch) {
return {
r: parseInt(rgbMatch[1]),
g: parseInt(rgbMatch[2]),
b: parseInt(rgbMatch[3])
};
}
// 无法解析的颜色返回默认值
return { r: 200, g: 200, b: 200 };
}
// 将RGB转回十六进制
function rgbToHex(r: number, g: number, b: number): string {
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
}
// 在两种颜色间进行插值
function interpolateColor(color1: { r: number; g: number; b: number; }, color2: { r: number; g: number; b: number; }, factor: number) {
return {
r: Math.round(color1.r + (color2.r - color1.r) * factor),
g: Math.round(color1.g + (color2.g - color1.g) * factor),
b: Math.round(color1.b + (color2.b - color1.b) * factor)
};
}
// 解析原始颜色和目标颜色
const sourceColor = parseColor(originalFill || '#808080');
const destColor = parseColor(targetColor);
// 根据力度插值计算当前颜色
const currentColor = interpolateColor(sourceColor, destColor, value);
const fillColor = rgbToHex(currentColor.r, currentColor.g, currentColor.b);
// 应用颜色变化
targetElement.setAttribute('fill', fillColor);
// 保留位移效果
const isLeftTrigger = pathId.toLowerCase().includes('left') || pathId.toLowerCase().includes('l2');
const movement = value * 5; // 最大移动2像素
let newTransform = originalTransform;
if (originalTransform.includes('translate')) {
newTransform = newTransform.replace(/translate\([^)]+\)/,
isLeftTrigger ? `translate(0, ${movement})` : `translate(0, ${movement})`);
} else {
newTransform = `translate(0, ${movement}) ${newTransform}`.trim();
}
element.setAttribute('transform', newTransform);
}
// 处理摇杆状态变化
function handleStickStateChange(logicalStick: LogicalButton, axes: { x: number, y: number; }) {
if (useOverlayButtons.value || !bodySvgRef.value?.$el) return;
const svgElement = bodySvgRef.value.$el;
const associatedComponents = findAssociatedComponents(controllerComponentsFromQuery.value, logicalStick, 'stick');
associatedComponents.forEach(componentDef => {
if (componentDef.path) {
const stickElement = findElementByPath(svgElement, componentDef.path);
if (stickElement) {
// 优先使用组件配置的灵敏度否则使用全局灵敏度设置再否则使用默认值5
const defaultSensitivity = typeof (componentDef as any).sensitivity === 'number' ? (componentDef as any).sensitivity : 5;
const sensitivity = stickSensitivityValue.value || defaultSensitivity;
const translateX = axes.x * sensitivity;
const translateY = axes.y * sensitivity;
const transformMap = originalElementTransforms.value;
const pathId = componentDef.path;
if (!transformMap.has(pathId)) {
transformMap.set(pathId, stickElement.getAttribute('transform') || '');
}
const baseTransform = transformMap.get(pathId) || '';
let dynamicTranslatePart = '';
if (Math.abs(translateX) > 0.01 || Math.abs(translateY) > 0.01) {
dynamicTranslatePart = `translate(${translateX.toFixed(2)}, ${translateY.toFixed(2)})`;
}
const newTransform = (dynamicTranslatePart + (baseTransform ? ' ' + baseTransform : '')).trim();
stickElement.setAttribute('transform', newTransform);
}
}
});
}
onMounted(() => {
nextTick(() => {
if (bodySvgRef.value?.$el) {
analyzeSvgStructure();
}
});
});
</script>
<style scoped>
.gamepad-display-wrapper {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
padding: 20px;
box-sizing: border-box;
overflow: hidden;
}
.gamepad-display-wrapper-inline {
position: relative;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
box-sizing: border-box;
overflow: hidden;
}
.gamepad-viewer {
width: 100%;
height: 100%;
max-width: 100%;
max-height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
position: relative;
}
.svg-container {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
position: relative;
max-width: 100%;
max-height: 100%;
}
.gamepad-body {
width: auto !important;
height: auto !important;
max-width: 100%;
max-height: 100%;
object-fit: contain;
margin: auto;
}
.overlay-controls {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
display: flex;
justify-content: center;
align-items: center;
}
.overlay-controls>* {
pointer-events: all;
}
.connection-status-overlay {
position: absolute;
top: 10px;
left: 50%;
transform: translateX(-50%);
color: white;
padding: 5px 10px;
border-radius: 4px;
z-index: 10;
}
.error-card {
max-width: 400px;
}
</style>

View File

@@ -1,49 +0,0 @@
<template>
<component
:is="svg"
v-if="svg"
:class="['gamepad-component', 'gamepad-stick']"
:style="transformedStyle"
/>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import type { Component } from 'vue';
import type { Position } from '@/types/gamepad';
interface Props {
svg?: Component; // 改为接受组件而非URL字符串
position?: Position;
axes?: { x: number; y: number };
}
const props = withDefaults(defineProps<Props>(), {
svg: undefined,
axes: () => ({ x: 0, y: 0 }),
position: () => ({ top: '0', left: '0', width: '5%' })
});
// 计算实际样式,加入摇杆位移
const transformedStyle = computed(() => {
const moveScale = 15; // 摇杆移动的最大距离(以像素为单位)
const translateX = props.axes.x * moveScale;
const translateY = props.axes.y * moveScale;
return {
top: props.position?.top,
left: props.position?.left,
width: props.position?.width,
height: props.position?.height || 'auto',
transform: `translate(${translateX}px, ${translateY}px)`
};
});
</script>
<style scoped>
.gamepad-component {
position: absolute;
user-select: none;
transition: transform 0.1s ease-out;
}
</style>

View File

@@ -1,510 +0,0 @@
<template>
<n-card
v-if="currentConfig"
size="small"
class="gamepad-settings-container"
>
<n-space
align="center"
size="small"
>
<n-text>选择控制器类型:</n-text>
<n-select
v-model:value="selectedType"
:options="gamepadTypeOptions"
size="small"
style="min-width: 150px"
/>
<n-button
size="small"
type="primary"
tag="a"
:href="gamepadDisplayUrl"
target="_blank"
>
打开独立显示窗口
</n-button>
</n-space>
<n-divider size="small" />
<n-text v-if="!gamepad.isGamepadConnected">
未检测到游戏手柄连接
</n-text>
<n-text v-else-if="gamepad.connectedGamepadInfo">
已连接: {{ gamepad.connectedGamepadInfo.id }} ({{ gamepad.connectedGamepadInfo.mapping }})
</n-text>
<n-alert
v-if="showConnectionMessage"
type="info"
closable
style="margin-top: 10px;"
@close="showConnectionMessage = false"
>
<template v-if="lastConnectedInfo">
手柄已连接: {{ lastConnectedInfo }}
</template>
<template v-else-if="lastDisconnectedInfo">
手柄已断开连接: {{ lastDisconnectedInfo }}
</template>
</n-alert>
<n-space
vertical
size="small"
>
<n-space
align="center"
size="small"
>
<n-checkbox v-model:checked="useOverlayButtons">
使用叠加式按钮 (更好的交互效果)
</n-checkbox>
<n-button
size="small"
@click="showComponentTree = !showComponentTree"
>
{{ showComponentTree ? '隐藏' : '显示' }}控制器结构 (调试)
</n-button>
</n-space>
<n-space
v-if="availableBodies.length > 1"
align="center"
size="small"
>
<n-text>选择手柄主体样式:</n-text>
<n-select
v-model:value="selectedBodyId"
:options="bodyOptions"
size="small"
style="min-width: 200px"
/>
</n-space>
<n-divider title-placement="left">
按键按下效果
</n-divider>
<n-space align="center">
<n-checkbox v-model:checked="enableCustomPressedColor">
自定义按下颜色
</n-checkbox>
<n-color-picker
v-if="enableCustomPressedColor"
v-model:value="customPressedColor"
:show-alpha="true"
size="small"
/>
<n-text v-else>
(默认使用反色效果)
</n-text>
</n-space>
<n-divider title-placement="left">
摇杆灵敏度
</n-divider>
<n-space
vertical
size="small"
>
<n-space align="center">
<n-text>移动幅度: {{ stickSensitivity }}</n-text>
<n-slider
v-model:value="stickSensitivity"
:min="1"
:max="20"
:step="1"
style="min-width: 200px; max-width: 300px"
/>
<n-input-number
v-model:value="stickSensitivity"
:min="1"
:max="40"
size="small"
style="width: 80px"
/>
<n-button
size="small"
@click="resetStickSensitivity"
>
重置
</n-button>
</n-space>
<n-text
size="small"
depth="3"
>
数值越大摇杆移动幅度越大默认值为5
</n-text>
</n-space>
<n-card
title="手柄实时预览"
size="small"
style="margin-top: 10px;"
>
<div style="position: relative; width: 100%; height: 300px; background-color: #333; border-radius: 8px; overflow: hidden;">
<GamepadDisplay
:key="selectedType"
:type="selectedType"
:body-id="selectedBodyId"
:overlay="useOverlayButtons"
:pressed-color="enableCustomPressedColor ? customPressedColor : null"
:view-box="customViewBoxInput"
:fullscreen-mode="false"
:inline-mode="true"
:stick-sensitivity="stickSensitivity"
/>
</div>
</n-card>
<n-grid
:cols="1"
>
<n-grid-item v-if="showComponentTree">
<n-card
size="small"
title="控制器结构 (数据视图)"
>
<n-space vertical>
<n-scrollbar style="max-height: 300px;">
<n-tree
:data="formatComponentsForTree(controllerComponents)"
selectable
:render-label="renderComponentLabel"
@update:selected-keys="handleComponentSelectedForDebug"
/>
</n-scrollbar>
<div
v-if="selectedComponentForDebug"
class="selected-component-info"
>
<n-divider title-placement="left">
选中组件信息 (调试)
</n-divider>
<n-descriptions
bordered
size="small"
>
<n-descriptions-item label="名称">
{{ selectedComponentForDebug.name }}
</n-descriptions-item>
<n-descriptions-item label="类型">
{{ selectedComponentForDebug.type }}
</n-descriptions-item>
<n-descriptions-item label="SVG路径">
{{ selectedComponentForDebug.path || '无' }}
</n-descriptions-item>
<n-descriptions-item label="逻辑按键">
{{ selectedComponentForDebug.logicalButton || 'N/A' }}
</n-descriptions-item>
</n-descriptions>
</div>
</n-space>
</n-card>
</n-grid-item>
</n-grid>
<n-collapse>
<n-collapse-item title="高级布局设置 (用于独立显示窗口)">
<n-space vertical>
<n-space align="center">
<n-text>自定义ViewBox:</n-text>
<n-input
v-model:value="customViewBoxInput"
placeholder="例如: 0 0 1543 956"
size="small"
style="width: 200px"
/>
<n-button
size="small"
@click="resetViewBox"
>
重置
</n-button>
</n-space>
<n-text size="small">
ViewBox格式: minX minY width height. 当前生效: {{ effectiveViewBoxForDisplay }}
</n-text>
</n-space>
</n-collapse-item>
</n-collapse>
</n-space>
</n-card>
<n-card v-else>
无效的游戏手柄类型: {{ currentGamepadType }}
</n-card>
</template>
<script setup lang="ts">
import { computed, watch, ref, onMounted, nextTick, h, onUnmounted } from 'vue';
import type { GamepadType, GamepadConfig } from '@/types/gamepad';
import GamepadDisplay from './GamepadDisplay.vue';
import { gamepadConfigs, controllerBodies, BodyOptionConfig, controllerStructures, ControllerComponentStructure } from '@/data/gamepadConfigs';
import { useGamepadStore } from '@/store/useGamepadStore';
import { useStorage } from '@vueuse/core';
import {
NCard, NSpace, NButton, NCheckbox, NSelect, NText, NCollapse,
NCollapseItem, NInput, NScrollbar, NDivider,
NTag, NColorPicker, NGrid, NGridItem, NTree, NDescriptions,
NDescriptionsItem, NAlert, NSlider, NInputNumber,
TreeOption
} from 'naive-ui';
interface Props {
viewBox?: string;
}
const props = withDefaults(defineProps<Props>(), {
viewBox: '',
});
// 基本设置
const selectedType = useStorage<GamepadType>('Setting.Gamepad.SelectedType', 'xbox');
const currentGamepadType = computed(() => selectedType.value);
const gamepadTypeOptions = [
{ label: 'Xbox', value: 'xbox' as GamepadType },
{ label: 'PlayStation', value: 'ps' as GamepadType },
{ label: 'Nintendo', value: 'nintendo' as GamepadType }
];
// 调试树组件相关
const selectedComponentForDebug = ref<ControllerComponentStructure | null>(null);
const controllerComponents = computed(() => controllerStructures[selectedType.value] || []);
// 存储、配置与状态
const viewBoxStorageKey = computed(() => `gamepad-viewBox-${selectedType.value}`);
const customViewBoxInput = useStorage<string>(viewBoxStorageKey, props.viewBox || '');
const useOverlayButtons = useStorage<boolean>(`Setting.Gamepad.UseOverlayButtons`, true);
const showComponentTree = ref(false);
// 摇杆灵敏度设置
const stickSensitivityStorageKey = computed(() => `gamepad-stick-sensitivity-${selectedType.value}`);
const stickSensitivity = useStorage<number>(stickSensitivityStorageKey, 5);
// 按下颜色配置
const pressedColorStorageKey = computed(() => `gamepad-pressed-color-${selectedType.value}`);
const customPressedColor = useStorage<string | null>(pressedColorStorageKey, null);
const enableCustomPressedColor = computed({
get: () => customPressedColor.value !== null && customPressedColor.value !== 'null',
set: (val) => {
if (!val) {
customPressedColor.value = null;
} else if (customPressedColor.value === null || customPressedColor.value === 'null') {
customPressedColor.value = '#FF0000FF';
}
}
});
// 游戏手柄状态与配置
const gamepad = useGamepadStore();
const currentConfig = computed<GamepadConfig | undefined>(() => gamepadConfigs[selectedType.value]);
// 控制器主体选项
const availableBodies = computed<BodyOptionConfig[]>(() => controllerBodies[selectedType.value] || []);
const bodyOptions = computed(() => availableBodies.value.map(body => ({
label: body.name,
value: body.name
})));
const bodyIdStorageKey = computed(() => `gamepad-body-${selectedType.value}`);
const selectedBodyId = useStorage<string>(bodyIdStorageKey, '');
// 当手柄类型变化时重置相关配置
watch(selectedType, () => {
nextTick(() => {
resetViewBox();
selectedComponentForDebug.value = null;
if (availableBodies.value.length > 0 && !availableBodies.value.some(b => b.name === selectedBodyId.value)) {
selectedBodyId.value = availableBodies.value[0].name;
} else if (availableBodies.value.length === 0) {
selectedBodyId.value = '';
}
});
}, { immediate: true });
// 确保选择的手柄主体有效
watch(() => [availableBodies.value, selectedType.value], () => {
if (availableBodies.value.length > 0) {
const currentSelectionIsValid = availableBodies.value.some(b => b.name === selectedBodyId.value);
if (!currentSelectionIsValid || !selectedBodyId.value) {
selectedBodyId.value = availableBodies.value[0].name;
}
} else {
selectedBodyId.value = '';
}
}, { immediate: true, deep: true });
// 从props更新viewBox
watch(() => props.viewBox, (newVal) => {
if (newVal && newVal !== customViewBoxInput.value) {
customViewBoxInput.value = newVal;
}
});
// 获取默认视图框
const defaultViewBoxForCurrentConfig = computed<string>(() => {
const selectedBodyConfig = availableBodies.value.find(b => b.name === selectedBodyId.value);
if (selectedBodyConfig?.defaultViewBox) {
return selectedBodyConfig.defaultViewBox;
}
return currentConfig.value?.defaultViewBox || '0 0 1000 1000';
});
// 实际应用的视图框
const effectiveViewBoxForDisplay = computed<string | undefined>(() => {
if (selectedType.value === 'ps') return 'PlayStation SVGs typically manage their own size';
return customViewBoxInput.value || defaultViewBoxForCurrentConfig.value;
});
// 连接状态提示
const lastConnectedInfo = ref<string>('');
const lastDisconnectedInfo = ref<string>('');
const showConnectionMessage = ref(false);
let unsubscribeConnected: (() => void) | undefined;
let unsubscribeDisconnected: (() => void) | undefined;
// 组件挂载时初始化
onMounted(() => {
// 注册手柄连接/断开事件监听器
unsubscribeConnected = gamepad.onConnected((gamepadInfo, index) => {
lastConnectedInfo.value = `${gamepadInfo.id} (索引: ${index})`;
lastDisconnectedInfo.value = '';
showConnectionMessage.value = true;
setTimeout(() => showConnectionMessage.value = false, 3000);
});
unsubscribeDisconnected = gamepad.onDisconnected((gamepadInfo, index) => {
lastDisconnectedInfo.value = `${gamepadInfo.id} (索引: ${index})`;
lastConnectedInfo.value = '';
showConnectionMessage.value = true;
setTimeout(() => showConnectionMessage.value = false, 3000);
});
// 初始化视图框和主体选择
if (!customViewBoxInput.value && props.viewBox) {
customViewBoxInput.value = props.viewBox;
}
if (availableBodies.value.length > 0 && !availableBodies.value.some(b => b.name === selectedBodyId.value)) {
selectedBodyId.value = availableBodies.value[0].name;
} else if (availableBodies.value.length === 0 && selectedBodyId.value) {
selectedBodyId.value = '';
}
});
// 组件卸载时清理监听器
onUnmounted(() => {
if (unsubscribeConnected) unsubscribeConnected();
if (unsubscribeDisconnected) unsubscribeDisconnected();
});
// 重置视图框为默认值
const resetViewBox = () => {
customViewBoxInput.value = defaultViewBoxForCurrentConfig.value;
};
// 重置摇杆灵敏度为默认值
const resetStickSensitivity = () => {
stickSensitivity.value = 5;
};
// 调试树相关函数
function findComponentByPath(components: ControllerComponentStructure[], path: string): ControllerComponentStructure | null {
if (!path) return null;
for (const component of components) {
if (component.path === path) return component;
if (component.childComponents) {
const found = findComponentByPath(component.childComponents, path);
if (found) return found;
}
}
return null;
}
// 将控制器组件格式化为树形数据
function formatComponentsForTree(components: ControllerComponentStructure[]): TreeOption[] {
return components.map(component => {
const key = component.path || `name:${component.name}:${Math.random().toString(36).substr(2, 9)}`;
return {
key,
label: component.name,
children: component.childComponents ? formatComponentsForTree(component.childComponents) : undefined,
component
};
});
}
// 自定义树节点渲染
function renderComponentLabel(info: { option: any }) {
const { option } = info;
const component = option.component as ControllerComponentStructure;
let iconType: "default" | "success" | "error" | "warning" | "primary" | "info" = 'default';
switch (component.type) {
case 'button': iconType = 'info'; break;
case 'stick': iconType = 'success'; break;
case 'trigger': iconType = 'warning'; break;
case 'dpad': iconType = 'primary'; break;
default: iconType = 'default'; break;
}
return h('div', { style: 'display: flex; align-items: center;' }, [
h(NTag, { type: iconType, size: 'small', style: 'margin-right: 6px;' }, { default: () => component.type }),
option.label
]);
}
// 处理组件选择事件
function handleComponentSelectedForDebug(keys: string[]) {
if (keys.length === 0) {
selectedComponentForDebug.value = null;
return;
}
const key = keys[0];
if (key.startsWith('name:')) {
const namePart = key.split(':', 2)[1];
for (const component of controllerComponents.value) {
if (component.name === namePart) {
selectedComponentForDebug.value = component;
return;
}
}
selectedComponentForDebug.value = null;
} else {
selectedComponentForDebug.value = findComponentByPath(controllerComponents.value, key);
}
}
// 生成独立显示窗口URL
const gamepadDisplayUrl = computed(() => {
const params = new URLSearchParams();
params.append('type', selectedType.value);
if (selectedBodyId.value) {
params.append('bodyId', selectedBodyId.value);
}
params.append('overlay', String(useOverlayButtons.value));
if (customPressedColor.value && customPressedColor.value !== 'null') {
params.append('pressedColor', customPressedColor.value);
} else {
params.append('pressedColor', 'null');
}
if (customViewBoxInput.value) {
params.append('viewBox', customViewBoxInput.value);
}
// 添加摇杆灵敏度参数
params.append('stickSensitivity', String(stickSensitivity.value));
return `/obs-store/gamepad?${params.toString()}`;
});
</script>
<style scoped>
.gamepad-settings-container {
max-width: 700px;
margin: 20px auto;
}
.custom-scrollbar {
max-height: 300px;
}
.selected-component-info {
margin-top: 10px;
}
</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>