vue3 實(shí)現(xiàn)牙位圖選擇器的實(shí)例代碼
效果圖

封裝組件:ToothChart.vue
<template>
<div class="tooth-chart-container">
<div class="tooth-chart">
<svg :width="computedWidth" :height="computedHeight" :viewBox="`0 0 ${viewBoxWidth} ${viewBoxHeight}`"
xmlns="http://www.w3.org/2000/svg">
<!-- 上頜牙齒 -->
<g class="upper-jaw">
<!-- 右上區(qū) (1-8) -->
<g v-for="tooth in upperRightTeeth" :key="tooth.number">
<rect :x="tooth.x" :y="tooth.y" :width="toothWidth" :height="toothHeight" :rx="toothRadius"
:class="['tooth', { selected: selectedTeeth.includes(tooth.number) }]"
@click="toggleTooth(tooth.number)" />
<text :x="tooth.x + toothWidth / 2" :y="tooth.y + toothHeight / 2 + 5" class="tooth-number"
@click="toggleTooth(tooth.number)">
{{ tooth.number }}
</text>
</g>
<!-- 左上區(qū) (9-16) -->
<g v-for="tooth in upperLeftTeeth" :key="tooth.number">
<rect :x="tooth.x" :y="tooth.y" :width="toothWidth" :height="toothHeight" :rx="toothRadius"
:class="['tooth', { selected: selectedTeeth.includes(tooth.number) }]"
@click="toggleTooth(tooth.number)" />
<text :x="tooth.x + toothWidth / 2" :y="tooth.y + toothHeight / 2 + 5" class="tooth-number"
@click="toggleTooth(tooth.number)">
{{ tooth.number }}
</text>
</g>
</g>
<!-- 下頜牙齒 -->
<g class="lower-jaw">
<!-- 右下區(qū) (17-24) -->
<g v-for="tooth in lowerRightTeeth" :key="tooth.number">
<rect :x="tooth.x" :y="tooth.y" :width="toothWidth" :height="toothHeight" :rx="toothRadius"
:class="['tooth', { selected: selectedTeeth.includes(tooth.number) }]"
@click="toggleTooth(tooth.number)" />
<text :x="tooth.x + toothWidth / 2" :y="tooth.y + toothHeight / 2 + 5" class="tooth-number"
@click="toggleTooth(tooth.number)">
{{ tooth.number }}
</text>
</g>
<!-- 左下區(qū) (25-32) -->
<g v-for="tooth in lowerLeftTeeth" :key="tooth.number">
<rect :x="tooth.x" :y="tooth.y" :width="toothWidth" :height="toothHeight" :rx="toothRadius"
:class="['tooth', { selected: selectedTeeth.includes(tooth.number) }]"
@click="toggleTooth(tooth.number)" />
<text :x="tooth.x + toothWidth / 2" :y="tooth.y + toothHeight / 2 + 5" class="tooth-number"
@click="toggleTooth(tooth.number)">
{{ tooth.number }}
</text>
</g>
</g>
<!-- 中線標(biāo)識(shí) -->
<line :x1="viewBoxWidth / 2" y1="50" :x2="viewBoxWidth / 2" :y2="viewBoxHeight - 50" stroke="#78909C" stroke-width="1"
stroke-dasharray="5,5" />
<!-- 分區(qū)標(biāo)識(shí) -->
<text :x="viewBoxWidth / 4" y="30" class="quadrant-label">右上區(qū) (1-8)</text>
<text :x="viewBoxWidth * 3 / 4" y="30" class="quadrant-label">左上區(qū) (9-16)</text>
<text :x="viewBoxWidth / 4" :y="viewBoxHeight - 20" class="quadrant-label">右下區(qū) (17-24)</text>
<text :x="viewBoxWidth * 3 / 4" :y="viewBoxHeight - 20" class="quadrant-label">左下區(qū) (25-32)</text>
</svg>
</div>
<!-- 備注區(qū)域 -->
<div class="notes-section">
<div v-if="selectedTeeth.length > 0">
<h3>選中牙齒: {{ selectedTeethWithPosition.join(', ') }}</h3>
<textarea v-model="notes" placeholder="請(qǐng)輸入治療備注..." class="notes-textarea"></textarea>
</div>
<div v-else class="no-selection">
請(qǐng)點(diǎn)擊牙齒進(jìn)行選擇
</div>
</div>
</div>
</template>
<script setup>
import { ref, watch, computed } from 'vue'
const props = defineProps({
modelValue: {
type: Object,
default: () => ({
selectedTeeth: [],
notes: ''
})
},
width: {
type: [Number, String],
default: '100%'
},
height: {
type: [Number, String],
default: '600'
},
// 新增的尺寸相關(guān)props
viewBoxWidth: {
type: Number,
default: 1000
},
viewBoxHeight: {
type: Number,
default: 600
},
toothWidth: {
type: Number,
default: 40
},
toothHeight: {
type: Number,
default: 60
},
toothRadius: {
type: Number,
default: 5
}
})
const emit = defineEmits(['update:modelValue'])
const selectedTeeth = ref([...props.modelValue.selectedTeeth])
const notes = ref(props.modelValue.notes)
// 計(jì)算屬性
const computedWidth = computed(() => typeof props.width === 'number' ? `${props.width}px` : props.width)
const computedHeight = computed(() => typeof props.height === 'number' ? `${props.height}px` : props.height)
// 計(jì)算選中牙齒及其位置信息
const selectedTeethWithPosition = computed(() => {
return selectedTeeth.value.map(num => {
const tooth = getAllTeeth().find(t => t.number === num)
return tooth ? `${num}(${getPositionName(num)})` : num
})
})
// 獲取所有牙齒數(shù)據(jù)
const getAllTeeth = () => [...upperRightTeeth, ...upperLeftTeeth, ...lowerRightTeeth, ...lowerLeftTeeth]
// 獲取牙齒位置名稱
const getPositionName = (toothNumber) => {
if (toothNumber >= 1 && toothNumber <= 8) return '右上'
if (toothNumber >= 9 && toothNumber <= 16) return '左上'
if (toothNumber >= 17 && toothNumber <= 24) return '右下'
if (toothNumber >= 25 && toothNumber <= 32) return '左下'
return ''
}
// 標(biāo)準(zhǔn)牙位布局?jǐn)?shù)據(jù) - 基于viewBox動(dòng)態(tài)計(jì)算
const upperRightTeeth = [
{ number: 1, x: props.viewBoxWidth / 2 - props.toothWidth * 4, y: 50 },
{ number: 2, x: props.viewBoxWidth / 2 - props.toothWidth * 3, y: 50 },
{ number: 3, x: props.viewBoxWidth / 2 - props.toothWidth * 2, y: 50 },
{ number: 4, x: props.viewBoxWidth / 2 - props.toothWidth * 4, y: 50 + props.toothHeight + 10 },
{ number: 5, x: props.viewBoxWidth / 2 - props.toothWidth * 3, y: 50 + props.toothHeight + 10 },
{ number: 6, x: props.viewBoxWidth / 2 - props.toothWidth * 2, y: 50 + props.toothHeight + 10 },
{ number: 7, x: props.viewBoxWidth / 2 - props.toothWidth * 4, y: 50 + (props.toothHeight + 10) * 2 },
{ number: 8, x: props.viewBoxWidth / 2 - props.toothWidth * 3, y: 50 + (props.toothHeight + 10) * 2 }
]
const upperLeftTeeth = [
{ number: 9, x: props.viewBoxWidth / 2 + props.toothWidth * 3, y: 50 + (props.toothHeight + 10) * 2 },
{ number: 10, x: props.viewBoxWidth / 2 + props.toothWidth * 2, y: 50 + (props.toothHeight + 10) * 2 },
{ number: 11, x: props.viewBoxWidth / 2 + props.toothWidth * 3, y: 50 + props.toothHeight + 10 },
{ number: 12, x: props.viewBoxWidth / 2 + props.toothWidth * 2, y: 50 + props.toothHeight + 10 },
{ number: 13, x: props.viewBoxWidth / 2 + props.toothWidth, y: 50 + props.toothHeight + 10 },
{ number: 14, x: props.viewBoxWidth / 2 + props.toothWidth * 3, y: 50 },
{ number: 15, x: props.viewBoxWidth / 2 + props.toothWidth * 2, y: 50 },
{ number: 16, x: props.viewBoxWidth / 2 + props.toothWidth, y: 50 }
]
const lowerRightTeeth = [
{ number: 17, x: props.viewBoxWidth / 2 - props.toothWidth * 4, y: props.viewBoxHeight / 2 + 20 },
{ number: 18, x: props.viewBoxWidth / 2 - props.toothWidth * 3, y: props.viewBoxHeight / 2 + 20 },
{ number: 19, x: props.viewBoxWidth / 2 - props.toothWidth * 2, y: props.viewBoxHeight / 2 + 20 },
{ number: 20, x: props.viewBoxWidth / 2 - props.toothWidth * 4, y: props.viewBoxHeight / 2 + 20 + props.toothHeight + 10 },
{ number: 21, x: props.viewBoxWidth / 2 - props.toothWidth * 3, y: props.viewBoxHeight / 2 + 20 + props.toothHeight + 10 },
{ number: 22, x: props.viewBoxWidth / 2 - props.toothWidth * 2, y: props.viewBoxHeight / 2 + 20 + props.toothHeight + 10 },
{ number: 23, x: props.viewBoxWidth / 2 - props.toothWidth * 4, y: props.viewBoxHeight / 2 + 20 + (props.toothHeight + 10) * 2 },
{ number: 24, x: props.viewBoxWidth / 2 - props.toothWidth * 3, y: props.viewBoxHeight / 2 + 20 + (props.toothHeight + 10) * 2 }
]
const lowerLeftTeeth = [
{ number: 25, x: props.viewBoxWidth / 2 + props.toothWidth * 3, y: props.viewBoxHeight / 2 + 20 + (props.toothHeight + 10) * 2 },
{ number: 26, x: props.viewBoxWidth / 2 + props.toothWidth * 2, y: props.viewBoxHeight / 2 + 20 + (props.toothHeight + 10) * 2 },
{ number: 27, x: props.viewBoxWidth / 2 + props.toothWidth * 3, y: props.viewBoxHeight / 2 + 20 + props.toothHeight + 10 },
{ number: 28, x: props.viewBoxWidth / 2 + props.toothWidth * 2, y: props.viewBoxHeight / 2 + 20 + props.toothHeight + 10 },
{ number: 29, x: props.viewBoxWidth / 2 + props.toothWidth, y: props.viewBoxHeight / 2 + 20 + props.toothHeight + 10 },
{ number: 30, x: props.viewBoxWidth / 2 + props.toothWidth * 3, y: props.viewBoxHeight / 2 + 20 },
{ number: 31, x: props.viewBoxWidth / 2 + props.toothWidth * 2, y: props.viewBoxHeight / 2 + 20 },
{ number: 32, x: props.viewBoxWidth / 2 + props.toothWidth, y: props.viewBoxHeight / 2 + 20 }
]
// 切換牙齒選擇狀態(tài)
const toggleTooth = (toothNumber) => {
const index = selectedTeeth.value.indexOf(toothNumber)
if (index === -1) {
selectedTeeth.value.push(toothNumber)
} else {
selectedTeeth.value.splice(index, 1)
}
updateModelValue()
}
// 更新模型值
const updateModelValue = () => {
emit('update:modelValue', {
selectedTeeth: [...selectedTeeth.value],
notes: notes.value,
selectedTeethWithPosition: [...selectedTeethWithPosition.value]
})
}
// 監(jiān)聽notes變化
watch(notes, () => {
updateModelValue()
})
// 監(jiān)聽props變化
watch(() => props.modelValue, (newVal) => {
if (JSON.stringify(newVal.selectedTeeth) !== JSON.stringify(selectedTeeth.value)) {
selectedTeeth.value = [...newVal.selectedTeeth]
}
if (newVal.notes !== notes.value) {
notes.value = newVal.notes
}
}, { deep: true })
// 暴露方法
defineExpose({
clearSelection: () => {
selectedTeeth.value = []
notes.value = ''
updateModelValue()
},
getSelectedTeeth: () => [...selectedTeeth.value],
getSelectedTeethWithPosition: () => [...selectedTeethWithPosition.value],
getNotes: () => notes.value
})
</script>
<style scoped>
.tooth-chart-container {
display: flex;
flex-direction: column;
gap: 20px;
font-family: 'Arial', sans-serif;
max-width: 100%;
margin: 0 auto;
}
.tooth-chart {
border: 1px solid #e0e0e0;
border-radius: 12px;
overflow: hidden;
background-color: #f8f9fa;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08);
}
.tooth {
fill: #ffffff;
stroke: #90a4ae;
stroke-width: 1.5;
cursor: pointer;
transition: all 0.3s ease;
}
.tooth:hover {
fill: #e3f2fd;
stroke: #42a5f5;
}
.tooth.selected {
fill: #bbdefb;
stroke: #1e88e5;
stroke-width: 2;
filter: drop-shadow(0 0 4px rgba(30, 136, 229, 0.4));
}
.tooth-number {
font-size: 22px;
font-weight: 600;
text-anchor: middle;
cursor: pointer;
user-select: none;
fill: #37474f;
}
.quadrant-label {
font-size: 26px;
fill: #78909C;
text-anchor: middle;
font-weight: 500;
}
.notes-section {
padding: 20px;
border: 1px solid #e0e0e0;
border-radius: 12px;
background-color: #f8f9fa;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08);
}
.notes-section h3 {
margin-top: 0;
margin-bottom: 15px;
color: #263238;
font-size: 18px;
}
.notes-textarea {
width: 100%;
min-height: 120px;
padding: 12px;
border: 1px solid #cfd8dc;
border-radius: 8px;
resize: vertical;
font-family: inherit;
font-size: 14px;
line-height: 1.5;
transition: border-color 0.3s;
}
.notes-textarea:focus {
outline: none;
border-color: #42a5f5;
box-shadow: 0 0 0 2px rgba(66, 165, 245, 0.2);
}
.no-selection {
color: #90a4ae;
text-align: center;
padding: 30px;
font-size: 16px;
}
</style>使用示例:
<template>
<div class="demo-container">
<h1>牙位圖選擇器</h1>
<ToothChart v-model="toothData" :width="chartWidth" :height="chartHeight" :tooth-width="toothWidth"
:tooth-height="toothHeight" />
<div class="actions">
<button @click="clearSelection">清除選擇</button>
<button @click="submitData">提交數(shù)據(jù)</button>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import ToothChart from '@/components/ToothChart.vue';
const toothData = ref({
selectedTeeth: [],
notes: '',
selectedTeethWithPosition: []
})
const chartWidth = ref('100%')
const chartHeight = ref('500px')
const toothWidth = ref(40)
const toothHeight = ref(60)
const clearSelection = () => {
toothData.value = {
selectedTeeth: [],
notes: '',
selectedTeethWithPosition: []
}
}
const submitData = () => {
alert(`已提交數(shù)據(jù):\n選中牙齒: ${toothData.value.selectedTeethWithPosition.join(', ')}\n備注: ${toothData.value.notes}`)
}
</script>
<style scoped lang="scss">
.demo-container {
max-width: 1000px;
margin: 0 auto;
padding: 20px;
font-family: Arial, sans-serif;
}
.actions {
display: flex;
gap: 10px;
margin: 20px 0;
}
.actions button {
padding: 8px 16px;
background: #42a5f5;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
transition: background 0.3s;
}
.actions button:hover {
background: #1e88e5;
}
</style>到此這篇關(guān)于vue3 實(shí)現(xiàn)牙位圖選擇器的文章就介紹到這了,更多相關(guān)vue3 選擇器內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
在Vue3里使用scss實(shí)現(xiàn)簡(jiǎn)單的換膚功能
這篇文章主要介紹了在Vue3里使用scss實(shí)現(xiàn)簡(jiǎn)單的換膚功能,主題色切換、亮色模式和暗黑模式切換、背景圖切換,文中有詳細(xì)的代碼示例供大家參考,需要的朋友可以參考下2024-12-12
vue實(shí)現(xiàn)鼠標(biāo)經(jīng)過(guò)動(dòng)畫
這篇文章主要為大家詳細(xì)介紹了vue實(shí)現(xiàn)鼠標(biāo)經(jīng)過(guò)動(dòng)畫的方法,文中示例代碼介紹的非常詳細(xì),具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2019-10-10
vue3中頁(yè)面跳轉(zhuǎn)兩種實(shí)現(xiàn)方式
在Vue3中Vue?Router是一個(gè)常用的路由管理庫(kù),它提供了一種簡(jiǎn)單而強(qiáng)大的方式來(lái)實(shí)現(xiàn)路由跳轉(zhuǎn)和導(dǎo)航,這篇文章主要給大家介紹了關(guān)于vue3中頁(yè)面跳轉(zhuǎn)的兩種實(shí)現(xiàn)方式,需要的朋友可以參考下2024-09-09
elementUI的table表格改變數(shù)據(jù)不更新問(wèn)題解決
最近在做vue的項(xiàng)目時(shí)發(fā)現(xiàn)了一個(gè)問(wèn)題,今天就來(lái)解決一下,本文主要介紹了elementUI的table表格改變數(shù)據(jù)不更新問(wèn)題解決,感興趣的可以了解一下2022-02-02
使用Capacitor將Vue項(xiàng)目轉(zhuǎn)換為Android App的操作步驟
這篇文章主要介紹了如何將Vue項(xiàng)目轉(zhuǎn)換為Android應(yīng)用,包括設(shè)置開發(fā)環(huán)境、打包Vue項(xiàng)目、安裝和配置Capacitor,以及如何處理網(wǎng)絡(luò)請(qǐng)求和配置圖標(biāo),此外,還包括了如何編譯生成APK文件和處理可能的錯(cuò)誤,需要的朋友可以參考下2025-10-10
vue3超出文本展示el tooltip實(shí)現(xiàn)示例
這篇文章主要為大家介紹了vue3超出文本展示el tooltip實(shí)現(xiàn)示例詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2023-02-02
vue2.0 + element UI 中 el-table 數(shù)據(jù)導(dǎo)出Excel的方法
下面小編就為大家分享一篇vue2.0 + element UI 中 el-table 數(shù)據(jù)導(dǎo)出Excel的方法,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。一起跟隨小編過(guò)來(lái)看看吧2018-03-03
Element?Table行的動(dòng)態(tài)合并及數(shù)據(jù)編輯示例
這篇文章主要為大家介紹了Element?Table行的動(dòng)態(tài)合并及數(shù)據(jù)編輯示例詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2022-07-07
Vue3.x的版本中build后dist文件中出現(xiàn)legacy的js文件問(wèn)題
這篇文章主要介紹了Vue3.x的版本中build后dist文件中出現(xiàn)legacy的js文件問(wèn)題及解決,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。如有錯(cuò)誤或未考慮完全的地方,望不吝賜教2022-07-07

