hldy_app/pages/watch/drawer/index.vue

709 lines
21 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!-- 轮盘一级圆盘 + 二级左半弧滚动盘-->
<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>