小程序模仿iphone苹果手机滑动选时间

1.需求:小程序仿照iPhone滑动选择时间段
2.代码实现方案:使用canvas绘制同时滑动调用手机震动
3.代码

java 复制代码
<template>
	<!-- 弹窗打开时锁根节点,配合 disableScroll + scroll-view 避免支付宝底层页面跟滑 -->
	<page-meta :page-style="timePageMetaStyle" />
	<view class="page">
		<scroll-view class="page-scroll" scroll-y :scroll-y="!timeModalShow && !currentAdjustConfirmShow" :show-scrollbar="false">
			<view class="page-scroll-inner">
		<view class="titleHead">错峰充电设置</view>

		<view class="body">
			<!-- 主开关 -->
			<view class="card card-toggle">
				<view class="card-toggle-row">
					<view class="card-toggle-text">
						<view class="card-title">错峰充电</view>
						<view class="card-desc">开启后,将根据峰谷时段自动调节充电电流</view>
					</view>
					<u-switch v-model="enabled" active-color="#1677FF" inactive-color="#E5E7EB" :size="36"></u-switch>
				</view>
			</view>

			<template v-if="enabled">
				<!-- 峰谷时段 -->
				<view class="card">
					<view class="pv-head">
						<view class="pv-title">峰谷时段</view>
						<view class="pv-edit" @click="openTimeModal">
							<image src="https://zd-app-data.oss-cn-shanghai.aliyuncs.com/static/anxinbao/edit-iconss.png" mode="widthFix" style="width: 26rpx; height: 26rpx;"></image>
							<text>编辑</text>
						</view>
					</view>
					<view v-if="0" class="ring-preview-wrap">
						<view class="ring-preview-fallback" :style="{ width: previewPx + 'px', height: previewPx + 'px' }">
							<view class="ring-preview-arc" :style="previewRingArcStyle"></view>
							<view class="ring-preview-inner" :style="previewRingInnerStyle"></view>
							<text class="ring-preview-txt">峰谷</text>
						</view>
					</view>
					<view class="pv-summary">峰:{{ peakSummary }} 谷:{{ valleySummary }}</view>
				</view>

				<!-- 适用充电模式 -->
				<view class="card">
					<view class="section-title">适用充电模式</view>
					<view class="mode-grid">
						<view v-for="m in visibleModes" :key="m.id" class="mode-item" @click="toggleMode(m.id)">
							<view class="mode-check" :class="{ on: modes[m.id] }">
								<text v-if="modes[m.id]" class="mode-tick">✓</text>
							</view>
							<text class="mode-label">{{ m.label }}</text>
						</view>
					</view>
					<view class="mode-tip">至少选择一种,错峰充电仅对选中模式生效</view>
				</view>

				<!-- 电流展示 -->
				<view class="current-panel">
					<view class="current-row">
						<view class="current-card">
							<view class="current-label">谷时段最大电流</view>
							<view class="current-val valley">{{ valleyMaxA }}<text class="current-unit">A</text></view>
						</view>
						<view class="current-card">
							<view class="current-label">峰时段最大电流</view>
							<view class="current-val peak">{{ peakMaxA }}<text class="current-unit">A</text></view>
						</view>
					</view>
					<view class="link-row" @click="goCurrentAdjust">
						<view class="adjust-icon" aria-hidden="true">
							<image src="https://zd-app-data.oss-cn-shanghai.aliyuncs.com/static/anxinbao/current-adjusts.png" mode="widthFix" style="width: 40rpx; height: 40rpx;"></image>
						</view>
						<text class="link-text">调节电流</text>
					</view>
				</view>

				<view class="hint-card">
					<view class="hint-line">
						<text class="hint-dot">·</text>
						<text class="hint-text">峰时段固定6A,谷时段电流可在【</text>
						<text class="hint-text">电流设置</text>
						<text class="hint-text">】中自定义</text>
					</view>
					<view class="hint-line">
						<text class="hint-dot">·</text>
						<text class="hint-text">充电中跨时段自动阀调节充电流上限</text>
					</view>
				</view>
			</template>
		</view>

		<view class="footer-btn">
			<u-button type="primary" :hair-line="false" :custom-style="primaryBtnStyle" @click="saveSettings">保存设置</u-button>
		</view>
			</view>
		</scroll-view>

		<!-- 自定义遮罩:避免 u-popup 内 scroll-view 导致支付宝小程序 canvas 不绘制 -->
		<!-- catchtouchmove:阻止蒙层/圆盘拖拽时底层页面跟滑(支付宝上 prevent 往往无效) -->
		<view v-if="timeModalShow" class="op-time-mask" catchtouchmove @touchmove.stop.prevent>
			<view class="op-time-mask-bg" catchtouchmove @tap="closeTimeModal(false)"></view>
			<view class="op-time-panel" @tap.stop>
				<view class="modal">
				<view class="modal-head">
					<text class="modal-title">峰谷时段</text>
					<view class="modal-close" @tap="closeTimeModal(false)">
						<u-icon name="close" size="36" color="#64748B"></u-icon>
					</view>
				</view>

				<view class="dial-wrap" :style="dialWrapSizeStyle" @touchstart.stop="onDialTouchStart"
					@touchmove.stop.prevent="onDialTouchMove" @touchend.stop="onDialTouchEnd" @touchcancel.stop="onDialTouchEnd">
					<canvas id="offPeakDialCanvas" canvas-id="offPeakDialCanvas" class="dial-canvas-full"
						:style="dialCanvasInlineStyle" :width="dialCanvasBufferSize" :height="dialCanvasBufferSize"></canvas>
					<view class="dial-tick-layer">
						<view v-for="item in dialTickMarks" :key="'tk-' + item.hour" class="dial-tick-item" :style="item.style"></view>
					</view>
					<view class="dial-hour-layer">
						<text v-for="item in dialHourMarks" :key="'hm-' + item.hour" class="dial-hour-item" :style="item.style">{{ item.hour }}</text>
					</view>
					<view class="handle h1" :style="handleStartStyle"
						@touchstart.stop="startHandleDrag(1)" @touchmove.stop.prevent="onHandleTouchMove"
						@touchend.stop="endHandleDrag" @touchcancel.stop="endHandleDrag">
						<view class="handle-dot"></view>
					</view>
					<view class="handle h2" :style="handleEndStyle"
						@touchstart.stop="startHandleDrag(2)" @touchmove.stop.prevent="onHandleTouchMove"
						@touchend.stop="endHandleDrag" @touchcancel.stop="endHandleDrag">
						<view class="handle-dot"></view>
					</view>
				</view>

				<view class="time-box">
					<view class="time-col">
						<view class="time-col-title">峰时段</view>
						<view class="time-col-range">{{ modalPeakRangeText }}</view>
					</view>
					<view class="time-col">
						<view class="time-col-title">谷时段</view>
						<view class="time-col-range">{{ modalValleyRangeText }}</view>
					</view>
				</view>

				<view class="restore-pill" :class="{ active: !isModalDefaultPeakRange }" @click="restoreDefaultTime">
					恢复默认(峰 08:00-22:00)
				</view>
				<view class="modal-tip">拖拽圆盘上的蓝色圆点调整峰时段</view>

				<view class="modal-confirm">
					<u-button type="primary" :hair-line="false" :custom-style="primaryBtnStyle" @click="confirmTimeModal">确认</u-button>
				</view>
				</view>
			</view>
		</view>

		<view v-if="currentAdjustConfirmShow" class="ca-confirm-mask" catchtouchmove @touchmove.stop.prevent>
			<view class="ca-confirm-mask-bg" @tap="closeCurrentAdjustConfirm"></view>
			<view class="ca-confirm-dialog" @tap.stop>
				<view class="ca-confirm-title">是否前往电流设置页面调节最大电流?</view>
				<view class="ca-confirm-actions">
					<view class="ca-btn ca-btn-cancel" @tap="closeCurrentAdjustConfirm">取消</view>
					<view class="ca-btn ca-btn-primary" @tap="confirmGoCurrentAdjust">去设置</view>
				</view>
			</view>
		</view>
	</view>
