Files
gao-customer-h5/src/components/BodyAreaSelector.vue
T
2026-08-20 14:57:50 +08:00

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>