标签 localStorage 下的文章

前端开发这几年,localStorage 和 sessionStorage 用得最多,cookie 偶尔也要打打交道。但说到 IndexedDB,很多人的反应是:“听说过,但没用过。”

今天聊聊这个被低估的浏览器内置数据库。

一、为什么需要另一个存储方案?

先看个实际场景。朋友公司做电商后台,产品经理要求:“能不能在列表页缓存 5000 条商品数据,让筛选和搜索快一点?”

第一版用 localStorage:

// 存储
localStorage.setItem('products', JSON.stringify(products)) // 5000条数据,页面卡了2秒

// 搜索
const keyword = '手机'
const allProducts = JSON.parse(localStorage.getItem('products')) // 又卡1秒
const results = allProducts.filter(p => p.name.includes(keyword)) // 遍历5000次

上线后用户反馈:“筛选时浏览器像卡住了一样。”

问题在哪?localStorage 有硬伤:

  • 同步操作,数据量大就阻塞页面
  • 只能存字符串,对象要序列化
  • 容量小(通常 5-10MB)
  • 只能全量读取,无法高效查询

二、IndexedDB 是什么?

简单说,它是浏览器里的 NoSQL 数据库。2011 年就出现了,但很多人不知道或觉得“用不上”。

几个关键特点:

  1. 容量大:通常能占硬盘 50%,几个 GB 没问题
  2. 异步操作:不卡页面
  3. 支持索引:查询速度快
  4. 能存多种类型:对象、文件、二进制数据都行

三、一个简单示例

如果你没用过,先看看基本用法:

// 1. 打开数据库
const request = indexedDB.open('myDB', 1)

// 2. 创建表结构(第一次或升级时)
request.onupgradeneeded = function(event) {
  const db = event.target.result
  
  // 创建对象存储(类似表)
  const store = db.createObjectStore('products', {
    keyPath: 'id',      // 主键
    autoIncrement: true // 自动生成ID
  })
  
  // 创建索引(加速查询的关键)
  store.createIndex('name', 'name')      // 按名称查
  store.createIndex('price', 'price')    // 按价格查
  store.createIndex('category', 'category') // 按分类查
}

// 3. 数据库就绪
request.onsuccess = function(event) {
  const db = event.target.result
  console.log('数据库已就绪')
}

四、核心优势:查询性能

这是 IndexedDB 真正厉害的地方。同样的 5000 条商品数据,查询完全不同:

// 用索引查,不需要遍历所有数据
async function searchProducts(keyword) {
  const transaction = db.transaction(['products'], 'readonly')
  const store = transaction.objectStore('products')
  const index = store.index('name') // 使用索引
  
  // 只搜索相关范围
  const range = IDBKeyRange.bound(keyword, keyword + '\uffff')
  const request = index.openCursor(range)
  
  return new Promise((resolve) => {
    const results = []
    request.onsuccess = function(event) {
      const cursor = event.target.result
      if (cursor) {
        results.push(cursor.value)
        cursor.continue() // 继续下一个
      } else {
        resolve(results) // 搜索完成
      }
    }
  })
}

// 毫秒级响应,不卡页面
const results = await searchProducts('手机')

你可以创建多个索引,实现各种复杂查询:

  • 价格区间筛选
  • 多条件组合查询
  • 分类统计
  • 模糊搜索

五、适用场景

什么情况下该考虑 IndexedDB?

1. 离线应用

邮件客户端、文档编辑器、笔记应用。数据先存本地,有网再同步。

2. 大数据缓存

电商商品目录、大量配置项、历史数据。替代接口频繁请求。

3. 文件管理

图片、PDF、音视频的本地缓存。不用每次都下载。

4. 游戏数据

存档、配置、资源文件。支持离线游戏。

5. 分析数据

收集用户行为,批量上传。避免频繁网络请求。

六、实用建议

1. 用封装库简化开发

原生 API 确实有点繁琐。推荐这些库:

// 用 idb 库(推荐)
import { openDB } from 'idb'

const db = await openDB('my-db', 1, {
  upgrade(db) {
    db.createObjectStore('products')
  }
})

// 操作简单多了
await db.add('products', { name: '商品1', price: 100 })
const products = await db.getAll('products')

2. 渐进增强

先判断支持性,不支持就降级:

function getStorage() {
  if ('indexedDB' in window) {
    return {
      type: 'indexedDB',
      save: saveToIndexedDB,
      load: loadFromIndexedDB
    }
  } else {
    console.log('降级到 localStorage')
    return {
      type: 'localStorage',
      save: saveToLocalStorage,
      load: loadFromLocalStorage
    }
  }
}

3. 注意版本迁移

修改表结构需要升级版本:

const request = indexedDB.open('myDB', 2) // 版本号+1

request.onupgradeneeded = function(event) {
  const db = event.target.result
  const oldVersion = event.oldVersion
  
  if (oldVersion < 1) {
    // 初始版本逻辑
  }
  
  if (oldVersion < 2) {
    // 版本2的升级逻辑
    // 比如添加新索引
    const store = event.currentTarget.transaction.objectStore('products')
    store.createIndex('createdAt', 'createdAt')
  }
}

七、什么时候不用?

IndexedDB 虽好,但也不是万能:

  • 存个用户 token → 用 localStorage 或 cookie
  • 会话级临时数据 → 用 sessionStorage
  • 简单配置项 → localStorage 更方便
  • 需要服务端读取 → cookie

记住:技术选型要看具体需求,不是越高级越好。

八、开始尝试

如果你从没用过 IndexedDB,可以从这些开始:

  1. 缓存接口数据:把频繁请求的 API 结果缓存起来
  2. 离线收藏功能:用户收藏的内容存本地
  3. 图片懒加载缓存:看过的图片存起来
  4. 表单草稿:复杂的表单数据实时保存

不需要一开始就大动干戈。找个合适的场景,先试试水。

写在最后

IndexedDB 在前端领域存在感不强,可能因为它解决的问题不是每个项目都会遇到。但当你真的需要处理大量客户端数据时,它会是个很好的选择。

技术没有绝对的好坏,只有合适与否。知道它的存在,了解它的能力,当合适的需求出现时,你就能做出更好的选择。


看完有点兴趣了?可以在个人项目里试试 IndexedDB,遇到问题欢迎交流。如果你已经在用,有什么经验或踩坑故事?评论区聊聊。

本文由mdnice多平台发布

前言

在上一篇文章中,我们探讨了组件内部以及深层嵌套对象的状态管理。但当我们把视角拉高,俯瞰整个应用时,会发现一个新的挑战:数据孤岛。

用户在“我的”页面修改了头像,首页的左上角是不是也得跟着变?用户在“设置”里开启了夜间模式,是不是所有的页面都要瞬间切换颜色?如果只靠组件之间的父子传递(Props),我们需要把这些状态一路从根节点透传下去,这种属性钻取简直是代码维护的噩梦。

更棘手的是,当用户划掉后台进程,杀掉应用,再次打开时,我们希望他上次设置的“夜间模式”依然生效。这就涉及到了内存数据与磁盘数据的同步问题。

在鸿蒙 HarmonyOS 6 (API 20) 中,ArkUI 给出了一套完整的应用级状态管理方案:AppStorage全局内存、LocalStorage页面级内存以及 PersistentStorage持久化存储。

今天,我们就来聊聊如何打通这三条数据流,构建一个数据互通且能记住用户习惯的应用。

这份优化后的文案去除了口语化的比喻和非必要的符号,采用了更专业、精准的技术语言,强调了核心概念与运行机制。


一、 AppStorage 应用内存状态的全局管理

AppStorage 是应用在内存中的全局状态容器。一旦在其中存入属性,应用内的任意 Ability、Page 或自定义组件均可访问和修改。

在声明式 UI 开发中,不建议在 build 函数中频繁直接调用 API。ArkUI 提供了两个专用的装饰器:

  • @StorageLink:建立双向同步。组件内状态的修改会同步至全局,并驱动其他订阅该状态的组件更新。
  • @StorageProp:建立单向订阅。仅接收全局状态的更新,组件内的修改不会同步回全局。

例如,将“当前用户 Token”存储在 AppStorage 中。在任意页面声明 @StorageLink('userToken'),该变量即可与全局状态联通。无论何处更新了 Token,所有页面的对应变量均会实时刷新。

二、 PersistentStorage 持久化数据同步

AppStorage 仅存在于内存中,应用退出后数据会被清除。PersistentStorage 的作用是将 AppStorage 中的特定属性写入磁盘文件,实现持久化。