</template>

<script>
	const STORAGE_KEY = 'offPeakChargingSettings';

	export default {
		data() {
			return {
				primaryBtnStyle: {
					width: '100%',
					height: '110rpx',
					lineHeight: '110rpx',
					borderRadius: '40rpx',
					fontSize: '28rpx'
				},
				enabled: false,
				peakStart: 8,
				peakEnd: 22,
				modes: {
					reserve: true,
					plug: true,
					toggle: true,
					card: true
				},
				valleyMaxA: 32,
				peakMaxA: 6,
				timeModalShow: false,
				currentAdjustConfirmShow: false,
				modalPeakStart: 8,
				modalPeakEnd: 22,
				draggingHandle: 0,
				dialRect: null,
				dialPx: 0,
				previewPx: 0,
				lastSnappedHour: null,
				dialDpr: 2,
				chargingModeList: [{
						id: 'reserve',
						label: '预约充电'
					},
					{
						id: 'plug',
						label: '即插即充'
					},
					{
						id: 'toggle',
						label: '启停充电'
					},
					{
						id: 'card',
						label: '刷卡充电'
					}
				]
			};
		},
		computed: {
			peakSummary() {
				return `${this.fmtHour(this.peakStart)} → ${this.fmtHour(this.peakEnd)}`;
			},
			valleySummary() {
				return `${this.fmtHour(this.peakEnd)} → ${this.fmtHour(this.peakStart)}`;
			},
			modalPeakRangeText() {
				return `${this.fmtHour(this.modalPeakStart)} - ${this.fmtHour(this.modalPeakEnd)}`;
			},
			modalValleyRangeText() {
				return `${this.fmtHour(this.modalPeakEnd)} - ${this.fmtHour(this.modalPeakStart)}`;
			},
			isModalDefaultPeakRange() {
				return this.modalPeakStart === 8 && this.modalPeakEnd === 22;
			},
			timePageMetaStyle() {
				return this.timeModalShow || this.currentAdjustConfirmShow ? 'overflow: hidden;' : '';
			},
			/** 与弹窗圆盘一致:0 点在正上方,顺时针;仅环形色带,无刻度数字 */
			previewConicGradient() {
				const cPeak = '#1677ff';
				const cValley = '#dfe7f2';
				const ps = ((this.peakStart % 24) + 24) % 24;
				const pe = ((this.peakEnd % 24) + 24) % 24;
				const h0 = (ps / 24) * 360;
				const h1 = (pe / 24) * 360;
				if (pe > ps) {
					return `conic-gradient(from 0deg, ${cValley} 0deg, ${cValley} ${h0}deg, ${cPeak} ${h0}deg, ${cPeak} ${h1}deg, ${cValley} ${h1}deg, ${cValley} 360deg)`;
				}
				if (pe < ps) {
					return `conic-gradient(from 0deg, ${cPeak} 0deg, ${cPeak} ${h1}deg, ${cValley} ${h1}deg, ${cValley} ${h0}deg, ${cPeak} ${h0}deg, ${cPeak} 360deg)`;
				}
				return `conic-gradient(from 0deg, ${cValley} 0deg, ${cValley} 360deg)`;
			},
			previewRingArcStyle() {
				const px = this.previewPx || 0;
				if (!px) return {};
				const d = Math.round(px * 0.86);
				return {
					width: d + 'px',
					height: d + 'px',
					background: this.previewConicGradient
				};
			},
			previewRingInnerStyle() {
				const px = this.previewPx || 0;
				if (!px) return {};
				const d = Math.round(px * 0.74);
				return {
					width: d + 'px',
					height: d + 'px'
				};
			},
			visibleModes() {
				return this.chargingModeList || [];
			},
			dialWrapSizeStyle() {
				const px = this.dialPx || uni.upx2px(440);
				return {
					width: px + 'px',
					height: px + 'px'
				};
			},
			ringHandlePullPx() {
				const w = this.dialPx || uni.upx2px(440);
				const r = w / 2;
				return Math.round(this.dialTrackGeom(r).midR);
			},
			dialCanvasInlineStyle() {
				const px = this.dialPx || uni.upx2px(440);
				return {
					width: px + 'px',
					height: px + 'px'
				};
			},
			dialCanvasSize() {
				return this.dialPx || uni.upx2px(440);
			},
			/** 物理像素宽高;快速拖动时限制为 2x,减轻 draw 压力,松手后再用满 pixelRatio */
			dialCanvasBufferSize() {
				const logical = this.dialCanvasSize;
				const base = this.dialDpr || 2;
				const dpr = this.draggingHandle ? Math.min(base, 2) : base;
				return Math.max(1, Math.round(logical * dpr));
			},
			handleStartStyle() {
				return this.buildHandleStyle(this.modalPeakStart);
			},
			handleEndStyle() {
				return this.buildHandleStyle(this.modalPeakEnd);
			},
			dialTickMarks() {
				const out = [];
				const pull = this.ringHandlePullPx;
				for (let h = 0; h < 24; h++) {
					const deg = (h / 24) * 360;
					const long = h % 2 === 0;
					const len = long ? uni.upx2px(20) : uni.upx2px(12);
					out.push({
						hour: h,
						style: {
							left: '50%',
							top: '50%',
							width: '2px',
							height: `${Math.max(4, len)}px`,
							opacity: long ? 1 : 0.75,
							transform: `translate(-50%, -50%) rotate(${deg}deg) translateY(-${pull - len / 2}px)`
						}
					});
				}
				return out;
			},
			dialHourMarks() {
				const out = [];
				const pull = this.ringHandlePullPx;
				const w = this.dialPx || uni.upx2px(440);
				const r = w / 2;
				const G = this.dialTrackGeom(r);
				const textRadius = (G.outerR + G.shellOuterTarget) / 2;
				const textOffset = Math.round(textRadius - G.midR);
				for (let h = 0; h < 24; h += 2) {
					const deg = (h / 24) * 360;
					out.push({
						hour: h,
						style: {
							left: '50%',
							top: '50%',
							transform: `translate(-50%, -50%) rotate(${deg}deg) translateY(-${pull + textOffset}px) rotate(${-deg}deg)`
						}
					});
				}
				return out;
			}
		},
		onLoad() {
			this.dialPx = uni.upx2px(440);
			this.previewPx = uni.upx2px(200);
			try {
				const si = uni.getSystemInfoSync();
				this.dialDpr = Math.min(3, Math.max(1, si.pixelRatio || 2));
			} catch (e) {
				this.dialDpr = 2;
			}
			this.loadLocal();
			this.syncCurrentFromPile();
		},
		methods: {
			/** r 为圆盘逻辑半径(dial 宽度的一半)。内环缩小、外白环贴近画布边缘,中间留白加宽。 */
			dialTrackGeom(r) {
				const innerR = r * 0.66;
				const outerR = r * 0.79;
				const midR = (outerR + innerR) / 2;
				const ringW = outerR - innerR;
				const padEdge = uni.upx2px(8);
				const shellOuterTarget = r - padEdge;
				const bandTotal = Math.max(shellOuterTarget - outerR, ringW * 1.25);
				const shellGap = bandTotal * 0.55;
				const shellWidth = bandTotal - shellGap;
				const shellMidR = outerR + shellGap + shellWidth / 2;
				return {
					innerR,
					outerR,
					midR,
					ringW,
					shellGap,
					shellWidth,
					shellMidR,
					shellOuterTarget
				};
			},
			hourToAngle(h) {
				return -Math.PI / 2 + (h / 24) * Math.PI * 2;
			},
			scheduleDialPaint(immediate = false) {
				if (!this.timeModalShow) return;
				if (immediate) {
					if (this._dialPaintTimer) clearTimeout(this._dialPaintTimer);
					this._dialPaintTimer = null;
					this._cancelDialDragFrame();
					this._dialPaintDragPending = false;
					this.$nextTick(() => this.renderDialCanvasLegacy());
					return;
				}
				// 拖动:按显示器刷新合并绘制;无 RAF 时用 16ms 定时器,避免同步重绘卡死
				if (this.draggingHandle) {
					this._dialPaintDragPending = true;
					if (this._dialDragFrameId != null) return;
					const onDragFrame = () => {
						this._dialDragFrameId = null;
						if (!this.timeModalShow) {
							this._dialPaintDragPending = false;
							return;
						}
						if (!this._dialPaintDragPending) return;
						this._dialPaintDragPending = false;
						this.renderDialCanvasLegacy();
						if (this._dialPaintDragPending) {
							this._dialDragFrameId = this._scheduleDialDragFrame(onDragFrame);
						}
					};
					this._dialDragFrameId = this._scheduleDialDragFrame(onDragFrame);
					return;
				}
				if (this._dialPaintTimer) clearTimeout(this._dialPaintTimer);
				this._dialPaintTimer = setTimeout(() => {
					this._dialPaintTimer = null;
					if (!this.timeModalShow) return;
					this.renderDialCanvasLegacy();
				}, 20);
			},
			_scheduleDialDragFrame(cb) {
				try {
					// #ifdef MP-ALIPAY
					if (typeof my !== 'undefined' && my.requestAnimationFrame) {
						return my.requestAnimationFrame(cb);
					}
					// #endif
					// #ifdef MP-WEIXIN
					if (typeof wx !== 'undefined' && wx.requestAnimationFrame) {
						return wx.requestAnimationFrame(cb);
					}
					// #endif
				} catch (e) {}
				if (typeof requestAnimationFrame === 'function') {
					return requestAnimationFrame(cb);
				}
				return setTimeout(cb, 16);
			},
			_cancelDialDragFrame() {
				if (this._dialDragFrameId == null) return;
				const id = this._dialDragFrameId;
				this._dialDragFrameId = null;
				try {
					// #ifdef MP-ALIPAY
					if (typeof my !== 'undefined' && my.cancelAnimationFrame) my.cancelAnimationFrame(id);
					// #endif
					// #ifdef MP-WEIXIN
					if (typeof wx !== 'undefined' && wx.cancelAnimationFrame) wx.cancelAnimationFrame(id);
					// #endif
				} catch (e) {}
				try {
					if (typeof cancelAnimationFrame === 'function') cancelAnimationFrame(id);
				} catch (e) {}
				try {
					clearTimeout(id);
				} catch (e) {}
			},
			_getDialCanvasContext() {
				// #ifdef MP-ALIPAY
				try {
					if (typeof my !== 'undefined' && my.createCanvasContext) {
						return my.createCanvasContext('offPeakDialCanvas');
					}
				} catch (e) {}
				// #endif
				try {
					return uni.createCanvasContext('offPeakDialCanvas', this);
				} catch (e) {}
				try {
					return uni.createCanvasContext('offPeakDialCanvas');
				} catch (e) {}
				return null;
			},
			/** 统一使用 canvas-id 上下文绘制,兼容支付宝与 uni-app 多端 */
			renderDialCanvasLegacy() {
				const logicalW = this.dialCanvasSize;
				if (!logicalW) return;
				const base = this.dialDpr || 2;
				const dpr = this.draggingHandle ? Math.min(base, 2) : base;
				const buf = Math.max(1, Math.round(logicalW * dpr));
				const ctx = this._getDialCanvasContext();
				if (!ctx || typeof ctx.draw !== 'function') return;
				if (typeof ctx.save === 'function') ctx.save();
				if (typeof ctx.setTransform === 'function') {
					ctx.setTransform(1, 0, 0, 1, 0, 0);
				}
				if (typeof ctx.clearRect === 'function') {
					ctx.clearRect(0, 0, buf, buf);
				} else {
					ctx.setFillStyle('#ffffff');
					ctx.fillRect(0, 0, buf, buf);
				}
				if (typeof ctx.scale === 'function') {
					ctx.scale(dpr, dpr);
				}

				const w = logicalW;
				const r = w / 2;
				const cx = r;
				const cy = r;
				const G = this.dialTrackGeom(r);
				const midR = G.midR;
				const ringW = G.ringW;

				// 最外层白色圆环(外径贴近画布,与内轨之间留白由 bandTotal 控制)
				ctx.beginPath();
				ctx.arc(cx, cy, G.shellMidR, 0, Math.PI * 2, false);
				ctx.setStrokeStyle('#ffffff');
				ctx.setLineWidth(Math.max(2, G.shellWidth));
				ctx.stroke();

				// 全时段浅灰底环(粗描边)
				ctx.beginPath();
				ctx.arc(cx, cy, midR, 0, Math.PI * 2, false);
				ctx.setStrokeStyle('#dfe7f2');
				ctx.setLineWidth(ringW);
				ctx.stroke();

				let a1 = this.hourToAngle(this.modalPeakStart);
				let a2 = this.hourToAngle(this.modalPeakEnd);
				if (this.modalPeakEnd <= this.modalPeakStart) a2 += Math.PI * 2;

				// 峰时段:按角度分段绘制渐变色环(#1979FF -> #3FBFFF)
				const startColor = '#3FBFFF';
				const endColor = '#1979FF';
				const toRgb = (hex) => {
					let s = String(hex || '').replace('#', '').trim();
					if (s.length === 3) s = s.split('').map((c) => c + c).join('');
					const n = parseInt(s, 16);
					if (!Number.isFinite(n)) return {
						r: 25,
						g: 121,
						b: 255
					};
					return {
						r: (n >> 16) & 255,
						g: (n >> 8) & 255,
						b: n & 255
					};
				};
				const c0 = toRgb(startColor);
				const c1 = toRgb(endColor);
				const mixColor = (t) => {
					const tt = Math.max(0, Math.min(1, t));
					const r = Math.round(c0.r + (c1.r - c0.r) * tt);
					const g = Math.round(c0.g + (c1.g - c0.g) * tt);
					const b = Math.round(c0.b + (c1.b - c0.b) * tt);
					return `rgb(${r},${g},${b})`;
				};
				const totalSweep = Math.max(1e-6, a2 - a1);
				const drawGradientArc = (start, end, offsetSweep) => {
					const sweep = Math.max(0, end - start);
					if (sweep <= 1e-6) return;
					const steps = Math.max(36, Math.ceil((sweep / (Math.PI * 2)) * 280));
					for (let i = 0; i < steps; i++) {
						const segS = start + (sweep * i) / steps;
						const segE = start + (sweep * (i + 1)) / steps;
						const p = (offsetSweep + (sweep * i) / steps) / totalSweep;
						ctx.beginPath();
						ctx.arc(cx, cy, midR, segS, segE, false);
						ctx.setStrokeStyle(mixColor(p));
						ctx.setLineWidth(ringW);
						if (typeof ctx.setLineCap === 'function') ctx.setLineCap('round');
						ctx.stroke();
					}
				};
				if (a2 - a1 >= Math.PI * 2 - 1e-6) {
					drawGradientArc(0, Math.PI * 2, 0);
				} else if (a2 <= Math.PI * 2) {
					drawGradientArc(a1, a2, 0);
				} else {
					const firstEnd = Math.PI * 2;
					const firstSweep = firstEnd - a1;
					drawGradientArc(a1, firstEnd, 0);
					drawGradientArc(0, a2 - Math.PI * 2, firstSweep);
				}

				// 不再额外 fill 中心白圆:支付宝上易与环带叠加顺序冲突;开头 clear已是白底
				if (typeof ctx.restore === 'function') ctx.restore();
				ctx.draw();
			},
			fmtHour(h) {
				const x = ((h % 24) + 24) % 24;
				return `${String(x).padStart(2, '0')}:00`;
			},
			loadLocal() {
				try {
					const raw = uni.getStorageSync(STORAGE_KEY);
					if (!raw) return;
					const o = typeof raw === 'string' ? JSON.parse(raw) : raw;
					if (typeof o.enabled === 'boolean') this.enabled = o.enabled;
					if (Number.isFinite(o.peakStart)) this.peakStart = o.peakStart;
					if (Number.isFinite(o.peakEnd)) this.peakEnd = o.peakEnd;
					if (o.modes && typeof o.modes === 'object') this.modes = {
						...this.modes,
						...o.modes
					};
					if (Number.isFinite(o.valleyMaxA)) this.valleyMaxA = o.valleyMaxA;
					if (Number.isFinite(o.peakMaxA)) this.peakMaxA = o.peakMaxA;
				} catch (e) {}
			},
			syncCurrentFromPile() {
				const pile = uni.getStorageSync('currentPile') || {};
				if (pile.offPeakValleyMaxA != null) this.valleyMaxA = Number(pile.offPeakValleyMaxA) || this.valleyMaxA;
				if (pile.offPeakPeakMaxA != null) this.peakMaxA = Number(pile.offPeakPeakMaxA) || this.peakMaxA;
			},
			toggleMode(id) {
				this.$set(this.modes, id, !this.modes[id]);
				const any = this.visibleModes.some((m) => this.modes[m.id]);
				if (!any) {
					this.$set(this.modes, id, true);
					uni.showToast({
						title: '至少选择一种模式',
						icon: 'none'
					});
				}
			},
			goCurrentAdjust() {
				this.currentAdjustConfirmShow = true;
			},
			closeCurrentAdjustConfirm() {
				this.currentAdjustConfirmShow = false;
			},
			confirmGoCurrentAdjust() {
				this.currentAdjustConfirmShow = false;
				uni.navigateTo({
					url: '/pages/bluetoothCurrent/index?flag=A'
				});
			},
			saveSettings() {
				const anyMode = this.visibleModes.some((m) => this.modes[m.id]);
				if (this.enabled && !anyMode) {
					uni.showToast({
						title: '请选择适用充电模式',
						icon: 'none'
					});
					return;
				}
				const payload = {
					enabled: this.enabled,
					peakStart: this.peakStart,
					peakEnd: this.peakEnd,
					modes: {
						...this.modes
					},
					valleyMaxA: this.valleyMaxA,
					peakMaxA: this.peakMaxA
				};
				uni.setStorageSync(STORAGE_KEY, JSON.stringify(payload));
				uni.showToast({
					title: '已保存',
					icon: 'success'
				});
			},
			openTimeModal() {
				if (!this.dialPx) this.dialPx = uni.upx2px(440);
				this.modalPeakStart = this.peakStart;
				this.modalPeakEnd = this.peakEnd;
				this.timeModalShow = true;
				this.lastSnappedHour = null;
				this.dialRect = null;
				this.$nextTick(() => {
					this.$nextTick(() => {
						setTimeout(() => {
							this.measureDialRect(() => this.scheduleDialPaint(true));
							// 兜底重绘,规避支付宝弹窗首帧不落图
							setTimeout(() => this.scheduleDialPaint(true), 80);
							setTimeout(() => this.scheduleDialPaint(true), 180);
							setTimeout(() => this.scheduleDialPaint(true), 320);
						}, 120);
					});
				});
			},
			closeTimeModal(confirm) {
				this.timeModalShow = false;
				this._cancelDialDragFrame();
				this._dialPaintDragPending = false;
				if (this._dialPaintTimer) {
					clearTimeout(this._dialPaintTimer);
					this._dialPaintTimer = null;
				}
				if (!confirm) {
					this.modalPeakStart = this.peakStart;
					this.modalPeakEnd = this.peakEnd;
				}
			},
			confirmTimeModal() {
				if (this.modalPeakStart === this.modalPeakEnd) {
					uni.showToast({
						title: '峰时段起止不能相同',
						icon: 'none'
					});
					return;
				}
				this.peakStart = this.modalPeakStart;
				this.peakEnd = this.modalPeakEnd;
				this.timeModalShow = false;
				this._cancelDialDragFrame();
				this._dialPaintDragPending = false;
				if (this._dialPaintTimer) {
					clearTimeout(this._dialPaintTimer);
					this._dialPaintTimer = null;
				}
			},
			restoreDefaultTime() {
				this.modalPeakStart = 8;
				this.modalPeakEnd = 22;
				this.hapticTick(8);
				this.$nextTick(() => this.scheduleDialPaint(true));
			},
			measureDialRect(done) {
				uni.createSelectorQuery()
					.select('.dial-wrap')
					.boundingClientRect()
					.exec((res) => {
						const rect = res && res[0];
						this.dialRect = rect || null;
						if (rect && rect.width > 0) {
							this.dialPx = Math.round(rect.width);
						}
						if (typeof done === 'function') done();
					});
			},
			hourFromPoint(clientX, clientY, rect) {
				if (!rect || rect.width <= 0) return 0;
				const cx = rect.left + rect.width / 2;
				const cy = rect.top + rect.height / 2;
				const dx = clientX - cx;
				const dy = clientY - cy;
				let deg = (Math.atan2(dx, -dy) * 180) / Math.PI;
				if (deg < 0) deg += 360;
				let hour = Math.round((deg / 360) * 24) % 24;
				return hour;
			},
			buildHandleStyle(hour) {
				const deg = (hour / 24) * 360;
				const pull = this.ringHandlePullPx;
				return {
					left: '50%',
					top: '50%',
					marginLeft: 0,
					marginTop: 0,
					transform: `translate(-50%, -50%) rotate(${deg}deg) translateY(-${pull}px)`
				};
			},
			startHandleDrag(which) {
				this.draggingHandle = which;
				this.lastSnappedHour = null;
				if (!this.dialRect) this.measureDialRect();
			},
			onHandleTouchMove(e) {
				if (!this.draggingHandle) return;
				const t = this.getTouchClient(e);
				if (!t) return;
				const step = () => {
					const rect = this.dialRect;
					if (!rect) return;
					const hour = this.hourFromPoint(t.x, t.y, rect);
					this.applyHandleHour(this.draggingHandle, hour);
				};
				if (this.dialRect) step();
				else this.measureDialRect(step);
			},
			endHandleDrag() {
				this.draggingHandle = 0;
				this.lastSnappedHour = null;
				if (this.timeModalShow) this.scheduleDialPaint(true);
			},
			getTouchClient(e) {
				const t = (e.changedTouches && e.changedTouches[0]) || (e.touches && e.touches[0]);
				if (!t) return null;
				let x = t.clientX;
				let y = t.clientY;
				if (x === undefined || y === undefined) {
					x = t.pageX;
					y = t.pageY;
				}
				if (x === undefined || y === undefined) {
					x = t.x;
					y = t.y;
				}
				return x != null && y != null ? {
					x,
					y
				} : null;
			},
			onDialTouchStart(e) {
				const t = this.getTouchClient(e);
				if (!t) return;
				const apply = () => {
					const rect = this.dialRect;
					if (!rect) return;
					const hour = this.hourFromPoint(t.x, t.y, rect);
					const d1 = this.angularDistHours(hour, this.modalPeakStart);
					const d2 = this.angularDistHours(hour, this.modalPeakEnd);
					this.draggingHandle = d1 <= d2 ? 1 : 2;
					this.applyHandleHour(this.draggingHandle, hour);
				};
				if (this.dialRect) apply();
				else this.measureDialRect(apply);
			},
			onDialTouchMove(e) {
				if (!this.draggingHandle) return;
				const t = this.getTouchClient(e);
				if (!t) return;
				const move = () => {
					const rect = this.dialRect;
					if (!rect) return;
					const hour = this.hourFromPoint(t.x, t.y, rect);
					this.applyHandleHour(this.draggingHandle, hour);
				};
				if (this.dialRect) move();
				else this.measureDialRect(move);
			},
			onDialTouchEnd() {
				this.draggingHandle = 0;
				this.lastSnappedHour = null;
				if (this.timeModalShow) this.scheduleDialPaint(true);
			},
			angularDistHours(a, b) {
				return Math.min((a - b + 24) % 24, (b - a + 24) % 24);
			},
			applyHandleHour(which, hour) {
				let s = this.modalPeakStart;
				let e = this.modalPeakEnd;
				if (which === 1) s = hour;
				else e = hour;
				if (s === e) {
					if (which === 1) s = (hour + 1) % 24;
					else e = (hour + 1) % 24;
				}
				if (this.modalPeakStart !== s || this.modalPeakEnd !== e) {
					this.modalPeakStart = s;
					this.modalPeakEnd = e;
					this.hapticTick(hour);
					this.scheduleDialPaint();
				}
			},
			hapticTick(hour) {
				if (this.lastSnappedHour === hour) return;
				if (this.draggingHandle) {
					const now = Date.now();
					if (now - (this._lastHapticAt || 0) < 65) return;
					this._lastHapticAt = now;
				}
				this.lastSnappedHour = hour;
				try {
					// #ifdef MP-ALIPAY
					if (typeof my !== 'undefined' && my.vibrateShort) {
						my.vibrateShort({});
						return;
					}
					// #endif
					if (uni.vibrateShort) uni.vibrateShort({});
				} catch (err) {}
			},
		}
	};
