news 2026/9/5 9:23:02

用SwiftUI打造Mac菜单栏LLM用量监视器:从0到1完整教程

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
用SwiftUI打造Mac菜单栏LLM用量监视器:从0到1完整教程

前阵子接了一个内部工具需求:让工程师在 Mac 上随时看到当前 LLM 账号的 Token 消耗和预估花费。最初想到的是浏览器扩展,但实际用下来发现体验不够直接——浏览器不开、页面不打开,就看不到数据。最后改成了一款常驻菜单栏的小组件:一个小胶囊显示用量数字,点击后弹出详细面板,既不影响工作流,又能随时掌握成本。

这篇文章把从 0 到 1 的实现过程整理成完整教程,涉及 SwiftUI 的MenuBarExtraNSStatusItem、网络请求、本地缓存、设置面板等知识点。不管你是 macOS 开发初学者,还是已经在做 LLM 工具链的开发者,都可以照着这篇文章做出一个属于自己的“Mac 菜单栏 LLM 用量监视器”。

1. 为什么要在 Mac 菜单栏显示 LLM 用量

1.1 场景与痛点

随着日常开发和业务中越来越多地接入大语言模型 API,Token 消耗和费用已经变成实际需要关注的问题。调用次数多了之后,账单往往不是立刻可见的,等到月底看到费用数字,才意识到某些场景的调用量远超预期。

这种场景下,最理想的状态是:用一个非常轻量、不会打断当前工作的小窗口,实时展示当前账号的 Token 使用量、调用次数、预估花费等信息。用户扫一眼就能知道“今天用量是否正常”“是不是某个定时任务跑超了”。

浏览器扩展的问题在于依赖浏览器常开,而且很多浏览器扩展只能拿到页面级数据,无法直接展示系统级状态。命令行工具虽然强大,但需要主动打开终端去执行,不够直观。于是“菜单栏扩展”成为最优解:它占用面积极小,不会遮挡代码编辑器,却能像一个小仪表盘一样常驻在屏幕顶部。

1.2 方案选型:浏览器扩展还是菜单栏扩展

先理清一个概念:这里说的 extension 不是 VS Code 扩展,也不是 Chrome 扩展,而是 macOS 系统上的菜单栏扩展组件。它的本质是一个常驻状态栏的 App 或 Agent,最常见的实现方式是 SwiftUI 的MenuBarExtra,或者 AppKit 的NSStatusItem

对比浏览器扩展,菜单栏扩展的主要优势是:

  • 不依赖浏览器:即使浏览器没有启动,菜单栏组件也能工作。
  • 系统级常驻:开机启动后自动出现在菜单栏,无需手动打开。
  • 信息密度可控:菜单栏里显示一句话摘要,点击后才展开详细数据。
  • 与系统交互更自然:支持快捷键、支持Settings场景、支持通知提醒。

典型的应用场景包括 API 成本监控、Token 剩余量提醒、服务健康状态展示、定时任务执行结果提示等。本文实现的组件,本质上就是一个“LLM 用量仪表盘”。

1.3 本文要实现的最终形态

项目标题里的 panel / pill / nub,分别对应三种 UI 形态:

  • pill:菜单栏上的小胶囊按钮,显示类似LLM 12.3K这样的简短文字,表示当前 Token 总量。
  • panel:点击菜单栏按钮后弹出的详细面板,展示输入 Token、输出 Token、预估花费、刷新时间等数据。
  • nub:菜单栏上的小圆点指示器,可以理解为连接状态或数据状态灯。

最终效果是:菜单栏出现一个小胶囊,打开后是一个面板,面板中展示详细的 LLM 用量信息。整体交互非常简单,但背后涉及的数据链路、缓存策略、网络请求、配置管理,都是实际工程中需要认真处理的环节。

2. 环境准备与项目初始化

2.1 开发环境与版本说明

本文示例基于 macOS 13 及以上系统版本。如果系统版本较低,SwiftUI 的MenuBarExtraAPI 可能不可用,建议改用 AppKit 的NSStatusItem方案实现,后文会单独说明。

开发工具方面,需要准备 Xcode 和 Swift。项目本身不依赖第三方包,使用系统自带的 SwiftUI、AppKit、Foundation 框架即可。

