沿途的风景 - admin
type: channel
link: https://inspirecl.asia/index.php/author/1/
description: admin
items: webman 生产部署实战：进程守护、平滑发布、监控告警与微服务化
items: 让 YoduPlayer 切页不断歌：给 Typecho 博客定制 PJAX 的完整实战
items: webman 高并发进阶：协程实战、连接池调优与压测方法论
items: webman 数据库实战：连接池、ORM 选型、事务边界与大数据处理
items: webman 中间件与依赖注入实战：洋葱模型、PHP-DI 容器与 AOP
items: webman 架构解析：常驻内存进程模型与 PHP-FPM 的本质差异
items: uni-app 状态管理实战：Pinia 集成、持久化适配与登录态设计
items: AB-Admin：给 Typecho 后台换上 Material Design 3 新装
items: uni-app Canvas 海报生成实战：绘制、二维码、保存相册与分享
items: uni-app WebSocket 即时通信实战：心跳保活、断线重连与消息列表

webman 生产部署实战：进程守护、平滑发布、监控告警与微服务化
type: item
date: 2026-08-22T13:40:29+08:00
description: webman 生产部署实战：进程守护、平滑发布、监控告警与微服务化把 webman 写出来是一回事，让它安稳跑在生产上是另一回事。没有 PHP-FPM 和 supervisord 的成熟套路兜底，常驻内存服务的部署、发布、监控都有一套自己的打法。这篇是 webman 系列收官：从部署形态、平滑发布到监控告警体系，最后聊聊什么时候该拆微服务。一、部署形态：三种选择1. 裸机/虚拟机 + systemd（中小项目首选）# /etc/systemd/system/webman.service [Unit] Description=webman app After=network.target mysql.service redis.service [Service] Type=forking WorkingDirectory=/var/www/app ExecStart=/usr/bin/php start.php start -d ExecReload=/usr/bin/php start.php reload ExecStop=/usr/bin/php start.php stop Restart=always RestartSec=3 [Install] WantedBy=multi-user.targetsystemctl daemon-reload systemctl enable --now webman systemctl reload webman # 平滑发布用 reload要点：Type=forking（start.php -d 是 daemonize 模式）、Restart=always 兜底进程崩溃、ExecReload 挂 reload 实现无感发布。2. Docker 容器化FROM php:8.2-cli-alpine RUN apk add --no-cache libpq libzip-dev && docker-php-ext-install pdo_mysql opcache COPY . /app WORKDIR /app RUN composer install --no-dev --optimize-autoloader EXPOSE 8787 # 容器内前台运行（容器本身就是守护者，不需要 -d） CMD ["php", "start.php", "start"]容器化要点：容器里必须前台运行（去掉 -d），让 PID 1 就是 master 进程，容器崩溃重启策略（restart: always）接管守护职责。K8s 场景用 readiness 探针指向健康检查接口。3. Supervisor 托管（多语言混合部署环境）老牌方案，配置直观，适合还有 Python/Go 混跑的机器。二、平滑发布：让 reload 真正"无感"php start.php reload 的机制是逐个重启 worker（上一篇讲过），但要真正做到无感发布，还有四个坑要填：坑一：reload 不加载的东西reload 只重新加载 worker，master 进程内存里的东西不会更新：路由配置文件、config/ 目录、bootstrap 钩子。改了这些必须 restart（master 重建，有感知）。发布脚本要区分：#!/bin/bash # 简易发布脚本 git pull composer install --no-dev --optimize-autoloader php start.php reload # 常规发布 # 改了 config/route/bootstrap 时：php start.php restart坑二：发布瞬间的连接抖动worker 逐个重启期间，正在处理中的请求要等它完成（超过 max_wait 会被强杀）。发布时把 max_wait_time 临时调大、避开高峰期，长任务（导出、批处理）走独立进程不随 worker 重启。坑三：静态文件缓存webman 自带静态文件支持时，浏览器缓存的旧 JS 配合新 HTML 可能白屏——资源文件名带 hash（app.a3f8c2.js）彻底解决。坑四：发布失败的回滚# 用软链接做版本切换，回滚 = 切回旧链接 ln -sfn /var/www/releases/20260823-0200 /var/www/app_current php start.php restart # 软链指向变了，restart 生效保留最近 5 个版本目录，回滚 10 秒完成。没有回滚方案的发布都是赌博。三、监控告警体系：四个层次常驻内存服务必须主动监控——它出问题的方式是"安静地僵死"，不是崩溃报错。层次一：进程存活（最基础）# 心跳脚本（cron 每分钟）…

