效果图

封装组件: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">
<!-- 右上区 (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>
<!-- 左上区 (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">
<!-- 右下区 (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>
<!-- 左下区 (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>
<!-- 中线标识 -->
<line :x1="viewboxwidth / 2" y1="50" :x2="viewboxwidth / 2" :y2="viewboxheight - 50" stroke="#78909c" stroke-width="1"
stroke-dasharray="5,5" />
<!-- 分区标识 -->
<text :x="viewboxwidth / 4" y="30" class="quadrant-label">右上区 (1-8)</text>
<text :x="viewboxwidth * 3 / 4" y="30" class="quadrant-label">左上区 (9-16)</text>
<text :x="viewboxwidth / 4" :y="viewboxheight - 20" class="quadrant-label">右下区 (17-24)</text>
<text :x="viewboxwidth * 3 / 4" :y="viewboxheight - 20" class="quadrant-label">左下区 (25-32)</text>
</svg>
</div>
<!-- 备注区域 -->
<div class="notes-section">
<div v-if="selectedteeth.length > 0">
<h3>选中牙齿: {{ selectedteethwithposition.join(', ') }}</h3>
<textarea v-model="notes" placeholder="请输入治疗备注..." class="notes-textarea"></textarea>
</div>
<div v-else class="no-selection">
请点击牙齿进行选择
</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'
},
// 新增的尺寸相关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)
// 计算属性
const computedwidth = computed(() => typeof props.width === 'number' ? `${props.width}px` : props.width)
const computedheight = computed(() => typeof props.height === 'number' ? `${props.height}px` : props.height)
// 计算选中牙齿及其位置信息
const selectedteethwithposition = computed(() => {
return selectedteeth.value.map(num => {
const tooth = getallteeth().find(t => t.number === num)
return tooth ? `${num}(${getpositionname(num)})` : num
})
})
// 获取所有牙齿数据
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 ''
}
// 标准牙位布局数据 - 基于viewbox动态计算
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 }
]
// 切换牙齿选择状态
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]
})
}
// 监听notes变化
watch(notes, () => {
updatemodelvalue()
})
// 监听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">提交数据</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(`已提交数据:\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>到此这篇关于vue3 实现牙位图选择器的文章就介绍到这了,更多相关vue3 选择器内容请搜索代码网以前的文章或继续浏览下面的相关文章希望大家以后多多支持代码网!
发表评论