版本需要根据你的项目实际情况调整。如果当前 Xcode 版本较新,Swift 编译器的并发检查会更严格,本文代码采用了相对保守的写法,尽量兼容不同版本。

2.2 创建 SwiftUI 项目

打开 Xcode,选择File -> New -> Project,选择macOS -> App,界面语言选择 Swift,生命周期选择 SwiftUI App。

项目名称可以取为LLMBuddy,这样后续代码中的 module 名称和目录结构都一致。创建完成后,默认生成的文件结构如下:

LLMBuddy/ ├── LLMBuddyApp.swift ├── ContentView.swift └── Assets.xcassets

本文的示例会在根目录下继续增加 Models、Services、Views 等目录,方便代码分层。

2.3 开启网络权限与沙盒配置

如果项目启用了 App Sandbox,需要在 Xcode 的Signing & Capabilities中开启Outgoing Connections (Client),否则网络请求会被沙盒拦截,出现“请求失败”或“无网络权限”的错误。

如果目标是从 App Store 分发,沙盒是必选项;如果只是自用或企业内部工具,也需要在开发阶段确认网络权限已开启。开启方式如下:

  • 选中 target
  • 选择Signing & Capabilities
  • 点击+ Capability
  • 添加App Sandbox
  • 勾选Network -> Outgoing Connections (Client)

如果用量查询接口是 HTTP 明文地址,还需要在Info.plist中配置 App Transport Security 例外。实际生产环境建议统一使用 HTTPS。

3. 核心原理拆解:菜单栏组件的三种形态

3.1 panel:点开后的用量详情面板

panel 是用户点击菜单栏按钮后看到的弹层,通常是一个小窗口或者 Popover。在 SwiftUI 中,可以通过MenuBarExtramenuBarExtraStyle(.window)实现。

MenuBarExtra可以理解成一个专门为菜单栏开发的 SwiftUI Scene。它自带两种展示样式:

  • .menu:点击后展示一个菜单,适合操作型入口。
  • .window:点击后展示一个自定义视图,适合内容型面板。

对于显示用量数据这种场景,应该选择.window,因为 panel 中需要展示多行数据、状态文字,以及可能的操作按钮,单一的菜单项不够灵活。

在实现中,要把弹层视图和菜单栏按钮视图分开。菜单栏按钮是一个很薄的视图,只负责显示摘要;点击后的 panel 是一个内容更丰富的视图,负责承载详细信息。

3.2 pill:菜单栏上的胶囊按钮

pill 是菜单栏上的按钮外观形态。macOS 菜单栏高度有限,通常不会像浏览器扩展那样展示大块内容,而是用一个小胶囊来承载最基本的信息。

一个典型的菜单栏 pill 由以下部分组成:

  • 图标:系统 SF Symbol 或者自定义小图标。
  • 简短文本:例如12.3K$2.34LLM
  • 状态点:一个绿色、黄色或红色的小圆点,表示数据是否新鲜。

可以用 SwiftUI 的Capsule形状配合背景色实现胶囊效果。

需要特别注意的是,菜单栏的空间非常有限,文本不能太长。如果用量数字过大,建议格式化显示,例如 12345 显示为12.3K。如果同时显示费用和 Token,可以只保留一个核心指标,其余放在点击后的 panel 中。

3.3 nub:状态指示灯与数据入口

nub 是组件中的“小凸块”或“状态点”,它的作用不只是装饰,而是快速传达状态信息。

可以在菜单栏胶囊中嵌入一个小圆点,当数据成功拉取时显示绿色,当请求失败或数据过期时显示黄色或红色。用户不需要点击面板,只看状态点颜色就能判断“数据是否正常”。

这种状态指示机制在运维工具中很常用。菜单栏组件空间有限,颜色是传递状态的最经济方式之一。

下面这个简单的 SwiftUI 视图可以把图标、状态点、文字组合成一个菜单栏按钮:

// 文件路径:LLMBuddy/Views/StatusBarLabel.swift import SwiftUI struct StatusBarLabel: View { let usage: LLMUsage? let hasError: Bool var body: some View { HStack(spacing: 5) { Circle() .fill(color) .frame(width: 7, height: 7) if let usage = usage { Text(usage.displayText) } else { Text("LLM") } } .font(.system(size: 11, weight: .semibold, design: .rounded)) .padding(.horizontal, 8) .padding(.vertical, 3) .background(Capsule().fill(Color.accentColor.opacity(0.12))) .overlay( Capsule().stroke(Color.accentColor.opacity(0.25), lineWidth: 0.5) ) .clipShape(Capsule()) .fixedSize() } private var color: Color { if hasError { return .orange } return usage == nil ? .gray : .green } }

这段代码中,Circle就是 nub,Capsule包裹整体形成 pill,点击后的详细内容则是 panel。三者组合起来,就完成了项目标题中描述的交互形态。

4. 完整实战:实现 LLM 用量菜单栏小部件

下面进入核心实操环节。我们会逐步实现一个可以运行的 macOS 菜单栏 LLM 用量监视器。

4.1 项目结构

在刚才创建的 Xcode 工程中,继续增加以下文件:

LLMBuddy/ ├── LLMBuddyApp.swift ├── Models/ │ ├── LLMUsage.swift │ └── LLMProviderConfig.swift ├── Services/ │ ├── LLMUsageService.swift │ └── UsageCache.swift ├── Views/ │ ├── StatusBarLabel.swift │ ├── MenuBarView.swift │ └── SettingsView.swift └── Info.plist

这个结构把数据模型、网络服务、缓存、视图分离,后续即使接入不同的 LLM Provider,也只需要替换 Model 和 Service,UI 层不受影响。

4.2 数据模型与配置读取

先定义LLMUsage数据模型,它表示一次用量查询的结果。为了让代码更通用,这里采用了一个假设的 JSON 结构,实际项目需要根据自己的服务端或第三方平台的返回格式调整字段。

// 文件路径:LLMBuddy/Models/LLMUsage.swift import Foundation struct LLMUsage: Codable, Equatable { var inputTokens: Int var outputTokens: Int var totalTokens: Int var costUSD: Double var updatedAt: Date var displayText: String { if totalTokens >= 1000 { return String(format: "%.1fK", Double(totalTokens) / 1000.0) } return "\(totalTokens)" } } struct LLMUsageResponse: Codable { let inputTokens: Int let outputTokens: Int let totalTokens: Int let costUsd: Double? func toModel() -> LLMUsage { LLMUsage( inputTokens: inputTokens, outputTokens: outputTokens, totalTokens: totalTokens, costUSD: costUsd ?? 0, updatedAt: Date() ) } }

这里定义了两个类型。LLMUsageResponse是网络层返回的原始结构,配合JSONDecoderkeyDecodingStrategy可以直接解析 snake_case 字段;LLMUsage是界面展示和缓存使用的统一模型。

接下来定义配置读取。API Key、接口地址、刷新间隔这些配置,简单场景下可以先存到UserDefaults,后面的最佳实践部分会说明更安全的做法。

// 文件路径:LLMBuddy/Models/LLMProviderConfig.swift import Foundation struct LLMProviderConfig { var apiKey: String var endpoint: URL var refreshInterval: TimeInterval static let defaultEndpoint = URL(string: "https://api.example.com/v1/usage")! static func loadFromUserDefaults() -> LLMProviderConfig { let defaults = UserDefaults.standard let apiKey = defaults.string(forKey: "apiKey") ?? "" let endpointString = defaults.string(forKey: "endpoint") ?? "" let endpoint = URL(string: endpointString) ?? defaultEndpoint let rawInterval = defaults.double(forKey: "refreshInterval") let refreshInterval = rawInterval >= 60 ? rawInterval : 300 return LLMProviderConfig( apiKey: apiKey, endpoint: endpoint, refreshInterval: refreshInterval ) } }

注意,这里的api.example.com只是示例地址,你需要替换成实际的用量查询接口。不同平台的接口路径、鉴权方式、返回结构不一样,这一层正是为了屏蔽差异而存在的。

4.3 网络请求与用量刷新

核心类是LLMUsageService,它负责:

  • 读取配置。
  • 发起网络请求。
  • 将结果写入缓存。
  • 定时刷新。
  • 向 SwiftUI 视图发布状态变化。