让 YoduPlayer 切页不断歌：给 Typecho 博客定制 PJAX 的完整实战
type: item
date: 2026-08-18T10:30:00+08:00
description: 让 YoduPlayer 切页不断歌：给 Typecho 博客定制 PJAX 的完整实战博客装了 YoduPlayer 这款背景音乐播放器后，一直有个很破坏体验的问题：音乐正放着，点进一篇文章，页面一刷新，歌就断了。访客每看一篇新文章就要重新点一次播放，背景音乐的"背景"两个字完全名存实亡。这篇文章记录我把这个问题彻底解决的全过程：从原理分析，到方案选型，再到自己动手写一个约 200 行的轻量 PJAX，最后处理掉一串切页后才会暴露的隐藏 Bug。完整代码都在文中，可以直接抄走用。一、先搞清楚：歌为什么会断浏览器里的音频播放依赖一个 DOM 元素 （YoduPlayer 里就是全局的 yaudio 对象）。而传统页面跳转的本质是：点击链接 → 浏览器销毁整个文档 → 请求新页面 → 重新解析 HTML/CSS/JS文档销毁的瞬间，挂在文档里的 元素跟着被销毁，播放自然中断。新页面里插件重新输出了一套播放器 HTML 和 JS，但那是一个全新的 yaudio，播放进度、当前曲目全部归零。所以问题的根源不在插件，而在于整页刷新这种导航方式本身。想让音乐不断，就不能销毁承载播放器的那份文档。二、方案选型：为什么是 PJAX解决思路业界已经很成熟了——局部刷新：导航时只替换页面的内容区域，头部、底部、播放器所在的 DOM 保持不动。实现方式常见的有三种：方案原理缺点iframe把整站套进框架页URL 不变、SEO 灾难、移动端体验差SPA 改造前端框架接管路由Typecho 主题基本要重写，成本过高PJAXAJAX 拉取新页面 + History API 改地址只需主题小幅配合PJAX（PushState + AJAX）的原理一句话就能说清：拦截链接点击 → preventDefault 阻止跳转 → XHR 拉取新页面 HTML → 用 DOMParser 解析 → 只取内容区域替换进当前文档 → pushState 更新地址栏整个过程文档从未销毁， 一直活着，音乐自然不断。同时 URL 会真实变化、浏览器前进后退可用、对搜索引擎完全透明——这是它碾压 iframe 的地方。YoduPlayer 的 README 里也写了"需要主题支持 pjax 或 instantclick"，说明作者早就预留了这条路，缺的只是主题侧的实现。我的主题是 Joe，直接引现成的 PJAX 库和主题代码有各种兼容性小毛病，所以干脆自己写了一个定制版。三、动手实现3.1 确定内容容器边界第一步是划分"哪些 DOM 切页时要换，哪些不能动"。看 Joe 主题的 index.php 结构： <?php $this->footer(); ?> 很清晰：#Joe 是内容容器，切页时替换它的 innerHTML；播放器、脚本初始化代码都在容器外，天生不受影响。如果你的主题结构不同，把这层边界划对是第一件事。3.2 核心 PJAX 脚本在主题里新建 assets/lib/pjax/pjax.js，核心不到 200 行：(function () { 'use strict'; var CONTAINER = '#Joe'; var NO_INSTANT = 'data-no-instant'; var xhr; history.replaceState({ url: location.href }, '', location.href); // 1. 拦截站内链接点击 document.addEventListener('click', function (e) { if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; var link = e.target.closest('a'); if (!link) return; if (link.target === '_blank' || link.hasAttribute('download')) return; var href = link.getAttribute('href'); if (!href || href.charAt(0) === '#' || href.indexOf('javascript:') === 0) return; var url; try { url = new URL(link.href); } catch (err) { return; } if (url.origin !== location.origin) return; // 外链放行 if (url.pathname.indexOf('/admin/') === 0) return; // 后台放行 if (url.h…

