Jelajahi Sumber

数据调整

caoziwu 2 minggu lalu
induk
melakukan
94077b7271

+ 11 - 0
src/renderer/src/assets/simulator.less

@@ -82,6 +82,17 @@
   margin-bottom: 16px;
 }
 
+/* 供电模式提示文字 */
+.spl-mode-hint {
+  font-size: 12px;
+  opacity: 0.6;
+}
+
+/* 重置按钮行 */
+.reset-row {
+  margin-top: 4px;
+}
+
 .field {
   display: flex;
   flex-direction: column;

+ 197 - 0
src/renderer/src/components/simulator/ParamTuner.vue

@@ -0,0 +1,197 @@
+<script setup lang="ts">
+/**
+ * 实时参数调节器
+ * 承载可实时调整的数值参数与 A↔D3 联动,变更即触发父组件重算
+ */
+import { ref } from 'vue'
+import { Link, Unlink } from './icons'
+import type { Direction, NumberParamKey, SpeakerParams } from '../../lib/speaker/types'
+import { DEG } from '../../lib/speaker/calc'
+
+type NumberField = {
+  kind: 'number'
+  key: NumberParamKey
+  symbol: string
+  labelKey: string
+  unit: string
+  step: number
+}
+
+type DirectionField = { kind: 'direction' }
+
+type FieldConfig = NumberField | DirectionField
+
+const props = defineProps<{
+  params: SpeakerParams
+  linked: boolean
+}>()
+
+const emit = defineEmits<{
+  (e: 'update', field: NumberParamKey, value: number): void
+  (e: 'toggle-link'): void
+  (e: 'set-dir', dir: Direction): void
+}>()
+
+// ---------- A↔D3 联动 ----------
+
+const flashKeys = ref<Array<NumberParamKey>>([])
+
+const flash = (key: NumberParamKey): void => {
+  // 先移除再加,保证连续触发也能重放动画
+  flashKeys.value = flashKeys.value.filter((k) => k !== key)
+  void requestAnimationFrame(() => {
+    flashKeys.value = [...flashKeys.value, key]
+  })
+}
+
+const syncD3From = (z: number, angle: number): void => {
+  if (angle > 0.05 && z > 0) {
+    const d3 = Number((z / Math.tan(angle * DEG)).toFixed(2))
+    emit('update', 'd3', d3)
+    flash('d3')
+  } else if (angle >= 89.95) {
+    emit('update', 'd3', 0)
+  }
+}
+
+const syncAFrom = (z: number, d3: number): void => {
+  if (d3 > 0.001 && z > 0) {
+    const angle = Number((Math.atan(z / d3) / DEG).toFixed(2))
+    emit('update', 'angle', angle)
+    flash('angle')
+  } else if (d3 <= 0.001) {
+    emit('update', 'angle', 90)
+  }
+}
+
+const onUpdate = (field: NumberParamKey, value: number | null): void => {
+  if (value === null) return
+  emit('update', field, value)
+
+  if (!props.linked) return
+
+  if (field === 'z' || field === 'angle') {
+    const z = field === 'z' ? value : props.params.z
+    const angle = field === 'angle' ? value : props.params.angle
+    syncD3From(z, angle)
+  } else if (field === 'd3') {
+    syncAFrom(props.params.z, value)
+  }
+}
+
+// ---------- 字段定义 ----------
+
+const fields: FieldConfig[] = [
+  {
+    kind: 'number',
+    key: 'z',
+    symbol: 'Z',
+    labelKey: 'params.z',
+    unit: 'm',
+    step: 0.01
+  },
+  { kind: 'direction' },
+  {
+    kind: 'number',
+    key: 'angle',
+    symbol: 'A',
+    labelKey: 'params.angle',
+    unit: '°',
+    step: 0.01
+  },
+  {
+    kind: 'number',
+    key: 'd1',
+    symbol: 'D1',
+    labelKey: 'params.d1',
+    unit: 'm',
+    step: 0.01
+  },
+  {
+    kind: 'number',
+    key: 'd2',
+    symbol: 'D2',
+    labelKey: 'params.d2',
+    unit: 'm',
+    step: 0.01
+  },
+  {
+    kind: 'number',
+    key: 'd3',
+    symbol: 'D3',
+    labelKey: 'params.d3',
+    unit: 'm',
+    step: 0.01
+  }
+]
+
+const isFlashing = (key: NumberParamKey): boolean => {
+  return flashKeys.value.includes(key)
+}
+
+const getFieldValue = (field: NumberField): number => {
+  return props.params[field.key] as number
+}
+</script>
+
+<template>
+  <div class="tuner-body">
+    <NGrid cols="3" responsive="screen" :x-gap="14" :y-gap="14" item-responsive>
+      <template v-for="field in fields" :key="field.kind === 'number' ? field.key : 'dir'">
+        <!-- 方向选择 -->
+        <NGi v-if="field.kind === 'direction'" span="3 m:1">
+          <div class="field">
+            <div class="field-label">
+              <span class="sym">H/V</span>
+              {{ $t('params.dir') }}
+            </div>
+            <NRadioGroup
+              :value="params.dir"
+              size="medium"
+              @update:value="(v) => emit('set-dir', v as Direction)"
+            >
+              <NRadioButton value="H">{{ $t('params.dirH') }}</NRadioButton>
+              <NRadioButton value="V">{{ $t('params.dirV') }}</NRadioButton>
+            </NRadioGroup>
+          </div>
+        </NGi>
+
+        <!-- 数值输入 -->
+        <NGi v-else span="3 s:1 m:1">
+          <div class="field" :class="{ flash: isFlashing(field.key) }">
+            <div class="field-label">
+              <span class="sym">{{ field.symbol }}</span>
+              {{ $t(field.labelKey) }}
+              <NTooltip v-if="field.key === 'd3'" trigger="hover">
+                <template #trigger>
+                  <NButton
+                    quaternary
+                    circle
+                    size="tiny"
+                    class="link-btn"
+                    :class="{ off: !linked }"
+                    @click="emit('toggle-link')"
+                  >
+                    <template #icon>
+                      <NIcon :component="linked ? Link : Unlink" />
+                    </template>
+                  </NButton>
+                </template>
+                {{ linked ? $t('params.linkOn') : $t('params.linkOff') }}
+              </NTooltip>
+            </div>
+            <NInputNumber
+              :value="getFieldValue(field)"
+              :step="field.step"
+              :show-button="false"
+              class="num-input"
+              @update:value="(v) => onUpdate(field.key, v)"
+            >
+              <template #suffix>{{ field.unit }}</template>
+            </NInputNumber>
+          </div>
+        </NGi>
+      </template>
+    </NGrid>
+  </div>
+</template>

+ 91 - 188
src/renderer/src/components/simulator/ParamsPanel.vue

@@ -1,103 +1,89 @@
 <script setup lang="ts">
-import { ref } from 'vue'
-import { Link, Unlink, UndoAlt } from './icons'
-import type { Direction, NumberParamKey, SpeakerParams } from '../../lib/speaker/types'
-import { DEG } from '../../lib/speaker/calc'
+import { computed, ref, Ref } from 'vue'
 import { useI18n } from 'vue-i18n'
 import { devicesConfig } from '../../config'
-const { t } = useI18n()
+import type { DeviceSpec } from '../../lib/speaker/types'
+import type { NumberParamKey } from '../../lib/speaker/types'
 
+const { t } = useI18n()
+interface SplMaxConfig {
+  standalone: Array<{ label: string; value: number }>
+  extension: Array<{ label: string; value: number }>
+}
 type NumberField = {
   kind: 'number'
   key: NumberParamKey
   symbol: string
   labelKey: string
   unit: string
-  disabled: boolean | (() => boolean)
   step: number
+  value: Ref<number> | number
 }
 
-type DirectionField = { kind: 'direction' }
-
-type FieldConfig = NumberField | DirectionField
-const props = defineProps<{
-  params: SpeakerParams
-  linked: boolean
-}>()
-
+type FieldConfig = NumberField
 const emit = defineEmits<{
-  (e: 'update', field: NumberParamKey, value: number): void
-  (e: 'toggle-link'): void
-  (e: 'set-dir', dir: Direction): void
-  (e: 'reset'): void
-  (e: 'apply-device', hCov: number, vCov: number): void
+  (e: 'apply-spec', spec: DeviceSpec): void
 }>()
 
-// ---------- 设备型号下拉:选择后自动应用水平/垂直指向角 ----------
-
 const modelOptions = devicesConfig.map((d) => ({
-  label: d.model == 'custom' ? t('params.custom') : d.model,
-  value: d.model
+  label: d.model === 'custom' ? t('params.custom') : d.model,
+  value: d.model,
+  horizontalAngle: d.horizontalAngle,
+  verticalAngle: d.verticalAngle,
+  splMax: d.splMax
 }))
+
 const selectedModel = ref<string | null>('custom')
 
-const onSelectModel = (model: string): void => {
-  selectedModel.value = model
-  const device = devicesConfig.find((d) => d.model === model)
-  if (device && model != 'custom') {
-    emit('apply-device', device.horizontalAngle!, device.verticalAngle!)
+const isExtension = ref(false)
+
+const powerMode = ref<string>('')
+
+const currentSplMaxConfig = ref<SplMaxConfig>({ standalone: [], extension: [] })
+const splMax = ref<number | null>(null)
+const hCov = ref<number | null>(null)
+const vCov = ref<number | null>(null)
+
+const powerModeOptions = computed(() => {
+  return isExtension.value
+    ? currentSplMaxConfig.value.extension
+    : currentSplMaxConfig.value.standalone
+})
+
+const apply = (): void => {
+  const device = devicesConfig.find((d) => d.model === selectedModel.value)
+  const isCustom = !device || device.model === 'custom'
+  hCov.value = isCustom ? hCov.value : device.horizontalAngle
+  vCov.value = isCustom ? vCov.value : device.verticalAngle
+  const data = {
+    hCov: hCov.value,
+    vCov: vCov.value,
+    splMax: splMax.value
   }
-}
 
-const onReset = (): void => {
-  selectedModel.value = null
-  emit('reset')
+  emit('apply-spec', data)
 }
 
-const flashKeys = ref<Array<NumberParamKey>>([])
-
-const flash = (key: NumberParamKey): void => {
-  // 先移除再加,保证连续触发也能重放动画
-  flashKeys.value = flashKeys.value.filter((k) => k !== key)
-  void requestAnimationFrame(() => {
-    flashKeys.value = [...flashKeys.value, key]
-  })
+const onSelectModel = (model: string, option): void => {
+  console.log('onSelectModel', model, option)
+  currentSplMaxConfig.value = option.splMax
+  selectedModel.value = model
+  isExtension.value = false
+  powerMode.value = ''
+  apply()
 }
 
-const syncD3From = (z: number, angle: number): void => {
-  if (angle > 0.05 && z > 0) {
-    const d3 = Number((z / Math.tan(angle * DEG)).toFixed(2))
-    emit('update', 'd3', d3)
-    flash('d3')
-  } else if (angle >= 89.95) {
-    emit('update', 'd3', 0)
-  }
+const onToggleExtension = (value: boolean): void => {
+  isExtension.value = value
+  powerMode.value = ''
+  splMax.value = powerModeOptions.value[0].value
+  apply()
 }
-const syncAFrom = (z: number, d3: number): void => {
-  if (d3 > 0.001 && z > 0) {
-    const angle = Number((Math.atan(z / d3) / DEG).toFixed(2))
-    emit('update', 'angle', angle)
-    flash('angle')
-  } else if (d3 <= 0.001) {
-    emit('update', 'angle', 90)
-  }
-}
-
-const onUpdate = (field: NumberParamKey, value: number | null): void => {
-  if (value === null) return
-  emit('update', field, value)
 
-  if (!props.linked) return
-
-  if (field === 'z' || field === 'angle') {
-    const z = field === 'z' ? value : props.params.z
-    const angle = field === 'angle' ? value : props.params.angle
-    syncD3From(z, angle)
-  } else if (field === 'd3') {
-    syncAFrom(props.params.z, value)
-  }
+const onSelectPowerMode = (mode: string): void => {
+  powerMode.value = mode
+  apply()
 }
-
 const fields: FieldConfig[] = [
   {
     kind: 'number',
@@ -105,17 +91,8 @@ const fields: FieldConfig[] = [
     symbol: 'SPLₘₐₓ',
     labelKey: 'params.splMax',
     unit: 'dB SPL',
-    step: 0.1,
-    disabled: false
-  },
-  {
-    kind: 'number',
-    key: 'z',
-    symbol: 'Z',
-    labelKey: 'params.z',
-    unit: 'm',
-    step: 0.01,
-    disabled: false
+    value: splMax,
+    step: 0.1
   },
   {
     kind: 'number',
@@ -123,8 +100,8 @@ const fields: FieldConfig[] = [
     symbol: 'H',
     labelKey: 'params.hCov',
     unit: '°',
-    step: 1,
-    disabled: () => selectedModel.value != 'custom'
+    value: hCov,
+    step: 1
   },
   {
     kind: 'number',
@@ -132,131 +109,66 @@ const fields: FieldConfig[] = [
     symbol: 'V',
     labelKey: 'params.vCov',
     unit: '°',
-    step: 1,
-    disabled: () => selectedModel.value != 'custom'
-  },
-  { kind: 'direction' },
-  {
-    kind: 'number',
-    key: 'angle',
-    symbol: 'A',
-    labelKey: 'params.angle',
-    unit: '°',
-    step: 0.01,
-    disabled: false
-  },
-  {
-    kind: 'number',
-    key: 'd1',
-    symbol: 'D1',
-    labelKey: 'params.d1',
-    unit: 'm',
-    step: 0.01,
-    disabled: false
-  },
-  {
-    kind: 'number',
-    key: 'd2',
-    symbol: 'D2',
-    labelKey: 'params.d2',
-    unit: 'm',
-    step: 0.01,
-    disabled: false
-  },
-  {
-    kind: 'number',
-    key: 'd3',
-    symbol: 'D3',
-    labelKey: 'params.d3',
-    unit: 'm',
-    step: 0.01,
-    disabled: false
+    value: vCov,
+    step: 1
   }
 ]
-
-const isFlashing = (key: NumberParamKey): boolean => {
-  return flashKeys.value.includes(key)
-}
-
-const getFieldValue = (field: NumberField): number => {
-  return props.params[field.key] as number
-}
-
-/** 求值字段的 disabled:支持布尔值或返回布尔的函数(响应式) */
-const isFieldDisabled = (field: NumberField): boolean => {
-  return typeof field.disabled === 'function' ? field.disabled() : field.disabled
-}
 </script>
 
 <template>
   <div class="params-body">
-    <!-- 设备型号选择:自动填充水平/垂直指向角 -->
     <div class="field model-field">
       <div class="field-label">
         <span class="sym">⌘</span>
         {{ $t('params.model') }}
       </div>
-      <NSelect
+      <n-select
         :value="selectedModel"
         :options="modelOptions"
         :placeholder="$t('params.modelPlaceholder')"
         filterable
         clearable
-        @update:value="(v) => v && onSelectModel(v)"
+        @update:value="onSelectModel"
       />
     </div>
+    <n-flex>
+      <div class="field model-field" v-if="selectedModel && selectedModel !== 'custom'">
+        <div class="field-label">
+          <span class="sym">🔊</span>
+          {{ $t('params.hasSubwoofer') }}
+        </div>
+        <NCheckbox :model-value="isExtension" @update:checked="onToggleExtension">
+          {{ $t('params.hasSubwooferOn') }}
+        </NCheckbox>
+      </div>
 
+      <div v-if="powerModeOptions.length > 0" class="field model-field">
+        <div class="field-label">
+          <span class="sym">⚡</span>
+          {{ $t('params.powerMode') }}
+          <div class="spl-mode-hint">{{ $t('params.splMaxAuto') }}</div>
+        </div>
+        <n-radio-group
+          v-model:value="splMax"
+          :options="powerModeOptions"
+          @update:value="onSelectPowerMode"
+        />
+      </div>
+    </n-flex>
     <NGrid cols="3" responsive="screen" :x-gap="14" :y-gap="14" item-responsive>
       <template v-for="field in fields" :key="field.kind === 'number' ? field.key : 'dir'">
-        <!-- 方向选择 -->
-        <NGi v-if="field.kind === 'direction'" span="3 m:1">
+        <NGi span="3 s:1 m:1">
           <div class="field">
-            <div class="field-label">
-              <span class="sym">H/V</span>
-              {{ $t('params.dir') }}
-            </div>
-            <NRadioGroup
-              :value="params.dir"
-              size="medium"
-              @update:value="(v) => emit('set-dir', v as Direction)"
-            >
-              <NRadioButton value="H">{{ $t('params.dirH') }}</NRadioButton>
-              <NRadioButton value="V">{{ $t('params.dirV') }}</NRadioButton>
-            </NRadioGroup>
-          </div>
-        </NGi>
-
-        <!-- 数值输入 -->
-        <NGi v-else span="3 s:1 m:1">
-          <div class="field" :class="{ flash: isFlashing(field.key) }">
             <div class="field-label">
               <span class="sym">{{ field.symbol }}</span>
               {{ $t(field.labelKey) }}
-              <NTooltip v-if="field.key === 'd3'" trigger="hover">
-                <template #trigger>
-                  <NButton
-                    quaternary
-                    circle
-                    size="tiny"
-                    class="link-btn"
-                    :class="{ off: !linked }"
-                    @click="emit('toggle-link')"
-                  >
-                    <template #icon>
-                      <NIcon :component="linked ? Link : Unlink" />
-                    </template>
-                  </NButton>
-                </template>
-                {{ linked ? $t('params.linkOn') : $t('params.linkOff') }}
-              </NTooltip>
             </div>
             <NInputNumber
-              :value="getFieldValue(field)"
+              :value="field.value"
               :step="field.step"
               :show-button="false"
-              :disabled="isFieldDisabled(field)"
+              :disabled="selectedModel !== 'custom'"
               class="num-input"
-              @update:value="(v) => onUpdate(field.key, v)"
             >
               <template #suffix>{{ field.unit }}</template>
             </NInputNumber>
@@ -264,14 +176,5 @@ const isFieldDisabled = (field: NumberField): boolean => {
         </NGi>
       </template>
     </NGrid>
-
-    <NSpace justify="end" class="reset-row">
-      <NButton size="small" quaternary @click="onReset">
-        <template #icon>
-          <NIcon :component="UndoAlt" :size="12" />
-        </template>
-        {{ $t('params.reset') }}
-      </NButton>
-    </NSpace>
   </div>
 </template>

+ 21 - 17
src/renderer/src/components/simulator/SpeakerSimulator.vue

@@ -4,12 +4,14 @@ import { SlidersH } from './icons'
 import type {
   Assessment,
   CalcResult,
+  DeviceSpec,
   Direction,
   NumberParamKey,
   SpeakerParams
 } from '../../lib/speaker/types'
 import { DEG, assess, calculate } from '../../lib/speaker/calc'
 import ParamsPanel from './ParamsPanel.vue'
+import ParamTuner from './ParamTuner.vue'
 import ThemeToggle from './ThemeToggle.vue'
 import DiagramPanel from './DiagramPanel.vue'
 import ResultPanel from './ResultPanel.vue'
@@ -55,16 +57,17 @@ const toggleLink = (): void => {
   linked.value = !linked.value
 }
 
-/** 更新单个参数字段并重算(ParamsPanel 的 @update) */
+/** 更新单个参数字段并重算(ParamTuner 的 @update) */
 const updateField = (field: NumberParamKey, value: number): void => {
   params[field] = value
   runCalc()
 }
 
-/** 应用设备型号:自动填充水平/垂直指向角并重算 */
-const applyDevice = (hCov: number, vCov: number): void => {
-  params.hCov = hCov
-  params.vCov = vCov
+/** 应用设备选型结果:仅覆盖非 null 的项 */
+const applySpec = (spec: DeviceSpec): void => {
+  if (spec.hCov !== null) params.hCov = spec.hCov
+  if (spec.vCov !== null) params.vCov = spec.vCov
+  if (spec.splMax !== null) params.splMax = spec.splMax
   runCalc()
 }
 
@@ -95,12 +98,21 @@ runCalc()
           <template #icon>
             <NIcon :component="SlidersH" />
           </template>
-          {{ $t('params.panel') }}
+          {{ $t('params.deviceSelect') }}
         </NButton>
         <ThemeToggle />
       </NSpace>
     </header>
 
+    <NCard :title="$t('params.tuner')" size="small" class="panel-card">
+      <ParamTuner
+        :params="params"
+        :linked="linked"
+        @update="updateField"
+        @toggle-link="toggleLink"
+        @set-dir="setDir"
+      />
+    </NCard>
     <ResultPanel :result="result" :assessment="assessment" />
 
     <NCard size="small" class="panel-card chart-card">
@@ -114,23 +126,15 @@ runCalc()
       </NTabs>
     </NCard>
 
-    <!-- 参数输入弹框 -->
+    <!-- 设备选型弹框 -->
     <BaseModel
       :show="showParams"
       style="max-width: 92vw"
-      :title="$t('params.panel')"
+      :title="$t('params.deviceSelect')"
       @submit="showParams = false"
       @close="showParams = false"
     >
-      <ParamsPanel
-        :params="params"
-        :linked="linked"
-        @update="updateField"
-        @toggle-link="toggleLink"
-        @set-dir="setDir"
-        @apply-device="applyDevice"
-        @reset="reset"
-      />
+      <ParamsPanel @apply-spec="applySpec" @reset="reset" />
     </BaseModel>
   </div>
 </template>

+ 104 - 8
src/renderer/src/config/index.ts

@@ -2,41 +2,137 @@ export const devicesConfig = [
   {
     model: 'custom',
     horizontalAngle: 0,
-    verticalAngle: 0
+    verticalAngle: 0,
+    splMax: {
+      standalone: [
+        { label: 'DC', value: 118 },
+        { label: 'PoE+', value: 117 },
+        { label: 'PoE', value: 115 }
+      ],
+      extension: [
+        { label: 'DC', value: 2 * 118 },
+        { label: 'PoE+', value: 2 * 114 },
+        { label: 'PoE', value: 2 * 112 }
+      ]
+    }
   },
   {
     model: 'IPS-M1-D2W',
     horizontalAngle: 140,
-    verticalAngle: 140
+    verticalAngle: 140,
+    splMax: {
+      standalone: [
+        { label: 'DC', value: 118 },
+        { label: 'PoE+', value: 117 },
+        { label: 'PoE', value: 115 }
+      ],
+      extension: [
+        { label: 'DC', value: 2 * 118 },
+        { label: 'PoE+', value: 2 * 114 },
+        { label: 'PoE', value: 2 * 112 }
+      ]
+    }
   },
   {
     model: 'IPS-M1-D1W',
     horizontalAngle: 120,
-    verticalAngle: 120
+    verticalAngle: 120,
+    splMax: {
+      standalone: [
+        { label: 'DC', value: 118 },
+        { label: 'PoE+', value: 117 },
+        { label: 'PoE', value: 115 }
+      ],
+      extension: [
+        { label: 'DC', value: 2 * 118 },
+        { label: 'PoE+', value: 2 * 114 },
+        { label: 'PoE', value: 2 * 112 }
+      ]
+    }
   },
   {
     model: 'IPS-M1-BW',
     horizontalAngle: 120,
-    verticalAngle: 120
+    verticalAngle: 120,
+    splMax: {
+      standalone: [
+        { label: 'DC', value: 118 },
+        { label: 'PoE+', value: 117 },
+        { label: 'PoE', value: 115 }
+      ],
+      extension: [
+        { label: 'DC', value: 2 * 118 },
+        { label: 'PoE+', value: 2 * 114 },
+        { label: 'PoE', value: 2 * 112 }
+      ]
+    }
   },
   {
     model: 'IPS-C1',
     horizontalAngle: 120,
-    verticalAngle: 120
+    verticalAngle: 120,
+    splMax: {
+      standalone: [
+        { label: 'DC', value: 118 },
+        { label: 'PoE+', value: 117 },
+        { label: 'PoE', value: 115 }
+      ],
+      extension: [
+        { label: 'DC', value: 2 * 118 },
+        { label: 'PoE+', value: 2 * 114 },
+        { label: 'PoE', value: 2 * 112 }
+      ]
+    }
   },
   {
     model: 'IPS-W1',
     horizontalAngle: 120,
-    verticalAngle: 140
+    verticalAngle: 140,
+    splMax: {
+      standalone: [
+        { label: 'DC', value: 118 },
+        { label: 'PoE+', value: 117 },
+        { label: 'PoE', value: 115 }
+      ],
+      extension: [
+        { label: 'DC', value: 2 * 118 },
+        { label: 'PoE+', value: 2 * 114 },
+        { label: 'PoE', value: 2 * 112 }
+      ]
+    }
   },
   {
     model: 'IPS-W1-R1',
     horizontalAngle: 120,
-    verticalAngle: 140
+    verticalAngle: 140,
+    splMax: {
+      standalone: [
+        { label: 'DC', value: 118 },
+        { label: 'PoE+', value: 117 },
+        { label: 'PoE', value: 115 }
+      ],
+      extension: [
+        { label: 'DC', value: 2 * 118 },
+        { label: 'PoE+', value: 2 * 114 },
+        { label: 'PoE', value: 2 * 112 }
+      ]
+    }
   },
   {
     model: 'IPS-H1',
     horizontalAngle: 120,
-    verticalAngle: 120
+    verticalAngle: 120,
+    splMax: {
+      standalone: [
+        { label: 'DC', value: 118 },
+        { label: 'PoE+', value: 117 },
+        { label: 'PoE', value: 115 }
+      ],
+      extension: [
+        { label: 'DC', value: 2 * 118 },
+        { label: 'PoE+', value: 2 * 114 },
+        { label: 'PoE', value: 2 * 112 }
+      ]
+    }
   }
 ]

+ 13 - 0
src/renderer/src/lib/speaker/types.ts

@@ -63,3 +63,16 @@ export interface SPLPoint {
   d: number
   spl: number
 }
+
+/**
+ * 设备选型结果(型号 + 是否有子喇叭 + 供电模式推导而来)
+ * null 表示该项不覆盖,保持用户当前手动值
+ */
+export interface DeviceSpec {
+  /** 水平指向角 (-6 dB) ° */
+  hCov: number | null
+  /** 垂直指向角 (-6 dB) ° */
+  vCov: number | null
+  /** 最大声压级 dB SPL */
+  splMax: number | null
+}

+ 6 - 0
src/renderer/src/locales/lang/en_US.json

@@ -33,6 +33,7 @@
   },
   "params": {
     "panel": "Parameters",
+    "deviceSelect": "Device Selection",
     "placeholder": "Enter a value",
     "model": "Device Model",
     "modelPlaceholder": "Select a model to auto-fill coverage angles",
@@ -50,6 +51,11 @@
     "linkOn": "Click to unlink A and D3",
     "linkOff": "Click to link A and D3",
     "custom":"Custom",
+    "hasSubwoofer": "Has Subwoofer",
+    "hasSubwooferOn": "Equipped with subwoofer (extension unit)",
+    "powerMode": "Power Mode",
+    "splMaxAuto": "Max SPL will be auto-filled by selected mode",
+    "tuner": "Live Parameter Tuning",
     "reset": "Reset defaults"
   },
   "diagram": {

+ 6 - 0
src/renderer/src/locales/lang/zh_CN.json

@@ -33,6 +33,7 @@
   },
   "params": {
     "panel": "参数输入",
+    "deviceSelect": "设备选型",
     "placeholder": "在此输入数值",
     "model": "设备型号",
     "modelPlaceholder": "选择型号自动填充指向角",
@@ -50,6 +51,11 @@
     "linkOn": "点击解除 A 与 D3 关联",
     "linkOff": "点击关联 A 与 D3",
     "custom":"自定义",
+    "hasSubwoofer": "是否有子喇叭",
+    "hasSubwooferOn": "配备子喇叭(扩展单元)",
+    "powerMode": "供电模式",
+    "splMaxAuto": "最大声压级将按所选模式自动填充",
+    "tuner": "实时参数调节",
     "reset": "重置默认值"
   },
   "diagram": {

+ 4 - 0
src/renderer/src/main.ts

@@ -24,6 +24,8 @@ import {
   NDialog,
   NModal,
   NTabs,
+  NCheckbox,
+  NFlex,
   NTabPane
 } from 'naive-ui'
 import App from './App.vue'
@@ -59,6 +61,8 @@ async function bootstrap(): Promise<void> {
         NTabs,
         NModal,
         NSelect,
+        NFlex,
+        NCheckbox,
         NTabPane
       ]
     })