HarmonyOS HMRouter 路由库 Demo 练习总结:从路由配置到商品管理增删改查

HarmonyOS HMRouter 路由库 Demo 练习总结:从路由配置到商品管理增删改查

前言

今天主要围绕鸿蒙项目中的 HMRouter 路由库做了一次完整 Demo 练习。

一开始只是一个简单的底部导航 Demo,通过 currentIndex 控制不同组件显示。后来逐步改造成真正的路由跳转,并在此基础上继续练习了路由传参、返回、拦截器、页面组件拆分、商品列表增删改查、搜索和详情页跳转等内容。

这篇文章主要是给自己做一个完整记录,后面如果忘记 HMRouter 的接入流程、push / replace / pop 区别、路由传参、拦截器、商品 Demo 的组件拆分方式,可以直接回来看这篇。


一、今天 Demo 的最终效果

最终 Demo 里主要有几个页面:

txt 复制代码
首页 TabHome
Home 商品管理页
Setting 设置页
ProductDetail 商品详情页

底部导航通过 HMRouter 实现真实路由跳转:

txt 复制代码
Index.ets
│
├── HMNavigation 路由容器
│
└── 底部导航栏
    ├── 首页 -> pages/TabHome
    ├── Home -> pages/Home
    └── Setting -> pages/Setting

商品管理页实现了:

txt 复制代码
商品列表展示
搜索商品
新增商品
删除商品
修改商品名称
点击详情跳转 ProductDetail

详情页实现了:

txt 复制代码
接收商品 id/title/from 参数
展示参数内容
点击返回 pop 回上一页

二、HMRouter 接入流程

HMRouter 的接入不是只安装一个包就结束,它分成运行时依赖、编译插件、路由扫描配置、运行时初始化几个部分。

整体流程:

txt 复制代码
安装 @hadss/hmrouter
↓
配置 @hadss/hmrouter-plugin
↓
配置 hmrouter_config.json
↓
EntryAbility 初始化 HMRouterMgr
↓
页面使用 @HMRouter 注册
↓
Index 使用 HMNavigation 承载页面
↓
HMRouterMgr.push / replace / pop 操作路由

三、安装运行库

在项目根目录执行:

bash 复制代码
ohpm install @hadss/hmrouter

这个包主要提供运行时代码:

ts 复制代码
HMRouter
HMRouterMgr
HMNavigation

也就是说,页面里能这样写:

ts 复制代码
import { HMRouter, HMRouterMgr, HMNavigation } from '@hadss/hmrouter'

四、配置编译插件

HMRouter 不是运行时自动扫描页面,而是依赖编译插件扫描 @HMRouter 装饰器,然后生成路由表。

在:

txt 复制代码
hvigor/hvigor-config.json5

中配置插件依赖:

json5 复制代码
{
  "modelVersion": "6.1.0",
  "dependencies": {
    "@hadss/hmrouter-plugin": "^1.2.2"
  }
}

注意:

txt 复制代码
@hadss/hmrouter 是运行库,用 ohpm install
@hadss/hmrouter-plugin 是编译插件,写在 hvigor-config.json5

五、entry 模块启用插件

在:

txt 复制代码
entry/hvigorfile.ts

中启用插件:

ts 复制代码
import { hapTasks } from '@ohos/hvigor-ohos-plugin'
import { hapPlugin } from '@hadss/hmrouter-plugin'

export default {
  system: hapTasks,
  plugins: [
    hapPlugin()
  ]
}

如果这一步没配好,运行时容易出现:

txt 复制代码
not exist pageUrl in store

因为页面没有被扫描到,路由表里没有对应页面。


六、配置路由扫描目录

在 entry 目录下创建:

txt 复制代码
entry/hmrouter_config.json

内容:

json 复制代码
{
  "scanDir": ["src/main/ets/pages"],
  "saveGeneratedFile": true
}

含义:

txt 复制代码
扫描 entry/src/main/ets/pages 下面的 @HMRouter 页面
并保存生成文件,方便排查路由表是否生成

注意这个文件应该放在:

txt 复制代码
entry/hmrouter_config.json

而不是项目根目录。


七、EntryAbility 初始化 HMRouter

在:

txt 复制代码
entry/src/main/ets/entryability/EntryAbility.ets

中初始化 HMRouter:

