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苹果手机滑动选择时间