mirror of
https://github.com/Megghy/vtsuru.live.git
synced 2025-12-10 20:36:55 +08:00
feat: 更新配置和组件以支持选择项功能, 开始手柄映射功能编写
- 在DynamicForm.vue中新增select组件支持 - 在VTsuruConfigTypes.ts中添加可选的条件显示属性 - 更新vite.config.mts以集成自定义SVGO插件 - 在components.d.ts中添加NDescriptionsItem组件声明 - 更新路由配置以包含obs_store模块
This commit is contained in:
378
src/views/manage/obs_store/OBSComponentStoreView.vue
Normal file
378
src/views/manage/obs_store/OBSComponentStoreView.vue
Normal file
@@ -0,0 +1,378 @@
|
||||
<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.username } 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(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 = { ...defaultConfig, ...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({
|
||||
name: currentSelectedComponent.value.settingName,
|
||||
config: JSON.stringify(componentConfigForEditing.value), // 保存编辑后的配置
|
||||
isPublic: 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>
|
||||
186
src/views/manage/obs_store/components/ExampleOBSComponent.vue
Normal file
186
src/views/manage/obs_store/components/ExampleOBSComponent.vue
Normal file
@@ -0,0 +1,186 @@
|
||||
<template>
|
||||
<NCard
|
||||
:title="localConfig.title || '示例 OBS 组件'"
|
||||
class="example-obs-component"
|
||||
>
|
||||
<NAlert
|
||||
:type="localConfig.alertType || '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 { ConfigItemType, 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,
|
||||
},
|
||||
{
|
||||
name: '刷新间隔 (秒)',
|
||||
key: 'refreshInterval',
|
||||
type: ConfigItemType.Number,
|
||||
default: 60,
|
||||
min: 5,
|
||||
description: '组件自动刷新的时间间隔(如果组件支持自动刷新)。',
|
||||
if: (config: ExampleConfigType) => config.enableAdvanced === true, // 条件显示
|
||||
}
|
||||
]);
|
||||
|
||||
export type ExampleConfigType = ExtractConfigData<typeof Config>;
|
||||
|
||||
export const DefaultConfig: ExampleConfigType = {
|
||||
title: '示例组件默认标题',
|
||||
alertType: 'success',
|
||||
alertTitle: '默认提示',
|
||||
contentText: '来自 DefaultConfig 的内容。点歌点歌点歌。关注vtsuru喵!',
|
||||
enableAdvanced: false,
|
||||
refreshInterval: 30,
|
||||
};
|
||||
</script>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { UserInfo } from '@/api/api-models';
|
||||
// ConfigItemType is imported in the script block above
|
||||
// import { ConfigItemDefinition, ConfigItemType, ExtractConfigData, defineTemplateConfig } from '@/data/VTsuruConfigTypes';
|
||||
import { NAlert, NButton, NCard, NForm, NFormItem, NInput, useMessage } from 'naive-ui';
|
||||
import { computed, ref, watch, onMounted } from 'vue';
|
||||
|
||||
// --- Props ---
|
||||
const props = defineProps<{
|
||||
config: ExampleConfigType; // 从父组件接收的配置
|
||||
userInfo?: UserInfo;
|
||||
biliInfo?: any;
|
||||
refreshSignal?: number; // 接收刷新信号
|
||||
}>();
|
||||
|
||||
// --- Emits (可选,如果子组件需要通知父组件配置更改) ---
|
||||
// const emits = defineEmits(['update:config']);
|
||||
|
||||
const message = useMessage();
|
||||
|
||||
// --- 本地状态 ---
|
||||
const refreshCount = ref(0);
|
||||
const dynamicTitle = ref(props.config?.title || '默认标题');
|
||||
|
||||
// --- 计算属性,合并传入的config和默认值,确保所有字段都存在 ---
|
||||
const localConfig = computed<ExampleConfigType>(() => {
|
||||
return {
|
||||
...DefaultConfig, // 先使用默认值
|
||||
...(props.config || {}), // 然后用传入的配置覆盖
|
||||
};
|
||||
});
|
||||
|
||||
// --- 监听刷新信号 ---
|
||||
watch(() => props.refreshSignal, (newValue, oldValue) => {
|
||||
if (newValue !== undefined && newValue !== oldValue) {
|
||||
refreshCount.value++;
|
||||
message.success(`'示例 OBS 组件' 已刷新 (信号: ${newValue})`);
|
||||
// 在这里执行组件的刷新逻辑,例如重新获取数据、重置状态等
|
||||
// fetchData();
|
||||
}
|
||||
});
|
||||
|
||||
// --- 方法 ---
|
||||
function updateTitle() {
|
||||
if (props.config) {
|
||||
// 这是直接修改 prop,Vue 会发出警告。在实际应用中,应该通过 emit 更新父组件的配置
|
||||
// (props.config as any).title = dynamicTitle.value;
|
||||
message.info('标题已在本地临时更改。若要保存,请通过父组件的配置面板。');
|
||||
// 要正确更新,应该 emit事件,例如:
|
||||
// emits('update:config', { ...localConfig.value, title: dynamicTitle.value });
|
||||
}
|
||||
}
|
||||
|
||||
// --- Expose (使得父组件可以通过 ref 访问 Config 和 DefaultConfig) ---
|
||||
// Vue 3 <script setup> 默认关闭,需要显式 defineExpose
|
||||
// 但对于 DynamicForm,它似乎能够通过某种方式访问导出的 Config 和 DefaultConfig
|
||||
// 如果父组件需要通过 ref 主动调用方法或访问属性,则需要 defineExpose
|
||||
// defineExpose({ Config, DefaultConfig, /* refreshMethod */ });
|
||||
|
||||
onMounted(() => {
|
||||
// console.log('ExampleOBSComponent mounted with config:', props.config);
|
||||
// console.log('Effective localConfig:', localConfig.value);
|
||||
// console.log('Exposed Config definition:', Config);
|
||||
// console.log('Exposed DefaultConfig:', DefaultConfig);
|
||||
dynamicTitle.value = localConfig.value.title;
|
||||
});
|
||||
|
||||
watch(() => props.config, (newConfig) => {
|
||||
dynamicTitle.value = newConfig?.title || DefaultConfig.title;
|
||||
}, { deep: true });
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.example-obs-component {
|
||||
border: 1px dashed var(--n-border-color);
|
||||
padding: 16px;
|
||||
}
|
||||
pre {
|
||||
background-color: var(--n-code-block-color);
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85em;
|
||||
white-space: pre-wrap; /* 确保长内容能换行 */
|
||||
word-break: break-all; /* 强制断词,防止溢出 */
|
||||
}
|
||||
</style>
|
||||
48
src/views/manage/obs_store/components/GamepadStick.vue
Normal file
48
src/views/manage/obs_store/components/GamepadStick.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<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>
|
||||
@@ -0,0 +1,47 @@
|
||||
<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>
|
||||
@@ -0,0 +1,688 @@
|
||||
<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>
|
||||
@@ -0,0 +1,49 @@
|
||||
<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>
|
||||
510
src/views/manage/obs_store/components/gamepads/GamepadViewer.vue
Normal file
510
src/views/manage/obs_store/components/gamepads/GamepadViewer.vue
Normal file
@@ -0,0 +1,510 @@
|
||||
<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>
|
||||
404
src/views/manage/obs_store/components/gamepads/SvgInspector.vue
Normal file
404
src/views/manage/obs_store/components/gamepads/SvgInspector.vue
Normal file
@@ -0,0 +1,404 @@
|
||||
<template>
|
||||
<n-card class="svg-inspector">
|
||||
<n-h3>SVG结构检查工具</n-h3>
|
||||
<n-space
|
||||
align="center"
|
||||
wrap
|
||||
size="small"
|
||||
>
|
||||
<n-select
|
||||
v-model:value="selectedType"
|
||||
:options="controllerOptions"
|
||||
style="min-width: 150px"
|
||||
/>
|
||||
|
||||
<n-select
|
||||
v-if="availableBodies.length > 0"
|
||||
v-model:value="selectedBodyId"
|
||||
:options="bodyOptions"
|
||||
style="min-width: 150px"
|
||||
/>
|
||||
|
||||
<n-button
|
||||
:disabled="isInspecting"
|
||||
type="primary"
|
||||
@click="inspectSvg"
|
||||
>
|
||||
分析SVG结构
|
||||
</n-button>
|
||||
</n-space>
|
||||
|
||||
<n-spin
|
||||
v-if="isInspecting"
|
||||
size="medium"
|
||||
style="margin: 20px 0"
|
||||
>
|
||||
<n-text>正在分析SVG结构...</n-text>
|
||||
</n-spin>
|
||||
|
||||
<div
|
||||
v-if="svgInfo.length > 0"
|
||||
class="results"
|
||||
>
|
||||
<n-h4>检测到的元素 ({{ svgInfo.length }}个):</n-h4>
|
||||
<n-space
|
||||
vertical
|
||||
size="small"
|
||||
>
|
||||
<n-space
|
||||
align="center"
|
||||
size="medium"
|
||||
>
|
||||
<n-input
|
||||
v-model:value="searchFilter"
|
||||
placeholder="搜索元素..."
|
||||
style="min-width: 250px"
|
||||
/>
|
||||
<n-checkbox v-model:checked="showLabelsOnly">
|
||||
只显示带标签的元素
|
||||
</n-checkbox>
|
||||
</n-space>
|
||||
|
||||
<n-scrollbar class="results-scrollbar">
|
||||
<n-space
|
||||
vertical
|
||||
size="small"
|
||||
>
|
||||
<n-card
|
||||
v-for="(item, index) in filteredSvgInfo"
|
||||
:key="index"
|
||||
size="small"
|
||||
:bordered="false"
|
||||
style="padding: 12px"
|
||||
:class="{ 'has-label': item.label, 'element-card': true }"
|
||||
@click="selectElement(item)"
|
||||
>
|
||||
<n-space
|
||||
vertical
|
||||
size="small"
|
||||
>
|
||||
<n-text><strong>ID:</strong> {{ item.id || '(无ID)' }}</n-text>
|
||||
<n-text v-if="item.label">
|
||||
<strong>标签:</strong> {{ item.label }}
|
||||
</n-text>
|
||||
<n-text><strong>类型:</strong> {{ item.tagName }}</n-text>
|
||||
<n-space
|
||||
vertical
|
||||
size="small"
|
||||
class="hide-rule"
|
||||
>
|
||||
<n-space size="small">
|
||||
<n-button
|
||||
size="tiny"
|
||||
@click.stop="selectElement(item)"
|
||||
>
|
||||
选择此元素
|
||||
</n-button>
|
||||
<n-button
|
||||
size="tiny"
|
||||
@click.stop="highlightElement(item)"
|
||||
>
|
||||
在SVG中高亮
|
||||
</n-button>
|
||||
</n-space>
|
||||
<n-code
|
||||
v-if="item.id"
|
||||
:code="getCssRuleForId(item.id)"
|
||||
language="css"
|
||||
show-line-numbers
|
||||
size="small"
|
||||
/>
|
||||
<n-code
|
||||
v-if="item.label"
|
||||
:code="getCssRuleForLabel(item.label)"
|
||||
language="css"
|
||||
show-line-numbers
|
||||
size="small"
|
||||
/>
|
||||
</n-space>
|
||||
</n-space>
|
||||
</n-card>
|
||||
</n-space>
|
||||
</n-scrollbar>
|
||||
</n-space>
|
||||
</div>
|
||||
</n-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted } from 'vue';
|
||||
import { gamepadConfigs, controllerBodies } from '@/data/gamepadConfigs';
|
||||
import type { GamepadType } from '@/types/gamepad';
|
||||
import {
|
||||
NCard,
|
||||
NSpace,
|
||||
NSelect,
|
||||
NButton,
|
||||
NSpin,
|
||||
NInput,
|
||||
NCheckbox,
|
||||
NText,
|
||||
NH3,
|
||||
NH4,
|
||||
NCode,
|
||||
NScrollbar
|
||||
} from 'naive-ui';
|
||||
|
||||
interface Props {
|
||||
controllerType?: GamepadType;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
controllerType: 'xbox',
|
||||
});
|
||||
|
||||
interface SvgElementInfo {
|
||||
id: string | null;
|
||||
label: string | null;
|
||||
tagName: string;
|
||||
fill?: string;
|
||||
stroke?: string;
|
||||
transform?: string;
|
||||
element?: Element; // 添加对实际DOM元素的引用
|
||||
visible: boolean; // 添加visible属性
|
||||
}
|
||||
|
||||
const selectedType = ref<GamepadType>(props.controllerType);
|
||||
const selectedBodyId = ref('');
|
||||
const isInspecting = ref(false);
|
||||
const svgInfo = ref<SvgElementInfo[]>([]);
|
||||
const searchFilter = ref('');
|
||||
const showLabelsOnly = ref(false);
|
||||
const highlightedElement = ref<Element | null>(null);
|
||||
|
||||
// 控制器类型选项
|
||||
const controllerOptions = [
|
||||
{ label: 'Xbox控制器', value: 'xbox' },
|
||||
{ label: 'PlayStation控制器', value: 'ps' },
|
||||
{ label: 'Nintendo控制器', value: 'nintendo' }
|
||||
];
|
||||
|
||||
// 定义emit事件
|
||||
const emit = defineEmits<{
|
||||
(e: 'element-selected', element: SvgElementInfo): void
|
||||
}>();
|
||||
|
||||
// 获取当前控制器类型的所有可用主体
|
||||
const availableBodies = computed(() => {
|
||||
return controllerBodies[selectedType.value] || [];
|
||||
});
|
||||
|
||||
// 转换为NSelect需要的选项格式
|
||||
const bodyOptions = computed(() => {
|
||||
const options = availableBodies.value.map(body => ({
|
||||
label: body.name,
|
||||
value: body.id
|
||||
}));
|
||||
|
||||
return [
|
||||
{ label: '(默认主体)', value: '' },
|
||||
...options
|
||||
];
|
||||
});
|
||||
|
||||
// 为n-code生成CSS代码字符串
|
||||
function getCssRuleForId(id: string) {
|
||||
return `.custom-visibility :deep([id="${id}"]) { visibility: visible; }`;
|
||||
}
|
||||
|
||||
function getCssRuleForLabel(label: string) {
|
||||
return `.custom-visibility :deep([inkscape\\:label="${label}"]) { visibility: visible; }`;
|
||||
}
|
||||
|
||||
// 根据选择的主体获取对应的SVG组件
|
||||
const currentBodySvg = computed(() => {
|
||||
if (selectedBodyId.value) {
|
||||
const selectedBody = availableBodies.value.find(b => b.id === selectedBodyId.value);
|
||||
if (selectedBody) return selectedBody.body;
|
||||
}
|
||||
|
||||
// 如果未选择特定主体或找不到所选主体,则使用默认主体
|
||||
return gamepadConfigs[selectedType.value]?.bodySvg;
|
||||
});
|
||||
|
||||
// 监听props变化,更新选择的控制器类型
|
||||
watch(() => props.controllerType, (newType) => {
|
||||
selectedType.value = newType;
|
||||
// 切换控制器类型时重置所选主体
|
||||
selectedBodyId.value = '';
|
||||
}, { immediate: true });
|
||||
|
||||
// 自动分析当前控制器类型
|
||||
onMounted(() => {
|
||||
// 延迟执行以确保组件完全挂载
|
||||
setTimeout(() => {
|
||||
inspectSvg();
|
||||
}, 500);
|
||||
});
|
||||
|
||||
const filteredSvgInfo = computed(() => {
|
||||
return svgInfo.value.filter(item => {
|
||||
// 筛选显示带标签的元素
|
||||
if (showLabelsOnly.value && !item.label) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 搜索过滤
|
||||
const filter = searchFilter.value.toLowerCase();
|
||||
if (!filter) return true;
|
||||
|
||||
return (
|
||||
(item.id && item.id.toLowerCase().includes(filter)) ||
|
||||
(item.label && item.label.toLowerCase().includes(filter)) ||
|
||||
item.tagName.toLowerCase().includes(filter)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// 选择元素并发送到父组件
|
||||
function selectElement(item: SvgElementInfo) {
|
||||
// 移除之前高亮的元素
|
||||
clearHighlight();
|
||||
|
||||
// 发出选中事件
|
||||
emit('element-selected', item);
|
||||
}
|
||||
|
||||
// 在SVG中高亮显示元素
|
||||
function highlightElement(item: SvgElementInfo) {
|
||||
// 清除之前的高亮
|
||||
clearHighlight();
|
||||
|
||||
if (item.element) {
|
||||
// 保存原始样式
|
||||
const originalStyle = item.element.getAttribute('style') || '';
|
||||
item.element.setAttribute('data-original-style', originalStyle);
|
||||
|
||||
// 应用高亮样式
|
||||
item.element.setAttribute('style', `${originalStyle}; outline: 2px solid red; outline-offset: 2px;`);
|
||||
highlightedElement.value = item.element;
|
||||
|
||||
// 5秒后自动清除高亮
|
||||
setTimeout(() => {
|
||||
clearHighlight();
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
// 清除高亮
|
||||
function clearHighlight() {
|
||||
if (highlightedElement.value) {
|
||||
const originalStyle = highlightedElement.value.getAttribute('data-original-style') || '';
|
||||
highlightedElement.value.setAttribute('style', originalStyle);
|
||||
highlightedElement.value.removeAttribute('data-original-style');
|
||||
highlightedElement.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 分析SVG结构
|
||||
async function inspectSvg() {
|
||||
try {
|
||||
isInspecting.value = true;
|
||||
svgInfo.value = [];
|
||||
|
||||
const svgComponent = currentBodySvg.value;
|
||||
if (!svgComponent) {
|
||||
throw new Error(`找不到${selectedType.value}控制器的SVG配置`);
|
||||
}
|
||||
|
||||
// 创建一个临时元素来加载SVG
|
||||
const tempContainer = document.createElement('div');
|
||||
tempContainer.style.position = 'absolute';
|
||||
tempContainer.style.visibility = 'hidden';
|
||||
document.body.appendChild(tempContainer);
|
||||
|
||||
// 使用Vue组件加载SVG (需要异步处理)
|
||||
const { createApp, h } = await import('vue');
|
||||
const app = createApp({
|
||||
render: () => h(svgComponent)
|
||||
});
|
||||
app.mount(tempContainer);
|
||||
|
||||
// 等待SVG呈现 (实际情况下可能需要更复杂的检测机制)
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// 现在分析临时容器中的SVG结构
|
||||
const svgElement = tempContainer.querySelector('svg');
|
||||
if (!svgElement) {
|
||||
throw new Error('无法在组件中找到SVG元素');
|
||||
}
|
||||
|
||||
// 递归收集SVG元素的信息
|
||||
collectSvgElements(svgElement);
|
||||
|
||||
// 清理
|
||||
app.unmount();
|
||||
document.body.removeChild(tempContainer);
|
||||
isInspecting.value = false;
|
||||
} catch (error) {
|
||||
console.error('SVG分析失败:', error);
|
||||
isInspecting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 递归收集SVG元素信息
|
||||
function collectSvgElements(element: Element) {
|
||||
// 检查是否有ID或inkscape:label
|
||||
const id = element.id;
|
||||
const label = element.getAttribute('inkscape:label');
|
||||
const computedStyle = window.getComputedStyle(element);
|
||||
|
||||
// 如果有ID或标签,添加到列表中
|
||||
if (id || label) {
|
||||
svgInfo.value.push({
|
||||
id,
|
||||
label,
|
||||
tagName: element.tagName.toLowerCase(),
|
||||
fill: computedStyle.fill !== 'none' ? computedStyle.fill : undefined,
|
||||
stroke: computedStyle.stroke !== 'none' ? computedStyle.stroke : undefined,
|
||||
transform: element.getAttribute('transform') || undefined,
|
||||
element, // 保存对DOM元素的引用
|
||||
visible: true // 默认可见
|
||||
});
|
||||
}
|
||||
|
||||
// 递归处理子元素
|
||||
Array.from(element.children).forEach(child => {
|
||||
collectSvgElements(child);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.svg-inspector {
|
||||
margin-bottom: 20px;
|
||||
max-height: 100vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.has-label {
|
||||
border-left: 3px solid #18a058 !important;
|
||||
}
|
||||
|
||||
.results {
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.element-card {
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.element-card:hover {
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.hide-rule {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.results-scrollbar {
|
||||
max-height: calc(80vh - 200px);
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user