vue2-client 1.19.72 → 1.19.73

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vue2-client",
3
- "version": "1.19.72",
3
+ "version": "1.19.73",
4
4
  "private": false,
5
5
  "scripts": {
6
6
  "serve": "SET NODE_OPTIONS=--openssl-legacy-provider && vue-cli-service serve --no-eslint",
@@ -29,7 +29,7 @@
29
29
  <template v-if="panel.title2 && panel.title2.length">
30
30
  <span
31
31
  v-for="(item, headerIndex) in panel.title2"
32
- :key="headerIndex"
32
+ :key="`panel-${panelIndex}-title2-${headerIndex}`"
33
33
  class="info-item"
34
34
  :style="config.title2Style">
35
35
  <!-- 根据showTitle是否显示键名 -->
@@ -41,7 +41,7 @@
41
41
  <template v-if="panel.title3 && Array.isArray(panel.title3) && panel.title3.length">
42
42
  <span
43
43
  v-for="(item, t3Index) in panel.title3"
44
- :key="t3Index"
44
+ :key="`panel-${panelIndex}-title3-${t3Index}`"
45
45
  :class="['time-item', { 'time-first': t3Index === 0 }]"
46
46
  :style="config.title3Style">
47
47
  <span v-if="item.showTitle">{{ item.key }}:</span>
@@ -171,7 +171,7 @@ export default {
171
171
  },
172
172
  // json名
173
173
  queryParamsName: {
174
- type: Object,
174
+ type: [String, Object],
175
175
  default: null
176
176
  },
177
177
  parameter: {
@@ -20,7 +20,6 @@
20
20
  <template v-if="!loadError">
21
21
  <!-- 插槽分组:由父组件通过具名插槽传入内容 -->
22
22
  <template v-if="realDataItem.slot">
23
- <div class="ant-descriptions-title" v-if="realDataItem.title">{{ realDataItem.title }}</div>
24
23
  <slot :name="realDataItem.slotName" :data="data"></slot>
25
24
  </template>
26
25
  <!-- 带有子的详情 -->
@@ -57,11 +56,12 @@
57
56
  <span :style="getFieldStyle(item, data)">
58
57
  <!-- 超链接样式兼容 -->
59
58
  <template v-if="item.isLink">
60
- <span role="link"
61
- tabindex="0"
62
- class="link-text"
63
- @click="handleLinkClick(item, data, $event)"
64
- @keyup.enter="handleLinkClick(item, data, $event)">
59
+ <span
60
+ role="link"
61
+ tabindex="0"
62
+ class="link-text"
63
+ @click="handleLinkClick(item, data, $event)"
64
+ @keyup.enter="handleLinkClick(item, data, $event)">
65
65
  {{ formatFieldValue(item.value, item, data) || '--' }}
66
66
  </span>
67
67
  </template>
@@ -75,7 +75,7 @@
75
75
  @click="handleEditField(item)"
76
76
  style="padding: 0 4px"
77
77
  >
78
- <a-icon type="edit" />
78
+ <a-icon type="edit"/>
79
79
  </a-button>
80
80
  </span>
81
81
  </a-descriptions-item>
@@ -95,7 +95,6 @@
95
95
  <template v-if="!loadError && realData[activeTab]">
96
96
  <!-- 插槽分组:由父组件通过具名插槽传入内容 -->
97
97
  <template v-if="realData[activeTab].slot">
98
- <div class="ant-descriptions-title" v-if="realData[activeTab].title">{{ realData[activeTab].title }}</div>
99
98
  <slot :name="realData[activeTab].slotName" :data="data"></slot>
100
99
  </template>
101
100
  <!-- 带有子的详情 -->
@@ -135,11 +134,12 @@
135
134
  <span :style="getFieldStyle(fieldItem, fieldItem.value, data)">
136
135
  <!-- 超链接样式兼容 - 修复变量引用错误 -->
137
136
  <template v-if="fieldItem.isLink">
138
- <span role="link"
139
- tabindex="0"
140
- class="link-text"
141
- @click="handleLinkClick(fieldItem, data, $event)"
142
- @keyup.enter="handleLinkClick(fieldItem, data, $event)"
137
+ <span
138
+ role="link"
139
+ tabindex="0"
140
+ class="link-text"
141
+ @click="handleLinkClick(fieldItem, data, $event)"
142
+ @keyup.enter="handleLinkClick(fieldItem, data, $event)"
143
143
  >
144
144
  {{ formatFieldValue(fieldItem.value, fieldItem, data) || '--' }}
145
145
  </span>
@@ -154,7 +154,7 @@
154
154
  @click="handleEditField(fieldItem)"
155
155
  style="padding: 0 4px"
156
156
  >
157
- <a-icon type="edit" />
157
+ <a-icon type="edit"/>
158
158
  </a-button>
159
159
  </span>
160
160
  </a-descriptions-item>
@@ -174,7 +174,6 @@
174
174
  <template v-if="!loadError">
175
175
  <!-- 插槽分组:由父组件通过具名插槽传入内容 -->
176
176
  <template v-if="realDataItem.slot">
177
- <div class="ant-descriptions-title" v-if="realDataItem.title">{{ realDataItem.title }}</div>
178
177
  <slot :name="realDataItem.slotName" :data="data"></slot>
179
178
  </template>
180
179
  <!-- 带有子的详情 -->
@@ -209,11 +208,12 @@
209
208
  </template>
210
209
  <span :style="getFieldStyle(item, item.value, data)">
211
210
  <template v-if="item.isLink">
212
- <span role="link"
213
- tabindex="0"
214
- class="link-text"
215
- @click="handleLinkClick(item, data, $event)"
216
- @keyup.enter="handleLinkClick(item, data, $event)"
211
+ <span
212
+ role="link"
213
+ tabindex="0"
214
+ class="link-text"
215
+ @click="handleLinkClick(item, data, $event)"
216
+ @keyup.enter="handleLinkClick(item, data, $event)"
217
217
  >
218
218
  {{ formatFieldValue(item.value, item, data) || '--' }}
219
219
  </span>
@@ -228,7 +228,7 @@
228
228
  @click="handleEditField(item)"
229
229
  style="padding: 0 4px"
230
230
  >
231
- <a-icon type="edit" />
231
+ <a-icon type="edit"/>
232
232
  </a-button>
233
233
  </span>
234
234
  </a-descriptions-item>
@@ -240,10 +240,10 @@
240
240
  </a-row>
241
241
  </template>
242
242
  <script>
243
- import { mapState } from 'vuex'
244
- import { getRealKeyData } from '@vue2-client/utils/formatter'
245
- import { getConfigByName } from '@vue2-client/services/api/common'
246
- import { executeStrFunctionByContext } from '@vue2-client/utils/runEvalFunction'
243
+ import {mapState} from 'vuex'
244
+ import {getRealKeyData} from '@vue2-client/utils/formatter'
245
+ import {getConfigByName} from '@vue2-client/services/api/common'
246
+ import {executeStrFunctionByContext} from '@vue2-client/utils/runEvalFunction'
247
247
  import XAddNativeForm from '@vue2-client/base-client/components/common/XAddNativeForm/XAddNativeForm.vue'
248
248
 
249
249
  export default {
@@ -269,7 +269,8 @@ export default {
269
269
  default: process.env.VUE_APP_SYSTEM_NAME
270
270
  }
271
271
  },
272
- mounted() {},
272
+ mounted() {
273
+ },
273
274
  beforeDestroy() {
274
275
  const formGroupContext = this.$refs.formGroupContext?.$el
275
276
  if (formGroupContext && formGroupContext.removeEventListener) {
@@ -295,7 +296,7 @@ export default {
295
296
  }
296
297
  },
297
298
  computed: {
298
- ...mapState('setting', { isMobile: 'isMobile' })
299
+ ...mapState('setting', {isMobile: 'isMobile'})
299
300
  },
300
301
  methods: {
301
302
  initConfig() {
@@ -319,7 +320,7 @@ export default {
319
320
  scrollToGroup(index) {
320
321
  const groupElement = this.$refs[`descriptions-item-${index}`][0]
321
322
  if (groupElement) {
322
- groupElement.scrollIntoView({ behavior: 'smooth' })
323
+ groupElement.scrollIntoView({behavior: 'smooth'})
323
324
  }
324
325
  },
325
326
  getConfig() {
@@ -335,7 +336,7 @@ export default {
335
336
  this.groups = groups
336
337
  this.realData = groups
337
338
  .map(group => {
338
- const dataItem = { title: group.name }
339
+ const dataItem = {title: group.name}
339
340
 
340
341
  if (group.type === 'slot') {
341
342
  // 插槽分组:由父组件通过具名插槽传入内容
@@ -144,6 +144,11 @@ export default {
144
144
  type: Array,
145
145
  default: undefined,
146
146
  },
147
+ // TAB 页签时由 XTab 传入,仅接收不参与逻辑(注册由 XTab onComponentMounted 完成)
148
+ slotRef: {
149
+ type: String,
150
+ default: undefined,
151
+ },
147
152
  // 是否小插件模式,小插件模式不会有各种边境
148
153
  isWidget: {
149
154
  type: Boolean,
@@ -413,6 +418,26 @@ export default {
413
418
  setGlobalData (obj) {
414
419
  this.globalData = obj
415
420
  },
421
+ /**
422
+ * 对外数据设置:仅当配置中存在 dataProcessScript 时执行该脚本,传入 data;不修改组件自身任何数据。
423
+ * 无脚本时直接返回,不做任何操作。脚本内 this 为当前 XReport 实例。
424
+ * @param {*} data 外部传入的数据,供脚本使用
425
+ * @returns {Promise<void>}
426
+ */
427
+ async setData (data) {
428
+ const script = this.config?.dataProcessScript
429
+ if (!script || typeof script !== 'string') {
430
+ return
431
+ }
432
+ try {
433
+ const result = executeStrFunctionByContext(this, script, [data])
434
+ if (result instanceof Promise) {
435
+ await result
436
+ }
437
+ } catch (e) {
438
+ console.error('[XReport setData] dataProcessScript 执行异常:', e)
439
+ }
440
+ },
416
441
  /**
417
442
  * @param configName 栅格配置名称
418
443
  * @param selectedId 选中得id
@@ -1040,6 +1065,7 @@ export default {
1040
1065
  if (this.registerMap !== undefined) {
1041
1066
  this.registerMap.push(this)
1042
1067
  }
1068
+ // TAB 页签的 slotRef 由 XTab 在 onComponentMounted 中统一注册(与 Cover 普通子组件一致),此处不再自我注册
1043
1069
  // 将原始数据备份保存
1044
1070
  if (this.configData) {
1045
1071
  this.dataCache = JSON.parse(JSON.stringify(this.configData))
@@ -31,6 +31,19 @@ export default {
31
31
  | localConfig | 本地配置 | Object | undefined |
32
32
  | dontFormat | 禁止已经格式化后的配置格式化| Boolean | false|
33
33
  | configData | 配置中的Data | Object | undefined |
34
+
35
+ **对外方法(通过 ref 或 registerMap 获取实例后调用)**
36
+
37
+ | 方法 | 说明 |
38
+ |------|------|
39
+ | setData(data) | 仅当配置中存在 `dataProcessScript` 时执行该脚本并传入 data;**不修改组件自身任何数据**。无脚本时什么都不做。返回 Promise。 |
40
+
41
+ **配置项(在 REPORT_GRID 的 JSON 配置中)**
42
+
43
+ | 字段 | 说明 |
44
+ |------|------|
45
+ | dataProcessScript | 可选。可执行函数字符串,签名为 `function(data) { ... }`。setData(data) 被调用时执行,传入 data;脚本内可自行处理数据(如写接口、改全局状态等)。`this` 为 XReport 实例。不写则 setData 调用无效。 |
46
+
34
47
  ## 例子1
35
48
  ----
36
49
  ```vue
@@ -28,6 +28,7 @@
28
28
  v-on="getEventHandlers(tab,index)"
29
29
  @hook:mounted="(h)=>onComponentMounted(h,tab,index)"
30
30
  :config-name="tab.slotConfig"
31
+ :slot-ref="tab.slotRef"
31
32
  :env="env"
32
33
  v-bind="compProp"
33
34
  :extra-data="extraData"
@@ -59,6 +60,7 @@ export default {
59
60
  setGlobalData: { default: false },
60
61
  getGlobalData: { default: false },
61
62
  generalFunction: { default: false },
63
+ registerComponent: { default: false },
62
64
  },
63
65
  provide () {
64
66
  return {
@@ -387,6 +389,14 @@ export default {
387
389
  return handlers
388
390
  },
389
391
  onComponentMounted (h, tab, index) {
392
+ // 与 Cover 普通子组件一致:带 slotRef 的 pane 由父级(XTab)注册到当前 Cover
393
+ if (tab.slotRef && this.registerComponent) {
394
+ const paneRef = this.$refs[`tab_com_${tab.slotType}_${index}`]
395
+ const component = Array.isArray(paneRef) ? paneRef[0] : paneRef
396
+ if (component) {
397
+ this.registerComponent(tab.slotRef, component)
398
+ }
399
+ }
390
400
  if (tab.slotType === 'x-add-native-form') {
391
401
  // 建议表单需要主动调用初始化方法
392
402
  getConfigByName(tab.slotConfig, tab.serviceName, async (res) => {
@@ -1,7 +1,12 @@
1
1
  <template>
2
2
  <div class="x-checkbox-container" :class="wrapperClassObject()">
3
3
  <a-checkbox-group v-model="innerValue" @change="onChange" class="x-checkbox-group">
4
- <div v-for="item in data" :key="item.value" class="x-checkbox-item-container">
4
+ <div
5
+ v-for="(item, idx) in data"
6
+ :key="'xcb-' + idx"
7
+ class="x-checkbox-item-container"
8
+ :class="{ 'x-checkbox-item-selected': isOptionSelected(item) }"
9
+ >
5
10
  <a-checkbox :value="item.value" class="x-checkbox-item">
6
11
  {{ item.label }}
7
12
  </a-checkbox>
@@ -25,6 +30,11 @@ export default {
25
30
  type: Object,
26
31
  default: null
27
32
  },
33
+ /** 选项列表(每项 { label, value });传入时优先使用,不走 queryParamsName 配置 */
34
+ options: {
35
+ type: Array,
36
+ default: null
37
+ },
28
38
  value: {
29
39
  type: Array,
30
40
  default: () => []
@@ -38,26 +48,49 @@ export default {
38
48
  }
39
49
  },
40
50
  created () {
41
- this.getData(this.queryParamsName)
51
+ if (this.options != null && Array.isArray(this.options)) {
52
+ this.useOptionsProp()
53
+ } else {
54
+ this.getData(this.queryParamsName)
55
+ }
42
56
  },
43
57
  watch: {
44
58
  value: {
45
59
  handler (val) {
46
- this.innerValue = val
60
+ this.innerValue = Array.isArray(val) ? this.resolveValuesToOptions(val) : (val || [])
61
+ },
62
+ deep: true
63
+ },
64
+ options: {
65
+ handler (val) {
66
+ if (val != null && Array.isArray(val)) {
67
+ this.data = val
68
+ this.innerValue = this.resolveValuesToOptions(this.value)
69
+ }
47
70
  },
48
71
  deep: true
49
72
  }
50
73
  },
51
74
  emits: ['change', 'init'],
52
75
  methods: {
76
+ /** 使用 options 参数作为数据源(不请求配置) */
77
+ useOptionsProp () {
78
+ this.data = this.options || []
79
+ this.innerValue = Array.isArray(this.value) && this.value.length > 0
80
+ ? this.resolveValuesToOptions(this.value)
81
+ : []
82
+ this.$emit('init', { config: null, options: this.data, value: this.innerValue })
83
+ },
53
84
  async getData (data) {
54
85
  getConfigByName(data, 'af-his', res => {
55
86
  // 1. 加载选项
56
87
  if (res.checkbox && Array.isArray(res.checkbox)) {
57
88
  this.data = res.checkbox
58
- // 2. 初始化默认值(优先级: 配置defaultValue > 空数组)
59
- if (res.defaultValue !== undefined && Array.isArray(res.defaultValue)) {
60
- this.innerValue = res.defaultValue
89
+ // 2. 初始化默认值(对象时匹配到选项引用,保证 v-model 与选项一致)
90
+ if (this.value !== undefined && Array.isArray(this.value) && this.value.length > 0) {
91
+ this.innerValue = this.resolveValuesToOptions(this.value)
92
+ } else if (res.defaultValue !== undefined && Array.isArray(res.defaultValue)) {
93
+ this.innerValue = this.resolveValuesToOptions(res.defaultValue)
61
94
  }
62
95
  // 3. 触发初始化事件
63
96
  this.$emit('init', {
@@ -70,6 +103,31 @@ export default {
70
103
  }
71
104
  })
72
105
  },
106
+ /** 将外部 value/defaultValue 数组解析为选项引用(对象项用 deepEqual 匹配) */
107
+ resolveValuesToOptions (arr) {
108
+ if (!Array.isArray(arr) || this.data.length === 0) return arr
109
+ return arr.map(v => {
110
+ const opt = this.data.find(item => this.deepEqual(item.value, v))
111
+ return opt ? opt.value : v
112
+ })
113
+ },
114
+ /** 判断当前选项是否被选中(支持对象 value) */
115
+ isOptionSelected (item) {
116
+ if (this.innerValue.some(v => v === item.value)) return true
117
+ if (typeof item.value === 'object' && item.value !== null) {
118
+ return this.innerValue.some(v => typeof v === 'object' && v !== null && this.deepEqual(v, item.value))
119
+ }
120
+ return false
121
+ },
122
+ /** 深度相等(用于对象 value 比较与匹配) */
123
+ deepEqual (a, b) {
124
+ if (a === b) return true
125
+ if (a == null || b == null || typeof a !== 'object' || typeof b !== 'object') return false
126
+ const keysA = Object.keys(a)
127
+ const keysB = Object.keys(b)
128
+ if (keysA.length !== keysB.length) return false
129
+ return keysA.every(key => keysB.includes(key) && this.deepEqual(a[key], b[key]))
130
+ },
73
131
  wrapperClassObject () {
74
132
  const attrs = this.$attrs || {}
75
133
  const classes = {}
@@ -1,7 +1,7 @@
1
1
  <template>
2
- <!-- 列表卡片模式 listMode: card -->
3
- <div class="x-list-wrapper" :class="wrapperClassObject" v-if="listMode" ref="listRef" @scroll="handleInfiniteOnLoad">
4
- <a-list :grid="{ gutter: 16, xs: 1, sm: 2, md: 2, lg: 3, xl: 3, xxl: 4 }" :data-source="localData">
2
+ <!-- 列表卡片模式 listMode: card,高度由 CSS 变量 --x-list-card-height 控制,默认来自配置 cardHeight -->
3
+ <div class="x-list-wrapper" :class="wrapperClassObject" :style="cardModeWrapperStyle" v-if="listMode" ref="listRef" @scroll="handleInfiniteOnLoad">
4
+ <a-list :grid="listGrid" :data-source="localData">
5
5
  <a-list-item slot="renderItem" slot-scope="item, index">
6
6
  <div
7
7
  class="card-a-col"
@@ -10,7 +10,7 @@
10
10
  @click="handleCardClick(index)"
11
11
  >
12
12
  <a-row class="card-row">
13
- <a-col class="id-a-col" :span="4" v-for="(detail, idx) in item.filter(d => d.label == label)" :key="idx">
13
+ <a-col v-if="showIdColumn" class="id-a-col" :span="4" v-for="(detail, idx) in item.filter(d => d.label == label)" :key="idx">
14
14
  {{ detail.value }}
15
15
  <div class="gender-icon" v-if="getHasPatient(item) && getGender(item)">
16
16
  <img
@@ -20,7 +20,7 @@
20
20
  />
21
21
  </div>
22
22
  </a-col>
23
- <a-col :span="20" class="id-a-col-2">
23
+ <a-col :span="showIdColumn ? 20 : 24" class="id-a-col-2">
24
24
  <template v-for="(detail, idx) in item">
25
25
  <div :key="`title_${idx}`" class="title-row" v-if="detail.type == 'title'">
26
26
  <a-tooltip :title="detail.value" placement="topLeft">
@@ -71,6 +71,19 @@
71
71
  </a-tooltip>
72
72
  <a-date-picker @change="onChange" />
73
73
  </span>
74
+ <!-- Option 区块:直接传数据对象;字段名由外部配置 JS 在每条数据上设置(questionLabelField/optionsField/multipleField/selectedField),选择后写入该对象的 selectedField 字段 -->
75
+ <XQuestionOptions
76
+ :key="`options_${index}_${idx}`"
77
+ v-else-if="detail.type == 'options'"
78
+ :question-data="detail"
79
+ :question-label-field="detail.questionLabelField"
80
+ :options-field="detail.optionsField"
81
+ :multiple-field="detail.multipleField"
82
+ :selected-field="detail.selectedField"
83
+ :name="'opt_'+index+'_'+idx"
84
+ :value="getDetailSelected(detail)"
85
+ @change="payload => $emit('optionChange', payload)"
86
+ />
74
87
  <a-tooltip
75
88
  :key="`${idx}-tooltip`"
76
89
  :title="`${detail.label}:${detail.value}`"
@@ -113,17 +126,17 @@
113
126
  slot-scope="item, index"
114
127
  class="list-item"
115
128
  @click="handleClick(index)"
116
- @mouseenter="enableHoverOptions && handleMouseEnter(index)"
129
+ @mouseenter="effectiveEnableHoverOptions && handleMouseEnter(index)"
117
130
  @mouseleave="handleMouseLeave"
118
131
  :class="{
119
- 'hover-active': enableHoverOptions && hoveredIndex === index,
132
+ 'hover-active': effectiveEnableHoverOptions && hoveredIndex === index,
120
133
  'selected-active': enableSelectRow && currentSelectedIndex === index
121
134
  }"
122
135
  >
123
136
  <i v-if="icon" class="icon-menu" :style="getIconStyle(item)"></i>
124
137
  <span
125
138
  class="item-text"
126
- :class="{ 'text-truncated': enableHoverOptions && hoveredIndex === index }"
139
+ :class="{ 'text-truncated': effectiveEnableHoverOptions && hoveredIndex === index }"
127
140
  :style="getTextStyle(index)"
128
141
  >
129
142
  {{ item.number }} {{ item.name }}
@@ -137,13 +150,13 @@
137
150
  :class="['confirm-btn', buttonMode ? 'hover-btn' : '']"
138
151
  @click.stop="click(index, idx)"
139
152
  >
140
- <span :class="{ 'hover-active': enableHoverOptions && hoveredIndex === index }">{{ name }}</span>
153
+ <span :class="{ 'hover-active': effectiveEnableHoverOptions && hoveredIndex === index }">{{ name }}</span>
141
154
  </a-button>
142
155
  </div>
143
156
 
144
157
  <!-- 悬浮选项框 -->
145
158
  <div
146
- v-show="enableHoverOptions && hoveredIndex === index"
159
+ v-show="effectiveEnableHoverOptions && hoveredIndex === index"
147
160
  class="hover-options"
148
161
  @mouseenter="handleOptionsEnter"
149
162
  @mouseleave="handleOptionsLeave"
@@ -177,11 +190,13 @@ export default {
177
190
  XTimeSelect: () => import('@vue2-client/base-client/components/his/XTimeSelect/XTimeSelect.vue'),
178
191
  XCheckbox: () => import('@vue2-client/base-client/components/his/XCheckbox/XCheckbox.vue'),
179
192
  XTitle: () => import('@vue2-client/base-client/components/his/XTitle/XTitle.vue'),
180
- XSelect: () => import('@vue2-client/base-client/components/his/XSelect/XSelect.vue')
193
+ XSelect: () => import('@vue2-client/base-client/components/his/XSelect/XSelect.vue'),
194
+ XQuestionOptions: () => import('@vue2-client/base-client/components/his/XQuestionOptions/XQuestionOptions.vue')
181
195
  },
182
196
  props: {
197
+ // 配置名(字符串)或配置对象;与 XTable 等一致,常为字符串如 "aiConsultationSuggestionListConfig"
183
198
  queryParamsName: {
184
- type: Object,
199
+ type: [String, Object],
185
200
  default: null
186
201
  },
187
202
  fixedQueryForm: {
@@ -233,6 +248,12 @@ export default {
233
248
  icon: false,
234
249
  buttonNames: [],
235
250
  listMode: undefined, // 列表模式
251
+ // 卡片栅格:默认每行多列;可通过配置 grid 覆盖,如 { xs:1, sm:1, md:1, lg:1, xl:1, xxl:1 } 每行 1 个
252
+ listGrid: { gutter: 16, xs: 1, sm: 2, md: 2, lg: 3, xl: 3, xxl: 4 },
253
+ // 卡片高度,由配置 cardHeight 指定,供 CSS 变量 --x-list-card-height 使用,默认 297px
254
+ listCardHeight: '297px',
255
+ // 是否显示左侧序号列(label 匹配的列);配置 showIdColumn: false 可隐藏
256
+ showIdColumn: true,
236
257
  buttonMode: true,
237
258
  hoveredIndex: -1, // 当前悬浮的索引
238
259
  isOptionsHovered: false, // 悬浮选项框是否悬浮
@@ -252,8 +273,10 @@ export default {
252
273
  // 下拉配置
253
274
  internalTitleOptions: [],
254
275
  titleValueByIndex: {},
276
+ // 配置加载后的悬浮选项开关(不直接改 prop)
277
+ internalEnableHoverOptions: undefined,
255
278
  // 选择请求的轻量防抖
256
- selectDebounceTimer: null
279
+ selectDebounceTimer: null,
257
280
  }
258
281
  },
259
282
  inject: ['getComponentByName'],
@@ -308,6 +331,12 @@ export default {
308
331
  currentSelectedIndex() {
309
332
  return typeof this.selectedIndex === 'number' ? this.selectedIndex : this.localSelectedIndex
310
333
  },
334
+ // 悬浮选项:优先使用配置加载结果,否则用 prop
335
+ effectiveEnableHoverOptions() {
336
+ return this.internalEnableHoverOptions !== undefined && this.internalEnableHoverOptions !== null
337
+ ? this.internalEnableHoverOptions
338
+ : this.enableHoverOptions
339
+ },
311
340
  enableSelectRow() {
312
341
  const a = this.$attrs || {}
313
342
  const val = a.enableSelection
@@ -320,6 +349,11 @@ export default {
320
349
  this.$emit(eventName, ...payload)
321
350
  }
322
351
  }
352
+ },
353
+ // 卡片模式下挂载 CSS 变量 --x-list-card-height,供 .card-a-col 使用
354
+ cardModeWrapperStyle() {
355
+ if (!this.listMode) return undefined
356
+ return { '--x-list-card-height': this.listCardHeight }
323
357
  }
324
358
  },
325
359
  methods: {
@@ -359,6 +393,32 @@ export default {
359
393
  },
360
394
  async getData(config, param) {
361
395
  const that = this
396
+ const applyResult = result => {
397
+ const list = Array.isArray(result) ? result : []
398
+ that.data = list
399
+ if (that.nowPage === 0) {
400
+ that.localData = list
401
+ }
402
+ if (Array.isArray(that.localData)) {
403
+ that.localData.forEach((row, idx) => {
404
+ if (that.titleValueByIndex[idx] !== undefined) return
405
+ if (Array.isArray(row)) {
406
+ const title = row.find(d => d && d.type === 'title')
407
+ if (title && title.titleRightValue !== undefined) {
408
+ that.$set(that.titleValueByIndex, idx, title.titleRightValue)
409
+ return
410
+ }
411
+ }
412
+ const def =
413
+ that.internalTitleOptions && that.internalTitleOptions.length
414
+ ? that.internalTitleOptions[0].value !== undefined
415
+ ? that.internalTitleOptions[0].value
416
+ : that.internalTitleOptions[0]
417
+ : undefined
418
+ if (def !== undefined) that.$set(that.titleValueByIndex, idx, def)
419
+ })
420
+ }
421
+ }
362
422
  getConfigByName(config, 'af-his', async res => {
363
423
  that.listMode = (await res.listMode) == 'card'
364
424
  that.logicName = await res.data
@@ -368,47 +428,32 @@ export default {
368
428
  that.buttonNames = (await res.buttonNames) || [] // 按钮文本
369
429
  that.buttonMode = (await res.buttonMode) || false // 按钮模式
370
430
  that.cardButtonsVisible = (await res.cardButtonsVisible) || false // 卡片按钮
371
- // 标题下拉:从配置拿 options: [{label,value,color}]
431
+ const grid = await res.grid
432
+ if (grid && typeof grid === 'object') {
433
+ that.listGrid = { gutter: 16, ...grid }
434
+ }
435
+ const cardH = await res.cardHeight
436
+ if (cardH != null) {
437
+ that.listCardHeight = typeof cardH === 'number' ? cardH + 'px' : String(cardH)
438
+ }
439
+ if (res.showIdColumn === false) that.showIdColumn = false
372
440
  if (Array.isArray(res.titleOptions)) {
373
441
  this.internalTitleOptions = res.titleOptions
374
442
  if (res.titleDefaultValue !== undefined && Array.isArray(this.localData)) {
375
- // 初始化每个卡片的默认值
376
443
  this.titleValueByIndex = {}
377
444
  }
378
445
  }
379
- this.enableHoverOptions = (await res.enableHoverOptions) || false // 悬浮选项框
380
- if (this.enableHoverOptions) {
446
+ const enableHover = (await res.enableHoverOptions) || false // 悬浮选项框
447
+ this.internalEnableHoverOptions = enableHover
448
+ if (enableHover) {
381
449
  this.select_options = await res.select_options
382
450
  }
383
451
  if (that.listMode) {
452
+ param = param || {}
384
453
  param.condition = `Limit ${that.nowPage}, ${that.pageSize}`
385
454
  }
386
455
  runLogic(res.data, param, 'af-his').then(result => {
387
- that.data = result
388
- if (that.nowPage === 0) {
389
- this.localData = result
390
- }
391
- // 初始化每张卡片的 title 选中值:优先读取模板 titleRightValue,否则使用配置默认或第一项
392
- if (Array.isArray(this.localData)) {
393
- this.localData.forEach((row, idx) => {
394
- if (this.titleValueByIndex[idx] !== undefined) return
395
- if (Array.isArray(row)) {
396
- const title = row.find(d => d && d.type === 'title')
397
- if (title && title.titleRightValue !== undefined) {
398
- this.$set(this.titleValueByIndex, idx, title.titleRightValue)
399
- return
400
- }
401
- }
402
- // fallback:配置默认或第一项
403
- const def =
404
- this.internalTitleOptions && this.internalTitleOptions.length
405
- ? this.internalTitleOptions[0].value !== undefined
406
- ? this.internalTitleOptions[0].value
407
- : this.internalTitleOptions[0]
408
- : undefined
409
- if (def !== undefined) this.$set(this.titleValueByIndex, idx, def)
410
- })
411
- }
456
+ applyResult(result)
412
457
  })
413
458
  })
414
459
  },
@@ -429,6 +474,26 @@ export default {
429
474
  if (!title || typeof title.hasPatient === 'undefined') return true
430
475
  return !!title.hasPatient
431
476
  },
477
+ /** 从某条 options 单元格上按 selectedField 读取当前选中值(供 :value 绑定) */
478
+ getDetailSelected(detail) {
479
+ if (!detail || detail.selectedField == null) return undefined
480
+ return detail[detail.selectedField]
481
+ },
482
+ /**
483
+ * 对外获取所有选项区块(即原始问题对象);选择结果已写在各自对象的 selectedField 指定字段上。
484
+ * @returns {Array<Object>} 每个元素为 type==='options' 的 detail 对象
485
+ */
486
+ getOptionValues() {
487
+ const out = []
488
+ if (!Array.isArray(this.localData)) return out
489
+ this.localData.forEach(row => {
490
+ if (!Array.isArray(row)) return
491
+ row.forEach(detail => {
492
+ if (detail && detail.type === 'options') out.push(detail)
493
+ })
494
+ })
495
+ return out
496
+ },
432
497
  // 标题右侧下拉选择
433
498
  handleTitleSelectChange(val, index) {
434
499
  // 重复值拦截:若值未变化则直接返回
@@ -515,6 +580,34 @@ export default {
515
580
  refreshList(param) {
516
581
  this.getData(this.queryParamsName, param)
517
582
  },
583
+ /**
584
+ * 外部可调用:直接设置列表数据(不经过 data 配置 / runLogic)。
585
+ * @param {Array} list - 列表数据,格式与当前 listMode 一致:默认模式 [{ number, name }, ...],卡片模式二维数组
586
+ */
587
+ setData(list) {
588
+ const arr = Array.isArray(list) ? list : []
589
+ this.data = arr
590
+ this.localData = arr
591
+ if (Array.isArray(this.localData)) {
592
+ this.localData.forEach((row, idx) => {
593
+ if (this.titleValueByIndex[idx] !== undefined) return
594
+ if (Array.isArray(row)) {
595
+ const title = row.find(d => d && d.type === 'title')
596
+ if (title && title.titleRightValue !== undefined) {
597
+ this.$set(this.titleValueByIndex, idx, title.titleRightValue)
598
+ return
599
+ }
600
+ }
601
+ const def =
602
+ this.internalTitleOptions && this.internalTitleOptions.length
603
+ ? this.internalTitleOptions[0].value !== undefined
604
+ ? this.internalTitleOptions[0].value
605
+ : this.internalTitleOptions[0]
606
+ : undefined
607
+ if (def !== undefined) this.$set(this.titleValueByIndex, idx, def)
608
+ })
609
+ }
610
+ },
518
611
  click(index, buttonIndex) {
519
612
  this.$emit('click', { data: this.data[index], name: this.buttonNames[buttonIndex] })
520
613
  },
@@ -904,7 +997,7 @@ export default {
904
997
  }
905
998
  .card-a-col {
906
999
  background-color: rgba(247, 249, 252);
907
- height: 297px;
1000
+ height: var(--x-list-card-height, 297px);
908
1001
  width: auto;
909
1002
  border-radius: 6px;
910
1003
  border: 1px solid #e5e9f0;
@@ -0,0 +1,193 @@
1
+ <template>
2
+ <div class="x-question-options">
3
+ <x-radio
4
+ v-if="!effectiveMultiple"
5
+ :options="mappedOptions"
6
+ :name="name"
7
+ :value="innerValue"
8
+ no-default-first
9
+ class="x-question-options-inner"
10
+ @change="onSingleChange"
11
+ />
12
+ <x-checkbox
13
+ v-else
14
+ :options="mappedOptions"
15
+ :value="innerValueArray"
16
+ class="x-question-options-inner"
17
+ @change="onMultipleChange"
18
+ />
19
+ </div>
20
+ </template>
21
+
22
+ <script>
23
+ import XRadio from '@vue2-client/base-client/components/his/XRadio/XRadio.vue'
24
+ import XCheckbox from '@vue2-client/base-client/components/his/XCheckbox/XCheckbox.vue'
25
+
26
+ export default {
27
+ name: 'XQuestionOptions',
28
+ components: { XRadio, XCheckbox },
29
+ props: {
30
+ /** 原始问题的 JSON 数据;传入时与 questionLabelField/optionsField/multipleField 一起使用,组件内部做转换并 emit 完整 { question, selected } */
31
+ questionData: {
32
+ type: Object,
33
+ default: null
34
+ },
35
+ /** 从原始问题中取「问题文案」的字段名 */
36
+ questionLabelField: {
37
+ type: String,
38
+ default: 'label'
39
+ },
40
+ /** 从原始问题中取「是否多选」的字段名 */
41
+ multipleField: {
42
+ type: String,
43
+ default: 'multiple'
44
+ },
45
+ /** 从原始问题中取「选项列表」的字段名 */
46
+ optionsField: {
47
+ type: String,
48
+ default: 'options'
49
+ },
50
+ /** 选择结果写入原始问题对象的字段名;传入时选择后直接修改 questionData[selectedField],后端数据无需转换 */
51
+ selectedField: {
52
+ type: String,
53
+ default: 'selected'
54
+ },
55
+ /** 选项列表(与 questionData 二选一,兼容旧用法) */
56
+ options: {
57
+ type: Array,
58
+ default: () => []
59
+ },
60
+ /** 是否多选(与 questionData 二选一) */
61
+ multiple: {
62
+ type: Boolean,
63
+ default: false
64
+ },
65
+ /** 当前选中值(v-model);单选为单值,多选为值数组 */
66
+ value: {
67
+ type: [String, Number, Array, Object],
68
+ default: undefined
69
+ },
70
+ name: {
71
+ type: String,
72
+ default: ''
73
+ }
74
+ },
75
+ data () {
76
+ return {
77
+ innerValue: undefined,
78
+ innerValueArray: []
79
+ }
80
+ },
81
+ computed: {
82
+ /** 是否使用「原始问题」模式(有 questionData 时从其中解析) */
83
+ useQuestionData () {
84
+ return this.questionData != null && typeof this.questionData === 'object'
85
+ },
86
+ /** 实际选项列表:questionData 模式取 questionData[optionsField],否则用 options */
87
+ effectiveOptions () {
88
+ if (this.useQuestionData) {
89
+ const arr = this.questionData[this.optionsField]
90
+ return Array.isArray(arr) ? arr : []
91
+ }
92
+ return this.options || []
93
+ },
94
+ /** 实际是否多选 */
95
+ effectiveMultiple () {
96
+ if (this.useQuestionData) return !!this.questionData[this.multipleField]
97
+ return this.multiple
98
+ },
99
+ mappedOptions () {
100
+ return this.effectiveOptions.map(opt => ({
101
+ label: this.optLabel(opt),
102
+ value: this.optVal(opt)
103
+ }))
104
+ }
105
+ },
106
+ watch: {
107
+ value: {
108
+ handler (val) {
109
+ if (this.effectiveMultiple) {
110
+ this.innerValueArray = Array.isArray(val) ? [...val] : []
111
+ } else {
112
+ this.innerValue = val
113
+ }
114
+ },
115
+ immediate: true
116
+ }
117
+ },
118
+ emits: ['input', 'change'],
119
+ methods: {
120
+ optVal (opt) {
121
+ return opt.value !== undefined ? opt.value : opt.label
122
+ },
123
+ optLabel (opt) {
124
+ if (opt.label !== undefined && opt.label !== null) return opt.label
125
+ if (opt.value != null && typeof opt.value === 'object') {
126
+ return (opt.value.text || opt.value.label || opt.value.name) || JSON.stringify(opt.value)
127
+ }
128
+ return opt.value
129
+ },
130
+ /** 发出变更:questionData 模式下写入 questionData[selectedField] 并 emit,否则仅 emit */
131
+ emitChange (selected) {
132
+ this.$emit('input', selected)
133
+ if (this.useQuestionData) {
134
+ this.$set(this.questionData, this.selectedField, selected)
135
+ this.$emit('change', { question: this.questionData, selected })
136
+ } else {
137
+ this.$emit('change', selected)
138
+ }
139
+ },
140
+ onSingleChange (val) {
141
+ this.innerValue = val
142
+ this.emitChange(val)
143
+ },
144
+ onMultipleChange (vals) {
145
+ this.innerValueArray = Array.isArray(vals) ? [...vals] : []
146
+ this.emitChange(this.innerValueArray)
147
+ }
148
+ }
149
+ }
150
+ </script>
151
+
152
+ <style scoped>
153
+ .x-question-options {
154
+ width: 100%;
155
+ margin-top: 8px;
156
+ }
157
+ .x-question-options-inner :deep(.x-radio-group),
158
+ .x-question-options-inner :deep(.x-checkbox-group) {
159
+ display: flex;
160
+ flex-wrap: wrap;
161
+ gap: 8px;
162
+ width: 100%;
163
+ }
164
+ .x-question-options-inner :deep(.x-radio-item-container),
165
+ .x-question-options-inner :deep(.x-checkbox-item-container) {
166
+ min-width: 120px;
167
+ padding: 8px 12px;
168
+ border: 1px solid #d9d9d9;
169
+ border-radius: 6px;
170
+ background: #fafafa;
171
+ cursor: pointer;
172
+ transition: border-color 0.2s, background 0.2s;
173
+ }
174
+ .x-question-options-inner :deep(.x-radio-item-container:hover),
175
+ .x-question-options-inner :deep(.x-checkbox-item-container:hover) {
176
+ border-color: #1890ff;
177
+ background: #e6f7ff;
178
+ }
179
+ .x-question-options-inner :deep(.x-radio-item-container.x-radio-item-selected),
180
+ .x-question-options-inner :deep(.x-checkbox-item-container.x-checkbox-item-selected) {
181
+ border-color: #1890ff;
182
+ background: #e6f7ff;
183
+ }
184
+ .x-question-options-inner :deep(.x-radio-item-container .ant-radio-wrapper),
185
+ .x-question-options-inner :deep(.x-checkbox-item-container .ant-checkbox-wrapper) {
186
+ margin-right: 0;
187
+ }
188
+ .x-question-options-inner :deep(.ant-radio-wrapper),
189
+ .x-question-options-inner :deep(.ant-checkbox-wrapper) {
190
+ font-size: 13px;
191
+ color: #333;
192
+ }
193
+ </style>
@@ -0,0 +1,2 @@
1
+ import XQuestionOptions from './XQuestionOptions.vue'
2
+ export default XQuestionOptions
@@ -0,0 +1,66 @@
1
+ # XQuestionOptions
2
+
3
+ 题目选项组件:根据「是否多选」展示单选或复选框,选项以卡片样式排列。支持两种用法:**原始问题模式**(传入 `questionData` + 字段名,组件内部转换并上报完整「问题 + 选中」对象)、**扁平模式**(传入 `options` + `multiple`,兼容旧用法)。
4
+
5
+ ## 何时使用
6
+
7
+ - 列表/卡片内需要「一道题 + 若干选项」的交互(如 AI 问诊推荐问题)
8
+ - 希望由组件持有「原始问题 JSON」并在变更时上报完整 `{ question, selected }`,父组件(如 XList)只做按问题记录
9
+
10
+ ## 引用方式
11
+
12
+ ```javascript
13
+ import XQuestionOptions from '@vue2-client/base-client/components/his/XQuestionOptions/XQuestionOptions.vue'
14
+ ```
15
+
16
+ ## 使用示例
17
+
18
+ **原始问题模式(推荐,用于 XList)**:传入原始问题 JSON 与字段名,变更时 `@change` 收到 `{ question, selected }`。
19
+
20
+ ```html
21
+ <x-question-options
22
+ :question-data="serverQuestion"
23
+ question-label-field="label"
24
+ options-field="options"
25
+ multiple-field="multiple"
26
+ v-model="selected"
27
+ @change="onAnswer"
28
+ />
29
+ ```
30
+
31
+ **扁平模式(兼容)**:直接传选项与是否多选。
32
+
33
+ ```html
34
+ <x-question-options
35
+ :options="[{ label: '有', value: '有发热' }, { label: '无', value: '无发热' }]"
36
+ :multiple="false"
37
+ v-model="selectedValue"
38
+ @change="onChange"
39
+ />
40
+ ```
41
+
42
+ ## API
43
+
44
+ | 参数 | 说明 | 类型 | 默认值 |
45
+ |----------------------|------------------------------------------------|---------|----------|
46
+ | questionData | 原始问题的 JSON;与下方字段名一起使用时组件内部转换 | Object | null |
47
+ | questionLabelField | 从问题对象中取「问题文案」的字段名(由外部配置 JS 在每条数据上设置) | String | - |
48
+ | multipleField | 从问题对象中取「是否多选」的字段名(同上) | String | - |
49
+ | optionsField | 从问题对象中取「选项列表」的字段名(同上) | String | - |
50
+ | selectedField | 选择结果写入问题对象的字段名(直接改 questionData[selectedField]);与上列字段名均由外部配置 JS 在每条数据上设置,XList 不设默认、不从后端 List 取 | String | - |
51
+ | options | 选项列表(与 questionData 二选一) | Array | [] |
52
+ | multiple | 是否多选(与 questionData 二选一) | Boolean | false |
53
+ | value | 当前选中值(v-model) | Any/Array | - |
54
+ | name | radio-group 的 name | String | '' |
55
+
56
+ ### 事件
57
+
58
+ | 事件名 | 说明 | 回调参数 |
59
+ |---------|------------|----------|
60
+ | input | v-model 用 | 选中值(单选为单值,多选为数组) |
61
+ | change | 选项变化 | **原始问题模式**:`{ question, selected }`;**扁平模式**:`selected` |
62
+
63
+ ### 数据约定
64
+
65
+ - **原始问题模式**:`questionData` 上应有 `[optionsField]`(选项数组)、`[multipleField]`(是否多选)、可选 `[questionLabelField]`(问题文案)。选项项可为 `{ label, value }` 或仅 `value` 为对象(展示从 value.text/label/name 取)。
66
+ - **扁平模式**:每项可为 `{ label, value }`;对象 value 时同上,支持深度相等比较。
@@ -5,11 +5,12 @@
5
5
  v-if="config.buttonMode"
6
6
  v-model="innerValue"
7
7
  @change="onChange"
8
+ :name="name"
8
9
  :class="['x-radio-group', 'x-radio-group-button', { 'x-radio-group-left': config.alignLeft }]"
9
10
  button-style="solid">
10
11
  <a-radio-button
11
- v-for="item in data"
12
- :key="item.value"
12
+ v-for="(item, idx) in data"
13
+ :key="'xradio-' + idx"
13
14
  :value="item.value"
14
15
  class="x-radio-button-item"
15
16
  >
@@ -22,14 +23,16 @@
22
23
  v-else
23
24
  v-model="innerValue"
24
25
  @change="onChange"
26
+ :name="name"
25
27
  :class="['x-radio-group', { 'x-radio-group-left': config.alignLeft }]">
26
28
  <div
27
- v-for="item in data"
28
- :key="item.value"
29
+ v-for="(item, idx) in data"
30
+ :key="'xradio-' + idx"
29
31
  class="x-radio-item-container"
30
32
  :class="{
31
33
  'x-radio-item-bordered': config.showBorder,
32
- 'x-radio-item-highlight': config.highlightBorder
34
+ 'x-radio-item-highlight': config.highlightBorder,
35
+ 'x-radio-item-selected': isOptionSelected(item)
33
36
  }"
34
37
  :style="getHighlightBorderStyle(item)">
35
38
  <div class="x-radio-item-wrapper">
@@ -45,7 +48,7 @@
45
48
  <div
46
49
  v-if="isTabStyle"
47
50
  class="x-radio-tab-icon"
48
- :class="{ 'x-radio-tab-icon-active': innerValue === item.value }"
51
+ :class="{ 'x-radio-tab-icon-active': isOptionSelected(item) }"
49
52
  ></div>
50
53
  <a-radio
51
54
  :value="item.value"
@@ -65,7 +68,7 @@
65
68
  }"
66
69
  ></div>
67
70
  <div
68
- v-if="isTabStyle && innerValue === item.value"
71
+ v-if="isTabStyle && isOptionSelected(item)"
69
72
  class="x-radio-tab-underline"
70
73
  ></div>
71
74
  </div>
@@ -81,11 +84,26 @@
81
84
  inject: ['getComponentByName'],
82
85
  props: {
83
86
  queryParamsName: {
84
- type: Object,
87
+ type: [String, Object],
85
88
  default: null
86
89
  },
90
+ /** 选项列表(每项 { label, value });传入时优先使用,不走 queryParamsName 配置 */
91
+ options: {
92
+ type: Array,
93
+ default: null
94
+ },
95
+ /** radio-group 的 name,用于区分同页多组 */
96
+ name: {
97
+ type: String,
98
+ default: ''
99
+ },
100
+ /** 为 true 时单选不默认选中第一项,便于区分用户是否真的选过 */
101
+ noDefaultFirst: {
102
+ type: Boolean,
103
+ default: false
104
+ },
87
105
  // eslint-disable-next-line vue/require-default-prop
88
- value: [String, Number]
106
+ value: [String, Number, Object]
89
107
  },
90
108
  data () {
91
109
  return {
@@ -103,11 +121,24 @@
103
121
  }
104
122
  },
105
123
  created () {
106
- this.getData(this.queryParamsName)
124
+ if (this.options != null && Array.isArray(this.options)) {
125
+ this.useOptionsProp()
126
+ } else {
127
+ this.getData(this.queryParamsName)
128
+ }
107
129
  },
108
130
  watch: {
109
131
  value (val) {
110
- this.innerValue = val
132
+ this.innerValue = this.resolveValueToOption(val)
133
+ },
134
+ options: {
135
+ handler (val) {
136
+ if (val != null && Array.isArray(val)) {
137
+ this.data = val
138
+ this.innerValue = this.resolveValueToOption(this.value)
139
+ }
140
+ },
141
+ deep: true
111
142
  }
112
143
  },
113
144
  computed: {
@@ -118,6 +149,18 @@
118
149
  },
119
150
  emits: ['change', 'init'],
120
151
  methods: {
152
+ /** 使用 options 参数作为数据源(不请求配置) */
153
+ useOptionsProp () {
154
+ this.data = this.options || []
155
+ if (this.value !== undefined && this.value !== null) {
156
+ this.innerValue = this.resolveValueToOption(this.value)
157
+ } else if (this.data.length > 0 && !this.noDefaultFirst) {
158
+ this.innerValue = this.data[0].value
159
+ } else {
160
+ this.innerValue = null
161
+ }
162
+ this.$emit('init', this.innerValue)
163
+ },
121
164
  async getData (data) {
122
165
  getConfigByName(data, 'af-his', res => {
123
166
  // 1. 先加载选项
@@ -148,15 +191,17 @@
148
191
  if (res.buttonMode !== undefined) {
149
192
  this.config.buttonMode = res.buttonMode
150
193
  }
151
- // 5. 初始化默认值(优先级: 配置defaultValue > 第一个选项)
194
+ // 5. 初始化默认值(优先级: 配置defaultValue > 第一个选项;noDefaultFirst 为 true 时不选第一项)
152
195
  if (this.value !== undefined && this.value !== null) {
153
- this.innerValue = this.value // 优先使用外部传入的 value
196
+ this.innerValue = this.resolveValueToOption(this.value)
154
197
  } else if (res.defaultValue !== undefined && res.defaultValue !== null) {
155
- // 使用配置中的defaultValue
156
- this.innerValue = res.defaultValue
157
- } else if (this.data.length > 0) {
158
- // 如果没有默认值但有选项,使用第一个选项
198
+ // 使用配置中的 defaultValue(对象时匹配到选项引用,保证 v-model 一致)
199
+ this.innerValue = this.resolveValueToOption(res.defaultValue)
200
+ } else if (this.data.length > 0 && !this.noDefaultFirst) {
201
+ // 如果没有默认值但有选项且允许默认第一项,使用第一个选项
159
202
  this.innerValue = this.data[0].value
203
+ } else {
204
+ this.innerValue = null
160
205
  }
161
206
  // 6. 触发初始化事件
162
207
  this.$emit('init', this.innerValue)
@@ -169,6 +214,30 @@
169
214
  this.innerValue = e.target.value
170
215
  this.$emit('change', e.target.value)
171
216
  },
217
+ /** 判断当前选项是否被选中(支持基本类型与对象 value) */
218
+ isOptionSelected (item) {
219
+ if (this.innerValue === item.value) return true
220
+ if (typeof this.innerValue === 'object' && this.innerValue !== null && typeof item.value === 'object' && item.value !== null) {
221
+ return this.deepEqual(this.innerValue, item.value)
222
+ }
223
+ return false
224
+ },
225
+ /** 将外部 value/defaultValue 解析为选项引用(对象时用 deepEqual 匹配,保证与 options 同引用) */
226
+ resolveValueToOption (val) {
227
+ if (val === undefined || val === null) return val
228
+ if (this.data.length === 0) return val
229
+ const opt = this.data.find(item => this.deepEqual(item.value, val))
230
+ return opt ? opt.value : val
231
+ },
232
+ /** 深度相等(用于对象 value 比较与匹配) */
233
+ deepEqual (a, b) {
234
+ if (a === b) return true
235
+ if (a == null || b == null || typeof a !== 'object' || typeof b !== 'object') return false
236
+ const keysA = Object.keys(a)
237
+ const keysB = Object.keys(b)
238
+ if (keysA.length !== keysB.length) return false
239
+ return keysA.every(key => keysB.includes(key) && this.deepEqual(a[key], b[key]))
240
+ },
172
241
  wrapperClassObject () {
173
242
  const attrs = this.$attrs || {}
174
243
  const classes = {}
@@ -186,14 +255,14 @@
186
255
  if (!this.config.highlightBorder) return {}
187
256
 
188
257
  // 如果当前选项被选中且有边框颜色配置,使用配置的颜色
189
- if (this.innerValue === item.value && item.borderColor) {
258
+ if (this.isOptionSelected(item) && item.borderColor) {
190
259
  return {
191
260
  borderColor: item.borderColor
192
261
  }
193
262
  }
194
263
 
195
264
  // 如果当前选项被选中且有indicatorColor配置,使用indicatorColor作为边框颜色
196
- if (this.innerValue === item.value && item.indicatorColor) {
265
+ if (this.isOptionSelected(item) && item.indicatorColor) {
197
266
  return {
198
267
  borderColor: item.indicatorColor
199
268
  }
@@ -24,7 +24,7 @@ export default {
24
24
  name: 'XSelect',
25
25
  props: {
26
26
  queryParamsName: {
27
- type: Object,
27
+ type: [String, Object],
28
28
  default: null
29
29
  }
30
30
  },