PersistentStorage 并非独立的数据库,而是 AppStorage 与文件系统之间的同步机制。通常在应用启动早期(如 EntryAbility 或页面加载前)调用 PersistentStorage.persistProp('key', defaultValue)。系统会检查磁盘中是否存在该键值:若存在,则读取并覆盖 AppStorage 中的值;若不存在,则使用默认值初始化,并建立磁盘映射。

连接建立后,开发者只需通过 @StorageLink 修改 AppStorage 中的数据,框架会自动监听变化并将新值写入磁盘,无需手动处理文件读写或 JSON 序列化。

三、 LocalStorage 模块化状态隔离

与全局的 AppStorage 不同,LocalStorage 用于解决模块化和多实例场景下的状态共享问题(如多窗口应用或独立模块)。

LocalStorage 的生命周期绑定在 Ability 或 UIAbility 上下文中,创建了一个隔离的状态容器。在页面加载时,可传入独立的 LocalStorage 实例,组件内部则使用 @LocalStorageLink 进行对接。这种机制能有效保证状态的封装性与安全性,防止因全局变量过多导致的状态污染。

四、 最佳实践 全局主题切换功能实现

以下通过“全局主题切换”功能(支持深色模式与字体大小调整)演示三者的协同工作。需求包括:设置在所有页面生效,且应用重启后配置依然保留。

实现时需遵循严格的初始化顺序:先持久化,再 UI 绑定。务必在 Ability 或文件顶层执行 PersistentStorage.persistProp,切勿在组件的 build 函数中执行持久化初始化,以免引发逻辑错误。

import { promptAction } from '@kit.ArkUI';

// =============================================================
// 1. 初始化持久化数据 (必须在 @Entry 之前执行)
// =============================================================
// 这里的逻辑是:
// 1. 检查磁盘是否有 'appThemeMode'。
// 2. 如果有,将其加载到 AppStorage 中。
// 3. 如果没有,则使用默认值 'light' 初始化 AppStorage,并写入磁盘。
PersistentStorage.persistProp('appThemeMode', 'light');
PersistentStorage.persistProp('appFontSize', 16);

@Entry
@Component
struct GlobalStatePage {
  // =============================================================
  // 2. UI 绑定全局状态 (@StorageLink)
  // =============================================================
  // @StorageLink 与 AppStorage 建立双向同步:
  // 读取:初始化时从 AppStorage 获取值。
  // 写入:当 this.themeMode 改变 -> AppStorage 更新 -> PersistentStorage 写入磁盘。
  @StorageLink('appThemeMode') themeMode: string = 'light';
  @StorageLink('appFontSize') fontSize: number = 16;