ts 复制代码
import { HMRouterMgr } from '@hadss/hmrouter'

onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
  HMRouterMgr.openLog('INFO')

  HMRouterMgr.init({
    context: this.context
  })
}

如果没初始化,可能会报:

txt 复制代码
ERR_INIT_NOT_READY
Framework initialization not completed

所以记住:

txt 复制代码
路由使用前,必须先在 EntryAbility 初始化 HMRouterMgr

八、注册路由页面

每个需要被 HMRouter 跳转的页面,都需要加:

ts 复制代码
@HMRouter({ pageUrl: 'pages/Home' })

例如 Home 页面:

ts 复制代码
import { HMRouter } from '@hadss/hmrouter'

@HMRouter({ pageUrl: 'pages/Home' })
@ComponentV2
export struct HomeBuilder {
  build() {
    Column() {
      Text('Home 页面')
    }
  }
}

这里的:

txt 复制代码
pages/Home

就是这个页面的路由地址。

跳转时也要保持完全一致:

ts 复制代码
HMRouterMgr.push({
  pageUrl: 'pages/Home'
})

九、Index 页面使用 HMNavigation

入口页面 Index.ets 中使用 HMNavigation 作为路由容器。

ts 复制代码
import { HMNavigation, HMRouterMgr } from '@hadss/hmrouter'

@Entry
@ComponentV2
struct Index {
  @Local currentTab: string = 'pages/TabHome'

  build() {
    Column() {
      HMNavigation({
        navigationId: 'MainNavigation',
        homePageUrl: 'pages/TabHome'
      })
        .layoutWeight(1)
        .width('100%')

      Row() {
        this.TabItem('首页', 'pages/TabHome')
        this.TabItem('Home', 'pages/Home')
        this.TabItem('Setting', 'pages/Setting')
      }
      .width('100%')
      .height(60)
      .backgroundColor('#FFFFFF')
    }
    .width('100%')
    .height('100%')
  }

  @Builder
  TabItem(title: string, pageUrl: string) {
    Column() {
      Text(title)
        .fontSize(14)
        .fontColor(this.currentTab === pageUrl ? '#007DFF' : '#666666')
    }
    .layoutWeight(1)
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .alignItems(HorizontalAlign.Center)
    .onClick(() => {
      this.currentTab = pageUrl

      HMRouterMgr.push({
        pageUrl: pageUrl,
        param: {
          title: title,
          from: '底部导航栏'
        }
      })
    })
  }
}

十、push / replace / pop 的区别

今天练习后,对这三个 API 的理解更清楚了。

1. push

push 是进入一个新页面,会把页面压入路由栈。

适合:

txt 复制代码
列表页 -> 详情页
商品页 -> 商品详情页
聊天列表 -> 聊天详情页

示例:

ts 复制代码
HMRouterMgr.push({
  pageUrl: 'pages/ProductDetail',
  param: {
    id: item.id,
    title: item.name,
    from: '商品列表'
  }
})

路由栈类似:

txt 复制代码
Home
↓ push
ProductDetail

2. pop

pop 是返回上一页。

适合:

txt 复制代码
详情页返回列表页
二级页面返回上一级

示例:

ts 复制代码
HMRouterMgr.pop()

路由栈:

txt 复制代码
Home -> ProductDetail
↓ pop
Home

3. replace

replace 是替换当前页面,不会无限堆栈。

适合底部 Tab 切换:

txt 复制代码
首页
Home
Setting

这几个页面是平级页面,所以更适合用 replace

ts 复制代码
HMRouterMgr.replace({
  pageUrl: item.pageUrl,
  param: {
    title: item.title,
    from: '底部导航栏'
  }
})

总结:

txt 复制代码
Tab 页面切换:replace
详情页跳转:push
页面返回:pop

十一、路由传参

跳转时通过 param 传参:

ts 复制代码
HMRouterMgr.push({
  pageUrl: 'pages/ProductDetail',
  param: {
    id: item.id,
    title: item.name,
    from: '商品列表'
  }
})

目标页面通过:

ts 复制代码
HMRouterMgr.getCurrentParam()

接收参数:

ts 复制代码
interface ProductDetailParam {
  id?: number
  title?: string
  from?: string
}

const param = HMRouterMgr.getCurrentParam() as ProductDetailParam

