hldy_app/pages/watch/drawer/index.vue

522 lines
14 KiB
Vue
Raw Normal View History

2025-08-13 17:19:40 +08:00
```vue
<!-- 轮盘一级圆盘 + 二级左半弧滚动盘-->
<template>
<view class="draw-all">
<!-- 摄像头 -->
<view class="carmera">
<image class="all-size" src="/static/index/watch/camera.png" />
</view>
<!-- 轮子背景 -->
<view class="roll">
<image class="all-size" src="/static/index/watch/panzi.png" />
</view>
<!-- 一级转盘完整圆盘可旋转自动校正选中最左侧 -->
<view ref="compass" class="compass-container" @touchstart.stop.prevent="onTouchStart"
@touchmove.prevent.stop="onTouchMove" @touchend.stop.prevent="onTouchEnd"
@touchcancel.stop.prevent="onTouchEnd" :style="wrapperStyle">
<view v-for="(item, i) in items" :key="i" class="compass-item" :style="itemStyle(item.baseAngle)">
<text :class="i===target?`item-label-target`: `item-label`" :style="labelStyle">
<view
style="z-index: 2;display: flex;flex-direction: column;justify-content: center;align-items: center;">
<image style="width: 50rpx;height: 50rpx;margin-bottom: 0rpx;"
:src="`/static/index/watch/Wheel/${i}${i===target?1:0}.png`" />
<view :style="i===target?{color:'#fff'}:{}">
{{ item.label }}
</view>
</view>
<image class="targetimge" src="/static/index/watch/bluetarget.png"
:style="{opacity:i===target?1:0}" />
</text>
</view>
</view>
<!-- 二级转盘只占左半球90°~270°竖向滚动有边界吸附最多 5 -->
<view v-if="items2.length" ref="compass2" class="compass-container second"
@touchstart.stop.prevent="onTouchStart2" @touchmove.prevent.stop="onTouchMove2"
@touchend.stop.prevent="onTouchEnd2" @touchcancel.stop.prevent="onTouchEnd2" :style="wrapperStyle2"
v-show="target">
<view v-for="(item, i) in items2" :key="i" class="compass-item" :style="itemStyle2(item.baseAngle)">
<text :class="i===target2?`item-label-second-target`: `item-label-second`" :style="labelStyle2">
<view
style="z-index: 2;display: flex;flex-direction: column;justify-content: center;align-items: center;">
<image style="width: 70rpx;height: 70rpx;margin-bottom: 0rpx;"
:src="`/static/index/watch/Wheel/${target}${i}${i===target2?1:0}.png`" />
<view v-show="target!==-1" :style="i===target2?{color:'#0E86EA'}:{}">
{{ item.label }}
</view>
</view>
</text>
</view>
</view>
<view class="" v-show="target===5">
<joystick @movecard="" :movebottom="44" :moveleft="-5" :pao="false" :notext="true" />
</view>
<!-- 一级手势蒙层 -->
<view v-if="dragging==='first'" class="gesture-mask" @touchmove.stop.prevent="onTouchMove"
@touchend.stop.prevent="onTouchEnd" @touchcancel.stop.prevent="onTouchEnd" />
<!-- 二级手势蒙层 -->
<view v-if="dragging==='second'" class="gesture-mask" @touchmove.stop.prevent="onTouchMove2"
@touchend.stop.prevent="onTouchEnd2" @touchcancel.stop.prevent="onTouchEnd2" />
</view>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed, nextTick, watch, onBeforeUnmount } from 'vue'
import joystick from '@/component/public/newgame/joysticknew.vue';
const emit = defineEmits([])
const props = defineProps({})
/* ===================== 一级:完整圆盘 ===================== */
// 10 个预设方向文字
const labels = ['开机', '静音', '对讲', '截屏', '录制', '方位', '清晰度', '分屏', '翻转', '告警']
const count = labels.length
// 一级每个文字对应的初始角度360 等分)
const items = reactive(
labels.map((label, idx) => ({
label,
baseAngle: (360 / count) * idx
}))
)
// 恢复定时器(最简单的实现)
let restoreTimer : number | null = null
let restoreTimer2 : number | null = null
const RESTORE_MS = 300 // 0.3s
// 当前累积旋转角度(一级)
const currentAngle = ref(0)
let startAngle = 0
const dragging = ref<null | 'first' | 'second'>(null)
let idleTimer : number | null = null
function armIdleFinish(handler : () => void, ms = 160) {
if (idleTimer) clearTimeout(idleTimer)
idleTimer = setTimeout(() => {
if (dragging.value) handler()
}, ms) as unknown as number
}
function clearIdle() {
if (idleTimer) {
clearTimeout(idleTimer)
idleTimer = null
}
}
// 圆心坐标(用于一级 atan2
const center = reactive({ x: 0, y: 0 })
// 获取圆心(以一级容器为基准)
onMounted(async () => {
await nextTick()
uni
.createSelectorQuery()
.select('.compass-container')
.boundingClientRect(rect => {
if (rect) {
center.x = rect.left + rect.width / 2
center.y = rect.top + rect.height / 2
}
})
.exec()
})
// 容器样式(一级)
const transitioning = ref(false)
const wrapperStyle = computed(() => ({
transform: `rotate(${currentAngle.value}deg)`,
transition: transitioning.value ? 'transform 0.3s ease-out' : 'none'
}))
// 单项样式(一级)
function itemStyle(baseAngle : number) {
const radius = 190
const rad = (baseAngle * Math.PI) / 180
const x = radius * Math.cos(rad)
const y = radius * Math.sin(rad)
return { transform: `translate(${x}px, ${y}px)` }
}
// 标签正立(一级)
const labelStyle = computed(() => ({
transform: `rotate(${-currentAngle.value}deg)`
}))
// atan2 角度(一级)
function getTouchAngle(e : TouchEvent) {
const t = e.touches[0]
const dx = t.clientX - center.x
const dy = t.clientY - center.y
return ((Math.atan2(dy, dx) * 180) / Math.PI) * 2 // 灵敏度×2
}
function clearRestoreTimer() {
if (restoreTimer !== null) {
clearTimeout(restoreTimer)
restoreTimer = null
}
}
// 选中最左侧索引(一级)
const target = ref(5)
const saveindex = ref(-1)
function startRestoreTimer() {
clearTimeout(restoreTimer)
restoreTimer = setTimeout(() => {
if (target.value === -1) {
target.value = saveindex.value
}
}, 300)
}
function startRestoreTimer2() {
clearTimeout(restoreTimer2)
restoreTimer2 = setTimeout(() => {
if (target2.value === -1) {
target2.value = saveindex2.value
}
}, 300)
}
function onTouchStart(e : TouchEvent) {
saveindex.value = target.value
target.value = -1
transitioning.value = false
startAngle = getTouchAngle(e) - currentAngle.value
dragging.value = 'first'
startRestoreTimer()
}
function onTouchMove(e : TouchEvent) {
const angle = getTouchAngle(e)
currentAngle.value = angle - startAngle
armIdleFinish(onTouchEnd)
// 重启恢复定时器0.3s 内没有新的 move 就恢复 saveindex
startRestoreTimer()
}
function getLeftmostIndex() {
let minDiff = Infinity
let idx = 0
items.forEach((item, i) => {
let real = (item.baseAngle + currentAngle.value) % 360
if (real < 0) real += 360
const diff = Math.abs(real - 180)
if (diff < minDiff) {
minDiff = diff
idx = i
}
})
return idx
}
function onTouchEnd() {
// 结束时先清定时器,避免同时恢复和吸附冲突
clearRestoreTimer()
const step = 360 / count
const raw = currentAngle.value
const nearest = Math.round(raw / step) * step
transitioning.value = true
currentAngle.value = nearest
const leftIndex = getLeftmostIndex()
target.value = leftIndex
// console.log("???", target.value)
setTimeout(() => (transitioning.value = false), 300)
dragging.value = null
}
onBeforeUnmount(() => {
if (restoreTimer !== null) clearTimeout(restoreTimer)
if (restoreTimer2 !== null) clearTimeout(restoreTimer2)
clearIdle()
})
/* ===================== 二级:左半弧竖向滚动盘 ===================== */
// 二级映射(每组最多 5 个)
const secondMap : Record<number, string[]> = {
0: [],
1: [],
2: [],
3: [],
4: [],
5: [],
6: ['超清', '流畅', '自动'],
7: ['180°全景', '四分屏', '360°全景', '全景拉伸', '原图'],
8: ['上下翻转', '关闭', '左右翻转'],
9: []
}
// 二级数据与状态
const items2 = reactive<{ label : string; baseAngle : number }[]>([])
const target2 = ref(0)
// 二级:半弧滚动偏移角(只允许在 [-Δ, +Δ]
const currentOffset2 = ref(0)
const step2 = ref(0) // Δ = 180 / (n + 1)
const transitioning2 = ref(false)
// 竖向滚动灵敏度(度/像素),可按设备调节
const DEG_PER_PX = 0.5
// 容器样式(二级:整体绕圆心微旋转 currentOffset2
const wrapperStyle2 = computed(() => ({
transform: `rotate(${currentOffset2.value}deg)`,
transition: transitioning2.value ? 'transform 0.25s ease-out' : 'none'
}))
// 标签正立(二级)
const labelStyle2 = computed(() => ({
transform: `rotate(${-currentOffset2.value}deg)`
}))
// 单项样式(二级:布局在左半弧 90°~270°
function itemStyle2(baseAngle : number) {
const radius = 240
const rad = (baseAngle * Math.PI) / 180
const x = radius * Math.cos(rad)
const y = radius * Math.sin(rad)
return { transform: `translate(${x}px, ${y}px)` }
}
// 固定角度位置(左半球从上到下)
const presetAngles = [135, 157.5, 180, 202.5, 225]
// 优先顺序:中间 → 上 → 下 → 上上 → 下下
function getBalancedAngles(n : number) : number[] {
const order = [2, 1, 3, 0, 4]
return order.slice(0, n).map(i => presetAngles[i])
}
const minOffset2 = ref(0);
const maxOffset2 = ref(0);
// 替换原来的 rebuildSecondByFirstIndex
function rebuildSecondByFirstIndex(firstIdx : number) {
const list = (secondMap[firstIdx] || []).slice(0, 5)
const angles = getBalancedAngles(list.length)
minOffset2.value = 180 - Math.max(...angles)
maxOffset2.value = 180 - Math.min(...angles)
items2.splice(0, items2.length)
if (!list.length) {
currentOffset2.value = 0
step2.value = 0
target2.value = -1
return
}
for (let i = 0; i < list.length; i++) {
items2.push({
label: list[i],
baseAngle: angles[i]
})
}
currentOffset2.value = 0
step2.value = 22.5 // 每个角度偏移
nextTick(() => {
target2.value = getLeftmostIndex2()
})
}
// 获取二级“最左侧”(最靠近 180°的索引
function getLeftmostIndex2() {
if (!items2.length) return -1
let minDiff = Infinity
let idx = 0
items2.forEach((item, i) => {
let real = (item.baseAngle + currentOffset2.value) % 360
if (real < 0) real += 360
const diff = Math.abs(real - 180)
if (diff < minDiff) {
minDiff = diff
idx = i
}
})
return idx
}
// 监听一级 target联动二级
watch(
() => target.value,
(idx) => {
if (idx >= 0) rebuildSecondByFirstIndex(idx)
},
{ immediate: true }
)
/* ---- 二级手势:竖向滚动到极限距离([-Δ, +Δ]),松手吸附到 Δ 的整数倍 ---- */
let startY2 = 0
let startOffset2 = 0
function clamp2(val : number) {
return Math.max(minOffset2.value, Math.min(maxOffset2.value, val))
}
const saveindex2 =ref(-1)
function onTouchStart2(e : TouchEvent) {
saveindex2.value = target2.value
const t = e.touches[0]
startY2 = t.clientY
startOffset2 = currentOffset2.value
transitioning2.value = false
target2.value = -1
dragging.value = 'second'
startRestoreTimer2()
}
function onTouchMove2(e : TouchEvent) {
if (!items2.length) return
const t = e.touches[0]
const dy = t.clientY - startY2
const Δ = step2.value || 1
// 上滑为正角度:使用 -dy
const raw = startOffset2 + (-dy) * DEG_PER_PX
currentOffset2.value = clamp2(raw, -Δ, +Δ)
armIdleFinish(onTouchEnd2)
startRestoreTimer2()
}
function onTouchEnd2() {
if (!items2.length) return
const Δ = step2.value || 1
// 吸附到 Δ 的整数倍,并限制在 [-Δ, +Δ]
const snapped = Math.round(currentOffset2.value / Δ) * Δ
transitioning2.value = true
currentOffset2.value = clamp2(snapped, -Δ, +Δ)
// 选中靠近 180° 的项
target2.value = getLeftmostIndex2()
setTimeout(() => (transitioning2.value = false), 250)
dragging.value = null
}
</script>
<style lang="less" scoped>
.draw-all {
width: 100%;
height: 100%;
background-color: #eff0f4;
overflow: hidden;
position: relative;
.carmera {
position: absolute;
right: 0;
bottom: 250rpx;
height: 600rpx;
width: 200rpx;
z-index: 1;
}
.roll {
position: absolute;
right: 0;
bottom: 0rpx;
height: 1300rpx;
width: 650rpx;
}
}
.compass-container {
width: 380px;
height: 380px;
border-radius: 50%;
position: absolute;
right: -60%;
transform: translateY(-60%);
bottom: 270rpx;
margin: auto;
touch-action: none;
z-index: 9999;
}
.compass-item {
position: absolute;
top: 50%;
left: 50%;
width: 100px;
height: 100px;
margin: -50px 0 0 -50px;
display: flex;
align-items: center;
justify-content: center;
}
.item-label {
font-size: 25rpx;
width: 130rpx;
height: 130rpx;
border-radius: 50%;
display: flex;
justify-content: center;
align-items: center;
background: linear-gradient(to bottom, #e6e7ed, #f4f5f7);
border: 2rpx solid #fff;
position: relative;
}
.item-label-target {
font-size: 25rpx;
width: 130rpx;
height: 130rpx;
border-radius: 50%;
display: flex;
justify-content: center;
align-items: center;
background: linear-gradient(to bottom, #e6e7ed, #f4f5f7);
position: relative;
}
.item-label-second {
font-size: 25rpx;
width: 130rpx;
height: 130rpx;
border-radius: 50%;
display: flex;
justify-content: center;
align-items: center;
position: relative;
}
.item-label-second-target {
font-size: 25rpx;
width: 130rpx;
height: 130rpx;
border-radius: 50%;
display: flex;
justify-content: center;
align-items: center;
position: relative;
}
.all-size {
width: 100%;
height: 100%;
}
.targetimge {
width: 150rpx;
height: 130rpx;
position: absolute;
right: 0;
top: 0;
z-index: 1;
transition: opacity 0.8s ease;
}
/* 二级容器放在一级之下,尺寸更大一圈(视觉在后面) */
.compass-container.second {
width: 480px;
height: 480px;
border-radius: 50%;
position: absolute;
right: -70%;
transform: translateY(-70%);
bottom: 172rpx;
margin: auto;
touch-action: none;
z-index: 1; // 在一级之下
}
.gesture-mask {
position: fixed;
left: 0;
top: 0;
right: 0;
bottom: 0;
z-index: 9999;
background: transparent;
/* 或 rgba(0,0,0,0.001) */
}
</style>
```