  build() {
    // 根容器:背景色根据主题动态变化
    Column() {
      // --- 顶部标题栏 ---
      Text('全局设置中心')
        .fontSize(24)
        .fontWeight(FontWeight.Bold)
        // 动态适配字体颜色
        .fontColor(this.themeMode === 'light' ? '#333333' : '#FFFFFF')
        .margin({ top: 40, bottom: 40 })

      // --- 设置选项卡片区域 ---
      Column({ space: 20 }) {

        // -----------------------------------------------------
        // 选项 1:字体大小调节
        // -----------------------------------------------------
        Row() {
          Text('全局字号')
            .fontSize(this.fontSize) // 【关键点】应用动态字号
            .fontColor(this.themeMode === 'light' ? '#333' : '#FFF')
            .fontWeight(FontWeight.Medium)

          // 调节按钮组
          Row() {
            Button('A-')
              .onClick(() => {
                if (this.fontSize > 12) {
                  this.fontSize -= 2; // 修改状态 -> 自动触发持久化保存
                } else {
                  promptAction.showToast({ message: '已经是最小字体了' })
                }
              })
              .backgroundColor(this.themeMode === 'light' ? '#F0F0F0' : '#444')
              .fontColor(this.themeMode === 'light' ? '#333' : '#FFF')
              .margin({ right: 10 })
              .width(40)
              .height(32)

            Button('A+')
              .onClick(() => {
                if (this.fontSize < 30) {
                  this.fontSize += 2; // 修改状态 -> 自动触发持久化保存
                } else {
                  promptAction.showToast({ message: '已经是最大字体了' })
                }
              })
              .backgroundColor(this.themeMode === 'light' ? '#F0F0F0' : '#444')
              .fontColor(this.themeMode === 'light' ? '#333' : '#FFF')
              .width(40)
              .height(32)
          }
        }
        .width('100%')
        .justifyContent(FlexAlign.SpaceBetween)
        .padding(16)
        .backgroundColor(this.themeMode === 'light' ? '#FFFFFF' : '#2C2C2C')
        .borderRadius(16)
        .shadow({ radius: 8, color: this.themeMode === 'light' ? '#10000000' : '#00000000', offsetY: 2 })

        // -----------------------------------------------------
        // 选项 2:主题模式切换
        // -----------------------------------------------------
        Row() {
          Column() {
            Text('夜间模式')
              .fontSize(this.fontSize)
              .fontColor(this.themeMode === 'light' ? '#333' : '#FFF')
            Text(this.themeMode === 'light' ? '当前:日间' : '当前:深色')
              .fontSize(12)
              .fontColor('#888')
              .margin({ top: 4 })
          }
          .alignItems(HorizontalAlign.Start)

          // 开关组件
          Toggle({ type: ToggleType.Switch, isOn: this.themeMode === 'dark' })
            .selectedColor('#0A59F7')
            .onChange((isOn: boolean) => {
              // 【关键点】修改状态
              // 这一步会瞬间触发:
              // 1. 本页面 UI 刷新(背景变黑/白)
              // 2. AppStorage 全局变量更新
              // 3. 磁盘文件写入
              this.themeMode = isOn ? 'dark' : 'light';

              promptAction.showToast({
                message: `已切换至${isOn ? '深色' : '日间'}模式,设置已自动保存`,
                duration: 2000
              });
            })
        }
        .width('100%')
        .justifyContent(FlexAlign.SpaceBetween)
        .padding(16)
        .backgroundColor(this.themeMode === 'light' ? '#FFFFFF' : '#2C2C2C')
        .borderRadius(16)
        .shadow({ radius: 8, color: this.themeMode === 'light' ? '#10000000' : '#00000000', offsetY: 2 })

        // --- 底部提示 ---
        Text('提示:该设置已持久化存储。您可以尝试彻底关闭应用(杀后台),再次打开时,上述设置依然生效。')
          .fontSize(12)
          .fontColor('#999')
          .margin({ top: 20 })
          .padding({ left: 10, right: 10 })
          .textAlign(TextAlign.Center)
          .lineHeight(18)

      }
      .padding(16)
    }
    .width('100%')
    .height('100%')
    // 全局背景色
    .backgroundColor(this.themeMode === 'light' ? '#F1F3F5' : '#121212')
    // 添加过渡动画,让主题切换更丝滑
    .animation({ duration: 300, curve: Curve.EaseInOut })
  }
}

总结

掌握了 AppStoragePersistentStorage,你就掌握了鸿蒙应用数据流动的“任督二脉”。AppStorage 打通了页面间的隔阂,让数据在内存中自由穿梭;PersistentStorage 则打通了内存与磁盘的界限,让关键数据得以长久保存。
在实际开发中,建议将所有的 Keys 定义为一个常量文件,避免魔术字符串满天飞。同时,虽然全局状态很好用,但也要克制,不要把所有数据都往里塞,只存放那些真正需要全局共享和持久化的“配置级”或“用户级”数据,保持应用内存的纯净与高效。

从零构建 GitHub Issues 集成:HagiCode 的前端直连实践

本文记录了在 HagiCode 平台中集成 GitHub Issues 的全过程。我们将探讨如何通过"前端直连 + 后端最小化"的架构,在保持后端轻量的同时,实现安全的 OAuth 认证与高效的 Issues 同步。

背景:为什么要集成 GitHub?

HagiCode 作为一个 AI 辅助开发平台,核心价值在于连接想法与实现。但在实际使用中,我们发现用户在 HagiCode 中完成了 Proposal(提案)后,往往需要手动将内容复制到 GitHub Issues 中进行项目跟踪。

这带来了几个明显的痛点:

  1. 工作流割裂:用户需要在两个系统之间来回切换,体验不仅不流畅,还容易导致关键信息在复制粘贴的过程中丢失。
  2. 协作不便:团队其他成员习惯在 GitHub 上查看任务,无法直接看到 HagiCode 中的提案进展。
  3. 重复劳动:每当提案更新,就要人工去 GitHub 更新对应的 Issue,增加不必要的维护成本。