this.productId = Number(param?.id ?? 0)
this.title = param?.title ?? '商品详情'
this.from = param?.from ?? '默认入口'

今天通过日志验证,传参是正常的:

txt 复制代码
详情页接收到参数:
{"id":1,"title":"苹果","from":"商品列表"}

十二、路由拦截器

今天还练习了全局路由拦截器,用于避免重复跳转同一个路由。

SameRouteInterceptor.ets

ts 复制代码
import {
  HMInterceptorAction,
  HMInterceptorInfo,
  IHMInterceptor
} from '@hadss/hmrouter'

export class SameRouteInterceptor implements IHMInterceptor {
  interceptorName: string = 'SameRouteInterceptor'
  priority: number = 100

  handle(info: HMInterceptorInfo): HMInterceptorAction {
    const fromPage: string = info.srcName ?? ''
    const targetPage: string = info.targetName ?? ''

    console.log(`[SameRouteInterceptor] from=${fromPage}, target=${targetPage}`)

    if (fromPage.length > 0 && targetPage.length > 0 && fromPage === targetPage) {
      console.log('[SameRouteInterceptor] 相同路由,拦截跳转')
      return HMInterceptorAction.DO_REJECT
    }

    return HMInterceptorAction.DO_NEXT
  }
}

注册方式:

ts 复制代码
HMRouterMgr.registerGlobalInterceptor({
  interceptor: new SameRouteInterceptor()
})

整体流程:

txt 复制代码
发起路由跳转
↓
进入 SameRouteInterceptor
↓
判断当前页面和目标页面是否相同
│
├── 相同:DO_REJECT,拦截跳转
└── 不同:DO_NEXT,继续跳转

十三、路由常量抽取

为了避免到处写死字符串,抽出了路由常量。

RouteConstants.ets

ts 复制代码
export class RouteConstants {
  static readonly MAIN_NAVIGATION_ID: string = 'MainNavigation'

  static readonly TAB_HOME: string = 'pages/TabHome'
  static readonly HOME: string = 'pages/Home'
  static readonly SETTING: string = 'pages/Setting'
  static readonly PRODUCT_DETAIL: string = 'pages/ProductDetail'
}

export class RouteTitle {
  static readonly TAB_HOME: string = '首页'
  static readonly HOME: string = 'Home'
  static readonly SETTING: string = 'Setting'
}

export class RouteFrom {
  static readonly TAB_BAR: string = '底部导航栏'
}

export class TabRouteItem {
  title: string = ''
  pageUrl: string = ''
}

export const TAB_ROUTES: TabRouteItem[] = [
  {
    title: RouteTitle.TAB_HOME,
    pageUrl: RouteConstants.TAB_HOME
  },
  {
    title: RouteTitle.HOME,
    pageUrl: RouteConstants.HOME
  },
  {
    title: RouteTitle.SETTING,
    pageUrl: RouteConstants.SETTING
  }
]

底部导航可以用 TAB_ROUTES 映射出来:

ts 复制代码
ForEach(TAB_ROUTES, (item: TabRouteItem) => {
  this.TabItem(item)
}, (item: TabRouteItem) => item.pageUrl)

这样以后新增 Tab,只需要改配置。


十四、商品管理 Demo

今天商品 Demo 实现了基础增删改查和搜索。

功能包括:

txt 复制代码
初始化商品假数据
展示商品列表
搜索商品
新增商品
删除商品
修改商品名称
点击详情跳转详情页

当前商品管理组件中包含输入状态、搜索状态、商品列表、编辑状态,以及新增、删除、修改、搜索等业务逻辑。:contentReference[oaicite:0]{index=0}


十五、Product 模型

Product.ets

ts 复制代码
export class Product {
  id: number = 0
  name: string = ''
  price: number = 0
  stock: number = 0
  desc: string = ''
}

字段含义:

txt 复制代码
id:商品 ID
name:商品名称
price:价格
stock:库存
desc:描述

十六、商品 Store

为了让列表页和详情页共享商品数据,需要把商品列表抽到 Store 中。

ProductStore.ets

ts 复制代码
import { Product } from '../models/Product'

@ObservedV2
export class ProductStore {
  @Trace products: Product[] = []