// 文件路径:LLMBuddy/Services/LLMUsageService.swift import Foundation import Combine final class LLMUsageService: ObservableObject { @Published var usage: LLMUsage? @Published var isRefreshing = false @Published var lastError: String? private var timer: Timer? private let usageCache = UsageCache() init() { if let cached = usageCache.load() { self.usage = cached } let config = LLMProviderConfig.loadFromUserDefaults() startAutoRefresh(interval: config.refreshInterval) } func startAutoRefresh(interval: TimeInterval = 300) { refresh() timer?.invalidate() let newTimer = Timer(timeInterval: interval, repeats: true) { [weak self] _ in Task { @MainActor [weak self] in self?.refresh() } } RunLoop.main.add(newTimer, forMode: .common) timer = newTimer } func refresh() { guard !isRefreshing else { return } isRefreshing = true lastError = nil Task { @MainActor [weak self] in guard let self else { return } do { let usage = try await self.fetchUsage() self.usage = usage self.usageCache.save(usage) } catch { self.lastError = error.localizedDescription } self.isRefreshing = false } } private func fetchUsage() async throws -> LLMUsage { let config = LLMProviderConfig.loadFromUserDefaults() guard !config.apiKey.isEmpty else { throw LLMUsageError.missingAPIKey } var request = URLRequest(url: config.endpoint) request.httpMethod = "GET" request.setValue("Bearer \(config.apiKey)", forHTTPHeaderField: "Authorization") request.timeoutInterval = 15 let (data, response) = try await URLSession.shared.data(for: request) guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { throw LLMUsageError.serverError } let decoder = JSONDecoder() decoder.keyDecodingStrategy = .convertFromSnakeCase let result = try decoder.decode(LLMUsageResponse.self, from: data) return result.toModel() } } enum LLMUsageError: LocalizedError { case missingAPIKey case serverError case invalidResponse var errorDescription: String? { switch self { case .missingAPIKey: return "请在设置中填写 API Key" case .serverError: return "服务端返回异常状态码" case .invalidResponse: return "响应格式不正确" } } }

这段代码中有几个关键点:

  • @Published属性用于向 SwiftUI 视图发布变化。
  • startAutoRefresh用一个Timer定时触发刷新,RunLoop.main.add时指定了.common模式,避免菜单栏被按住时定时器暂停。
  • fetchUsage使用async/await发起请求,返回值是统一的LLMUsage模型。
  • 解析 JSON 时使用.convertFromSnakeCase,这样服务端返回input_tokens时会自动映射为inputTokens

4.4 实现菜单栏视图与弹出面板

菜单栏入口使用 SwiftUI 的MenuBarExtra,它同时承担 pill 和 panel 的职责。

// 文件路径:LLMBuddy/LLMBuddyApp.swift import SwiftUI @main struct LLMBuddyApp: App { @StateObject private var service = LLMUsageService() var body: some Scene { MenuBarExtra { MenuBarView() .environmentObject(service) } label: { StatusBarLabel( usage: service.usage, hasError: service.lastError != nil ) } .menuBarExtraStyle(.window) Settings { SettingsView() .environmentObject(service) } } }

这里的关键配置是.menuBarExtraStyle(.window),它让点击菜单栏按钮后显示一个自定义窗口,也就是 panel。

接下来是MenuBarView,也就是点击菜单栏按钮后展示的详细面板:

// 文件路径:LLMBuddy/Views/MenuBarView.swift import SwiftUI import AppKit struct MenuBarView: View { @EnvironmentObject private var service: LLMUsageService var body: some View { VStack(alignment: .leading, spacing: 12) { Text("LLM 用量") .font(.headline) if let usage = service.usage { Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 8) { GridRow { Text("Token 总量") Text(usage.totalTokens.formatted()) .gridColumnAlignment(.trailing) } GridRow { Text("输入 Token") Text(usage.inputTokens.formatted()) .gridColumnAlignment(.trailing) } GridRow { Text("输出 Token") Text(usage.outputTokens.formatted()) .gridColumnAlignment(.trailing) } if usage.costUSD > 0 { GridRow { Text("预估花费") Text(String(format: "$%.4f", usage.costUSD)) .gridColumnAlignment(.trailing) } } GridRow { Text("更新时间") Text(usage.updatedAt.formatted(date: .omitted, time: .standard)) .gridColumnAlignment(.trailing) } } .font(.system(.body, design: .monospaced)) } else { Text("暂无数据,请检查设置或网络") .foregroundStyle(.secondary) } if let error = service.lastError { Text(error) .font(.caption) .foregroundStyle(.red) } HStack { Spacer() Button("退出") { NSApplication.shared.terminate(nil) } } } .padding(16) .frame(width: 320) } }

