feat(select): DoctorDeptSelect / DoctorTitleSelect 加 "其他" 逃生口 (虚拟项 + 下方 input + 自动回填识别)
This commit is contained in:
@@ -1,55 +1,161 @@
|
|||||||
<template>
|
<template>
|
||||||
<dict-select
|
<!--
|
||||||
v-model="selected"
|
公共科室下拉控件 (带"其他"逃生口)
|
||||||
:dict-type="DICT_TYPE.DEPARTMENT"
|
|
||||||
:value-field="valueField"
|
行为契约 (前端控件自管理, 父组件无感知):
|
||||||
:placeholder="placeholder"
|
- 标准字典项: 用户从下拉选 → v-model = 字典 label/value
|
||||||
:disabled="disabled"
|
- 选 "其他" (控件自己虚拟追加, 不来自后端字典): 下方出现 el-input → v-model = input 内容
|
||||||
:clearable="clearable"
|
- 回填: modelValue 不在字典里 → 自动按 "其他 + input" 显示, input 预填原值
|
||||||
:filterable="filterable"
|
|
||||||
:multiple="multiple"
|
用法 (与之前一样, 父组件不变):
|
||||||
class="doctor-dept-select"
|
<doctor-dept-select v-model="form.department" value-field="label" filterable placeholder="搜索科室" />
|
||||||
@change="handleChange"
|
-->
|
||||||
/>
|
<div class="doctor-other-select">
|
||||||
|
<el-select
|
||||||
|
v-model="selectedKey"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:disabled="disabled"
|
||||||
|
:clearable="clearable"
|
||||||
|
:filterable="filterable"
|
||||||
|
:loading="loading"
|
||||||
|
style="width: 100%"
|
||||||
|
@change="handleSelectChange"
|
||||||
|
@clear="handleClear"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="o in displayOptions"
|
||||||
|
:key="o.key"
|
||||||
|
:label="o.label"
|
||||||
|
:value="o.key"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
<el-input
|
||||||
|
v-if="showOtherInput"
|
||||||
|
v-model="otherText"
|
||||||
|
:placeholder="otherPlaceholder"
|
||||||
|
class="other-input"
|
||||||
|
maxlength="50"
|
||||||
|
@input="emitValue"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch } from 'vue'
|
import { ref, computed, watch, onMounted } from 'vue'
|
||||||
import DictSelect from '@/components/DictSelect.vue'
|
import { getActiveDict, DICT_TYPE } from '@/api/dict'
|
||||||
import { DICT_TYPE } from '@/api/dict'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 公共科室下拉控件 (医生/科室字典, 与 RuoYi sys_dept 区分)
|
|
||||||
* v-model 默认绑 ID (deptId), 也可改为绑 name 字符串
|
|
||||||
*
|
|
||||||
* 用法:
|
|
||||||
* <dept-select v-model="form.deptId" />
|
|
||||||
* <dept-select v-model="form.department" value-field="label" filterable placeholder="搜索科室" />
|
|
||||||
*/
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
modelValue: { type: [String, Number, Array], default: null },
|
modelValue: { type: [String, Number], default: null },
|
||||||
/** 'value' (deptId) 或 'label' (科室名称) */
|
/** 'value' (ID) 或 'label' (科室名称字符串) */
|
||||||
valueField: { type: String, default: 'value' },
|
valueField: { type: String, default: 'label' },
|
||||||
placeholder: { type: String, default: '请选择科室' },
|
placeholder: { type: String, default: '请选择科室' },
|
||||||
|
otherPlaceholder: { type: String, default: '请输入其他科室' },
|
||||||
disabled: { type: Boolean, default: false },
|
disabled: { type: Boolean, default: false },
|
||||||
clearable: { type: Boolean, default: true },
|
clearable: { type: Boolean, default: true },
|
||||||
filterable: { type: Boolean, default: false },
|
filterable: { type: Boolean, default: false }
|
||||||
multiple: { type: Boolean, default: false }
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue', 'change'])
|
const emit = defineEmits(['update:modelValue', 'change'])
|
||||||
|
|
||||||
const selected = ref(props.modelValue ?? (props.multiple ? [] : null))
|
// "其他" 虚拟项的内部 key — 不暴露给父组件, 仅用于区分 selectedKey 状态
|
||||||
watch(() => props.modelValue, v => {
|
const OTHER_KEY = '__other__'
|
||||||
selected.value = v ?? (props.multiple ? [] : null)
|
|
||||||
|
// 原始后端字典项 (label/value/sort/...)
|
||||||
|
const rawOptions = ref([])
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
// el-select 内部绑定的 key (可能 = 字典值, 可能 = OTHER_KEY)
|
||||||
|
const selectedKey = ref(props.modelValue ?? null)
|
||||||
|
|
||||||
|
// "其他" 输入框独立内容
|
||||||
|
const otherText = ref(props.modelValue ?? '')
|
||||||
|
|
||||||
|
// 是否显示下方 input
|
||||||
|
const showOtherInput = computed(() => selectedKey.value === OTHER_KEY)
|
||||||
|
|
||||||
|
// 给 el-select 渲染的 options (raw + 末尾追加 "其他" 虚拟项)
|
||||||
|
const displayOptions = computed(() => {
|
||||||
|
const raw = rawOptions.value.map(o => ({
|
||||||
|
key: props.valueField === 'label' ? o.label : o.value,
|
||||||
|
label: o.label
|
||||||
|
}))
|
||||||
|
return [...raw, { key: OTHER_KEY, label: '其他' }]
|
||||||
})
|
})
|
||||||
|
|
||||||
function handleChange(val, opt) {
|
// 检测 modelValue 是否在 rawOptions 里
|
||||||
emit('update:modelValue', val)
|
function isValueInOptions(v) {
|
||||||
emit('change', val, opt)
|
if (v == null || v === '') return false
|
||||||
|
return rawOptions.value.some(o => (props.valueField === 'label' ? o.label : o.value) === v)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 从外部 modelValue 同步 → 内部 selectedKey + otherText
|
||||||
|
function syncFromModelValue(v) {
|
||||||
|
if (v == null || v === '') {
|
||||||
|
selectedKey.value = null
|
||||||
|
otherText.value = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (isValueInOptions(v)) {
|
||||||
|
selectedKey.value = v
|
||||||
|
otherText.value = ''
|
||||||
|
} else {
|
||||||
|
// 不在字典里 → 走 "其他" 路径
|
||||||
|
selectedKey.value = OTHER_KEY
|
||||||
|
otherText.value = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 内部状态 → emit 给父组件
|
||||||
|
function emitValue() {
|
||||||
|
let v
|
||||||
|
if (selectedKey.value === OTHER_KEY) {
|
||||||
|
v = otherText.value || ''
|
||||||
|
} else if (selectedKey.value == null) {
|
||||||
|
v = ''
|
||||||
|
} else {
|
||||||
|
v = selectedKey.value
|
||||||
|
}
|
||||||
|
emit('update:modelValue', v)
|
||||||
|
emit('change', v)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSelectChange(key) {
|
||||||
|
// 切换到 "其他" 时, 若原 selectedKey 不是 OTHER_KEY (即之前不是 "其他"), 清空 otherText 让用户从零输入
|
||||||
|
if (key === OTHER_KEY && selectedKey.value !== OTHER_KEY) {
|
||||||
|
otherText.value = ''
|
||||||
|
}
|
||||||
|
selectedKey.value = key
|
||||||
|
emitValue()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClear() {
|
||||||
|
selectedKey.value = null
|
||||||
|
otherText.value = ''
|
||||||
|
emitValue()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
rawOptions.value = await getActiveDict(DICT_TYPE.DEPARTMENT)
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[DoctorDeptSelect] load failed', e)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await load()
|
||||||
|
// 字典加载完后再做一次同步: 避免初始 modelValue 在字典未加载时被错判为 "其他"
|
||||||
|
syncFromModelValue(props.modelValue)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.modelValue, v => syncFromModelValue(v))
|
||||||
|
watch(() => rawOptions.value, () => syncFromModelValue(props.modelValue))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.doctor-dept-select { width: 100%; }
|
.doctor-other-select { display: flex; flex-direction: column; gap: 6px; width: 100%; }
|
||||||
|
.other-input { width: 100%; }
|
||||||
</style>
|
</style>
|
||||||
@@ -1,55 +1,155 @@
|
|||||||
<template>
|
<template>
|
||||||
<dict-select
|
<!--
|
||||||
v-model="selected"
|
公共医生职称下拉控件 (带"其他"逃生口)
|
||||||
:dict-type="DICT_TYPE.TITLE"
|
|
||||||
:value-field="valueField"
|
行为契约 (前端控件自管理, 父组件无感知):
|
||||||
:placeholder="placeholder"
|
- 标准字典项: 用户从下拉选 → v-model = 字典 label/value
|
||||||
:disabled="disabled"
|
- 选 "其他" (控件自己虚拟追加, 不来自后端字典): 下方出现 el-input → v-model = input 内容
|
||||||
:clearable="clearable"
|
- 回填: modelValue 不在字典里 → 自动按 "其他 + input" 显示, input 预填原值
|
||||||
:filterable="filterable"
|
|
||||||
:multiple="multiple"
|
用法 (与之前一样, 父组件不变):
|
||||||
class="doctor-title-select"
|
<doctor-title-select v-model="form.title" value-field="label" filterable placeholder="搜索职称" />
|
||||||
@change="handleChange"
|
-->
|
||||||
/>
|
<div class="doctor-other-select">
|
||||||
|
<el-select
|
||||||
|
v-model="selectedKey"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:disabled="disabled"
|
||||||
|
:clearable="clearable"
|
||||||
|
:filterable="filterable"
|
||||||
|
:loading="loading"
|
||||||
|
style="width: 100%"
|
||||||
|
@change="handleSelectChange"
|
||||||
|
@clear="handleClear"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="o in displayOptions"
|
||||||
|
:key="o.key"
|
||||||
|
:label="o.label"
|
||||||
|
:value="o.key"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
<el-input
|
||||||
|
v-if="showOtherInput"
|
||||||
|
v-model="otherText"
|
||||||
|
:placeholder="otherPlaceholder"
|
||||||
|
class="other-input"
|
||||||
|
maxlength="50"
|
||||||
|
@input="emitValue"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch } from 'vue'
|
import { ref, computed, watch, onMounted } from 'vue'
|
||||||
import DictSelect from '@/components/DictSelect.vue'
|
import { getActiveDict, DICT_TYPE } from '@/api/dict'
|
||||||
import { DICT_TYPE } from '@/api/dict'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 公共医生职称下拉控件
|
|
||||||
* v-model 默认绑 ID (titleId), 也可改为绑 name 字符串
|
|
||||||
*
|
|
||||||
* 用法:
|
|
||||||
* <title-select v-model="form.titleId" />
|
|
||||||
* <title-select v-model="form.doctorTitle" value-field="label" placeholder="选职称" />
|
|
||||||
*/
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
modelValue: { type: [String, Number, Array], default: null },
|
modelValue: { type: [String, Number], default: null },
|
||||||
/** 'value' (titleId) 或 'label' (职称名称) */
|
/** 'value' (ID) 或 'label' (职称名称字符串) */
|
||||||
valueField: { type: String, default: 'value' },
|
valueField: { type: String, default: 'label' },
|
||||||
placeholder: { type: String, default: '请选择医生职称' },
|
placeholder: { type: String, default: '请选择医生职称' },
|
||||||
|
otherPlaceholder: { type: String, default: '请输入其他职称' },
|
||||||
disabled: { type: Boolean, default: false },
|
disabled: { type: Boolean, default: false },
|
||||||
clearable: { type: Boolean, default: true },
|
clearable: { type: Boolean, default: true },
|
||||||
filterable: { type: Boolean, default: false },
|
filterable: { type: Boolean, default: false }
|
||||||
multiple: { type: Boolean, default: false }
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue', 'change'])
|
const emit = defineEmits(['update:modelValue', 'change'])
|
||||||
|
|
||||||
const selected = ref(props.modelValue ?? (props.multiple ? [] : null))
|
// "其他" 虚拟项的内部 key — 不暴露给父组件, 仅用于区分 selectedKey 状态
|
||||||
watch(() => props.modelValue, v => {
|
const OTHER_KEY = '__other__'
|
||||||
selected.value = v ?? (props.multiple ? [] : null)
|
|
||||||
|
// 原始后端字典项
|
||||||
|
const rawOptions = ref([])
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
// el-select 内部绑定的 key
|
||||||
|
const selectedKey = ref(props.modelValue ?? null)
|
||||||
|
|
||||||
|
// "其他" 输入框独立内容
|
||||||
|
const otherText = ref(props.modelValue ?? '')
|
||||||
|
|
||||||
|
// 是否显示下方 input
|
||||||
|
const showOtherInput = computed(() => selectedKey.value === OTHER_KEY)
|
||||||
|
|
||||||
|
// 给 el-select 渲染的 options (raw + 末尾追加 "其他" 虚拟项)
|
||||||
|
const displayOptions = computed(() => {
|
||||||
|
const raw = rawOptions.value.map(o => ({
|
||||||
|
key: props.valueField === 'label' ? o.label : o.value,
|
||||||
|
label: o.label
|
||||||
|
}))
|
||||||
|
return [...raw, { key: OTHER_KEY, label: '其他' }]
|
||||||
})
|
})
|
||||||
|
|
||||||
function handleChange(val, opt) {
|
function isValueInOptions(v) {
|
||||||
emit('update:modelValue', val)
|
if (v == null || v === '') return false
|
||||||
emit('change', val, opt)
|
return rawOptions.value.some(o => (props.valueField === 'label' ? o.label : o.value) === v)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function syncFromModelValue(v) {
|
||||||
|
if (v == null || v === '') {
|
||||||
|
selectedKey.value = null
|
||||||
|
otherText.value = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (isValueInOptions(v)) {
|
||||||
|
selectedKey.value = v
|
||||||
|
otherText.value = ''
|
||||||
|
} else {
|
||||||
|
selectedKey.value = OTHER_KEY
|
||||||
|
otherText.value = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitValue() {
|
||||||
|
let v
|
||||||
|
if (selectedKey.value === OTHER_KEY) {
|
||||||
|
v = otherText.value || ''
|
||||||
|
} else if (selectedKey.value == null) {
|
||||||
|
v = ''
|
||||||
|
} else {
|
||||||
|
v = selectedKey.value
|
||||||
|
}
|
||||||
|
emit('update:modelValue', v)
|
||||||
|
emit('change', v)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSelectChange(key) {
|
||||||
|
if (key === OTHER_KEY && selectedKey.value !== OTHER_KEY) {
|
||||||
|
otherText.value = ''
|
||||||
|
}
|
||||||
|
selectedKey.value = key
|
||||||
|
emitValue()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClear() {
|
||||||
|
selectedKey.value = null
|
||||||
|
otherText.value = ''
|
||||||
|
emitValue()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
rawOptions.value = await getActiveDict(DICT_TYPE.TITLE)
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[DoctorTitleSelect] load failed', e)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await load()
|
||||||
|
syncFromModelValue(props.modelValue)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.modelValue, v => syncFromModelValue(v))
|
||||||
|
watch(() => rawOptions.value, () => syncFromModelValue(props.modelValue))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.doctor-title-select { width: 100%; }
|
.doctor-other-select { display: flex; flex-direction: column; gap: 6px; width: 100%; }
|
||||||
|
.other-input { width: 100%; }
|
||||||
</style>
|
</style>
|
||||||
Reference in New Issue
Block a user