  initProducts(): void {
    if (this.products.length > 0) {
      return
    }

    const p1 = new Product()
    p1.id = 1
    p1.name = '苹果'
    p1.price = 5
    p1.stock = 100
    p1.desc = '新鲜红苹果,适合日常购买'

    const p2 = new Product()
    p2.id = 2
    p2.name = '香蕉'
    p2.price = 3
    p2.stock = 80
    p2.desc = '软糯香甜,适合早餐'

    const p3 = new Product()
    p3.id = 3
    p3.name = '牛奶'
    p3.price = 12
    p3.stock = 50
    p3.desc = '早餐搭配,补充蛋白质'

    this.products = [p1, p2, p3]
  }

  addProduct(name: string): void {
    const p = new Product()
    p.id = Date.now()
    p.name = name
    p.price = Math.floor(Math.random() * 20) + 1
    p.stock = Math.floor(Math.random() * 100) + 1
    p.desc = '这是一个新增商品'

    this.products = [...this.products, p]
  }

  deleteProduct(id: number): void {
    this.products = this.products.filter((item: Product) => {
      return item.id !== id
    })
  }

  updateProductName(id: number, name: string): void {
    this.products = this.products.map((item: Product) => {
      if (item.id === id) {
        const p = new Product()
        p.id = item.id
        p.name = name
        p.price = item.price
        p.stock = item.stock
        p.desc = item.desc
        return p
      }
      return item
    })
  }

  findProductById(id: number): Product | undefined {
    return this.products.find((item: Product) => {
      return Number(item.id) === Number(id)
    })
  }
}

export const productStore: ProductStore = new ProductStore()

理解:

txt 复制代码
ProductStore 是商品数据中心
ProductManager 从这里展示商品
ProductDetail 通过 id 从这里查询商品

十七、ProductManager 商品管理组件

ProductManager 是业务组件,负责商品列表的 UI 和交互。

主要状态:

ts 复制代码
@Local inputKeyword: string = ''
@Local searchKeyword: string = ''
@Local newName: string = ''
@Local editingId: number = -1
@Local editingName: string = ''

1. 搜索

这里区分了两个状态:

txt 复制代码
inputKeyword:输入框正在输入的内容
searchKeyword:真正用于搜索的关键词

这样可以实现"点击搜索按钮后再搜索",而不是输入时自动搜索。

ts 复制代码
Button('搜索')
  .onClick(() => {
    this.searchKeyword = this.inputKeyword.trim()
  })

过滤逻辑:

ts 复制代码
private getShowProducts(key: string): Product[] {
  if (key.length === 0) {
    return this.store.products
  }

  return this.store.products.filter((item: Product) => {
    return item.name.includes(key)
  })
}

2. 新增

ts 复制代码
private addProduct(): void {
  const name = this.newName.trim()

  if (name.length === 0) {
    return
  }

  this.store.addProduct(name)
  this.newName = ''
}

核心思想:

txt 复制代码
表单输入 newName
点击新增
调用 store.addProduct
清空输入框

3. 删除

ts 复制代码
private deleteProduct(id: number): void {
  this.store.deleteProduct(id)
}

核心思想:

txt 复制代码
点击删除
根据 id 删除商品
列表重新渲染

4. 修改

ts 复制代码
private startEditProduct(item: Product): void {
  this.editingId = item.id
  this.editingName = item.name
}

private saveEditProduct(): void {
  const name = this.editingName.trim()

  if (this.editingId < 0 || name.length === 0) {
    return
  }

  this.store.updateProductName(this.editingId, name)

  this.editingId = -1
  this.editingName = ''
}

核心思想:

txt 复制代码
点击改名
↓
记录 editingId 和 editingName
↓
显示编辑输入框
↓
点击保存
↓
更新商品名称
↓
清空编辑状态

5. 跳转详情

ts 复制代码
private openDetail(item: Product): void {
  HMRouterMgr.push({
    pageUrl: RouteConstants.PRODUCT_DETAIL,
    param: {
      id: item.id,
      title: item.name,
      from: '商品列表'
    }
  })
}

流程:

txt 复制代码
点击详情
↓
传 id/title/from
↓
跳转 ProductDetail

十八、ProductDetail 商品详情页

详情页先做最小版本:只展示路由参数。

ProductDetail.ets

ts 复制代码
import { HMRouter, HMRouterMgr } from '@hadss/hmrouter'
import { PageShell } from '../components/PageShell'