为了解决这个问题,我们决定引入 GitHub Issues Integration 功能,打通 HagiCode 会话与 GitHub 仓库的连接,实现"一键同步"。

关于 HagiCode

嘿,介绍一下我们正在做的东西

我们正在开发 HagiCode —— 一款 AI 驱动的代码智能助手,让开发体验变得更智能、更便捷、更有趣。

智能 —— AI 全程辅助,从想法到代码,让编码效率提升数倍。便捷 —— 多线程并发操作,充分利用资源,开发流程顺畅无阻。有趣 —— 游戏化机制和成就系统,让编码不再枯燥,充满成就感。

项目正在快速迭代中,如果你对技术写作、知识管理或者 AI 辅助开发感兴趣,欢迎来 GitHub 看看~


技术选型:前端直连 vs 后端代理

在设计集成方案时,摆在我们面前的有两条路:传统的"后端代理模式"和更激进的"前端直连模式"。

方案对比

在传统的后端代理模式中,前端所有的请求都要先经过我们的后端,再由后端去调用 GitHub API。这虽然逻辑集中,但给后端带来了不小的负担:

  1. 后端臃肿:需要编写专门的 GitHub API 客户端封装,还要处理 OAuth 的复杂状态机。
  2. Token 风险:用户的 GitHub Token 必须存储在后端数据库中,虽然可以加密,但毕竟增加了安全风险面。
  3. 开发成本:需要数据库迁移来存储 Token,还需要维护一套额外的同步服务。

前端直连模式则要轻量得多。在这个方案中,我们只利用后端来处理最敏感的"密钥交换"环节(OAuth callback),获取到 Token 后,直接存在浏览器的 localStorage 里。后续创建 Issue、更新评论等操作,直接由前端发 HTTP 请求到 GitHub。

对比维度后端代理模式前端直连模式
后端复杂度需要完整的 OAuth 服务和 GitHub API 客户端仅需一个 OAuth 回调端点
Token 管理需加密存储在数据库,有泄露风险存储在浏览器,仅用户自己可见
实施成本需数据库迁移、多服务开发主要是前端工作量
用户体验逻辑统一,但服务器延迟可能稍高响应极快,直接与 GitHub 交互

考虑到我们要的是快速集成和最小化后端改动,最终我们采用了"前端直连模式"。这就像给浏览器发了一张"临时通行证",拿到证之后,浏览器就可以自己去 GitHub 办事了,不需要每次都找后端管理员批准。


核心设计:数据流与安全

在确定架构后,我们需要设计具体的数据流。整个同步流程的核心在于如何安全地获取 Token 并高效地利用它。

整体架构图

整个系统可以抽象为三个角色:浏览器(前端)、HagiCode 后端、GitHub。

+--------------+        +--------------+        +--------------+
|  前端 React  |        |    后端      |        |    GitHub    |
|              |        |   ASP.NET    |        |    REST API  |
|  +--------+  |        |              |        |              |
|  |  OAuth |--+--------> /callback    |        |              |
|  |  流程  |  |        |              |        |              |
|  +--------+  |        |              |        |              |
|              |        |              |        |              |
|  +--------+  |        |  +--------+  |        |  +--------+  |
|  |GitHub  |  +------------>Session |  +----------> Issues |  |
|  |API     |  |        |  |Metadata|  |        |  |        |  |
|  |直连    |  |        |  +--------+  |        |  +--------+  |
|  +--------+  |        |              |        |              |
+--------------+        +--------------+        +--------------+

关键点在于:只有 OAuth 的一小步(获取 code 换 token)需要经过后端,之后的粗活累活(创建 Issue)都是前端直接跟 GitHub 打交道。

同步数据流详解

当用户点击 HagiCode 界面上的"Sync to GitHub"按钮时,会发生一系列复杂的动作:

用户点击 "Sync to GitHub"
         │
         ▼
1. 前端检查 localStorage 获取 GitHub Token
         │
         ▼
2. 格式化 Issue 内容(将 Proposal 转换为 Markdown)
         │
         ▼
3. 前端直接调用 GitHub API 创建/更新 Issue
         │
         ▼