webman 高并发进阶：协程实战、连接池调优与压测方法论
type: item
date: 2026-08-15T18:27:21+08:00
description: webman 高并发进阶：协程实战、连接池调优与压测方法论webman 裸奔就能跑出很高的 QPS，但"框架快"不等于"业务快"——一个同步 HTTP 调用就能让 worker 的事件循环卡住，吞吐瞬间跌到个位数。这篇讲 webman 高并发的三板斧：协程化改造、连接池调优、压测方法论。这是 webman 中高级的分水岭。一、先理解瓶颈：worker 事件循环是怎么被卡死的webman 的 worker 是单进程单线程事件循环（Reactor 模型）。在同一个 worker 里，请求是串行处理的：worker-1 事件循环： 事件：请求A到达 → 处理A（其中调用外部API耗时 200ms，同步阻塞！） （这 200ms 内，分配给 worker-1 的所有其他请求全部排队） 事件：请求B到达 → 处理B推论：平均响应时间 = 排队时间 + 处理时间。 只要处理路径上有阻塞调用，worker 的吞吐上限就是 1 / 平均阻塞时长。200ms 的外部调用意味着单个 worker 每秒最多 5 个请求，16 个 worker 也就 80 QPS——和框架本身的几十万 QPS 上限毫无关系。解法只有两条：消灭阻塞（异步/协程化），或者增加并行度（协程）。二、协程：webman 的三种姿势1. 内置 Swoole/Swow 协程驱动webman 支持通过配置切换协程运行时：// config/server.php 'settings' => [ 'event_loop' => \Workerman\Events\Swoole::class, // 或 Swow ],开启后，worker 内自动协程化：遇到 IO（MySQL、Redis、HTTP）时挂起当前协程，让出事件循环处理其他请求——同一个 worker 从串行变成并发。Swoole 与 Swow 的选择：Swoole 生态成熟但与 PHP 原生扩展偶有兼容性问题（hook 覆盖不全的扩展会退化为阻塞）；Swow 是 PHP 官方系方案更干净但生态年轻。生产选型建议：现有业务全是标准 PDO/cURL 的选 Swoole 一把梭；有奇怪扩展依赖的先在预发验证 Swow。2. 协程客户端协程化后最大的红利是并发聚合调用：use Swoole\Coroutine; use function Swoole\Coroutine\go; use function Swoole\Coroutine\waitGroup; // 聚合页：并行拉取用户/商品/优惠券，总耗时 = max 而不是 sum $wg = new waitGroup(); $results = []; go(function () use ($wg, &$results) { $wg->add(); $results['user'] = $this->userApi->get($uid); $wg->done(); }); go(function () use ($wg, &$results) { $wg->add(); $results['item'] = $this->itemApi->get($itemId); $wg->done(); }); $wg->wait(3.0); // 总超时 3s // 原来 200+150+100=450ms，现在 max(200,150,100)=200msBFF/聚合接口的标准优化手段，P99 延迟直接砍半。3. 协程的三个纪律纪律一：协程切换点后不信任局部状态。 yield 期间其他协程可能改了共享数据。请求级数据老老实实走 $request 属性，全局状态放 Redis。纪律二：连接必须绑协程。 协程 A 半截的事务，切换后协程 B 接着用同一个连接 = 事务串台。Swoole 的 PDO hook + webman/database 的连接池已处理绑定关系，但你自己 new PDO 存静态属性再跨协程用，必炸。纪律三：CPU 密集任务协程救不了。 协程只解决 IO 并发，CPU 密集计算照样卡死事件循环。重计算丢给自定义进程或队列，别在 worker 里跑。三、连接池：协程时代的正确配置协程化后并发量上来了，单连接成了新瓶颈，必须配连接池（webman 通过 webman/database/webman/redis 的连接池实现，Swoole 驱动下生效）：// config/database.php（连接池相关参数） 'pool' => [ 'max_connections' => 32, // 每进程最大连接数 'min_connections' => 2, // 保底连接 'connect_timeout' => 3, // 取连接超时 'wait_timeout' …

