183 lines
3.5 KiB
Vue
183 lines
3.5 KiB
Vue
<template>
|
|
<div class="body-selector" :class="{ readonly: props.readonly }">
|
|
<svg
|
|
viewBox="0 0 240 500"
|
|
class="figure"
|
|
role="group"
|
|
aria-label="正面人体调理部位选择"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
>
|
|
<g class="face-guide" aria-hidden="true">
|
|
<circle cx="110" cy="56" r="3.5" />
|
|
<circle cx="130" cy="56" r="3.5" />
|
|
<path d="M120 62L116 72H124" />
|
|
<path d="M108 80C113 85 127 85 132 80" />
|
|
</g>
|
|
<g
|
|
v-for="area in areas"
|
|
:key="area.key"
|
|
class="area"
|
|
:class="{ active: isSelected(area.key) }"
|
|
role="checkbox"
|
|
:tabindex="props.readonly ? -1 : 0"
|
|
:aria-label="area.label"
|
|
:aria-checked="isSelected(area.key)"
|
|
@click="toggle(area.key)"
|
|
@keydown.enter.prevent="toggle(area.key)"
|
|
@keydown.space.prevent="toggle(area.key)"
|
|
>
|
|
<title v-text="area.label" />
|
|
<ellipse
|
|
v-if="area.shape === 'ellipse'"
|
|
:cx="area.cx"
|
|
:cy="area.cy"
|
|
:rx="area.rx"
|
|
:ry="area.ry"
|
|
/>
|
|
<rect
|
|
v-else-if="area.shape === 'rect'"
|
|
:x="area.x"
|
|
:y="area.y"
|
|
:width="area.width"
|
|
:height="area.height"
|
|
:rx="area.radius"
|
|
/>
|
|
<path v-else :d="area.path" />
|
|
</g>
|
|
</svg>
|
|
<p class="selected" v-text="selectedText" />
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { bodyAreaLabelMap, bodyAreaList } from "@/utils/bodyArea";
|
|
|
|
const props = defineProps<{
|
|
modelValue: {
|
|
selectedCodeList: string[];
|
|
};
|
|
readonly?: boolean;
|
|
}>();
|
|
|
|
const emit = defineEmits<{
|
|
(e: "update:modelValue", value: { selectedCodeList: string[] }): void;
|
|
}>();
|
|
|
|
const selectedText = computed(() => {
|
|
const selectedLabelList = selectedCodeList.value.map(item => areaLabelMap.get(item) || item);
|
|
return selectedLabelList.length ? `已选部位:${selectedLabelList.join("、")}` : "已选部位:未选择";
|
|
});
|
|
|
|
const areas = bodyAreaList;
|
|
const areaLabelMap = bodyAreaLabelMap;
|
|
const selectedCodeList = computed(() => props.modelValue?.selectedCodeList || []);
|
|
|
|
function isSelected(code: string): boolean {
|
|
return selectedCodeList.value.includes(code);
|
|
}
|
|
|
|
function toggle(code: string): void {
|
|
if (props.readonly) {
|
|
return;
|
|
}
|
|
|
|
const next = new Set(selectedCodeList.value);
|
|
if (next.has(code)) {
|
|
next.delete(code);
|
|
} else {
|
|
next.add(code);
|
|
}
|
|
|
|
emit("update:modelValue", {
|
|
selectedCodeList: Array.from(next)
|
|
});
|
|
}
|
|
</script>
|
|
|
|
<style scoped lang="less">
|
|
.body-selector {
|
|
display: flex;
|
|
padding: .75rem;
|
|
gap: .75rem;
|
|
overflow: hidden;
|
|
align-items: center;
|
|
flex-direction: column;
|
|
justify-content: center;
|
|
border-radius: 1.25rem;
|
|
|
|
.figure {
|
|
width: 100%;
|
|
height: 31rem;
|
|
max-width: 16rem;
|
|
touch-action: manipulation;
|
|
}
|
|
|
|
.face-guide {
|
|
fill: none;
|
|
stroke: #94a3b8;
|
|
stroke-width: 2;
|
|
stroke-linecap: round;
|
|
stroke-linejoin: round;
|
|
}
|
|
|
|
.area {
|
|
fill: transparent;
|
|
stroke: #64748b;
|
|
cursor: pointer;
|
|
outline: none;
|
|
pointer-events: all;
|
|
stroke-width: 2;
|
|
transition: fill .2s ease, stroke .2s ease, stroke-width .2s ease;
|
|
|
|
&:focus-visible {
|
|
stroke: #0f766e;
|
|
stroke-width: 4;
|
|
}
|
|
|
|
&.active {
|
|
fill: rgba(15, 118, 110, .24);
|
|
stroke: #0f766e;
|
|
stroke-width: 4;
|
|
}
|
|
}
|
|
|
|
&.readonly {
|
|
.area {
|
|
cursor: default;
|
|
}
|
|
}
|
|
|
|
.selected {
|
|
margin: 0;
|
|
color: #52607a;
|
|
font-size: .875rem;
|
|
line-height: 1.6;
|
|
text-align: center;
|
|
}
|
|
}
|
|
|
|
:global(html.theme-dark) {
|
|
.body-selector {
|
|
background: rgba(15, 23, 42, .72);
|
|
|
|
.face-guide {
|
|
stroke: #cbd5e1;
|
|
}
|
|
|
|
.area {
|
|
fill: transparent;
|
|
stroke: #94a3b8;
|
|
|
|
&.active {
|
|
fill: rgba(94, 234, 212, .24);
|
|
stroke: #5eead4;
|
|
}
|
|
}
|
|
|
|
.selected {
|
|
color: #cbd5e1;
|
|
}
|
|
}
|
|
}
|
|
</style>
|