4. 调用 HagiCode 后端 API 更新 Session.metadata (存储 Issue URL 等信息)
         │
         ▼
5. 后端通过 SignalR 广播 SessionUpdated 事件
         │
         ▼
6. 前端接收事件,更新 UI 显示"已同步"状态

安全设计

安全问题始终是集成第三方服务的重中之重。我们做了以下考量:

  1. 防 CSRF 攻击:在 OAuth 跳转时,生成随机的 state 参数并存入 sessionStorage。回调时严格验证 state,防止请求被伪造。
  2. Token 存储隔离:Token 仅存储在浏览器的 localStorage 中,利用同源策略(Same-Origin Policy),只有 HagiCode 的脚本才能读取,避免了服务器端数据库泄露波及用户。
  3. 错误边界:针对 GitHub API 常见的错误(如 401 Token 过期、422 验证失败、429 速率限制),设计了专门的错误处理逻辑,给用户以友好的提示。

实践:代码实现细节

纸上得来终觉浅,咱们来看看具体的代码是怎么实现的。

1. 后端最小化改动

后端只需要做两件事:存储同步信息、处理 OAuth 回调。

数据库变更
我们只需要在 Sessions 表增加一个 Metadata 列,用来存储 JSON 格式的扩展信息。

-- 添加 metadata 列到 Sessions 表
ALTER TABLE "Sessions" ADD COLUMN "Metadata" text NULL;

实体与 DTO 定义

// src/HagiCode.DomainServices.Contracts/Entities/Session.cs
public class Session : AuditedAggregateRoot<SessionId>
{
    // ... 其他属性 ...

    /// <summary>
    /// JSON metadata for storing extension data like GitHub integration
    /// </summary>
    public string? Metadata { get; set; }
}

// DTO 定义,方便前端序列化
public class GitHubIssueMetadata
{
    public required string Owner { get; set; }
    public required string Repo { get; set; }
    public int IssueNumber { get; set; }
    public required string IssueUrl { get; set; }
    public DateTime SyncedAt { get; set; }
    public string LastSyncStatus { get; set; } = "success";
}

public class SessionMetadata
{
    public GitHubIssueMetadata? GitHubIssue { get; set; }
}

2. 前端 OAuth 流程

这是连接的入口。我们使用标准的 Authorization Code Flow。

// src/HagiCode.Client/src/services/githubOAuth.ts

// 生成授权 URL 并跳转
export async function generateAuthUrl(): Promise<string> {
  const state = generateRandomString(); // 生成防 CSRF 的随机串
  sessionStorage.setItem('hagicode_github_state', state);
  
  const params = new URLSearchParams({
    client_id: clientId,
    redirect_uri: window.location.origin + '/settings?tab=github&oauth=callback',
    scope: ['repo', 'public_repo'].join(' '),
    state: state,
  });
  
  return `https://github.com/login/oauth/authorize?${params.toString()}`;
}

// 在回调页面处理 Code 换取 Token
export async function exchangeCodeForToken(code: string, state: string): Promise<GitHubToken> {
  // 1. 验证 State 防止 CSRF
  const savedState = sessionStorage.getItem('hagicode_github_state');
  if (state !== savedState) throw new Error('Invalid state parameter');

  // 2. 调用后端 API 进行 Token 交换
  // 注意:这里必须经过后端,因为需要 ClientSecret,不能暴露在前端
  const response = await fetch('/api/GitHubOAuth/callback', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ code, state, redirectUri: window.location.origin + '/settings?tab=github&oauth=callback' }),
  });

  if (!response.ok) throw new Error('Failed to exchange token');
  
  const token = await response.json();
  
  // 3. 存入 LocalStorage
  saveToken(token);
  return token;
}

3. GitHub API 客户端封装

有了 Token 之后,我们就需要一个强有力的工具来调 GitHub API。

// src/HagiCode.Client/src/services/githubApiClient.ts

const GITHUB_API_BASE = 'https://api.github.com';