Grid是 macOS 13 引入的布局组件,非常适合这种对齐要求高的数据展示。如果项目最低版本是 macOS 12,可以用HStackLazyVGrid替代。

4.5 设置面板

设置面板主要负责配置 API Key、接口地址和刷新间隔。这里先用@AppStorage简化,实际上线前建议改成 Keychain 保存密钥。

// 文件路径:LLMBuddy/Views/SettingsView.swift import SwiftUI struct SettingsView: View { @AppStorage("apiKey") private var apiKey = "" @AppStorage("endpoint") private var endpoint = "https://api.example.com/v1/usage" @AppStorage("refreshInterval") private var refreshInterval = 300.0 @EnvironmentObject private var service: LLMUsageService var body: some View { Form { Section("LLM 服务配置") { TextField("API Key", text: $apiKey) TextField("用量查询接口", text: $endpoint) Stepper(value: $refreshInterval, in: 60...3600, step: 60) { Text("自动刷新间隔:\(Int(refreshInterval)) 秒") } } Section("说明") { Text("当前为演示版本,API Key 存储在 UserDefaults。正式使用请改为 Keychain 保存。") } Section { Button("立即刷新") { service.refresh() } } } .formStyle(.grouped) .padding(20) .frame(width: 460) } }

设置面板中的字段和LLMProviderConfig.loadFromUserDefaults()中的 key 一一对应。这样修改完成后,点击立即刷新LLMUsageService会重新读取配置并拉取数据。

4.6 运行与验证

完成以上代码后,在 Xcode 中直接点击 Run。预期效果如下:

  1. 菜单栏出现一个小胶囊,左侧有一个小圆点,右侧显示LLM或缓存中的用量摘要。
  2. 首次启动且没有配置 API Key 时,点击胶囊后弹出面板,显示“暂无数据”以及“请在设置中填写 API Key”。
  3. 通过Cmd + ,打开设置面板,填写 API Key 和用量查询接口地址,点击立即刷新
  4. 请求成功后,菜单栏文本更新为类似12.3K的数字,面板中显示 Token 明细和更新时间。

如果网络请求失败,可以在 Xcode Console 中查看lastError的打印内容,或者直接看面板中的红色错误提示。

5. 常见问题与排查思路

菜单栏组件看似简单,实际运行时仍然会遇到不少问题。下面整理了几个高频场景。

问题现象常见原因解决思路
菜单栏没有图标项目没有运行到MenuBarExtra场景,或 Xcode 缓存异常确认 target 正确;清理 DerivedData 后重新运行
点击图标没反应menuBarExtraStyle设置不合适,或面板视图崩溃改为.window.menu测试;检查面板视图是否有异常代码
一直显示“暂无数据”API Key 未配置、接口地址不对、响应格式不匹配先到设置面板确认配置,再用 curl 测试接口
网络请求失败沙盒未开启网络权限,或 ATS 拦截 HTTP 地址开启Outgoing Connections;生产环境使用 HTTPS
数据长时间不更新Timer被系统调度延后,或刷新逻辑在主线程被阻塞将 Timer 加入.common模式;确认请求没有超时返回
API Key 明文暴露使用UserDefaults存储密钥改用 Keychain 保存,至少也应对密钥做混淆处理
菜单栏文案太长被截断胶囊内容超出菜单栏可用空间使用抽象数字格式,例如12.3K,减少文本宽度

如果遇到报错,可以参考下面的排查顺序:

  1. 查看 Console 日志中是否有LLMUsageError相关输出。
  2. 用 curl 直接请求用量接口,确认接口本身可访问,并观察返回的 JSON 字段。
  3. 检查 Xcode 的沙盒配置是否开启了网络客户端权限。
  4. MenuBarView中临时加一行Text(service.lastError ?? ""),方便看到真实错误信息。
  5. 如果接口返回的字段和模型不匹配,优先检查JSONDecoderkeyDecodingStrategy字段名映射。