</script>

<style lang="scss" scoped>
	.page {
		height: 100vh;
		overflow: hidden;
		display: flex;
		flex-direction: column;
		box-sizing: border-box;
		background: #f3f4f6;
	}

	.page-scroll {
		flex: 1;
		height: 0;
		width: 100%;
	}

	.page-scroll-inner {
		padding: 40rpx 0 30rpx;
		box-sizing: border-box;
		display: flex;
		flex-direction: column;
		min-height: 100%;
	}

	.titleHead {
		font-size: 48rpx;
		font-weight: 600;
		color: #2d3748;
		padding: 0 50rpx 24rpx;
		font-family: PingFangSC-Semibold, PingFang SC;
	}

	.body {
		padding: 0 40rpx;
	}

	.card {
		background: #fff;
		padding: 32rpx 28rpx;
		margin-bottom: 28rpx;
		box-shadow: 0rpx 0rpx 30rpx 14rpx #ECEBEC;
		border-radius: 40rpx;
	}

	.card-toggle-row {
		display: flex;
		align-items: flex-start;
		justify-content: space-between;
		gap: 24rpx;
	}

	.card-title {
		font-size: 28rpx;
		font-weight: 600;
		color: #2d3748;
		margin-bottom: 12rpx;
	}

	.card-desc {
		font-size: 20rpx;
		color: #1677FF;
		line-height: 2.3;
	}

	.pv-head {
		display: flex;
		align-items: center;
		justify-content: space-between;
		margin-bottom: 18rpx;
	}

	.pv-title {
		font-size: 28rpx;
		font-weight: 600;
		color: #2d3748;
	}

	.pv-edit {
		display: flex;
		align-items: center;
		gap: 8rpx;
		font-size: 20rpx;
		font-weight: 500;
		color: #1677ff;
	}

	.ring-preview-wrap {
		display: flex;
		justify-content: center;
		margin: 8rpx 0 16rpx;
	}

	.ring-preview-fallback {
		position: relative;
		border-radius: 50%;
		box-sizing: border-box;
		display: flex;
		align-items: center;
		justify-content: center;
		overflow: visible;
	}

	.ring-preview-arc {
		position: absolute;
		left: 50%;
		top: 50%;
		transform: translate(-50%, -50%);
		border-radius: 50%;
		z-index: 0;
	}

	.ring-preview-inner {
		position: absolute;
		left: 50%;
		top: 50%;
		transform: translate(-50%, -50%);
		border-radius: 50%;
		background: #fafbfc;
		z-index: 1;
		box-sizing: border-box;
	}

	.ring-preview-txt {
		position: relative;
		z-index: 2;
		font-size: 22rpx;
		color: #94a3b8;
	}

	.pv-summary {
		font-size: 20rpx;
		font-weight: 500;
		color: #1677ff;
		text-align: left;
		line-height: 1.35;
	}

	.section-title {
		font-size: 28rpx;
		font-weight: 600;
		color: #2d3748;
		margin-bottom: 28rpx;
	}

	.mode-grid {
		display: flex;
		flex-direction: row;
		flex-wrap: nowrap;
		align-items: center;
		justify-content: space-between;
		width: 100%;
	}

	.mode-item {
		display: flex;
		flex-direction: row;
		align-items: center;
		flex: 1;
		min-width: 0;
		justify-content: center;
		gap: 8rpx;
	}

	.mode-check {
		flex-shrink: 0;
		width: 32rpx;
		height: 32rpx;
		border-radius: 6rpx;
		box-sizing: border-box;
		border: 2rpx solid #1677ff;
		display: flex;
		align-items: center;
		justify-content: center;
		background: #fff;

		&.on {
			background: #1677ff;
			border-color: #1677ff;
		}
	}

	.mode-tick {
		color: #fff;
		font-size: 20rpx;
		font-weight: 600;
		line-height: 1;
	}

	.mode-label {
		font-size: 24rpx;
		color: #2d3748;
		white-space: nowrap;
	}

	.mode-tip {
		margin-top: 24rpx;
		font-size: 18rpx;
		color: rgba(45,55,72,.27);
		line-height: 1.4;
	}

	.current-panel {
		background: #fff;
		border-radius: 26rpx;
		padding: 22rpx;
		margin-bottom: 24rpx;
		box-shadow: 0 0 30rpx 14rpx rgba(236, 235, 236, 0.25);
	}

	.current-row {
		display: flex;
		gap: 20rpx;
	}

	.current-card {
		flex: 1;
		background: #f3f4f6;
		border-radius: 18rpx;
		padding: 28rpx 16rpx 24rpx;
		text-align: center;
	}

	.current-label {
		font-size: 24rpx;
		color: #2D3748;
		margin-bottom: 14rpx;
	}

	.current-val {
		font-size: 32rpx;
		font-weight: 600;
		line-height: 1;

		&.valley {
			color: #1677ff;
		}

		&.peak {
			color: #2d3748;
		}
	}

	.current-unit {
		font-size: 24rpx;
		margin-left: 4rpx;
	}

	.link-row {
		display: flex;
		align-items: center;
		justify-content: flex-end;
		gap: 10rpx;
		margin-top: 18rpx;
		padding-right: 4rpx;
	}

	.link-text {
		font-size: 24rpx;
		font-weight: 500;
		color: #1677ff;
	}

	.adjust-icon {
		width: 40rpx;
		height: 40rpx;
		display: flex;
		flex-direction: column;
		gap: 5rpx;
	}

	.adjust-line {
		position: relative;
		height: 4rpx;
		background: #1677ff;
		border-radius: 999rpx;
	}

	.adjust-dot {
		position: absolute;
		top: 50%;
		width: 8rpx;
		height: 8rpx;
		border-radius: 50%;
		background: #1677ff;
		transform: translateY(-50%);
	}

	.line-top .adjust-dot {
		left: 5rpx;
	}

	.line-mid .adjust-dot {
		left: 15rpx;
	}

	.line-bot .adjust-dot {
		left: 23rpx;
	}

	.hint-card {
		padding: 8rpx 6rpx 0;
	}

	.hint-line {
		display: flex;
		align-items: flex-start;
		line-height: 1.5;

		& + .hint-line {
			margin-top: 2rpx;
		}
	}

	.hint-dot {
		color: #2f8cff;
		font-size: 28rpx;
		line-height: 1.45;
		margin-right: 6rpx;
		flex-shrink: 0;
	}

	.hint-text {
		font-size: 22rpx;
		color: #2f8cff;
		line-height: 1.5;
	}

	.hint-link {
		font-size: 22rpx;
		color: #2f8cff;
		text-decoration: underline;
	}

	.footer-btn {
		flex-shrink: 0;
		padding: 24rpx 50rpx calc(24rpx + env(safe-area-inset-bottom));
		background: linear-gradient(180deg, rgba(243, 244, 246, 0) 0%, #f3f4f6 28%);
		margin-top: auto;

		/deep/ .u-btn {
			width: 100%;
		}
	}

	.ca-confirm-mask {
		position: fixed;
		left: 0;
		right: 0;
		top: 0;
		bottom: 0;
		z-index: 10090;
		display: flex;
		align-items: center;
		justify-content: center;
		padding: 0 48rpx;
	}

	.ca-confirm-mask-bg {
		position: absolute;
		left: 0;
		right: 0;
		top: 0;
		bottom: 0;
		background: rgba(0, 0, 0, 0.35);
	}

	.ca-confirm-dialog {
		position: relative;
		z-index: 1;
		width: 100%;
		max-width: 620rpx;
		background: #fff;
		border-radius: 20rpx;
		padding: 40rpx 34rpx 30rpx;
		box-sizing: border-box;
	}

	.ca-confirm-title {
		font-size: 36rpx;
		color: #2d3748;
		line-height: 1.5;
		margin-bottom: 34rpx;
	}

	.ca-confirm-actions {
		display: flex;
		gap: 24rpx;
	}

	.ca-btn {
		flex: 1;
		height: 64rpx;
		border-radius: 12rpx;
		font-size: 26rpx;
		display: flex;
		align-items: center;
		justify-content: center;
		box-sizing: border-box;
	}

	.ca-btn-cancel {
		background: #fff;
		border: 1px solid #9ea3ac;
		color: #5f636b;
	}

	.ca-btn-primary {
		background: #1677ff;
		color: #fff;
	}

	.op-time-mask {
		position: fixed;
		left: 0;
		right: 0;
		top: 0;
		bottom: 0;
		z-index: 10080;
		display: flex;
		align-items: flex-end;
		justify-content: stretch;
	}

	.op-time-mask-bg {
		position: absolute;
		left: 0;
		right: 0;
		top: 0;
		bottom: 0;
		background: rgba(0, 0, 0, 0.45);
	}

	.op-time-panel {
		position: relative;
		z-index: 1;
		width: 100%;
		max-width: none;
		background: #fff;
		border-radius: 28rpx 28rpx 0 0;
		box-shadow: 0 -8rpx 40rpx rgba(0, 0, 0, 0.12);
	}

	.modal {
		padding: 28rpx 28rpx calc(36rpx + env(safe-area-inset-bottom));
	}

	.modal-head {
		display: flex;
		align-items: center;
		justify-content: space-between;
		margin-bottom: 12rpx;
	}

	.modal-title {
		font-size: 34rpx;
		font-weight: 600;
		color: #1e293b;
	}

	.modal-close {
		padding: 8rpx;
	}

	.dial-wrap {
		position: relative;
		margin: 16rpx auto 24rpx;
		min-width: 440rpx;
		min-height: 440rpx;
		box-sizing: border-box;
		border-radius: 50%;
		box-shadow: 0 12rpx 48rpx rgba(15, 23, 42, 0.08);
	}

	.dial-canvas-full {
		position: absolute;
		left: 0;
		top: 0;
		display: block;
		z-index: 1;
		pointer-events: none;
		border-radius: 50%;
	}

	.dial-tick-layer {
		position: absolute;
		left: 0;
		top: 0;
		right: 0;
		bottom: 0;
		z-index: 4;
		pointer-events: none;
	}

	.dial-tick-item {
		position: absolute;
		background: #c7d2e2;
		border-radius: 999rpx;
		transform-origin: center center;
	}

	.dial-hour-layer {
		position: absolute;
		left: 0;
		top: 0;
		right: 0;
		bottom: 0;
		z-index: 5;
		pointer-events: none;
	}

	.dial-hour-item {
		position: absolute;
		font-size: 20rpx;
		line-height: 1;
		color: #62748a;
		font-weight: 500;
		transform-origin: center center;
	}

	.handle {
		position: absolute;
		width: 56rpx;
		height: 56rpx;
		z-index: 10;
		display: flex;
		align-items: center;
		justify-content: center;
	}

	.handle-dot {
		width: 32rpx;
		height: 32rpx;
		border-radius: 50%;
		background: #3fbfff;
		border: none;
		box-shadow: 0rpx 0rpx 16rpx 6rpx rgba(57, 181, 255, 0.44);
	}

	.h2 .handle-dot {
		background: #1979ff;
		box-shadow: 0rpx 0rpx 16rpx 6rpx rgba(39, 147, 255, 0.42);
	}

	.time-box {
		display: flex;
		gap: 20rpx;
		margin-bottom: 20rpx;
		padding: 0 50rpx;
	}

	.time-col {
		flex: 1;
		background: #F1F1F1;
		border-radius: 18rpx;
		padding: 20rpx 12rpx;
		text-align: center;
	}

	.time-col-title {
		font-size: 24rpx;
		color: #2D3748;
		margin-bottom: 14rpx;
	}

	.time-col-range {
		font-size: 24rpx;
		color: #1677ff;
	}

	.restore-pill {
		text-align: center;
		padding: 18rpx 24rpx;
		background: #F1F1F1;
		color: #1677ff;
		font-size: 24rpx;
		border-radius: 18rpx;
		margin-bottom: 14rpx;
		width: calc(100% - 80rpx);
		margin: 24rpx auto 30rpx;

		&.active {
			background: #1677ff;
			color: #fff;
		}
	}

	.modal-tip {
		font-size: 22rpx;
		color: #94a3b8;
		text-align: center;
		margin-bottom: 28rpx;
	}

	.modal-confirm {
		padding: 0 8rpx;
		width: calc(100% - 80rpx);
		margin: 0 auto;

		/deep/ .u-btn {
			width: 100%;
		}
	}
</style>

4.效果

小程序模仿iphone苹果手机滑动选择时间

相关推荐
杰建云1671 小时前
小程序如何做裂变?
小程序·小程序制作
阳光雨滴2 小时前
微信小程序使用canvas自定义富文本内容做图片分享
微信小程序·小程序
杰建云1672 小时前
小程序如何做活动?
小程序·小程序制作
这是个栗子2 小时前
【微信小程序问题解决】微信小程序全局 navigationBarTitleText 不起作用
微信小程序·小程序·导航栏
lpfasd1233 小时前
从“惯性思维”到“规则驱动”:一次微信小程序修复引发的 AI 编程范式思考
人工智能·微信小程序·小程序
万岳科技程序员小金3 小时前
从0到1搭建AI真人数字人小程序:源码方案与落地流程详解
人工智能·小程序·ai数字人小程序·ai数字人系统源码·ai数字人软件开发·ai真人数字人平台搭建
wanhengidc3 小时前
云服务器和物理服务器的不同之处
运维·服务器·网络·网络协议·智能手机
皮皮虾12344 小时前
云手机技术是怎么实现的
智能手机
星空下的曙光4 小时前
uniapp编译到微信小程序接口获取不到数据uni.request
微信小程序·小程序·uni-app