interface ProductDetailParam {
  id?: number
  title?: string
  from?: string
}

@HMRouter({ pageUrl: 'pages/ProductDetail' })
@ComponentV2
export struct ProductDetail {
  @Local title: string = '商品详情'
  @Local from: string = '默认入口'
  @Local productId: number = 0

  aboutToAppear(): void {
    const param = HMRouterMgr.getCurrentParam() as ProductDetailParam

    console.log('详情页接收到参数:' + JSON.stringify(param))

    this.productId = Number(param?.id ?? 0)
    this.title = param?.title ?? '商品详情'
    this.from = param?.from ?? '默认入口'
  }

  @Builder
  BackButtonBuilder() {
    Text('‹')
      .fontSize(32)
      .width(40)
      .height(40)
      .textAlign(TextAlign.Center)
      .onClick(() => {
        HMRouterMgr.pop()
      })
  }

  @Builder
  ContentBuilder() {
    Column({ space: 16 }) {
      Text('商品详情页')
        .fontSize(28)
        .fontWeight(FontWeight.Bold)

      Text(`商品ID:${this.productId}`)
        .fontSize(18)

      Text(`商品名称:${this.title}`)
        .fontSize(18)

      Text(`来源:${this.from}`)
        .fontSize(18)

      Button('返回')
        .onClick(() => {
          HMRouterMgr.pop()
        })
    }
    .width('100%')
    .height('100%')
    .padding(20)
    .justifyContent(FlexAlign.Center)
    .alignItems(HorizontalAlign.Start)
  }

  build() {
    PageShell({
      title: this.title,
      from: this.from,
      leftBuilder: this.BackButtonBuilder,
      contentBuilder: this.ContentBuilder
    })
  }
}

为什么先这样写?

txt 复制代码
先验证路由传参
再验证根据 id 查询 Store
不要一次把路由、Store、UI 全混在一起排错

十九、PageShell 和 BuilderParam

今天继续练习了公共页面壳组件。

PageShell 结构

txt 复制代码
PageShell
│
├── leftBuilder
└── contentBuilder

含义:

txt 复制代码
leftBuilder:左侧区域,比如返回按钮
contentBuilder:页面主体内容

@BuilderParam 本质就是:

txt 复制代码
把一段 UI 当参数传给子组件

类似:

txt 复制代码
Vue slot
React children / render props

二十、Home 页面变薄

原来 Home 页面里有很多内容:

txt 复制代码
路由注册
路由参数
商品模型
商品假数据
搜索
新增
删除
修改
列表 UI

拆分后:

txt 复制代码
Home.ets
│
├── @HMRouter 注册
├── 接收路由参数
├── 使用 PageShell
└── 挂载 ProductManager

这样页面职责更清晰。


二十一、当前目录结构

txt 复制代码
entry/src/main/ets/
│
├── pages/
│   ├── Index.ets
│   ├── Home.ets
│   ├── ProductDetail.ets
│   ├── TabHome.ets
│   └── Setting.ets
│
├── components/
│   ├── PageShell.ets
│   └── product/
│       └── ProductManager.ets
│
├── models/
│   └── Product.ets
│
├── stores/
│   ├── ProductStore.ets
│   └── TabState.ets
│
├── constants/
│   └── RouteConstants.ets
│
└── interceptors/
    └── SameRouteInterceptor.ets

理解:

txt 复制代码
pages:路由页面
components:业务组件 / 公共 UI
models:数据模型
stores:共享状态
constants:常量配置
interceptors:路由拦截器

二十二、今天遇到的问题总结

1. not exist pageUrl in store

含义:

txt 复制代码
路由表中没有这个 pageUrl

常见原因:

txt 复制代码
@HMRouter 没写
pageUrl 不一致
插件没执行
scanDir 不对
没 Clean / Rebuild
用 Previewer 跑

解决:

txt 复制代码
检查 @HMRouter
检查 hmrouter_config.json
检查 entry/hvigorfile.ts
Clean Project
Rebuild Project
Run 到模拟器

2. ERR_INIT_NOT_READY

含义:

txt 复制代码
HMRouterMgr 还没有初始化

解决:

ts 复制代码
HMRouterMgr.init({
  context: this.context
})

3. install sign info inconsistent

含义:

txt 复制代码
模拟器里有旧包残留,签名不一致

