feat: 更新配置和组件以支持选择项功能, 开始手柄映射功能编写

- 在DynamicForm.vue中新增select组件支持
- 在VTsuruConfigTypes.ts中添加可选的条件显示属性
- 更新vite.config.mts以集成自定义SVGO插件
- 在components.d.ts中添加NDescriptionsItem组件声明
- 更新路由配置以包含obs_store模块
This commit is contained in:
2025-05-11 05:49:50 +08:00
parent f2f7a7e8af
commit 1ae528b9a9
264 changed files with 72948 additions and 7 deletions

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

View 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) {
// 这是直接修改 propVue 会发出警告。在实际应用中,应该通过 emit 更新父组件的配置
// (props.config as any).title = dynamicTitle.value;
message.info('标题已在本地临时更改。若要保存,请通过父组件的配置面板。');
// 要正确更新,应该 emit事件例如
// emits('update:config', { ...localConfig.value, title: dynamicTitle.value });
}
}
// --- Expose (使得父组件可以通过 ref 访问 Config 和 DefaultConfig) ---
// Vue 3 <script setup> 默认关闭,需要显式 defineExpose
// 但对于 DynamicForm它似乎能够通过某种方式访问导出的 Config 和 DefaultConfig
// 如果父组件需要通过 ref 主动调用方法或访问属性,则需要 defineExpose
// defineExpose({ Config, DefaultConfig, /* refreshMethod */ });
onMounted(() => {
// console.log('ExampleOBSComponent mounted with config:', props.config);
// console.log('Effective localConfig:', localConfig.value);
// console.log('Exposed Config definition:', Config);
// console.log('Exposed DefaultConfig:', DefaultConfig);
dynamicTitle.value = localConfig.value.title;
});
watch(() => props.config, (newConfig) => {
dynamicTitle.value = newConfig?.title || DefaultConfig.title;
}, { deep: true });
</script>
<style scoped>
.example-obs-component {
border: 1px dashed var(--n-border-color);
padding: 16px;
}
pre {
background-color: var(--n-code-block-color);
padding: 8px;
border-radius: 4px;
font-size: 0.85em;
white-space: pre-wrap; /* 确保长内容能换行 */
word-break: break-all; /* 强制断词,防止溢出 */
}
</style>

View File

@@ -0,0 +1,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>

View File

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

View File

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

View File

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

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

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