6. 最佳实践与工程建议

6.1 密钥管理:从 UserDefaults 到 Keychain

作为演示,前文使用了@AppStorage保存 API Key。这个做法开发调试没有问题,但真实项目中非常危险。UserDefaults是明文存储,任何能读取应用沙盒数据的进程或脚本都有可能拿到密钥。

macOS 环境下,推荐使用 Keychain 保存密钥。最简单的方式是使用系统自带的Security框架,封装一个 KeychainStore。核心思路是:

  • 写入时使用SecItemAddSecItemUpdate
  • 读取时使用SecItemCopyMatching
  • 删除时使用SecItemDelete

虽然代码上比UserDefaults复杂一些,但这是保护 API Key 的底线。如果团队成员较多,还应该考虑对 Key 做访问控制,例如设置为“仅在当前用户登录后可用”。

如果暂时不想引入 Keychain,最低限度也要避免在日志中打印 API Key。很多请求失败日志会把URLRequesthttpBody打出,密钥很容易随日志泄露。

6.2 缓存、刷新频率与系统调度

菜单栏组件的一个重要使用场景是“开机即显示”。如果每次启动都先发起网络请求,用户会看到一段时间的空白状态。更好的做法是:

  • 启动时先从本地缓存读取上一次的用量数据,立即展示。
  • 然后异步发起网络请求,成功后更新缓存和界面。
  • 请求失败时保留旧数据,并显示一条淡化的错误提示。

缓存文件可以放在Application Support目录下。为了保证缓存的数据结构稳定,建议在UsageCache中单独管理编码和解码逻辑。

刷新频率也要克制。LLM 用量不是实时计费数据,通常 5 到 15 分钟刷新一次足够。不要设置成每秒请求一次,否则既浪费服务端资源,也容易触发风控限制。另外,macOS 在低电量模式下可能会延迟 Timer,这是系统行为,不要认为程序有 Bug。

6.3 多 Provider

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/4 16:32:56

FastReport VCL 4.15迁移实战:Delphi 7到10.2

简介&#xff1a;适用于 Delphi 7–Tokyo 10.2 的 FastReport VCL 4.15 完整源码包&#xff0c;面向需要深度定制报表功能的 Delphi 开发者&#xff0c;适合用于学习报表引擎架构、理解设计器与数据绑定机制&#xff0c;或解决跨版本集成时的兼容性问题。FastReport 内置丰富的…

作者头像 李华
网站建设 2026/9/5 3:23:19

MAX30102心率血氧采集实战:从IIC通信到PPG信号处理

简介&#xff1a;本资源是一套基于STM32平台实现MAX30102心率与血氧饱和度&#xff08;SpO₂&#xff09;实时监测的完整嵌入式开发工程&#xff0c;面向嵌入式初学者、智能健康设备开发者及高校电子类课程实践者&#xff0c;解决传感器驱动、IC通信配置、生理信号算法落地等核…

作者头像 李华
网站建设 2026/9/4 23:05:47

Python实战:从维基百科与Spotify API抓取并分析歌手Billboard榜单数据

这次我们来看一个音乐数据分析项目&#xff0c;它聚焦于解析美国歌手P!nk在Billboard Hot 100榜单上的历史成绩。对于音乐爱好者、数据分析师或内容创作者来说&#xff0c;手动整理一位歌手几十年的打榜数据既繁琐又容易出错。这个项目通过程序化方式&#xff0c;快速、准确地提…

作者头像 李华
网站建设 2026/9/4 16:56:42

TPU-GF打印全解析:玻璃纤维增强TPU的调参技巧与工程应用

做机械结构件、工装或者机器人相关毕业设计的朋友&#xff0c;应该都有过这种纠结&#xff1a;想要柔性缓冲&#xff0c;想到 TPU&#xff0c;但纯 TPU 往往偏软、偏粘、受热容易变形&#xff1b;想要高刚性和耐温&#xff0c;想到碳纤尼龙或者 PC&#xff0c;但这类材料韧性又…

作者头像 李华