522 lines
14 KiB
Vue
522 lines
14 KiB
Vue
```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>
|
||
``` |