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 +1 -1
- package/src/base-client/components/common/XCollapse/XCollapse.vue +3 -3
- package/src/base-client/components/common/XDescriptions/XDescriptionsGroup.vue +30 -29
- package/src/base-client/components/common/XReportGrid/XReport.vue +26 -0
- package/src/base-client/components/common/XReportGrid/index.md +13 -0
- package/src/base-client/components/common/XTab/XTab.vue +10 -0
- package/src/base-client/components/his/XCheckbox/XCheckbox.vue +64 -6
- package/src/base-client/components/his/XList/XList.vue +136 -43
- package/src/base-client/components/his/XQuestionOptions/XQuestionOptions.vue +193 -0
- package/src/base-client/components/his/XQuestionOptions/index.js +2 -0
- package/src/base-client/components/his/XQuestionOptions/index.md +66 -0
- package/src/base-client/components/his/XRadio/XRadio.vue +88 -19
- package/src/base-client/components/his/XSelect/XSelect.vue +1 -1
package/package.json
CHANGED
|
@@ -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
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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 {
|
|
244
|
-
import {
|
|
245
|
-
import {
|
|
246
|
-
import {
|
|
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', {
|
|
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({
|
|
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 = {
|
|
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
|
|
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.
|
|
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.
|
|
59
|
-
if (
|
|
60
|
-
this.innerValue =
|
|
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="
|
|
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="
|
|
129
|
+
@mouseenter="effectiveEnableHoverOptions && handleMouseEnter(index)"
|
|
117
130
|
@mouseleave="handleMouseLeave"
|
|
118
131
|
:class="{
|
|
119
|
-
'hover-active':
|
|
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':
|
|
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':
|
|
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="
|
|
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
|
-
|
|
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
|
-
|
|
380
|
-
|
|
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
|
-
|
|
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,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="
|
|
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="
|
|
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':
|
|
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 &&
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
265
|
+
if (this.isOptionSelected(item) && item.indicatorColor) {
|
|
197
266
|
return {
|
|
198
267
|
borderColor: item.indicatorColor
|
|
199
268
|
}
|