解决:

powershell 复制代码
hdc list targets
hdc -t 设备ID uninstall com.example.myapplication

4. 参数正常,但详情页显示商品不存在

排查后发现:

txt 复制代码
路由参数正常
问题不在 getCurrentParam
而是在根据 id 查询 Store 数据这一层

处理策略:

txt 复制代码
先简化详情页,只展示路由参数
再逐步增加 Store 查询逻辑

二十三、鸿蒙基础知识点复盘

今天实际用到的鸿蒙基础包括:

txt 复制代码
@ComponentV2
@Builder
@BuilderParam
@Param
@Local
@ObservedV2
@Trace
build()
aboutToAppear()
Column
Row
Stack
ForEach
TextInput
Button
gesture()
HMRouter
HMNavigation
HMRouterMgr.push
HMRouterMgr.replace
HMRouterMgr.pop
HMRouterMgr.getCurrentParam

二十四、整体流程图

txt 复制代码
应用启动
│
▼
EntryAbility.onCreate
│
├── HMRouterMgr.openLog
└── HMRouterMgr.init
        │
        ▼
Index.ets
│
├── HMNavigation
│
└── Bottom TabBar
    ├── TabHome
    ├── Home
    └── Setting
          │
          ▼
Home.ets
│
├── 接收路由参数
├── PageShell 外壳
└── ProductManager
      │
      ├── 商品搜索
      ├── 商品新增
      ├── 商品删除
      ├── 商品修改
      └── 点击详情
            │
            ▼
      ProductDetail.ets
      │
      ├── getCurrentParam
      ├── 展示商品参数
      └── pop 返回

二十五、学习心得

今天最大的收获不是简单写了一个商品增删改查 Demo,而是理解了一个鸿蒙业务页面从路由到组件再到状态的组织方式。

以前是:

txt 复制代码
所有代码写在一个页面里
能跑就行

现在开始尝试拆成:

txt 复制代码
路由页面
业务组件
模型
Store
常量
拦截器

这更接近公司真实项目的结构。

HMRouter 第一次接入确实有点绕,因为它涉及:

txt 复制代码
运行库
编译插件
路由扫描
路由表
EntryAbility 初始化
模拟器运行

但跑通一次之后,后面就可以当模板复用。


二十六、后续计划

下一步继续完善:

txt 复制代码
1. ProductDetail 根据 id 从 ProductStore 获取完整商品数据
2. ProductManager 继续拆成 ProductSearchBar / ProductAddBar / ProductList
3. 练习详情页修改商品后返回列表刷新
4. 继续理解 push / replace / pop 的使用边界
5. 将 Setting 页面改造成简单聊天 Demo
6. 模拟 AI 接口请求、打字机输出、聊天历史记录

总结

今天完成了从普通页面 Demo 到路由化业务 Demo 的完整练习。

掌握了:

txt 复制代码
HMRouter 接入
路由跳转
路由传参
页面返回
路由拦截器
底部导航
商品 CRUD
组件拆分
Store 思想
日志排查

这次练习让我对鸿蒙项目有了更清晰的认识:

txt 复制代码
页面不是只写 UI
还要理解路由、状态、组件拆分、工程配置和调试流程
相关推荐
李剑一1 小时前
520了,程序员就得有点儿独特的浪漫
前端·three.js
initialD大辉1 小时前
打破 3D 开发壁垒:一个低代码/零代码数字孪生平台的前后端全栈架构演进
前端·数据可视化
VOLUN1 小时前
🚀 Vue3 + Element Plus 实战:封装一个“可配置列 + 拖拽 + 固定 + 全屏”的 TableSetting 组件
前端
前端小蜗1 小时前
转生到 AI 时代,我不再相信一键生成代码的传说
前端·人工智能·架构
文心快码BaiduComate2 小时前
520,Comate Mission模式跨越界限,和你达成最「深」联动
前端·数据库·后端
来恩10032 小时前
Java Web三大作用域对象
java·开发语言·前端
在繁华处2 小时前
轻棋局(四):前端 SPA 实战
前端
不是山谷.:.2 小时前
前端性能优化全解析:从原理到落地,覆盖全领域与多技术栈
前端·笔记·性能优化·状态模式
sakana2 小时前
我开源了我的cgzskill,帮Claude装上长期记忆
前端