hldy_app_mini/pages/watch/drawer/index.vue

709 lines
21 KiB
Vue
Raw Normal View History

2025-11-05 15:59:48 +08:00
<!-- 轮盘一级圆盘 + 二级左半弧滚动盘-->
<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;"
:class="i===target&&!opensecondmenu?`pulse`: ``"
:src="`/static/index/watch/Wheel/${i+1}${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>
<!-- 二级转盘 -->
<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 !== -1">
<view v-for="(item, i) in items2" :key="i" class="compass-item" :style="itemStyle2(item.baseAngle)">
<text class="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;"
:class="i===target2&&opensecondmenu?`pulse`: ``" :src="`/static/index/watch/Wheel/${target === -1 ? 0 : target+1}${i}${i===secondMapTarget[target]?1:0}.png`" />
<view v-show="target!==-1" :style="i===secondMapTarget[target]?{color:'#0E86EA'}:{}">
{{ item.label }}
</view>
</view>
</text>
</view>
</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'
const props = defineProps({
opensecondmenu: {
type: Boolean,
default: false
},
})
const emit = defineEmits(["firstIndex", "secondIndex"])
/* ========== 基础数据 ========== */
const labels = ['静音', '对讲', '截屏', '录制', '方位', '清晰度', '分屏', '翻转', '告警']
const secondMapTarget = ref([0, 1, 1, 1, 1, 0, 0, 3, 1])
const count = labels.length
const items = reactive(labels.map((label, idx) => ({
label,
baseAngle: (360 / count) * idx
})))
/* ========== 定时器 & 常量 ========== */
let restoreTimer : number | null = null
let restoreTimer2 : number | null = null
const ANIM_MS = 300
const RESTORE_MS = 300
/* ========== 角度状态 ========== */
// 逻辑角(连续累积,可超过多圈)—— 由手势增量累加得到
const currentAngle = ref(0)
// 用于渲染到 DOM 的“显示角”,我们会对它做最短角度动画
const displayAngle = ref(0)
const startchange = () => {
secondMapTarget.value[target.value] = target2.value;
}
// 拖拽状态
const dragging = ref<null | 'first' | 'second'>(null)
let prevTouchAngle : number | null = null
let lastMoveDelta = 0
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()
// 初始对齐:用 currentAngle (0) 找到左侧项并对齐 displayAngle
await nextTick()
const init = getLeftmostIndexFromAngle(currentAngle.value)
target.value = init
// 直接把 display 对齐到 current无动画
displayAngle.value = currentAngle.value
focusIndex(init, false)
})
/* ========== 显示样式依赖 displayAngle ========== */
const transitioning = ref(false)
const wrapperStyle = computed(() => ({
transform: `rotate(${displayAngle.value}deg)`,
transition: transitioning.value ? `transform ${ANIM_MS}ms ease-out` : 'none'
}))
const labelStyle = computed(() => ({ transform: `rotate(${-displayAngle.value}deg)` }))
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)` }
}
/* ========== 角度工具函数 ========== */
function normalize360(a : number) {
let v = a % 360
if (v < 0) v += 360
return v
}
// 返回 toAngle 相对于 fromAngle 的最短有向角差(-180,180]
function signedShortestAngleDiff(toAngle : number, fromAngle : number) {
// 直接利用归一化到 [0,360) 再差值并修正
const a = normalize360(toAngle)
const b = normalize360(fromAngle)
let diff = a - b
if (diff > 180) diff -= 360
if (diff <= -180) diff += 360
return diff
}
// 给出 targetAngle(0..360),返回与 reference 最接近的等价角(可能是 target±360k
function chooseClosestEquivalent(targetAngle : number, reference : number) {
const base = normalize360(targetAngle)
let best = base
let bestDiff = Math.abs(best - reference)
for (let k = -3; k <= 3; k++) {
const cand = base + k * 360
const d = Math.abs(cand - reference)
if (d < bestDiff) {
bestDiff = d
best = cand
}
}
return best
}
/* ====== touch 角度 (标准 atan2 返回 -180..180) ====== */
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
}
/* ========== 选中 & 恢复计时 ========== */
const target = ref(5)
const saveindex = ref(-1)
function clearRestoreTimer() {
if (restoreTimer !== null) { clearTimeout(restoreTimer); restoreTimer = null }
}
function startRestoreTimer() {
clearRestoreTimer()
restoreTimer = setTimeout(() => {
if (target.value === -1) target.value = saveindex.value
}, RESTORE_MS) as unknown as number
}
function startRestoreTimer2() {
clearTimeout(restoreTimer2)
restoreTimer2 = setTimeout(() => {
if (target2.value === -1) target2.value = saveindex2.value ;emit("secondIndex", target2.value)
}, RESTORE_MS) as unknown as number
}
/* ========== touch handlers一级使用 currentAngle 做逻辑displayAngle 做渲染) ========== */
function onTouchStart(e : TouchEvent) {
saveindex.value = target.value
target.value = -1
transitioning.value = false
prevTouchAngle = getTouchAngle(e)
lastMoveDelta = 0
dragging.value = 'first'
startRestoreTimer()
}
function onTouchMove(e : TouchEvent) {
if (prevTouchAngle === null) {
prevTouchAngle = getTouchAngle(e); return
}
const angle = getTouchAngle(e)
// 计算相对增量(短差),累加到 currentAngle逻辑角
const delta = signedShortestAngleDiff(angle, prevTouchAngle as number)
lastMoveDelta = delta
currentAngle.value = currentAngle.value + delta
// 直接把显示角同步到当前逻辑角(无动画)
transitioning.value = false
displayAngle.value = currentAngle.value
prevTouchAngle = angle
armIdleFinish(onTouchEnd)
startRestoreTimer()
}
function onTouchEnd() {
prevTouchAngle = null
clearRestoreTimer()
// 吸附到网格snap
const step = 360 / count
const snapUnit = (count % 2 === 0) ? step : step / 2
const raw = currentAngle.value
const snappedBase = Math.round(raw / snapUnit) * snapUnit
const targetNorm = normalize360(snappedBase) // 0..360 的目标表示
// 逻辑角:选择一个跟 raw 相近的等价角(保持数值连续性)
const finalAngle = chooseClosestEquivalent(targetNorm, raw)
// 立即把逻辑角更新为 finalAngle这样后续逻辑以 finalAngle 为准)
currentAngle.value = finalAngle
// 计算显示端要动画到哪个“等价角”——选择对 displayAngle 最接近的等价角
const displayTarget = chooseClosestEquivalent(targetNorm, displayAngle.value)
// 计算最短差并让 displayAngle 增量变化CSS 会走最短路径)
let diff = displayTarget - displayAngle.value
if (diff > 180) diff -= 360
if (diff <= -180) diff += 360
// 启动过渡动画
transitioning.value = true
displayAngle.value = displayAngle.value + diff
// 先立刻更新 target逻辑上的选中项
const leftIndex = getLeftmostIndexFromAngle(finalAngle)
if (target.value !== leftIndex) {
emit("firstIndex", leftIndex)
target.value = leftIndex
}
// chuangti()
// 动画结束后:关闭过渡、并用无动画方式把 displayAngle 数值对齐到逻辑角 currentAngle
setTimeout(() => {
transitioning.value = false
// 把 displayAngle 对齐到 currentAngle 的数值(可能相差整圈),但无动画
displayAngle.value = currentAngle.value
}, ANIM_MS)
dragging.value = null
lastMoveDelta = 0
}
/* ========== focusIndex外部调用或按钮触发的精确对齐 ========== */
function angleForIndex(index : number) {
if (!items[index]) return 0
return normalize360(180 - items[index].baseAngle)
}
function focusIndex(index : number, animate = true) {
if (index == null || index < 0 || index >= items.length) return
const angle = angleForIndex(index) // 0..360 target
// 逻辑上选择最接近 currentAngle 的等价角
const finalAngle = chooseClosestEquivalent(angle, currentAngle.value)
currentAngle.value = finalAngle
// 显示端选择对 displayAngle 最接近的等价角去动画
const displayTarget = chooseClosestEquivalent(angle, displayAngle.value)
if (!animate) {
transitioning.value = false
displayAngle.value = finalAngle
// target.value = index
if (target.value !== index) {
emit("firstIndex", index)
target.value = index
}
return
}
let diff = displayTarget - displayAngle.value
if (diff > 180) diff -= 360
if (diff <= -180) diff += 360
transitioning.value = true
displayAngle.value = displayAngle.value + diff
if (target.value !== index) {
emit("firstIndex", index)
target.value = index
}
setTimeout(() => {
transitioning.value = false
displayAngle.value = currentAngle.value
}, ANIM_MS)
dragging.value = null
}
/* ========== 更稳健的 getLeftmost基于任意角度 ========== */
function angleDiff(a : number, b : number) {
const diff = Math.abs(normalize360(a) - normalize360(b))
return Math.min(diff, 360 - diff)
}
function getLeftmostIndexFromAngle(angle : number) {
if (!items.length) return -1
const cur = normalize360(angle)
let minDiff = Infinity
let idx = 0
items.forEach((item, i) => {
const targetAngleForItem = normalize360(180 - item.baseAngle)
const diff = angleDiff(cur, targetAngleForItem)
if (diff < minDiff - 1e-9) {
minDiff = diff
idx = i
}
})
return idx
}
function getLeftmostIndex() {
return getLeftmostIndexFromAngle(currentAngle.value)
}
/* ================= 二级(竖向左半弧) ================ */
const secondMapByLabel : Record<string, string[]> = {
'静音': ['开启静音', '关闭静音',], '对讲': ['开启对讲', '关闭对讲',], '截屏': ["截屏"], '录制': ['开启录制', '关闭录制',], '方位': ['开启方位', '关闭方位',],
'清晰度': ['高清', '流畅'],
'分屏': ['原图', '四分屏', '180°全景', '360°全景', '环状全景'],
'翻转': ['左右翻转', '上下翻转', '中心翻转', '关闭'],
'告警': ['开启告警', '关闭告警',]
}
const items2 = reactive<{ label : string; baseAngle : number }[]>([])
const target2 = ref(0)
const currentOffset2 = ref(0)
const step2 = ref(0)
const transitioning2 = ref(false)
const DEG_PER_PX = 0.5
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)` }))
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)
function rebuildSecondByFirstIndex(firstIdx : number) {
const firstLabel = items[firstIdx]?.label
const list = (firstLabel && secondMapByLabel[firstLabel]) ? secondMapByLabel[firstLabel].slice(0, 5) : []
const angles = getBalancedAngles(list.length)
minOffset2.value = 180 - Math.max(...(angles.length ? angles : [180]))
maxOffset2.value = 180 - Math.min(...(angles.length ? angles : [180]))
items2.splice(0, items2.length)
if (!list.length) {
currentOffset2.value = 0
step2.value = 0
target2.value = -1
emit("secondIndex", target2.value)
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();emit("secondIndex", target2.value) })
}
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
}
watch(() => target.value, (idx) => {
if (idx >= 0) {
rebuildSecondByFirstIndex(idx)
focusIndex(idx, true)
} else {
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
emit("secondIndex", target2.value)
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
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)
setTimeout(() => (transitioning2.value = false), 250)
dragging.value = null
if (target.value !== getLeftmostIndex2()) {
emit("secondIndex", target2.value)
target2.value = getLeftmostIndex2()
}
}
/* ========== 外部控制与清理 ========== */
function moveFirstUp() {
if (dragging.value === 'first') return
const cur = getLeftmostIndex(); if (cur < 0) return
const next = (cur - 1 + items.length) % items.length
focusIndex(next, true);
if (target.value !== next) {
emit("firstIndex", next)
target.value = next
}
// chuangti()
}
function moveFirstDown() {
if (dragging.value === 'first') return
const cur = getLeftmostIndex(); if (cur < 0) return
const next = (cur + 1) % items.length
focusIndex(next, true);
if (target.value !== next) {
emit("firstIndex", next)
target.value = next
}
// chuangti()
}
/* ======= 为二级盘新增外部控制(按偏移量,而不是按索引) ======= */
function moveSecondUp() {
if (dragging.value === 'second') return
if (!items2.length) return
const Δ = step2.value || 1
// 手指向上 -> currentOffset2 增大 (onTouchMove 中也是这样:向上使 currentOffset2 增加)
let target = currentOffset2.value + Δ
// 把目标对齐到 step 网格并限制到 min/max
target = Math.round(target / Δ) * Δ
target = clamp2(target)
transitioning2.value = true
currentOffset2.value = target
// 更新选中项(根据新的 offset 计算最靠近 180° 的项)
target2.value = getLeftmostIndex2()
emit("secondIndex", target2.value)
setTimeout(() => (transitioning2.value = false), 250)
}
function moveSecondDown() {
if (dragging.value === 'second') return
if (!items2.length) return
const Δ = step2.value || 1
// 手指向下 -> currentOffset2 减小
let target = currentOffset2.value - Δ
target = Math.round(target / Δ) * Δ
target = clamp2(target)
transitioning2.value = true
currentOffset2.value = target
target2.value = getLeftmostIndex2()
emit("secondIndex", target2.value)
setTimeout(() => (transitioning2.value = false), 250)
}
defineExpose({ moveFirstUp, moveFirstDown, moveSecondUp, moveSecondDown, startchange })
onBeforeUnmount(() => {
if (restoreTimer !== null) clearTimeout(restoreTimer)
if (restoreTimer2 !== null) clearTimeout(restoreTimer2)
clearIdle()
})
</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: -65%;
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;
}
.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: -75%;
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;
}
.pulse {
/* 可调参数 */
--scale: 1.5;
--dur: 1.1s;
animation: pulse var(--dur) ease-in-out infinite;
transform-origin: center center;
will-change: transform;
}
/* 放大到一定值再回到原始(平滑) */
@keyframes pulse {
0% {
transform: scale(1);
}
50% {
transform: scale(var(--scale));
}
100% {
transform: scale(1);
}
}
.targetbutton {
--color: #99C9FD;
--thick: 2px;
--radius: 60rpx;
--outline-offset: -10rpx;
/* 外扩多少 */
/* 内层虚线(你现在用的) */
border-radius: var(--radius);
// background-color: #ddf0ff;
/* 内部背景 */
animation: scalePulse 360ms cubic-bezier(.2, .8, .2, 1);
/* 外层虚线:放在 outline不会影响元素尺寸 */
outline: var(--thick) dashed var(--color);
outline-offset: var(--outline-offset);
/* 保证文本 / 子元素在最上层 */
position: relative;
z-index: 1;
}
</style>