首页
直播
壁纸
友链
搜索
1
微信小程序 Skyline 渲染引擎实战:worklet 动画从原理到落地
327 阅读
2
微信小程序支付全链路实战:JSAPI 下单、调起支付、回调验签与退款
308 阅读
3
微信小程序云开发实战:云函数、云数据库与云存储的正确使用姿势
280 阅读
4
小程序接口安全攻防:签名、防重放、防刷与数据加密实战
276 阅读
5
微信小程序分包进阶:独立分包、预下载与分包异步化实战
272 阅读
服务器运维
后端技术
前端技术
梯子
数据库
小程序
登录
搜索
标签搜索
fastadmin
Redis
微信小程序
前端开发
RabbitMQ
Go
Golang
服务器
codex
buildadmin
小程序
mysql
Nginx
Docker
Vue3
Node.js
MySQL优化
Linux
TypeScript
JWT
沿途的风景
累计撰写
82
篇文章
累计收到
0
条评论
首页
栏目
服务器运维
后端技术
前端技术
梯子
数据库
小程序
页面
直播
壁纸
友链
搜索到
82
篇与
» admin
的结果
2026-08-01
AB-Admin:给 Typecho 后台换上 Material Design 3 新装
前言Typecho 的默认后台界面,怎么说呢——功能齐全,但长相确实停留在上个时代的审美。白底蓝链接、紧凑的表格、没有圆角没有过渡动画,每次写文章都像在填 Excel 表格。如果你也有同感,那 AB-Admin(Admin Beautify) 可能正是你在找的东西。这是一款基于 Material Design 3 设计体系的 Typecho 后台美化增强插件,号称"可能是史上颜值最高的 Typecho 后台美化插件"。本文结合我博客上实际安装使用的 v2.1.43 版本,从功能介绍、安装配置到实际体验,完整聊聊这个插件。插件概览项目信息插件名AB-Admin(原名 Admin Beautify)作者LHL当前版本v2.1.43设计体系Material Design 3(MD3)Typecho 版本基于 1.3.0 开发,兼容 1.2.1GitHubgithub.com/lhl77/Typecho-Plugin-AdminBeautify配套插件AB Store(AdminBeautifyStore)插件仓库核心功能1. MD3 主题色系统AB-Admin 不是简单换个 CSS 皮,而是注入了一套完整的 Material Design 3 设计 Token 变量。打开插件代码可以看到,它定义了几十组 CSS 自定义属性::root { --md-primary: #6750A4; --md-on-primary: #FFFFFF; --md-surface: #FFFBFF; --md-on-surface: #1C1B1F; --md-outline: #79747E; /* ... 几十个变量 */ }暗色模式也有一套对应变量(--md-dark-primary、--md-dark-surface 等),切换时自动生效。这意味着插件不仅仅改了配色,而是建立了一套可被其他插件引用的设计系统。插件内置了多种预设配色方案(紫色、红色、蓝色等),也可以自定义主题色。颜色值会根据亮/暗模式自动生成对应的明暗变体。2. 亮暗模式切换通过 <html> 标签的 data-theme="light" 或 data-theme="dark" 属性手动切换,不依赖系统偏好。这比 @media (prefers-color-scheme: dark) 更灵活——你可以白天用亮色、晚上切暗色,不管系统设置是什么。对于插件开发者来说,暗色样式建议用 [data-theme="dark"] 选择器覆盖,而不是依赖媒体查询:/* 正确写法 */ .my-card { background: var(--md-surface-container-low, #f7f2fa); } [data-theme="dark"] .my-card { background: var(--md-surface-container-low, #1d1b20); }3. 响应式设计内置移动端适配,在手机上自动切换为顶部折叠菜单模式。后台管理不再只能坐在电脑前操作——手机上也能舒舒服服地审阅评论、管理文章。4. 自定义登录页背景支持设置登录页背景图片 URL,并可调整虚化方式和虚化大小。告别千篇一律的默认登录页,给博客一个有辨识度的后台入口。5. 双布局模式支持侧边栏布局和顶部导航布局两种后台布局,根据个人习惯自由切换。侧边栏模式更传统,顶部导航模式则更接近现代 Web 应用的风格。6. AJAX 页面导航后台页面切换采用 AJAX 方式,不完整重载页面。每次导航后会派发 ab:pageload 事件,让兼容脚本知道页面变了、该重新执行修复了。// 监听 AB-Admin 的 AJAX 导航事件 document.addEventListener('ab:pageload', function(e) { var url = e.detail.url; console.log('页面已切换到:', url); // 在这里重新绑定 DOM 事件、注入样式等 });这个设计直接影响了兼容脚本的工作方式——稍后详细说。开发者 APIAB-Admin 不只是"好看",它还提供了一套面向插件开发者的 API。横幅通知:showNotice()if (typeof AdminBeautify !== 'undefined') { AdminBeautify.showNotice('操作成功!', { type: 'success' }); }调用前记得判断 AdminBeautify 是否存在,避免插件未启用时报错。对话框 API:alert / confirm / promptAB-Admin 覆写了原生的 alert、confirm、prompt,将它们替换为 MD3 风格的异步弹窗。同时也提供了 Promise 化的 API:// 异步确认框 const result = await AdminBeautify.confirm('确定删除这篇文章?'); if (result) { // 用户点了确认 } // 异步输入框 const name = await AdminBeautify.prompt('请输入分类名称:');踩坑提醒:覆写后,原生 confirm() 和 prompt() 不再阻塞——它们会立即返回 false / null。如果你的老代码依赖同步返回值,逻辑会被打断,需要改用 Promise API。兼容性脚本系统这是 AB-Admin 最有特色的设计之一。插件目录下有个 assets/compat/ 文件夹,专门存放用于修复其他插件排版问题的 JS 脚本:assets/compat/ ├── FuckAdComment.js # 反广告评论增强 ├── Links.js # Links Plus 友链插件兼容 ├── Mirages.js # Mirages 主题兼容 ├── Notice.js # Notice 通知插件兼容 ├── TelegramNotice.js # Telegram 推送插件兼容 ├── TeStore.js # TeStore 插件仓库兼容 ├── Typecho121.js # Typecho 1.2.1 版本兼容 └── README.md # 开发文档工作原理AB-Admin 自动扫描 compat/ 目录下所有 .js 文件每个脚本的元数据(名称、简介、适用插件)展示在设置页面用户可单独启用/禁用每个脚本支持一键从 GitHub 同步最新兼容脚本支持添加外部 JS 链接加载额外兼容脚本脚本开发规范每个兼容脚本需要在头部注释中声明元数据:/** * @name MyPlugin 兼容 * @description 修复 MyPlugin 在 AB-Admin 下的排版问题 * @plugins MyPlugin * @version 1.0.0 * @author YourName */关键注意事项:必须判断页面:通过 URL 或 DOM 确认是否为目标页面,不要影响其他页面监听 ab:pageload:由于 AJAX 导航,脚本只在首次加载执行一次,不监听此事件则跳转后修复不生效保持幂等:修复函数可能被多次调用,避免重复注入;离开目标页面时需清理已注入的样式兼容暗色模式:同时处理 [data-theme="dark"] 下的显示效果AB Store:配套插件仓库AB-Admin 还有一个配套插件 AdminBeautifyStore(AB Store),它是一个 Typecho 插件仓库,可以在后台直接搜索、安装、管理其他插件,不需要再手动下载上传。AB Store 的主要功能:在后台浏览插件市场一键安装/更新插件支持开发者投稿与 AB-Admin 深度集成,安装的插件自动适配兼容脚本两个插件配合使用,Typecho 的插件管理体验直接拉满,从"手动 FTP 时代"进入了"应用商店时代"。安装方法方法一:手动安装前往 GitHub Releases 下载最新版本解压后将 AdminBeautify 文件夹上传至 /usr/plugins/进入后台 → 控制台 → 插件 → 启用 AdminBeautify清除浏览器缓存(重要!)方法二:通过 AB Store 安装如果你已经装了 AB Store,可以直接在插件市场搜索安装。安装后配置启用插件后进入设置页面,可以配置:主题色:选择预设方案或自定义颜色布局模式:侧边栏 / 顶部导航登录页背景:填入图片 URL,调整虚化参数兼容脚本:逐个启用/禁用字体:可选加载 Noto Sans SC外部 JS:添加额外兼容脚本链接实际体验我博客上实际安装的就是 v2.1.43 版本。几个直观感受:界面:MD3 风格确实好看很多,圆角卡片、层次分明的阴影、流畅的过渡动画,写文章时心情都好了。暗色模式在夜间使用非常舒适,不是简单的黑白反转,而是有一套完整的暗色色板。性能:主 JS 文件约 200KB,CSS 约 197KB,体积不算小但可以接受。AJAX 导航让后台切换页面几乎无感,比原生整页刷新快不少。兼容性:内置的兼容脚本覆盖了常见的 Typecho 插件(Notice、Links Plus、TelegramNotice 等),基本开箱即用。如果你用的是不太常见的插件,可能需要自己写兼容脚本,但文档写得很清楚,门槛不高。稳定性:从 2.1.4x 版本起完全不再加密 JS 代码,透明度很高。作者更新也比较勤快,GitHub 上的 issue 响应及时。注意事项清除缓存:启用后如果样式异常,先清浏览器缓存异步弹窗陷阱:confirm() / prompt() 不再阻塞,老代码需要适配1.2.1 兼容性:Typecho 1.2.1 下导航栏可能有 CSS 问题,需在设置中开启 1.2.1 兼容脚本CSS 冲突:如果之前装过其他后台美化插件(如 SimpleAdmin),可能存在样式冲突弹窗 z-index:第三方插件的全局弹窗建议 z-index 设为 9999 以上(AB 侧边栏约 1000)字体继承:自定义组件建议用 font-family: inherit 跟随页面字体总结AB-Admin 是目前 Typecho 生态里完成度最高的后台美化插件,没有之一。它不只是换了一套皮肤,而是建立了一套完整的设计系统——从 MD3 色彩变量到开发者 API,从兼容脚本机制到 AJAX 导航,每个环节都考虑到了。如果你还在用 Typecho 默认后台,强烈建议试试。装完之后你会觉得:原来 Typecho 后台也可以这么好看。GitHub:github.com/lhl77/Typecho-Plugin-AdminBeautifyAB Store:github.com/lhl77/Typecho-Plugin-AdminBeautifyStore作者博客:blog.lhl.one
2026年08月01日
21 阅读
0 评论
0 点赞
2026-08-01
uni-app Canvas 海报生成实战:绘制、二维码、保存相册与分享
生成推广海报是营销类小程序的高频需求:商品详情页"分享海报"、邀请好友"专属邀请码"。要在 Canvas 上绘制图片、文字、二维码,再保存到相册或转发,每一步都有跨端陷阱。本文完整实现一个生产级海报生成方案。一、需求拆解一张典型推广海报包含四层:背景图(设计师出图,固定模板)商品图/用户头像(网络图片,动态)文案文字(标题、价格、邀请语)小程序码/二维码(后端生成或前端绘制)┌─────────────────┐ │ 背景图模板 │ │ ┌───────┐ │ │ │ 商品图 │ │ │ └───────┘ │ │ 商品标题 │ │ ¥99.00 │ │ ┌────┐ 长按识别 │ │ │二维码│ │ │ └────┘ │ └─────────────────┘二、踩坑第一关:网络图片必须先下载Canvas 的 drawImage 在小程序端不能直接绘制网络图片——必须先 uni.getImageInfo 下载到本地临时路径:async function loadImages(urls) { const promises = urls.map(url => new Promise((resolve, reject) => { uni.getImageInfo({ src: url, success: resolve, fail: reject }) }) ) return Promise.all(promises) }两个前置条件:图片域名要加入 downloadFile 合法域名(小程序后台配置),否则真机直接失败开发者工具勾选"不校验合法域名"只在开发阶段有效,上线前必须配置好三、Canvas 绘制核心代码模板与画布初始化<template> <view class="poster-mask" v-if="visible" @click="visible = false"> <view class="poster-wrap" @click.stop> <canvas canvas-id="posterCanvas" id="posterCanvas" class="poster-canvas" :style="{ width: canvasWidth + 'px', height: canvasHeight + 'px' }" /> <view class="poster-actions"> <button class="action-btn" @click="saveToAlbum">保存到相册</button> </view> </view> </view> </template> <script setup> import { ref } from 'vue' const visible = ref(false) // 以 750x1200 设计稿为例,画布用 2 倍图保证清晰度 const DESIGN_WIDTH = 750 const DESIGN_HEIGHT = 1200 const canvasWidth = ref(0) const canvasHeight = ref(0) async function show(posterData) { visible.value = true // 按屏幕宽度等比缩放画布,但内部坐标系按设计稿 const { windowWidth } = uni.getSystemInfoSync() canvasWidth.value = windowWidth * 0.9 canvasHeight.value = canvasWidth.value * (DESIGN_HEIGHT / DESIGN_WIDTH) await draw(posterData) } defineExpose({ show }) </script>绘制函数async function draw(data) { const scale = canvasWidth.value / DESIGN_WIDTH // 微信新版 canvas 2d 接口更清晰,旧 canvas-id 接口在新基础库中表现不稳 const ctx = uni.createCanvasContext('posterCanvas') // 1. 预下载所有网络图片 const [bg, goodsImg, avatar, qrCode] = await loadImages([ data.bgUrl, data.goodsUrl, data.avatarUrl, data.qrUrl ]) ctx.drawImage(bg.path, 0, 0, DESIGN_WIDTH, DESIGN_HEIGHT) // 2. 商品图(圆角矩形裁剪) drawRoundImage(ctx, goodsImg.path, 40, 200, 670, 500, 16) // 3. 标题:手动换行 drawWrappedText(ctx, data.title, { x: 40, y: 740, maxWidth: 670, lineHeight: 44, maxLines: 2, fontSize: 34, color: '#333' }) // 4. 价格 ctx.setFillStyle('#e93b3d') ctx.setFontSize(28) ctx.fillText('¥', 40, 860) ctx.setFontSize(44) ctx.fillText(data.price, 70, 860) // 5. 头像 + 昵称 drawRoundImage(ctx, avatar.path, 40, 1000, 72, 72, 36) ctx.setFillStyle('#666') ctx.setFontSize(26) ctx.fillText(`${data.nickname} 邀请你`, 130, 1045) // 6. 二维码 ctx.drawImage(qrCode.path, 560, 980, 160, 160) ctx.setFillStyle('#999') ctx.setFontSize(22) ctx.fillText('长按识别', 570, 1175) // 7. 绘制(scale 到实际画布大小) ctx.scale(scale, scale) ctx.draw(false, () => { // 绘制完成回调,可以导出了 posterReady.value = true }) }圆角图片裁剪Canvas 1.0 接口没有原生圆角,用路径裁剪实现:function drawRoundImage(ctx, path, x, y, w, h, r) { ctx.save() ctx.beginPath() // 圆角矩形路径 ctx.moveTo(x + r, y) ctx.arcTo(x + w, y, x + w, y + h, r) ctx.arcTo(x + w, y + h, x, y + h, r) ctx.arcTo(x, y + h, x, y, r) ctx.arcTo(x, y, x + w, y, r) ctx.closePath() ctx.clip() // 裁剪 ctx.drawImage(path, x, y, w, h) ctx.restore() }文字自动换行Canvas 1.0 的 fillText 不换行,手动按宽度切分:function drawWrappedText(ctx, text, { x, y, maxWidth, lineHeight, maxLines, fontSize, color }) { ctx.setFillStyle(color) ctx.setFontSize(fontSize) const chars = text.split('') let line = '' let lines = [] for (const char of chars) { const test = line + char if (ctx.measureText(test).width > maxWidth) { lines.push(line) line = char if (lines.length === maxLines) break } else { line = test } } if (lines.length < maxLines && line) lines.push(line) // 最后一行超限时加省略号 if (lines.length === maxLines && line) { let last = lines[maxLines - 1] while (ctx.measureText(last + '...').width > maxWidth) { last = last.slice(0, -1) } lines[maxLines - 1] = last + '...' } lines.forEach((l, i) => { ctx.fillText(l, x, y + i * lineHeight) }) }四、导出与保存相册async function saveToAlbum() { if (!posterReady.value) { return uni.showToast({ title: '海报生成中', icon: 'none' }) } // 1. 导出画布为图片 const [err, res] = await new Promise((resolve) => { uni.canvasToTempFilePath({ canvasId: 'posterCanvas', // 重要:destWidth/Height 用设计稿尺寸,解决导出模糊问题 destWidth: DESIGN_WIDTH * 2, destHeight: DESIGN_HEIGHT * 2, success: resolve, fail: resolve }) }) if (err) { return uni.showToast({ title: '导出失败', icon: 'none' }) } // 2. 保存到相册(需要授权) const [saveErr] = await new Promise((resolve) => { uni.saveImageToPhotosAlbum({ filePath: res.tempFilePath, success: () => resolve([null]), fail: (e) => resolve([e]) }) }) if (saveErr) { // 授权被拒的处理 if (saveErr.errMsg.includes('auth deny') || saveErr.errMsg.includes('authorize')) { showModalThenOpenSetting() } else { uni.showToast({ title: '保存失败', icon: 'none' }) } return } uni.showToast({ title: '已保存到相册', icon: 'success' }) } function showModalThenOpenSetting() { uni.showModal({ title: '提示', content: '需要相册权限才能保存海报,请在设置中开启', success: (res) => { if (res.confirm) { uni.openSetting({}) } } }) }保存相册授权被拒是必踩的坑:第一次拒绝后,后续调用 saveImageToPhotosAlbum 会直接 fail,必须引导用户去 openSetting 手动开启。这段兜底代码一定要有。五、小程序码的两种来源方式一:后端生成(推荐)带参小程序码必须由后端调 wxacode.getUnlimited 接口生成:// 前端只要传参 const qrUrl = await request.post('/qrcode/generate', { scene: `uid=${userStore.userInfo.id}`, // 最大 32 字符 page: 'pages/goods/detail' })scene 参数会出现在 onLoad 的 options.query 里,注意 32 字符上限和仅支持数字/字母/部分符号。方式二:前端绘制普通二维码不需要小程序码(H5 分享场景)时,可以纯前端生成。引一个小巧的 QR 库(如 weapp-qrcode),把矩阵点画到 canvas 上即可,零网络请求。六、新版 Canvas 2D 接口(进阶)微信基础库 2.9+ 提供 Canvas 2D 接口,类型对齐 Web 标准,清晰度控制更好:async function drawWithCanvas2d(data) { // 获取 canvas 节点 const query = uni.createSelectorQuery() query.select('#posterCanvas').fields({ node: true, size: true }) const { node, width } = await new Promise(r => query.exec(res => r(res[0]))) const dpr = uni.getSystemInfoSync().pixelRatio node.width = width * dpr // 物理像素 node.height = canvasHeight.value * dpr const ctx = node.getContext('2d') ctx.scale(dpr, dpr) // 之后就是标准 Web Canvas API:roundRect、drawImage(Image 实例)... const img = node.createImage() img.src = bgTempPath await new Promise(r => { img.onload = r }) ctx.drawImage(img, 0, 0, width, canvasHeight.value) }新版接口的图片要用 node.createImage() 创建 Image 实例加载,支持 ctx.roundRect() 原生圆角,长望建议迁移。旧接口(canvas-id + createCanvasContext)目前仍广泛使用,本文两种都给了方案。七、完整流程串联用户点击"生成海报" → show(posterData) 打开弹层 → loadImages 预下载(失败 toast + 埋点) → ctx 绘制各图层 → draw 回调标记 ready → 用户点"保存" → canvasToTempFilePath 导出(destWidth 2倍防模糊) → saveImageToPhotosAlbum(授权被拒 → openSetting 引导) → toast 成功总结网络图片绘制前必须 getImageInfo 下载,域名要配白名单导出防模糊:destWidth/destHeight 设为逻辑尺寸 × 2Canvas 1.0 圆角靠 clip 裁剪,文字换行靠 measureText 手动切保存相册被拒授权后必须 openSetting 引导,这是审核与差评高发点带参小程序码后端生成,scene 限 32 字符新项目直接上 Canvas 2D 接口,对齐 Web 标准海报功能链路长、坑密集,把本文的流程清单存好,下次实现能省一半时间。
2026年08月01日
23 阅读
0 评论
0 点赞
2026-07-31
uni-app WebSocket 即时通信实战:心跳保活、断线重连与消息列表
客服聊天、订单状态推送、实时协作——WebSocket 是小程序实时通信的主力方案。但 uni.connectSocket 只是一根裸管道:弱网下说断就断、切后台被系统杀掉、消息去重和顺序全要自己做。本文实现一套健壮的 Socket 管理层,并以聊天消息列表为落地场景。一、原生 API 的局限// 裸用 uni.connectSocket 的典型代码 uni.connectSocket({ url: 'wss://api.example.com/ws' }) uni.onSocketMessage((res) => { console.log(res.data) // 然后呢? })生产环境马上会遇到的问题:连接会静默断开:网络抖动、服务器超时、手机切后台,断了没人通知业务层没有重连机制:断开后必须用户手动刷新页面消息没有可靠性保障:发送方不知道对方收没收到,重复消息无法识别所以要封装的核心能力:心跳检测 + 指数退避重连 + 消息 ACK。二、Socket 管理类实现// utils/socket.js class SocketManager { constructor(options = {}) { this.url = options.url this.heartbeatInterval = options.heartbeatInterval || 30000 this.reconnectMaxDelay = options.reconnectMaxDelay || 30000 this.task = null // SocketTask this.isConnected = false this.manualClosed = false // 主动关闭标记,区分意外断开 this.heartbeatTimer = null this.reconnectTimer = null this.reconnectCount = 0 // 重连次数(用于退避) this.messageHandlers = new Map() // 按消息类型分发 this.pendingQueue = [] // 未连接时的待发送队列 this.seq = 0 // 消息序号,用于 ACK this.pendingAcks = new Map() // seq -> { resolve, timer } } // ========== 连接管理 ========== connect() { if (this.isConnected) return this.manualClosed = false // uni-app 返回 SocketTask,用任务级监听而非全局监听(支持多连接) this.task = uni.connectSocket({ url: this.url, success: () => {}, fail: () => this.scheduleReconnect() }) this.task.onOpen(() => { this.isConnected = true this.reconnectCount = 0 this.startHeartbeat() // 重连成功后:重新拉取离线消息(关键!) this.emit('reconnected') // 冲积积压消息 while (this.pendingQueue.length) { const payload = this.pendingQueue.shift() this.task.send({ data: JSON.stringify(payload) }) } }) this.task.onMessage((res) => this.handleMessage(res)) this.task.onClose(() => { this.isConnected = false this.stopHeartbeat() if (!this.manualClosed) { this.scheduleReconnect() // 意外断开才重连 } }) this.task.onError(() => { this.isConnected = false this.stopHeartbeat() if (!this.manualClosed) { this.scheduleReconnect() } }) } close() { this.manualClosed = true this.stopHeartbeat() clearTimeout(this.reconnectTimer) this.task && this.task.close({}) } // ========== 指数退避重连 ========== scheduleReconnect() { if (this.manualClosed) return if (this.reconnectTimer) return // 已有重连任务 this.reconnectCount++ if (this.reconnectCount > 10) { this.emit('dead') // 放弃重连,通知上层 return } // 指数退避:1s 2s 4s 8s...封顶 30s,加随机抖动防止雪崩 const delay = Math.min( 1000 * Math.pow(2, this.reconnectCount - 1), this.reconnectMaxDelay ) + Math.random() * 1000 this.reconnectTimer = setTimeout(() => { this.reconnectTimer = null this.connect() }, delay) } // ========== 心跳保活 ========== startHeartbeat() { this.stopHeartbeat() this.heartbeatTimer = setInterval(() => { // 期望 5 秒内收到 pong,超时视为假死 this.sendWithTimeout({ type: 'ping' }, 5000) .catch(() => { // pong 没回来:连接假死,强制重建 this.task && this.task.close({}) this.isConnected = false this.scheduleReconnect() }) }, this.heartbeatInterval) } stopHeartbeat() { clearInterval(this.heartbeatTimer) this.heartbeatTimer = null } // ========== 消息处理 ========== handleMessage(res) { let msg try { msg = JSON.parse(res.data) } catch (e) { return } // 心跳响应单独处理 if (msg.type === 'pong') { this.emit('_pong', msg) return } // ACK 响应:匹配待确认消息 if (msg.type === 'ack' && this.pendingAcks.has(msg.ackFor)) { const pending = this.pendingAcks.get(msg.ackFor) clearTimeout(pending.timer) this.pendingAcks.delete(msg.ackFor) pending.resolve() return } // 业务消息按类型分发 this.emit(msg.type, msg) } // ========== 发送(带 ACK 确认)========== send(data, { ack = true } = {}) { if (!this.isConnected) { // 未连接先入队,连接成功后冲积 this.pendingQueue.push(data) return Promise.resolve() } if (!ack) { return new Promise((resolve, reject) => { this.task.send({ data: JSON.stringify(data), success: resolve, fail: reject }) }) } // 带 seq 的可靠发送:超时未 ACK 视为失败 this.seq++ const payload = { ...data, seq: this.seq } return new Promise((resolve, reject) => { const timer = setTimeout(() => { this.pendingAcks.delete(this.seq) reject(new Error('消息未确认')) }, 10000) this.pendingAcks.set(this.seq, { resolve, timer }) this.task.send({ data: JSON.stringify(payload) }) }) } sendWithTimeout(data, timeout) { return Promise.race([ this.send(data), new Promise((_, reject) => setTimeout(reject, timeout)) ]) } // ========== 事件订阅 ========== on(event, handler) { if (!this.messageHandlers.has(event)) { this.messageHandlers.set(event, new Set()) } this.messageHandlers.get(event).add(handler) return () => this.off(event, handler) // 返回取消函数 } off(event, handler) { this.messageHandlers.get(event)?.delete(handler) } emit(event, payload) { this.messageHandlers.get(event)?.forEach(fn => fn(payload)) } } export default SocketManager三、与生命周期联动Socket 生命周期必须挂到 App 和页面上:// App.vue import SocketManager from '@/utils/socket' import { useUserStore } from '@/stores/user' let socket = null export default { onLaunch() { socket = new SocketManager({ url: `wss://api.example.com/ws?token=${useUserStore().token}` }) uni.$socket = socket }, onShow() { // 从后台回前台:直接重连(后台时连接多半已被杀) uni.$socket.connect() }, onHide() { // 切后台:主动断开省电省流量,回前台再连 uni.$socket.close() } }切后台策略:小程序切后台后 WebSocket 会在数秒内被系统挂起,与其等它假死,不如 onHide 主动 close、onShow 立即重连,配合服务端的离线消息补拉,体验反而更好。四、实战:聊天消息列表<script setup> import { ref, nextTick } from 'vue' const messages = ref([]) const inputText = ref('') const scrollTo = ref('') let offHandlers = [] onLoad() { const socket = uni.$socket // 订阅新消息 offHandlers.push( socket.on('chat:message', (msg) => { // 去重:服务端消息 ID if (messages.value.some(m => m.id === msg.id)) return messages.value.push({ id: msg.id, content: msg.content, fromMe: false, time: msg.time }) scrollToBottom() }), // 重连后补拉离线消息 socket.on('reconnected', async () => { const lastId = messages.value.length ? messages.value[messages.value.length - 1].id : null const offline = await request.get('/chat/messages', { afterId: lastId, limit: 50 }) // 按时间合并去重 const existIds = new Set(messages.value.map(m => m.id)) offline.filter(m => !existIds.has(m.id)) .forEach(m => messages.value.push({ id: m.id, content: m.content, fromMe: false, time: m.time })) scrollToBottom() }) ) } onUnload() { // 页面销毁必须解绑,否则消息处理器泄漏 offHandlers.forEach(off => off()) } async function send() { const text = inputText.value.trim() if (!text) return inputText.value = '' // 乐观更新:先上屏,发送失败再标记 const tempId = `temp_${Date.now()}` messages.value.push({ id: tempId, content: text, fromMe: true, time: Date.now(), sending: true }) scrollToBottom() try { await uni.$socket.send({ type: 'chat:send', content: text }) const msg = messages.value.find(m => m.id === tempId) if (msg) msg.sending = false } catch (e) { const msg = messages.value.find(m => m.id === tempId) if (msg) { msg.sending = false msg.failed = true // 标记失败,支持点击重发 } } } function scrollToBottom() { nextTick(() => { scrollTo.value = `msg-${messages.value.length - 1}` }) } </script> <template> <scroll-view scroll-y class="chat-list" :scroll-into-view="scrollTo"> <view v-for="(msg, index) in messages" :key="msg.id" :id="`msg-${index}`" class="msg-row" :class="{ mine: msg.fromMe }" > <view class="bubble"> <text>{{ msg.content }}</text> <text v-if="msg.sending" class="status">…</text> <text v-else-if="msg.failed" class="status failed" @click="resend(msg)">!</text> </view> </view> </scroll-view> <view class="input-bar"> <input v-model="inputText" confirm-type="send" @confirm="send" /> <button size="mini" @click="send">发送</button> </view> </template>几个体验细节:乐观更新 + 状态标记:消息立即上屏,sending 三点、failed 感叹号可点重发scroll-into-view 滚到底:比手动计算 scroll-top 简单可靠onUnload 解绑:on() 返回的取消函数必须调用,否则页面销毁后 handler 仍在跑五、服务端配合要点前端这层机制需要后端按约定配合,联调前对齐协议:// 客户端 ping → 服务端 pong { "type": "ping" } { "type": "pong" } // 客户端带 seq 发送 → 服务端回 ack { "type": "chat:send", "content": "hello", "seq": 1001 } { "type": "ack", "ackFor": 1001 } // 服务端推送(带全局唯一 id,客户端用于去重) { "type": "chat:message", "id": "srv_888888", "content": "hi", "time": 1724360000000 }服务端还需要:消息落库 + 离线消息接口(客户端重连后按 afterId 补拉)、单连接的 token 校验(URL 带 token 或首条消息鉴权)、同一账号多端登录的踢下线策略。六、小程序特殊限制wss 必须:正式环境只能连 wss,且域名要配到 socket 合法域名并发连接数:微信小程序同时最多 2 个 WebSocket 连接,多路复用靠消息 type 分发而不是开多条连接后台挂起:切后台约 5 秒后收不到消息,所以离线补拉机制不是可选项而是必选项App 端差异:App 端没有 2 连接限制,但同样受省电策略影响,onHide/onShow 的断连重连逻辑全端通用总结SocketManager 四件套:心跳保活、指数退避重连(带随机抖动)、发送队列、ACK 确认onHide 主动断、onShow 立即连、reconnected 事件触发离线补拉消息三要素:全局唯一 ID 去重、乐观更新上屏、失败可重发小程序最多 2 条并发连接,靠 type 分发复用单连接与后端先对齐协议(ping/ack/id)再动工,能省掉大量联调返工这套 Socket 层写一次可以用在所有项目里,聊天、推送、协作编辑都只是消息 type 的差异。
2026年07月31日
30 阅读
0 评论
0 点赞
2026-07-28
uni-app 打包发布与热更新实战:云打包、证书管理、审核与 wgt 热更
开发完成只是半程,把 uni-app 项目发布到微信、H5、App 三个渠道,每条路都有独立的流程和坑:证书管理、各平台审核规则、App 热更新。本文讲透发布链路的完整实操。一、发布全景渠道产物发布方式更新机制微信小程序wxapkg微信后台上传 + 提审冷启动异步更新H5静态文件部署到服务器/CDN刷新即最新App(Android)apk / aab应用市场/官网分发整包更新 + wgt 热更新App(iOS)ipaApp Store + TestFlight整包更新 + wgt(受限)二、微信小程序发布发布流程# 1. HBuilderX 发行 → 小程序-微信,生成编译产物 # 产物在 unpackage/dist/build/mp-weixin # 2. 用微信开发者工具打开该目录 # 3. 工具栏「上传」→ 填版本号和备注 → 上传为开发版本 # 4. 微信公众平台 → 版本管理 → 提交审核 → 审核通过 → 全量发布也可以用 miniprogram-ci 做命令行自动化上传:// scripts/upload-mp.js const ci = require('miniprogram-ci') async function upload() { const project = new ci.Project({ appid: 'wx_xxxxxxxx', type: 'miniProgram', projectPath: './unpackage/dist/build/mp-weixin', privateKeyPath: './private.key' // 微信后台下载的上传密钥 }) await ci.upload({ project, version: require('../package.json').version, desc: 'release: ' + new Date().toLocaleString(), setting: { es6: true, minify: true } }) }配到 package.json scripts 里,CI/CD 流水线一键发布。审核避坑清单微信审核被拒的高频原因,发布前自查:类目与功能不符:有交易功能但类目没选电商;有社区内容但没选社交类目诱导分享:分享得奖励、强制分享解锁功能的文案全部要清掉测试账号:提审备注里必须给一个可登录的测试账号(有登录功能时)隐私接口声明:用了 getLocation、chooseAddress、相机等隐私接口,必须在 app.json 的 requiredPrivateInfos 声明并申请权限虚拟支付:iOS 端虚拟商品(会员、课程)不能直接用微信支付,要走 IAP 或隐藏入口完整可体验:每个 tab 每个入口都要能点,"敬请期待"的空页面是高频被拒项小程序的静默更新机制小程序新版本发布后,已打开的用户用的仍是旧版,下次冷启动时检查更新。强制立即更新的方案:// App.vue const updateManager = uni.getUpdateManager() updateManager.onCheckForUpdate((res) => { console.log('是否有新版本:', res.hasUpdate) }) updateManager.onUpdateReady(() => { uni.showModal({ title: '更新提示', content: '新版本已准备好,是否重启应用?', success(res) { if (res.confirm) { updateManager.applyUpdate() // 立即应用新版本并重启 } } }) }) updateManager.onUpdateFailed(() => { uni.showToast({ title: '更新失败,请删除小程序重新打开', icon: 'none' }) })三、H5 发布构建与部署# HBuilderX 发行 → 网站-H5,或 CLI npx uni build # 产物 unpackage/dist/build/h5部署要点:# Nginx 配置:history 路由需要 fallback server { listen 443 ssl; server_name h5.example.com; root /var/www/h5; index index.html; location / { try_files $uri $uri/ /index.html; } # 静态资源长缓存(文件名带 hash) location /static/ { expires 1y; add_header Cache-Control "public, immutable"; } }跨域处理:H5 调后端接口要么后端开 CORS,要么 Nginx 加反向代理把 /api 转发到服务端,生产环境强烈推荐代理方案,前端请求同源路径,省掉 CORS 一切麻烦。微信 H5 特殊处理在微信内打开的 H5 要做微信 JS-SDK 授权(自定义分享卡片、微信支付):公众号后台配 JS 接口安全域名后端实现签名接口(access_token → jsapi_ticket → signature)前端 wx.config 注入配置后才能调分享等 API四、App 打包:云打包与证书证书体系认知Android:正式签名证书(keystore):自己用 keytool 生成,务必自己保管,丢了就无法给老用户覆盖升级云打包时可以用 DCloud 公共测试证书(仅测试),正式包必须用自己的证书# 生成 Android 证书 keytool -genkey -alias myapp -keyalg RSA -keysize 2048 -validity 36500 \ -keystore myapp.keystoreiOS:需要 Apple 开发者账号(个人版 $99/年)证书(p12)+ 描述文件(mobileprovision)+ Bundle ID 三件套发布 App Store 需要 distribution 证书;真机调试需要 development 证书云打包实操HBuilderX → 发行 → 原生App-云打包 → 选择平台(Android/iOS) → 填证书信息(Android: keystore + 密码 + 别名) → 勾选原生插件(推送、地图、支付等模块) → 打包云打包排队可能要几分钟到半小时。打完的产物:Android:apk 直接可装;上架 Google Play 需要转 aabiOS:ipa 需要 TestFlight 或 XCode 上传(Windows 用户的经典痛点,需要 Mac 或云打包的"越狱包/测试包"过渡)Android 各应用市场分发国内市场(华为、小米、OPPO、vivo、应用宝)每个都要单独注册开发者、上传软著或承诺函、单独提审。实际操作建议:官网直接放 apk 下载(最快触达存量用户)主流市场覆盖上架(获取新用户的主要渠道)各市场对隐私政策、权限用途说明的审查口径不同,逐个过隐私合规(上架必过项)国内 App 上架的硬性要求:首次启动弹《隐私政策》,用户同意前不得初始化任何采集 SDK(uni-app 里注意 App.vue onLaunch 的时机,把需要合规前置的 SDK init 放到同意回调之后)申请权限(定位、相册、相机)必须先弹用途说明在应用市场后台填写隐私采集清单五、wgt 热更新:不发整包的升级App 发版要走市场审核,周期长。纯前端代码(页面、JS、样式)的修改可以打 wgt 包热更新,只有原生层变更(新增原生插件、修改 SDK 配置)才必须整包升级。服务端版本接口// GET /app/version?platform=android&version=1.2.0 { "code": 0, "data": { "forceUpdate": false, "wgtUrl": "https://cdn.example.com/app/1.2.1.wgt", "apkUrl": "https://cdn.example.com/app/app-1.3.0.apk", "updateLog": "1. 修复已知问题\n2. 优化性能" } }wgtUrl 有值走热更,只有 apkUrl 走整包(判断逻辑:版本号第一位变化 → 整包,否则 wgt)。客户端热更新实现// App.vue export default { onLaunch() { this.checkUpdate() }, methods: { async checkUpdate() { // #ifdef APP-PLUS const { version, platform } = await this.getAppInfo() try { const res = await request.get('/app/version', { platform: platform === 'android' ? 'android' : 'ios', version }) if (this.compareVersion(res.wgtVersion, version) > 0) { this.doWgtUpdate(res) } else if (this.compareVersion(res.version, version) > 0) { this.doApkUpdate(res) // 大版本整包 } } catch (e) { // 静默失败,不影响启动 } // #endif }, getAppInfo() { return new Promise((resolve) => { plus.runtime.getProperty(plus.runtime.appid, (widgetInfo) => { resolve({ version: widgetInfo.version, // wgt 版本 platform: uni.getSystemInfoSync().platform }) }) }) }, compareVersion(v1, v2) { const a = v1.split('.').map(Number) const b = v2.split('.').map(Number) for (let i = 0; i < 3; i++) { if (a[i] > b[i]) return 1 if (a[i] < b[i]) return -1 } return 0 }, doWgtUpdate({ wgtUrl, updateLog, forceUpdate }) { uni.showModal({ title: `发现新版本`, content: updateLog, showCancel: !forceUpdate, success: (res) => { if (!res.confirm) return uni.showLoading({ title: '更新中...' }) uni.downloadFile({ url: wgtUrl, success: (downloadRes) => { if (downloadRes.statusCode !== 200) return plus.runtime.install( downloadRes.tempFilePath, { force: false }, () => { uni.hideLoading() uni.showModal({ title: '更新完成', content: '重启应用后生效', showCancel: false, success: () => plus.runtime.restart() }) }, (e) => { uni.hideLoading() uni.showToast({ title: '安装失败', icon: 'none' }) } ) }, fail: () => { uni.hideLoading() uni.showToast({ title: '下载失败', icon: 'none' }) } }) } }) } } }wgt 包的生成与发布HBuilderX → 发行 → 原生App-制作应用wgt包生成 .wgt 文件上传到 CDN,更新服务端版本接口的 wgtVersion 字段即可。wgt 热更的纪律:原生层改动绝不发 wgt:新增了原生插件、改了 manifest 的 SDK 配置,wgt 升级后必崩wgt 版本号独立递增:和应用整包版本分开管理,plus.runtime.getProperty 拿到的是 wgt 版本iOS 慎用:Apple 对热更新口径收紧,wgt 仅限 JS 资源修复,且不保证过审安全;能走 App Store 就走 App Store六、版本管理建议package.json 的 version 作为唯一真实源,小程序、App 版本号统一从这读取云打包产物按 app-android-v1.2.1.apk 命名归档到对象存储,出问题可回滚服务端保留接口灰度能力:wgtUrl 按用户 ID 取模灰度放量,热更出问题影响面可控小程序保留"版本回退"操作权限:微信后台可在 24 小时内回退到上一版总结小程序发布链路:上传 → 提审 → 全量,miniprogram-ci 可自动化;静默更新用 UpdateManager 主动提示Android 证书自己生成自己保管,丢了等于丢掉全部老用户;iOS 三件套缺一不可合规是上架硬门槛:隐私弹窗前不初始化任何采集 SDKwgt 热更新只适用于前端层变更,原生变更必须整包版本号统一管理 + CDN 归档 + 灰度放量,是热更安全的最后防线发布链路琐碎且各平台规则持续变动,本文流程可作为 checklist,每次发版对照走一遍。
2026年07月28日
35 阅读
0 评论
0 点赞
2026-07-27
uni-app 登录授权全流程实战:微信登录、手机号验证码与头像昵称填写
微信登录体系这几年持续收紧:getUserProfile 收回了、头像昵称获取改填写了、手机号要企业认证了。网上大量教程已经过时。本文基于当前有效的接口体系,实现一套完整的登录授权方案,并覆盖 H5 与 App 端的差异。一、当前微信登录接口的正确认知先纠正几个过时认知:接口现状wx.getUserInfo已回收,返回匿名数据wx.getUserProfile已于 2022 年后收紧,新版本基本不可用头像昵称官方推荐用「头像昵称填写能力」(open-type 按钮 + input type=nickname)getPhoneNumber可用,但需要企业主体小程序,且按次收费wx.login 换 openid正常可用,这是静默登录的基础结论:openid 换 token 做静默登录 + 头像昵称用户主动填 + 手机号授权按钮,是当前唯一合规的组合。二、静默登录:openid 链路用户打开小程序不需要任何操作就完成注册/登录:前端 wx.login 获取 code → POST /auth/silent-login { code } → 后端 code2session 换 openid + session_key → 后端按 openid 查/建用户 → 签发 token → 前端存 token,静默登录完成// stores/user.js async silentLogin() { const [err, res] = await uni.login({ provider: 'weixin' }) if (err) return const data = await request.post('/auth/silent-login', { code: res.code }) this.token = data.accessToken this.isNewUser = data.isNewUser // 后端标记:openid 没绑定过手机号 }后端 code2session 注意点:code 只能用一次、5 分钟有效;session_key 千万不能下发到前端(安全隐患,微信明确禁止);unionid 需要绑定开放平台才能拿到,多端账号打通靠它。三、头像昵称填写:官方推荐方案微信现在要求用户"主动填写"头像和昵称,配套了两个专用 UI 能力:头像:button open-type="chooseAvatar"<template> <button class="avatar-btn" open-type="chooseAvatar" @chooseavatar="onChooseAvatar"> <image class="avatar" :src="avatarUrl || '/static/default-avatar.png'" mode="aspectFill" /> <text class="avatar-tip">点击选择头像</text> </button> </template> <script setup> import { ref } from 'vue' const avatarUrl = ref('') function onChooseAvatar(e) { // 临时文件路径,需要上传到自己的服务器换取永久 URL avatarUrl.value = e.detail.avatarUrl } </script>关键:e.detail.avatarUrl 是临时路径,小程序重启即失效,必须立刻上传:async function onChooseAvatar(e) { const tempPath = e.detail.avatarUrl uni.showLoading({ title: '上传中' }) try { const url = await uploadFile(tempPath) // 走自己的上传接口 avatarUrl.value = url } finally { uni.hideLoading() } }昵称:input type="nickname"<template> <view class="form-item"> <text class="label">昵称</text> <input v-model="nickname" type="nickname" placeholder="请输入昵称" @blur="onNicknameBlur" /> </view> </template> <script setup> import { ref } from 'vue' const nickname = ref('') function onNicknameBlur(e) { // type=nickname 的 input 在部分机型 v-model 同步不及时,blur 时兜底读取 nickname.value = e.detail.value } </script>type="nickname" 会唤起微信官方的昵称快捷填写键盘(自动带入微信昵称),这是目前唯一合规的获取昵称方式。提交完善资料async function saveProfile() { if (!avatarUrl.value) return uni.showToast({ title: '请选择头像', icon: 'none' }) if (!nickname.value.trim()) return uni.showToast({ title: '请填写昵称', icon: 'none' }) await request.post('/me/profile', { avatar: avatarUrl.value, nickname: nickname.value }) userStore.fetchProfile() uni.showToast({ title: '保存成功' }) setTimeout(() => uni.navigateBack(), 800) }四、手机号授权:企业认证方案getPhoneNumber 需要 button 触发,且小程序必须完成微信认证(企业主体):<template> <button class="phone-btn" open-type="getPhoneNumber" @getphonenumber="onGetPhone" > 授权手机号登录 </button> </template> <script setup> async function onGetPhone(e) { const detail = e.detail // 用户拒绝授权 if (!detail.code) { return uni.showToast({ title: '您取消了授权', icon: 'none' }) } // 新版接口:detail.code 交给后端,后端调 getuserphonenumber 换手机号 const data = await request.post('/auth/bind-phone', { code: detail.code }) userStore.userInfo = data.user uni.showToast({ title: '登录成功' }) } </script>当前流程(2023 之后的版本):用户点击授权按钮 → e.detail.code(动态令牌) → POST /auth/bind-phone { code } → 后端用 code + access_token 调微信接口换真实手机号 → 绑定用户,返回更新后的用户信息注意事项:个人主体小程序用不了这个能力,认证费用 300 元/年,手机号验证按次计费(约 0.03 元/次)旧版 encryptedData + iv 解密方案还能用但不推荐,code 方案更安全且免维护密钥计费压力大的场景可以改做短信验证码登录(自建),绕开微信计费短信验证码登录(自建方案)<template> <view class="sms-login"> <view class="input-row"> <input v-model="phone" type="number" maxlength="11" placeholder="手机号" /> </view> <view class="input-row"> <input v-model="smsCode" type="number" maxlength="6" placeholder="验证码" /> <button class="sms-btn" :disabled="countdown > 0" @click="sendSms"> {{ countdown > 0 ? `${countdown}s后重试` : '获取验证码' }} </button> </view> <button class="login-btn" @click="loginBySms">登录</button> </view> </template> <script setup> import { ref, onUnmounted } from 'vue' const phone = ref('') const smsCode = ref('') const countdown = ref(0) let timer = null async function sendSms() { if (!/^1[3-9]\d{9}$/.test(phone.value)) { return uni.showToast({ title: '手机号格式错误', icon: 'none' }) } await request.post('/auth/sms/send', { phone: phone.value }) countdown.value = 60 timer = setInterval(() => { if (--countdown.value <= 0) clearInterval(timer) }, 1000) } async function loginBySms() { const data = await request.post('/auth/sms/login', { phone: phone.value, code: smsCode.value }) userStore.token = data.accessToken userStore.userInfo = data.user uni.reLaunch({ url: '/pages/index/index' }) } onUnmounted(() => timer && clearInterval(timer)) </script>后端要点:验证码 5 分钟有效、同一手机号 60 秒内不可重发、验证失败 5 次锁定、按手机号 + IP 双维度限流防刷。五、多端登录差异// utils/auth.js —— 收敛各端登录入口 export async function doLogin() { // #ifdef MP-WEIXIN const [err, res] = await uni.login({ provider: 'weixin' }) return request.post('/auth/wx-login', { code: res.code }) // #endif // #ifdef APP-PLUS // App 端:一键登录(运营商授权) const [loginErr, loginRes] = await uni.login({ provider: 'univerify' }) return request.post('/auth/univerify-login', { accessToken: loginRes.authResult.access_token, openid: loginRes.authResult.openid }) // #endif // #ifdef H5 // H5 端:微信公众号网页授权 const appId = 'wx_xxx' const redirect = encodeURIComponent(location.href) location.href = `https://open.weixin.qq.com/connect/oauth2/authorize?appid=${appId}` + `&redirect_uri=${redirect}&response_type=code&scope=snsapi_userinfo#wechat_redirect` // #endif }H5 回调页解析 code 换 token:// H5 授权回调页面 onLoad() { const code = new URLSearchParams(location.search).get('code') if (code) { const data = await request.post('/auth/h5-wx-login', { code }) userStore.token = data.accessToken } }六、登录拦截的优雅实现全局拦截而非每页手写判断。方案是封装路由跳转 + 页面 meta 声明:// pages.json 页面需要登录的加 custom 字段(或维护一个白名单数组) const LOGIN_REQUIRED = ['pages/cart/cart', 'pages/order/list'] // 重写跳转方法统一拦截 const originalNavigateTo = uni.navigateTo uni.navigateTo = function(options) { const path = options.url.split('?')[0].replace(/^\//, '') const userStore = useUserStore() if (LOGIN_REQUIRED.includes(path) && !userStore.isLoggedIn) { return originalNavigateTo({ url: `/pages/login/login?redirect=${encodeURIComponent(options.url)}` }) } return originalNavigateTo(options) }登录成功后回跳:async function handleLoginSuccess() { const redirect = decodeURIComponent( new URLSearchParams(location.search).get('redirect') || getCurrentPagesArgs('redirect') || '' ) uni.reLaunch({ url: redirect || '/pages/index/index' }) }总结静默登录靠 wx.login + code2session,session_key 留在服务端头像用 open-type="chooseAvatar",昵称用 type="nickname",临时文件必须立即上传手机号授权需要企业认证 + 计费,自建短信验证码是省钱替代登录入口用条件编译分端收敛,重写 navigateTo 做全局登录拦截常见过时方案自查:getUserProfile、encryptedData 解密、无企业认证却调 getPhoneNumber登录授权是合规重灾区,本文方案基于当前有效接口,建议每半年对照微信官方文档核对一次。
2026年07月27日
45 阅读
0 评论
0 点赞
1
...
3
4
5
...
17
0:00