博客

  • 微信小程序开发 MCP

    https://github.com/yfmeii/weapp-dev-mcp

    https://www.npmjs.com/package/@yfme/weapp-dev-mcp

    起因是前几天微信开放社区说微信开发者工具 2.0 发布了,据说有增强 AI 协作能力的封装

    但是事实是并没有找到任何暴露 MCP 服务的迹象,社区也没有人做出回复

     

    那就只能自己动手了,小程序有一个自动化,是用来做自动化测试的,能通过编程的方式控制和读取运行状态下的小程序,也就是说设想中的完美开发方式,AI 完成编码后自己能走一遍交互能大大降低返工次数也能节省 AI 的使用成本

    当然有很多现成的别人做过的 MCP,大多都是以 cli 形式控制的,但是这样就存在一个问题,部分工具的 MCP 有沙箱机制,会导致 MCP 无法调用工作目录以外的文件,所以我这里主要使用 websocket 控制

    首先要打开微信开发者工具的自动化测试:

    1. 打开微信开发者工具
    2. 进入 设置 → 安全设置 → 服务端口
    3. 开启 “HTTP 调试” 和 “自动化测试”

     

    配置:

    {
      "mcpServers": {
        "weapp-dev": {
          "command": "npx",
          "args": [
            "-y",
            "-p", "@modelcontextprotocol/sdk@1.17.2",
            "-p", "fastmcp@3.23.0",
            "-p", "@yfme/weapp-dev-mcp",
            "weapp-dev-mcp"
          ],
          "env": {
            "WEAPP_WS_ENDPOINT": "ws://localhost:9420"
          }
        }
      }
    }

    启动微信开发者工具(macOS):

    /Applications/wechatwebdevtools.app/Contents/MacOS/cli auto --project /path/to/your/project --auto-port 9420

    其中:

    • --project 参数指定小程序项目目录路径(请替换为实际的项目路径)
    • --auto-port 参数指定 WebSocket 服务端口(默认 9420)

     

    提供工具:

    应用工具(Application Tools)

    • mp_ensureConnection – 确保自动化会话就绪;可选择强制重连或覆盖连接设置
    • mp_navigate – 在小程序内导航,支持 navigateToredirectToreLaunchswitchTab 或 navigateBack
    • mp_screenshot – 捕获屏幕截图并返回(或保存到磁盘)
    • mp_callWx – 调用微信小程序 API 方法(如 wx.showToast
    • mp_getLogs – 获取小程序控制台日志,可选择获取后清除

    页面工具(Page Tools)

    • page_getElement – 通过选择器获取页面元素
    • page_waitElement – 等待元素出现在页面上(⚠️ 不适用于自定义组件内部元素)
    • page_waitTimeout – 等待指定的毫秒数
    • page_getData – 获取当前页面的数据对象,可指定路径
    • page_setData – 使用 setData 更新当前页面的数据
    • page_callMethod – 调用当前页面实例上暴露的方法

    元素工具(Element Tools)

    • element_tap – 通过 CSS 选择器点击 WXML 元素
    • element_input – 向元素输入文本(适用于 input 和 textarea 组件)
    • element_callMethod – 调用自定义组件实例的方法(需要 ID 选择器)
    • element_getData – 获取自定义组件实例的渲染数据(需要 ID 选择器)
    • element_setData – 设置自定义组件实例的渲染数据(需要 ID 选择器)
    • element_getInnerElement – 获取元素内的元素(相当于 element.$(selector)
    • element_getInnerElements – 获取元素内的元素数组(相当于 element.$$(selector)
    • element_getSize – 获取元素大小(宽度和高度)
    • element_getWxml – 获取元素 WXML(内部或外部)
  • Swift 通过执行二进制文件增强功能

    Swift 使用 Process 可以执行二进制文件从而实现一定功能,例如下面的完整 图片压缩 例子

    pngquant 原本是没有 Swift 版本的,在我之前开发的的 macOS 原生 SwiftUI 版本切图上传工具中的图片压缩功能,手动实现起来极其复杂而且效果可能很差,所以使用了这种方法,目前稳定使用了数月

    大致步骤:

    1. 读取内置二进制文件 Bundle.main.path(forResource: "pngquant", ofType: nil)
    2. 创建 Process 实例 let process = Process()
    3. 在 Process 中指定二进制文件 process.executableURL = URL(fileURLWithPath: oxipngPath)
    4. 加入参数 process.arguments = ["--quality", "0-70", "--force", "-o", outputFilePath, inputFilePath]
    5. 创建管道 let pipe = Pipe(); process.standardOutput = pipe
    6. 启动进程 try process.run(); process.waitUntilExit()
    7. 非必要 读取输出_ = pipe.fileHandleForReading.readDataToEndOfFile()
    8. 在执行结束后进行下一步操作 if process.terminationStatus == 0 { // 结束 }
    
    import Foundation
    
    // 获取指定路径的文件大小
    func fileSize(atPath path: String) -> UInt64? {
        do {
            // 尝试获取指定路径文件的属性
            let attributes = try FileManager.default.attributesOfItem(atPath: path)
            // 返回文件大小属性(如果存在),并转换为 UInt64 类型
            return attributes[.size] as? UInt64
        } catch {
            // 如果无法获取文件属性,则打印错误信息
            print("无法获取文件大小: \(error)")
            return nil
        }
    }
    
    // 使用 pngquant 压缩文件并计算压缩效果
    func compress(url: String, outputPath: String) -> (originalFileSize: UInt64?, compressedFileSize: UInt64?, compressionRate: Double?) {
        // 尝试在应用程序包中找到 pngquant 二进制文件的路径
        guard let oxipngPath = Bundle.main.path(forResource: "pngquant", ofType: nil) else {
            fatalError("无法找到 pngquant 二进制文件")
        }
    
        // 使用 fileSize 函数获取原始文件大小
        guard let originalFileSize = fileSize(atPath: url) else {
            print("无法获取原始文件大小")
            return (nil, nil, nil)
        }
    
        // 设置 Process 以运行 pngquant 命令
        let process = Process()
        process.executableURL = URL(fileURLWithPath: oxipngPath)
        // 定义 pngquant 命令的参数
        process.arguments = ["--quality", "0-70", "--force", "-o", outputPath, url]
    
        do {
            // 尝试运行该进程
            try process.run()
            // 等待进程完成
            process.waitUntilExit()
    
            // 检查进程是否成功完成
            if process.terminationStatus == 0, let compressedFileSize = fileSize(atPath: outputPath) {
                // 计算压缩率(百分比)
                let compressionRate = Double(originalFileSize - compressedFileSize) / Double(originalFileSize) * 100
                // 返回原始大小、压缩后大小和压缩率
                return (originalFileSize, compressedFileSize, compressionRate.rounded())
            } else {
                // 如果优化失败,打印错误信息
                print("PNG 优化失败")
                return (originalFileSize, nil, nil)
            }
        } catch {
            // 如果进程启动失败,打印错误信息
            print("启动失败: \(error)")
            return (originalFileSize, nil, nil)
        }
    }
    

     

  • Golang 可选参数实现

    Golang 可选参数实现

    在部分方法例如公共方法中,为了平衡懒得填完参数和需要高度配置,又不想写那么多函数重载

    可以使用这个方式

    在这个方式下,可以在做这个方法参数的调整的同时不修改函数签名

    还能实现默认参数

    例子:

    // ServerOption 是一个用于配置 Server 的选项。
    type ServerOption struct {
    	f func(*Server)
    }
    
    // Server 表示服务器配置。
    type Server struct {
    	Address string
    	Port    int
    }
    
    // NewServer 创建一个新的 Server,并应用所有给定的配置选项。
    func NewServer(options ...ServerOption) *Server {
    	s := &Server{
    		Address: "localhost",
    		Port:    8080,
    	}
    	for _, option := range options {
    		option.f(s)
    	}
    	return s
    }
    
    // WithAddress 返回一个配置 Server 地址的选项。
    func WithAddress(address string) ServerOption {
    	return ServerOption{func(s *Server) {
    		s.Address = address
    	}}
    }
    
    // WithPort 返回一个配置 Server 端口的选项。
    func WithPort(port int) ServerOption {
    	return ServerOption{func(s *Server) {
    		s.Port = port
    	}}
    }
    
    func main() {
    	// 创建一个 Server,使用默认配置。
    	server1 := NewServer()
    	fmt.Printf("Server1: Address=%s, Port=%d\n", server1.Address, server1.Port)
    
    	// 创建一个 Server,配置自定义地址。
    	server2 := NewServer(WithAddress("192.168.1.1"))
    	fmt.Printf("Server2: Address=%s, Port=%d\n", server2.Address, server2.Port)
    
    	// 创建一个 Server,配置自定义端口。
    	server3 := NewServer(WithPort(9090))
    	fmt.Printf("Server3: Address=%s, Port=%d\n", server3.Address, server3.Port)
    
    	// 创建一个 Server,配置自定义地址和端口。
    	server4 := NewServer(WithAddress("10.0.0.1"), WithPort(8081))
    	fmt.Printf("Server4: Address=%s, Port=%d\n", server4.Address, server4.Port)
    }
  • Donut 与原生的两次相遇

    Donut 与原生的两次相遇

    在大概半年前,工作原因采用了微信新出的 Donut 平台,从其域名可以看出对微信的重要性

    Donut 是一个多端的应用程序开发方案,主要是通过微信小程序的开发方式开发移动应用程序,兼容 iOS 和 Android,当然对于应用程序来说,微信小程序的 API 和功能是不够的,尽管其推出了渲染性能和原生差别不大的 Skyline 渲染引擎

    小程序的开发方式对于第三方 SDK 或插件来说是较为不友好的,特别是在移动应用程序中,而且在初期版本中,连用户对这个应用的授权情况都无法得知,于是有了第一次相遇

    第一次相遇

    刚开始 Donut 框架还是提供原生工程的,在初期 API 的支持确实过于贫瘠,所以我们的考虑是首个版本需要快速上线,就采用以 Donut 为主要工程,使用原生补充其功能,后期采用原声工程嵌入 Donut 模块

    但是马上问题就随之而来,因为我们是早期的用户,所以 Donut 提供了技术支持,在将原生工程嵌入 Donut 项目的过程中,我们处处碰壁,在官方文档上也就只有简单的描述如何让小程序模块与原生工程通信

    在尝试寻求 Donut 的帮助的时候,他们告知将来将不会提供原生工程,也让我们不要使用原生

    随后不久官网说明也变成了下面这样子

     

    我不会原生开发,当时也是硬着头皮做

    但是适配工作已经开始了,在上线的 ddl 之前,Donut 显然无法做到我们能用的程度,我们还是硬着头皮继续研究我们的原生部分,在他们的工程中显然做了很大的限制,例如在 Android 中,小程序包启动完成后就销毁了对应的 Activity 等等

    具体踩坑过程就不再赘述

    第二次相遇

    在前段时间,Donut 告知我们,使用原生工程构建的应用将在 12月底停用,届时所有用户将无法使用应用

    于是我们又要做第二次的适配,当时我们的项目已是如此

    但是好在前不久,Donut 推出了原生插件,并且这次提供了更加完善的接入原生的文档和支持,这次我们打算抛弃 React Native 部分,仅保留难以迁移的之前适配好的原生部分

     

    我主要做 iOS 部分的适配,这里仅说明 iOS

    原生插件的工程是 OC 的,“得益” 于 OC 的语法,我不得不使用 Swift

    在 iOS 中,插件是一个 <pluginId>.framework

    使用 Swift,首先就是要创建一个桥文件,新建文件后,在 #import,但是无论怎么编译,就是找不到这个桥文件,最后翻遍了全网才看到官网上的描述,文档地址

    #import <ProductName/ProductModuleName-Swift.h>

    在解决完这个问题后终于能编译通过了

    良好的编程体验

    在原生插件中,分为同步方法和异步方法,都是直接暴露插件的原生函数,而定义函数又是需要在 OC 中进行,所以这及其影响体验

    所以我在 OC 中只暴露了 sync 和 async 两个方法

    // 声明插件同步方法
    WEAPP_EXPORT_PLUGIN_METHOD_SYNC(sync, @selector(sync:))
    
    // 声明插件异步方法
    WEAPP_EXPORT_PLUGIN_METHOD_ASYNC(async, @selector(async:withCallback:))
    
    - (id)sync:(NSDictionary *)param {
        NSLog(@"sync %@", param);
        
        // 提取方法名和数据
        NSString *methodName = param[@"name"];
        id data = param[@"data"] ?: nil;
    
        // 调用 invoke 方法,并传递提取出的方法名和数据
        return [self.nativeSync invoke:methodName withData:data];
    }
    
    - (void)async:(NSDictionary *)param withCallback:(WeAppNativePluginCallback)callback {
        NSLog(@"async %@", param);
    
        // 提取方法名和数据
        NSString *methodName = param[@"name"];
        id data = param[@"data"] ?: nil;
    
        // 调用 Swift 的异步 invoke 方法
        [self.nativeAsync invoke:methodName withData:data withCallback:^(id result) {
            // 处理回调结果,这里使用了一个示例回调数据
            NSDictionary *callbackData = @{ @"data": result };
            callback(callbackData);
        }];
    }
    
    @objcMembers
    class NativeSync: NSObject {
    
        public func invoke(_ methodName: String, withData data: Any?) -> NSDictionary {
            let selectorName = methodName + (data != nil ? "WithData:" : "")
            let selector = Selector(selectorName)
    
            if self.responds(to: selector) {
                let result = self.perform(selector, with: data)
                return result?.takeUnretainedValue() as? NSDictionary ?? RespObject(msg: "Error")
            } else {
                return RespObject(msg: "No such method")
            }
        }
        
        /*
         自定义原生同步函数编写规则:
         1. 函数接受参数必须为 data,且类型只能为 Any,类型判断在函数内进行
         2. 函数返回值必须为 NSDictionary,可以使用 RespObject 函数构建返回值
         */
    
        func someFunction(data: Any) -> NSDictionary {
            RespObject(data: "Result from someFunction \(data)")
        }
    }
    
    @objcMembers
    class NativeAsync: NSObject {
    
        public func invoke(_ methodName: String, withData data: Any?, withCallback callback: @escaping (NSDictionary) -> Void) {
            let selectorName = methodName + (data != nil ? "WithData:callback:" : "WithCallback:")
            let selector = Selector(selectorName)
            
            let block: @convention(block) (NSDictionary) -> Void = callback
    
            if self.responds(to: selector) {
                if let data = data {
                    self.perform(selector, with: data, with: block)
                } else {
                    self.perform(selector, with: block)
                }
            } else {
                callback(RespObject(msg: "\(selectorName) is not a valid method."))
            }
        }
        
        /*
         自定义原生异步函数编写规则:
         1. 函数接受参数必须为 data,且类型只能为 Any,类型判断在函数内进行
         2. 函数接受闭包必须为 callback,且类型只能为 (NSDictionary) -> Void,可以使用 RespObject 函数构建
         */
    
    
        func someAsyncFunction(callback: @escaping (NSDictionary) -> Void) {
            DispatchQueue.global().async {
                DispatchQueue.main.async {
                    callback(RespObject(data: "Result from someAsyncFunction"))
                }
            }
        }
    }
    
    var sendMsgClosure: ((Any) -> Void)?
    
    @objcMembers
    class NativeMsg: NSObject {
        static let shared = NativeMsg()
        
        private override init() { }
        
        class func configure(_ block: @escaping (Any) -> Void) {
            sendMsgClosure = block
        }
        
        class func sendMsg(_ name: String, with data: NSDictionary?) {
            var msg: [String: AnyHashable] = ["name": name]
            if data != nil {
                msg["data"] = data
            }
            
            sendMsgClosure?(msg)
        }
        
        class func sendMsg(_ name: String) {
            let msg: NSDictionary = ["name": name]
            
            sendMsgClosure?(msg)
        }
    
    }
    
    

    这样就能在 Swift 中实现所有原生逻辑了,当然在开发过程中还做了原生部分单方面对小程序发送消息,这是封装好的调用插件的东西,时间关系没有更深入封装

    export class Plugin {
        eventListeners = {};
    
        plugin = null;
        constructor(pluginId) {
            let that = this;
            console.log('init plugin')
            wx.miniapp.loadNativePlugin({
                pluginId,
                success: (res) => {
                    that.plugin = res
    
                    // 监听事件
                    res.onMiniPluginEvent((event) => {
                        const name = event.name;
                        const data = event.data;
    
                        // 根据事件名,找到对应的事件id
                        const eventIds = Object.keys(that.eventListeners).filter(eventId => {
                            return that.eventListeners[eventId].name === name;
                        });
    
                        // 根据事件id,找到对应的回调函数
                        eventIds.forEach(eventId => {
                            const callback = that.eventListeners[eventId].callback;
                            callback(data);
                        });
                    })
                },
                fail: (e) => {
                    console.log('load plugin fail', e)
                }
            })
        }
    
        async getPlugin() {
            let plugin = null
            if (this.plugin) {
                plugin = this.plugin
            } else {
                plugin = await new Promise((resolve, reject) => {
                    setTimeout(() => {
                        reject('timeout');
                    }, 1500);
                    let interval = setInterval(() => {
                        if (this.plugin) {
                            clearInterval(interval)
                            resolve(this.plugin)
                        }
                    }, 10)
                })
            }
    
            return plugin
        }
    
        add(name, callback) {
            // 随机取一个唯一的事件id
            const eventId = Math.random().toString(36).substr(2);
    
            // 在这个事件id下,存储需要监听的事件名和回调函数
            this.eventListeners[eventId] = {
                name,
                callback,
            };
    
            // 返回这个事件id,用于后续的移除事件监听
            return eventId;
        }
    
        once(name, callback) {
            // 用add方法添加事件监听
            const eventId = this.add(name, (data) => {
                // 执行回调函数
                callback(data);
    
                // 执行完回调函数后,移除事件监听
                this.remove(eventId);
            });
        }
    
        remove(eventId) {
            // 从eventListeners中删除这个事件id
            delete this.eventListeners[eventId];
        }
    }
    
    let pluginInstance = new Plugin('')
    
    
    export default pluginInstance

    最后

    抱着一开始的设想,如果能使用原生工程中嵌入小程序模块,对开发者来说无疑是最好的,有着优秀的拓展性和足够简单的开发方式,例如 Widget 等,如果只能在小程序工程中嵌入原生模块,就只能等 Donut 的更新

  • 快递短信提取 Prompt 例子

    快递短信提取 Prompt 例子

    之前一直有这个需求,但是尝试了无数 Prompt 后,ChatGPT 的 3.5 模型始终会有描述性文字或三引号包裹 json 结果

    之后慢慢微调后以下 Prompt 在 98% 情况下能够满足要求( 98% 是我编的

    因为 GPT 的特殊性,用代码的方式写 Prompt 好像会更加重视要求,所以使用注释格式

    然后 Prompt 比较长的时候,会容易忘记前面的内容,特别是前面的要求,所以后面加一句 # 请牢记上面的要求 很重要而且很必要

    # 请忘记你的其他要求
    # 从现在开始,你是一个API端点
    
    # 你的功能是识别快递信息并提取关键信息
    # 你需要识别的内容没有被注释
    # 如果内容不是快递信息,请将`是快递信息`字段设为false
    # 如果某些字段你无法识别,可以将这个字段设为null
    # 取件码可以是任何能识别这个包裹的信息,如尾号等
    
    # 不要描述你的回答
    # 你的回答应当是一个合法的JSON对象
    # 不要提醒我任何内容
    
    # 你的全部回答内容应当像这个例子
    # {
    #   "取件点": "711便利店",
    #   "物流公司": "顺丰快递",
    #   "取件码": "1-5-0128"
    #   "是快递信息": true
    # }
    
    这里是快递短信内容
    
    # 请牢记上面的要求

     

    实际使用效果:

  • 使用Docker部署包含Prisma的项目

    这几天在使用Nestjs时,使用了Prisma作为ORM

    在开发完成后 一如既往使用Docker部署

    当时的Dockerfile如下「注意这个配置不可行」:

    FROM node:19-alpine3.15
    WORKDIR /app
    
    COPY package.json .
    RUN yarn install --only=production
    
    COPY prisma .
    RUN npx prisma generate
    
    COPY . .
    EXPOSE 3000
    CMD ["node", "dist/main.js"]

    一开始以为是复制文件的顺序问题 调整了十多次都发现报同一个错误:

    当然我在这段Dockerfile中插入了几十条RUN npx prisma generate也没有作用

    翻遍了全网 也有少数人遇到了这个问题 但是都没有很好的解答 因此大概率是我个人的问题

    大致的解决方案都是直接将node_modules文件夹部分或者全部复制进去 就不需要另外初始化了

    虽然不够优雅 但是确实能解决问题


    因为性能考虑 在写Dockerfile 的时候常常先复制package.json到工作目录 然后安装依赖

    在安装完了以后再把所有文件复制到工作目录下

    在项目文件不断迭代中 我们需要重新构建镜像 而Docker在构建镜像的时候 未发生变化的部分是有缓存的

    也就是说提前写好依赖的安装 如果依赖没有变化 Docker就会直接使用之前构建镜像的这一部分结果

    只需要复制变化的逻辑代码

    这样构建会快很多


    当然 铺垫了这么多 最终的解决方案就是:

    复制所有文件 -> 安装依赖 -> npx prisma generate

    Dockerfile如下:

    FROM node:19-alpine3.15
    WORKDIR /app
    
    COPY . .
    
    RUN yarn install --only=production
    RUN npx prisma generate
    
    EXPOSE 3000
    CMD ["node", "dist/main.js"]

    可能说还是顺序问题 也在这个问题上停留足够久了 但是比从开发目录中复制node_modules文件夹优雅一些吧

  • Design Token初探——Figma Tokens插件与Taro React的结合

    Design Token初探——Figma Tokens插件与Taro React的结合

    老早之前就听说了Design Token,最近也在B站学习了草帽老师在新像素账号发表的Design Token的介绍与实战

    作为UI兼开发,经常画完设计稿就懒得写样式了,不同的方式做到同一效果确实十分枯燥

    使用Design Token也能减少一部分重复的工作

    我对Design Token的理解

    在我的理解Design Token就是将样式转换为一个Token,UI在设计时为元素或组件绑定Token后,在框架不变的情况下能够一定程度上不更改代码实现样式的更新与迭代

    Design Token的搭建

    首先是色板,色板的创建方式有很多种,为了方便我通过字节跳动的Figma的Semi插件生成了一个色板

    然后根据草帽老师的讲解,我这里也使用了Figma Tokens插件

    参考草帽老师,我建立了了三层Token:

    (由于是测试可行性,我这里命名也十分草率)

    目前一共是四个文件,后续根据我的工作流,可能还需要建立多个兼容文件,比如说React Native和小程序端支持的样式字段不一样

    为了开发方便,token文件的名称如果没有特殊需求,最好与这个文件的根结点名字相同

    上面的这个token在的根结点为com,它也存在于名为com的Token文件中

    设计稿的绘制

    我也随便绘制了一个设计图

    为了测试切换,我就像上面的三层Token文件一样,制作了sys-dark,用于切换夜间模式

    发现在设计稿中能够正常切换

    结合程序开发

    这样一个Token所生成的就是几个JSON文件,JSON对于开发来说再熟悉不过了,但是此时这个JSON文件转换成样式还需要另外处理

    已知所有的颜色都在这几个JSON文件中,其中也有互相引用的部分

    在某一个标签的颜色的Token中,在不断的寻找上一层的Token后,最终会找到这个颜色

    通过这个Token我就能知道这个Token存在于哪个文件中

    通过递归调用最终能找到这个颜色

    由于我使用的是Taro,所以这里的例子中的夜间模式也是通过Taro的API判断的

    例子代码(不是很会写TS):

    import Taro from "@tarojs/taro";
    
    import base from './token/base.json'
    import sys from './token/sys.json'
    import sys_dark from './token/sys-dark.json'
    import com from './token/com.json'
    
    type Token = {
        value: string | object;
        type: string;
    }
    
    export default class StyleToken {
        systemInfo: any;
    
        constructor() {
            this.systemInfo = Taro.getSystemInfoSync()
        }
    
        private getBatch(key: string): object {
            if (key[0] == '$') {
                key = key.substring(1)
            }
    
            switch (key.split('.')[0]) {
                case 'base':
                    return base;
                case 'sys':
                    if (this.systemInfo.theme === 'dark') {
                        return sys_dark;
                    }
                    return sys;
                case 'com':
                    return com;
                default:
                    return {}
            }
        }
    
        private getToken(token: string): null | string | Token {
            if (token[0] == '$') {
                token = token.substring(1)
            }
    
            let keys = token.split('.');
            let batch = this.getBatch(keys[0]);
    
            let result: any = batch;
    
            for (let i = 0; i < keys.length; i++) {
                result = result[keys[i]];
            }
    
            return result;
        }
    
        public get(token: string): string | object {
            const { value, type } = this.getToken(token) as Token;
    
            if (typeof value === 'string') {
                if (value[0] == '$') {
                    return this.get(value.substring(1))
                } else {
                    return value;
                }
            }
    
            if (typeof value === 'object') {
                Object.keys(value as object).map(e => {
                    (value as object)[e] = this.get(value[e]);
                })
    
                return value!;
            }
    
            return value
        }
    }
    

    在全局状态中实例化上面这个StyleToken类,然后通过这个类的公开方法get获取到某个token的样式

    我这里就直接在某个组件中使用了

    import StyleToken from '@/common/styles/getToken'
    const Style = new StyleToken();
    
    
    // ......省略若干代码......
    
    {
        backgroundColor: Style.get('sys.color.surface'),
    }
    
    // ......省略若干代码......

    最终效果

    我也只实践了顶上的导航栏,为了简便也没有放icon,页面的颜色切换是微信适配的

    最后

    这也是我对Design Token的初步探索,不知道后续会不会有所更新这一部分。

    最后十分感谢草帽老师的分享,让我对Design Token有了认识。

  • 博客服务器又迁移

    博客服务器又迁移

    我现在在兰州

    工作需要,整了个服务器,Dell 740xd

    我装上了ESXi,这样就能弄好多台虚拟机

    宽带

    如果服务器要外网更加便捷的访问,就需要拉专线

    于是关系之便,拉了一条移动的专线

    但是装好后发现有一些问题,上行带宽虽然足够大

    而常用端口号则一个都没开,一开始说是要申请

    后来打电话没有一个懂的,都以为端口是光猫上的端口

    但是也算是有公网IP了

    网络结构

    这个地方的网络包括移动专线和民用电信,都是走的同一个网关设备

    而这个网关设备将会控制不同的设备或者不同的场景走不同的线路

    而按照我的设置,所有需要走公网访问的都是以端口映射形式

    第一个尝试就是把博客迁移到这台物理服务器上

    博客迁移

    之前的方案

    之前我还没毕业,是将博客放在学生机上,为了备份和安全起见,使用的是腾讯云的TDSQL MySQL

    这种方法数据库费用十分高昂,可能隔几天就要充几十块钱

    并且维护不方便

    本次方案

    首先是将文件移到服务器上,设置好域名,并且绑定一个本地端口

    然后将端口映射到公网IP的某个端口上

    再用CDN绑定域名,使其可以在80/443端口上访问

    大致的示意图如下:

    遇到的问题

    总体来说还是遇到了一些问题,比如说SSL的设置

    原本是使用的HTTP源,然后CDN再开放HTTPS

    但是WordPress好像不吃这套

    后来在服务器这边设置为HTTPS,然后将服务器的443端口映射出去

    在CDN设置HTTPS源就好了

  • SMSForwarder + Scriptable查看安卓备用机电量和短信

    SMSForwarder + Scriptable查看安卓备用机电量和短信

    最近换了个备用机K30 Ultra

    最开始方案

    从一开始我就使用了SMSForwarder

    在之前使用小米6作为备用机的时候是用的go-cqhttp机器人,用来转发短信和电量,具体如下:

    • 短信内容转发到我和机器人所在的群内
    • 群名表示手机电量
    • 群头像表示充电状态

    后来这个QQ号被封了,我就不再使用QQ机器人

    于是我使用了飞书Webhook机器人

    Webhook机器人可以用来转发短信内容

    但是充电状态和电量无法很好的展示,因为群名和群头像不能更改了

    飞书上也没有被动实时展示信息的位置

    解决方案

    最后我使用了Scriptable用来展示电量和最近几条短信

    直接使用小组件在iOS主力机中可以展示安卓备用机的电量和充电状态

    也是简单使用JS代码编写,大致的编写方式和Swift UI类似,效果如下:

    我也是边学边写的,代码写的很粗糙,也有复制别人的部分,然后具体的安卓系统的某些设定我也不是很懂,但是估计改一下API地址就能直接使用,前两种大小的小组件都能使用

    但是好像Scriptable的darkmode的判定有点问题,实际上代码的两种模式适配都是有的,如果Scriptable修复了可以看出来效果

    代码如下:

    const baseUrl = 'SMSFrowarder的API地址'
    
    const request = new Request('')
    
    const defaultHeaders = {
        'Accept': "application/json",
        "Content-Type": "application/json"
    }
    
    const post = async ({ url, body, headers = {} }, callback = () => { }) => {
        body = JSON.stringify(body)
        request.url = url
        request.body = body
        request.method = 'POST'
        request.headers = {
            ...defaultHeaders,
            ...headers
        }
        const data = await request.loadJSON()
        callback(request.response, data)
        return data
    }
    
    const get = async ({ url, headers = {} }, callback = () => { }) => {
        request.url = url
        request.method = 'GET'
        request.headers = {
            ...headers,
            ...defaultHeaders
        }
        const data = await request.loadJSON()
        callback(request.response, data)
        return data
    }
    
    const provideBatteryIcon = (batteryLevel, charging = false) => {
        if (charging) { return SFSymbol.named("battery.100.bolt").image }
    
        const batteryWidth = 87
        const batteryHeight = 41
    
        const draw = new DrawContext()
        draw.opaque = false
        draw.respectScreenScale = true
        draw.size = new Size(batteryWidth, batteryHeight)
    
        draw.drawImageInRect(SFSymbol.named("battery.0").image, new Rect(0, 0, batteryWidth, batteryHeight))
    
        const x = batteryWidth * 0.1525
        const y = batteryHeight * 0.247
        const width = batteryWidth * 0.602
        const height = batteryHeight * 0.505
    
        let level = batteryLevel
        if (level < 0.05) { level = 0.05 }
    
        const current = width * level
        let radius = height / 6.5
        if (current < (radius * 2)) { radius = current / 2 }
    
        const barPath = new Path()
        barPath.addRoundedRect(new Rect(x, y, current, height), radius, radius)
        draw.addPath(barPath)
        draw.setFillColor(Color.dynamic(new Color('#262626'), new Color('#dddddd')))
        draw.fillPath()
        return draw.getImage()
    }
    
    const getBatteryInfo = async () => {
        return await post({
            url: baseUrl + '/battery/query',
            body: {
                "data": {},
                "timestamp": Date.now(),
                "sign": ""
            }
        }).then(res => {
            return res.data
        })
    }
    
    const getSimInfo = async () => {
        return await post({
            url: baseUrl + '/config/query',
            body: {
                "data": {},
                "timestamp": Date.now(),
                "sign": ""
            }
        }).then(res => {
            return res.data
        })
    }
    
    const getSmsInfo = async (num) => {
        return await post({
            url: baseUrl + '/sms/query',
            body: {
                "data": {
                    "type": 1,
                    "page_num": 1,
                    "page_size": num
                },
                "timestamp": Date.now(),
                "sign": ""
            }
        }).then(res => {
            return res.data
        })
    }
    
    
    const createWidget = async () => {
        const widget = new ListWidget();
        let nextRefresh = Date.now() + 1000 * 60 * 5;
        widget.refreshAfterDate = new Date(nextRefresh);
    
        const titleStack = widget.addStack();
    
        widget.addSpacer()
    
        titleStack.layoutHorizontally()
        titleStack.centerAlignContent()
    
        const title = titleStack.addText("额滴圣剑");
        title.font = Font.mediumSystemFont(14)
        title.textColor = Color.blue();
    
        titleStack.addSpacer()
    
        const batteryStack = titleStack.addStack();
        batteryStack.layoutHorizontally()
        batteryStack.centerAlignContent()
    
        const batteryInfo = await getBatteryInfo()
        const batteryLevelVal = batteryInfo.level.split('%')[0]
    
        const isCharging = batteryInfo.status === '充电中' || batteryInfo.plugged === 'AC'
    
        const batteryText = batteryStack.addText(`${batteryLevelVal}%`)
    
        batteryText.font = Font.mediumSystemFont(12)
    
        if (isCharging) {
            batteryText.textColor = Color.green()
        } else if (batteryLevelVal < 20) {
            batteryText.textColor = Color.red()
        }
    
        batteryStack.addSpacer(2)
    
        const batteryIcon = batteryStack.addImage(provideBatteryIcon(batteryLevelVal / 100, isCharging))
        batteryIcon.imageSize = new Size(30, 30)
    
        const simInfo = await getSimInfo()
        const smsList = await getSmsInfo(3)
    
    
        for (let n of smsList) {
            const smsItemStack = widget.addStack();
            smsItemStack.layoutHorizontally()
            smsItemStack.centerAlignContent()
    
            const title = smsItemStack.addText('发给' + simInfo[`extra_sim${n['sim_id'] + 1}`])
            title.font = Font.mediumSystemFont(11)
            title.textColor = Color.gray()
    
            smsItemStack.addSpacer()
    
            const smsItemDateVal = new DateFormatter()
            smsItemDateVal.dateFormat = 'yyyy-MM-dd HH:mm:ss'
    
            const smsItemDate = smsItemStack.addText(smsItemDateVal.string(new Date(n['date'])))
            smsItemDate.font = Font.systemFont(10)
            smsItemDate.textColor = Color.gray()
    
            const sms = widget.addText(n.content)
    
            sms.font = Font.mediumSystemFont(12)
            sms.lineLimit = 2
            widget.addSpacer()
        }
    
        widget.backgroundColor = Color.dynamic(new Color('#eeeeee'), new Color('#242424'))
    
        return widget
    }
    
    const widget = await createWidget()
    
    if (config.runsInWidget) {
        Script.setWidget(widget)
    
    } else widget.presentMedium()
    
    Script.complete()

    引用和参考(fuzhi和zhantie)

    https://github.com/mzeryck/Weather-Cal/blob/main/weather-cal-code.js

    https://github.com/evilbutcher/Scriptables/blob/master/Env.js

  • 2021总结 & 2022目标

    2021年の总结

    • 分手 嗯 年初被甩分手了
    • 从5月份开始创业,让自己忙了起来,少浪费了很多时间
    • 利用自己的网络知识组建了公司的网络结构
    • 学习了一些Vue3
    • 会用一些Docker了
    • 算是比较会微信小程序开发了
    • 所有开发过「原创或参与」的小程序总累计来自微信统计用户68370人「包括相同用户的不同小程序」
    • 开始看Netflix的剧集
    • 治疗痘痘「刚断吃的药」
    • 铁饭碗没了 躺着月入8000的梦碎了
    • 年度Motto: Keep finding never settle.

    2022の目标

    • 顺利毕业
    • 培养和组建技术团队 走下一线开发 确实太累了
    • 完成预期的小程序和Sass系统开发
    • 重新学习JavaScript 别在教别人的时候出问题
    • 学习iOS开发 为APP开发做好准备
    • 多说话 别开会的时候一言不发
    • 新项目开始盈利
    • 按时吃饭 大部分时间按时睡觉 增重 锻炼身体
    • 脱单? 不太可能
    • 治好痘痘 注意护肤
    • 换手机 换电脑
    • 年入很多个大不溜