webman 数据库实战：连接池、ORM 选型、事务边界与大数据处理
type: item
date: 2026-08-15T09:22:54+08:00
description: webman 数据库实战：连接池、ORM 选型、事务边界与大数据处理数据库是 webman 与传统 FPM 框架差异最大的环节。FPM 下每个请求开新连接、请求结束关闭，天然隔离；webman 的每个 worker 进程持有自己的连接并跨请求复用——连接管理从"框架的事"变成了"你的事"。这篇讲透连接池配置、think-orm 与 Eloquent 的选型、事务的正确边界，以及千万级数据的处理姿势。一、常驻内存下的连接模型先看清楚 webman 的数据库连接结构：worker-1 ─── 连接A（MySQL）──┐ worker-2 ─── 连接B ├── MySQL max_connections 上限（如 1000） worker-3 ─── 连接C │ ...（16 个 worker） │ └── Redis 连接同理推论一：最大连接数 = worker 进程数 × 每进程连接数。 16 个 worker、每个用了 2 个连接配置（读写分离），就是 32 个常驻连接。上线前先算这笔账，别和 MySQL 的 max_connections 打架。推论二：连接是懒建立的。 进程启动时不连库，第一次查询才建立。所以刚 reload 完的进程有冷启动延迟，压测时记得先预热。推论三：连接会"死"。 MySQL wait_timeout（默认 8 小时）内没有活动，服务端单方面断开，webman 侧还握着死连接，下一个请求直接 MySQL has gone away。二、断线重连：三层配置兜底以 think-orm（webman 默认集成）为例：// config/thinkorm.php return [ 'default' => 'mysql', 'connections' => [ 'mysql' => [ 'type' => 'mysql', 'hostname' => '127.0.0.1', 'database' => 'app', 'username' => 'root', 'password' => 'root', 'charset' => 'utf8mb4', 'break_reconnect' => true, // ① 断线自动重连 'fields_cache' => true, // ② 字段信息缓存（常驻内存的红利） ], ], ];三层兜底缺一不可：break_reconnect => true：ORM 层检测断连自动重连心跳保活：定时进程每 5 分钟发一条 SELECT 1，让连接永不超时（webman 的 config/process.php 里加自定义进程实现）重试幂等：断线重连只对查询安全，写操作重试必须保证幂等，否则可能双写// 心跳进程示例 return [ 'db-keepalive' => [ 'handler' => function () { while (true) { Db::query('SELECT 1'); sleep(300); } }, ], ];三、ORM 选型：think-orm 还是 Laravel Eloquent？webman 官方两者都支持，选型看团队基因：维度think-ormEloquent（illuminate/database）上手成本ThinkPHP 系团队零成本Laravel 系团队零成本模型功能基础够用关联预加载、访问器、软删更丰富常驻内存适配fields_cache 等针对性优化需要自己注意静态缓存生态迁移TP 项目平移Laravel 项目平移中大型项目的建议：ORM 只做"查询构造 + 结果映射"，复杂查询直接裸 SQL 或查询构造器。模型层越薄，常驻内存下的心智负担越小。一个 Eloquent 用户必须知道的区别：FPM 下 DB::connection() 每请求新建，webman 下连接跨请求驻留——事务没有提交/回滚，下一个请求会接着在这个事务里跑，数据不一致的幽灵bug就是这么来的。think-orm 的 Db::startTrans() 同理。// ❌ 事故现场：异常没回滚，连接带着事务回到池里 try { Db::startTrans(); // ... 写操作 throw new \RuntimeException('boom'); // 没有 catch 回滚！ } finally { Db::commit(); // finally 里 commit，异常时也 commit 了半截数据？ }四、事务的正确姿势铁律一：事务边界必须完整闭合try { Db::startTrans(); $this->deductStock($skuId, $num); // 扣库存 $this->createOrder($uid, $skuI…