// 核心请求封装
async function githubApi<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
  const token = localStorage.getItem('gh_token');
  if (!token) throw new Error('Not connected to GitHub');
  
  const response = await fetch(`${GITHUB_API_BASE}${endpoint}`, {
    ...options,
    headers: {
      ...options.headers,
      Authorization: `Bearer ${token}`,
      Accept: 'application/vnd.github.v3+json', // 指定 API 版本
    },
  });
  
  // 错误处理逻辑
  if (!response.ok) {
    if (response.status === 401) throw new Error('GitHub Token 失效,请重新连接');
    if (response.status === 403) throw new Error('无权访问该仓库或超出速率限制');
    if (response.status === 422) throw new Error('Issue 验证失败,可能标题重复');
    throw new Error(`GitHub API Error: ${response.statusText}`);
  }
  
  return response.json();
}

// 创建 Issue
export async function createIssue(owner: string, repo: string, data: { title: string, body: string, labels: string[] }) {
  return githubApi(`/repos/${owner}/${repo}/issues`, {
    method: 'POST',
    body: JSON.stringify(data),
  });
}

4. 内容格式化与同步

最后一步,就是把 HagiCode 的 Session 数据转换成 GitHub Issue 的格式。这有点像"翻译"工作。

// 将 Session 对象转换为 Markdown 字符串
function formatIssueForSession(session: Session): string {
  let content = `# ${session.title}\n\n`;
  content += `**> HagiCode Session:** #${session.code}\n`;
  content += `**> Status:** ${session.status}\n\n`;
  content += `## Description\n\n${session.description || 'No description provided.'}\n\n`;
  
  // 如果是 Proposal 类型,添加额外字段
  if (session.type === 'proposal') {
    content += `## Chief Complaint\n\n${session.chiefComplaint || ''}\n\n`;
    // 添加一个深链接,方便从 GitHub 跳回 HagiCode
    content += `---\n\n**[View in HagiCode](hagicode://sessions/${session.id})**\n`;
  }
  
  return content;
}

// 点击同步按钮的主逻辑
const handleSync = async (session: Session) => {
  try {
    const repoInfo = parseRepositoryFromUrl(session.repoUrl); // 解析仓库 URL
    if (!repoInfo) throw new Error('Invalid repository URL');

    toast.loading('正在同步到 GitHub...');
    
    // 1. 格式化内容
    const issueBody = formatIssueForSession(session);
    
    // 2. 调用 API
    const issue = await githubApiClient.createIssue(repoInfo.owner, repoInfo.repo, {
      title: `[HagiCode] ${session.title}`,
      body: issueBody,
      labels: ['hagicode', 'proposal', `status:${session.status}`],
    });
    
    // 3. 更新 Session Metadata (保存 Issue 链接)
    await SessionsService.patchApiSessionsSessionId(session.id, {
      metadata: {
        githubIssue: {
          owner: repoInfo.owner,
          repo: repoInfo.repo,
          issueNumber: issue.number,
          issueUrl: issue.html_url,
          syncedAt: new Date().toISOString(),
        }
      }
    });

    toast.success('同步成功!');
  } catch (err) {
    console.error(err);
    toast.error('同步失败,请检查 Token 或网络');
  }
};

总结与展望

通过这套"前端直连"方案,我们用最少的后端代码实现了 GitHub Issues 的无缝集成。

收获

  1. 开发效率高:后端改动极小,主要是数据库加一个字段和一个简单的 OAuth 回调接口,大部分逻辑都在前端完成。
  2. 安全性好:Token 不经过服务器数据库,降低了泄露风险。
  3. 用户体验佳:直接从前端发起请求,响应速度快,不需要经过后端中转。

注意事项

在实际部署时,有几个坑大家要注意:

  • OAuth App 设置:记得在 GitHub OAuth App 设置里填正确的 Authorization callback URL(通常是 http://localhost:3000/settings?tab=github&oauth=callback)。
  • 速率限制:GitHub API 对未认证请求限制较严,但用 Token 后通常足够(5000次/小时)。
  • URL 解析:用户输入的 Repo URL 千奇百怪,记得正则要匹配 .git 后缀、SSH 格式等情况。

后续增强

目前的功能还是单向同步(HagiCode -> GitHub)。未来我们计划通过 GitHub Webhooks 实现双向同步,比如在 GitHub 里关闭 Issue,HagiCode 这边的会话状态也能自动更新。这需要我们在后端暴露一个 Webhook 接收端点,这也是下一步要做的有趣工作。

希望这篇文章能给你的第三方集成开发带来一点灵感!如果有问题,欢迎在 HagiCode GitHub 上提 Issue 讨论。