webman 中间件与依赖注入实战：洋葱模型、PHP-DI 容器与 AOP
type: item
date: 2026-08-06T00:49:04+08:00
description: webman 中间件与依赖注入实战：洋葱模型、PHP-DI 容器与 AOPwebman 的请求处理链路看似简单（路由 → 中间件 → 控制器），但中间的洋葱模型和 PHP-DI 容器藏着不少中级开发者没吃透的细节。这篇文章把中间件的执行顺序、全局/路由级/方法级三种挂载方式、依赖注入的边界一次性讲清楚，最后再聊一个常驻内存框架特有的话题——AOP。一、洋葱模型：代码顺序与执行顺序是两回事class AuthMiddleware implements MiddlewareInterface { public function process(Request $request, callable $handler): Response { // ── 前置逻辑（进入时执行）── $userId = $this->jwt->parseToken($request); $request->userId = $userId; $response = $handler($request); // ← 下一层洋葱 // ── 后置逻辑（返回时执行）── $response->withHeader('X-Trace-Id', $request->header('x-trace-id')); return $response; } }三层中间件的执行顺序：请求 → [A前置] → [B前置] → [C前置] → Controller 响应 ← [A后置] ← [B后置] ← [C后置] ←最容易踩的坑：异常发生时的执行路径。 如果控制器抛异常，中间件的后置代码默认不会执行——除非你在中间件里 try/catch 包住 $handler($request)。跨域中间件经常因此"时灵时不灵"：异常响应没带 CORS 头，浏览器报的不是后端错误而是跨域错误。所以 CORS 中间件要这样写：try { $response = $handler($request); } catch (\Throwable $e) { $response = response($e->getMessage(), 500); } return $response->withHeaders([ 'Access-Control-Allow-Origin' => '*', 'Access-Control-Allow-Headers' => 'Content-Type, Authorization', ]);二、三种挂载粒度1. 全局中间件（config/middleware.php）return [ // 每个请求都会经过 app\middleware\AccessLog::class, app\middleware\Cors::class, ];适合：日志、CORS、限流。注意全局中间件每个请求都会跑，别放重逻辑。2. 路由/应用级中间件// config/route.php Route::group('/admin', function () { Route::resource('/users', app\admin\controller\UserController::class); })->middleware([ app\middleware\AdminAuth::class, ]); // 或整个应用目录生效：app/admin/config/middleware.php return [ app\middleware\AdminAuth::class, ];多应用（多入口目录）模式下，app/{应用名}/config/middleware.php 只对该应用生效——这是做多租户后台、API 版本隔离的天然工具。3. 控制器方法级（中间件别名）// config/middleware.php 里注册别名 return [ 'auth' => [app\middleware\Auth::class], ]; // 控制器里按方法指定 class OrderController { #[Middleware(['auth'])] // 整个控制器生效 public function index() {} #[Middleware(['auth', 'throttle:60,1'])] public function create() {} }执行优先级：全局 → 应用级 → 路由级 → 方法级。同层内按注册顺序。三、实战：三个高频中间件的正确写法限流中间件（基于 Redis 滑动窗口）class Throttle implements MiddlewareInterface { public function __construct(private Redis $redi…

webman 架构解析：常驻内存进程模型与 PHP-FPM 的本质差异
type: item
date: 2026-08-04T22:09:17+08:00
description: webman 架构解析：常驻内存进程模型与 PHP-FPM 的本质差异webman 是基于 Workerman 构建的高性能 PHP 框架，官方基准 QPS 常态化压到十几万。但很多人把 webman 当"换个写法的 Laravel"来用，结果内存泄漏、全局状态污染这些经典问题轮番上演。要真正用好 webman，必须先理解它与 PHP-FPM 在架构上的本质差异——这是所有后续实践的地基。一、两种执行模型：请求级生命周期 vs 常驻内存PHP-FPM：每次请求都是新生浏览器 → Nginx → PHP-FPM(worker) → 加载框架 → 初始化 → 处理请求 → 返回 → 销毁一切FPM 模式下，每个请求结束都会释放全部内存、销毁所有变量。代价是每次请求都要重新加载框架（几十毫秒的 bootstrap），但好处是状态绝对隔离——你再怎么写全局变量，下一个请求都是白纸。webman：一次加载，永久服务浏览器 → webman(worker 进程) → 直接路由分发 → 处理请求 → 返回（框架和对象留在内存）webman 启动时把框架、路由表、配置全部加载进内存，之后每个请求只是"触发一次回调"。QPS 高的根源就在这：省掉了每请求一次的框架 bootstrap 开销。这个差异带来两个直接推论：性能提升的本质是"摊销"：初始化成本只付一次，请求越多收益越大内存不自动释放：请求之间共享进程内存，全局状态会"存活"——这是福也是祸二、进程模型：master / worker 的分工master 进程（不处理业务） ├── 监听端口、管理 worker ├── fork 出 N 个 worker 进程 └── 收到 reload 信号时逐个平滑重启 worker │ ├── worker-1 ← 每个进程独立事件循环，可同时 hold 住数千连接 ├── worker-2 ├── ... └── worker-N（默认 = CPU 核数）三个关键认知：1. worker 数不是越多越好。 webman 是多进程 + 每进程事件循环（非阻塞 IO）模型，worker 数建议等于 CPU 核数。如果你的业务有阻塞调用（同步 HTTP 客户端、同步 RPC），连接会"卡住"事件循环，这时才需要加进程数来对冲——但更好的解法是消灭阻塞调用（后面协程篇细讲）。2. 进程间不共享内存。 你在 worker-1 里写的静态变量，worker-2 看不见。跨进程共享状态要用 Redis 或进程间通信，别指望 static 属性全站生效。3. request 对象请求间隔离，但对象池里的组件不是。 框架层面的组件（DB 连接、日志实例）跨请求复用，这是性能来源；你自己的类如果注册成了单例，它同样跨请求存活。三、启动流程：从 public/index.php 说起// public/index.php（简化） require_once __DIR__ . '/../vendor/autoload.php'; support\App::run();App::run() 内部做了什么：1. 加载 .env 与 config/*.php（此后修改配置需重启） 2. 扫描 app/controller 生成路由表（注解路由另算） 3. 初始化容器（webman 基于 PHP-DI，支持构造器注入） 4. 加载 bootstrap/*.php（进程启动时执行一次的钩子） 5. 初始化数据库/Redis 连接（懒加载，首次使用时建立） 6. master fork workers → 每个 worker 进入事件循环等待请求bootstrap 目录是理解 webman 的钥匙：放在这里的代码每个进程只执行一次。数据库连接的建立、连接池的初始化、Swoole 协程运行时的开启，都在这个阶段。官方的 webman/database、webman/redis 等插件都是通过 bootstrap 文件挂载的。四、常驻内存的四宗罪与解法罪一：静态属性 / 全局变量跨请求污染// ❌ 经典事故：上一单用户的数据泄漏给下一单 class OrderService { public static ?array $currentUser = null; } // 请求A设置了 currentUser，请求B进来时它还活着！解法：请求级数据一律走 Request 对象传递，或用 $request->xxx 属性挂载。静态属性只允许存"进程级不变量"（配置、编译好的正则、容器实例）。罪二：内存泄漏每请求增长的数组、未释放的大结果集、监听器越挂越多……FPM 下这些泄漏随请求结束被清零，webman 下会累积到 memory_limit 进程崩溃。排查手段：// 在中间件里记录内存水…

uni-app 状态管理实战：Pinia 集成、持久化适配与登录态设计
type: item
date: 2026-08-04T02:50:27+08:00
description: 页面通信篇里简单提过 Pinia，但真实项目的状态管理远不止"装个库"。多端 storage 差异、持久化插件适配、登录态设计、模块划分，每一步都有坑。本文把 uni-app 状态管理的完整方案一次性讲透。一、为什么 uni-app 项目需要状态管理先看反例——不用状态管理时的典型代码：// 每个页面都要重复拉用户信息 onShow() { const userId = uni.getStorageSync('user_id') const res = await request.get(`/users/${userId}`) this.user = res }问题：重复请求、数据不同步（A 页改了昵称 B 页还是旧的、storage 读写散落各处。状态管理的本质是把跨页面共享的响应式数据收口到一个地方。哪些数据该进全局 store：数据类型示例方案登录态token、用户信息store + 持久化业务共享状态购物车、选中的收货地址store页面间临时传值列表页→详情页参数URL / EventChannel单页面私有状态表单数据data / ref判断标准：两个以上无父子关系的页面需要读同一份数据 → 上 store；只在跳转链上传递 → 用页面通信方案。不要为了"显得规范"把所有东西塞进 store。二、Pinia 集成// main.js import { createSSRApp } from 'vue' import { createPinia } from 'pinia' import App from './App.vue' export function createApp() { const app = createSSRApp(App) app.use(createPinia()) return { app } }// stores/user.js import { defineStore } from 'pinia' export const useUserStore = defineStore('user', { state: () => ({ token: '', userInfo: null }), getters: { isLoggedIn: (state) => !!state.token, displayName: (state) => state.userInfo?.nickname || '未登录' }, actions: { async login(code) { const res = await request.post('/auth/wx-login', { code }) this.token = res.accessToken this.userInfo = res.user }, logout() { this.token = '' this.userInfo = null uni.reLaunch({ url: '/pages/login/login' }) } } })// 任意页面使用 import { useUserStore } from '@/stores/user' const userStore = useUserStore() userStore.isLoggedIn // getter，响应式 await userStore.login(code) // action三、持久化：storage 适配是关键直接持久化的问题小程序端没有 localStorage，Pinia 官方的 pinia-plugin-persistedstate 默认走 localStorage，在小程序端直接报错。两个解决路径：方案一：手写持久化（推荐，无依赖）// utils/persist.js import { watch, toRaw } from 'vue' /** * 将 store state 持久化到 uni storage * @param {Store} store pinia store 实例 * @param {string} key 存储键名 */ export function persistStore(store, key) { // 启动时恢复 const saved = uni.getStorageSync(key) if (saved) { store.$patch(JSON.parse(saved)) } // 变更时保存（deep watch） store.$subscribe((mutation, state) => { try { uni.setStorageSync(key, JSON.stringify(toRaw(state))) } …

AB-Admin：给 Typecho 后台换上 Material Design 3 新装
type: item
date: 2026-08-01T12:00:00+08:00
description: 前言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. 亮暗模式切换通过 标签的 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 风格的异步弹窗。同时也提供了…

uni-app Canvas 海报生成实战：绘制、二维码、保存相册与分享
type: item
date: 2026-08-01T07:50:04+08:00
description: 生成推广海报是营销类小程序的高频需求：商品详情页"分享海报"、邀请好友"专属邀请码"。要在 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 绘制核心代码模板与画布初始化 <view class="poster-mask" v-if="visible" @click="visible = false"> <view class="poster-wrap" @click.stop> <view class="poster-actions"> 保存到相册 </view> </view> </view> 绘制函数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, () => { // 绘制完成回调，可以导出了 po…

uni-app WebSocket 即时通信实战：心跳保活、断线重连与消息列表
type: item
date: 2026-07-31T11:33:22+08:00
description: 客服聊天、订单状态推送、实时协作——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({}) } // ===…