소스 검색

docs(epic-014): 更新史诗文档和故事进展状态并迁移RPC客户端

- 更新史诗014 PRD文档,标记故事014.001完成状态
- 更新故事014.001文档,添加任务5并标记进展
- 将mini项目中的RPC客户端基础设施迁移到共享UI组件包
- 添加hono依赖到共享包,支持RPC客户端
- 将mini中的cn.ts和platform.ts改为从共享包重新导出
- 更新共享包的index.ts导出RPC工具和工具函数

Generated with [Claude Code](https://claude.com/claude-code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
yourname 1 개월 전
부모
커밋
0a2a0fd72b

+ 11 - 6
docs/prd/epic-014-mini-ui-component-modularization.md

@@ -108,12 +108,17 @@
    - 验证构建和运行正常
 
 **验收标准:**
-- [ ] `mini-shared-ui-components`包创建成功,可通过workspace引用
-- [ ] 所有12个UI组件成功迁移,功能正常
-- [ ] 包可以独立构建和测试
-- [ ] mini项目构建成功,无类型错误
-- [ ] 现有页面功能正常,无视觉或功能回归
-- [ ] 组件单元测试覆盖率达标
+- [x] `mini-shared-ui-components`包创建成功,可通过workspace引用
+- [x] 所有12个UI组件成功迁移,功能正常
+- [x] 包可以独立构建和测试(类型检查和Jest测试通过)
+- [ ] mini项目构建成功,无类型错误(依赖已添加,UI组件导入待更新)
+- [ ] 现有页面功能正常,无视觉或功能回归(待UI组件导入更新后验证)
+- [ ] 组件单元测试覆盖率达标(基础测试框架已建立,待扩展测试覆盖)
+
+**进展状态:**
+- ✅ 已完成:创建包结构、迁移12个UI组件、建立独立测试套件、迁移RPC客户端基础设施
+- 🔄 进行中:更新mini项目UI组件导入、验证构建和运行时功能
+- 📋 待开始:扩展组件单元测试覆盖率
 
 ### 故事014-02:将yongren模块页面拆分为独立包
 **背景:** yongren模块的页面目前都集中在mini/src/pages/yongren/目录下,耦合度高,不利于独立测试和部署。需要将各个子页面拆分为独立的包,提高模块化程度。

+ 9 - 1
docs/stories/014.001.mini-shared-ui-components.md

@@ -50,10 +50,18 @@ Ready for Review
     - [x] 建立测试配置导出机制
 - [x] 任务4:更新mini项目依赖 (验收标准: 4, 5)
   - [x] 在mini的package.json中添加对新包的引用
-  - [ ] 将现有的组件导入替换为从包中导入
+  - [x] 将现有的组件导入替换为从包中导入(仅cn.ts和platform.ts)
+  - [ ] 将现有的组件导入替换为从包中导入(UI组件)
   - [ ] 验证构建和运行时功能
   - [ ] 运行类型检查以确保无TypeScript错误
 
+- [x] 任务5:迁移RPC客户端基础设施 (验收标准: 1, 3)
+  - [x] 迁移rpc-client.ts并创建通用可配置版本
+  - [x] 迁移response-polyfill.ts
+  - [x] 迁移headers-polyfill.js
+  - [x] 更新共享包的index.ts导出RPC工具
+  - [x] 处理重复文件(cn.ts, platform.ts)从共享包重新导出
+
 ## 开发说明
 
 ### 先前故事洞察

+ 2 - 1
mini-ui-packages/mini-shared-ui-components/package.json

@@ -36,7 +36,8 @@
     "react": "^18.0.0",
     "react-dom": "^18.0.0",
     "react-hook-form": "^7.62.0",
-    "@weapp-tailwindcss/merge": "^1.2.3"
+    "@weapp-tailwindcss/merge": "^1.2.3",
+    "hono": "4.8.5"
   },
   "devDependencies": {
     "@testing-library/jest-dom": "^6.8.0",

+ 10 - 1
mini-ui-packages/mini-shared-ui-components/src/index.ts

@@ -10,4 +10,13 @@ export { Label, labelVariants } from './label'
 export { Navbar, NavbarPresets, createNavbar } from './navbar'
 export { PageContainer } from './page-container'
 export { TabBar } from './tab-bar'
-export { UserStatusBar } from './user-status-bar'
+export { UserStatusBar } from './user-status-bar'
+
+// 导出工具函数
+export { cn } from './utils/cn'
+export { getPlatform, isWeapp, isH5 } from './utils/platform'
+
+// 导出RPC客户端工具
+export { createRpcClient, type RpcClientConfig, type RpcClient } from './utils/rpc/rpc-client'
+export { ResponsePolyfill, registerGlobalResponsePolyfill } from './utils/rpc/response-polyfill'
+export { default as HeadersPolyfill, registerGlobalHeadersPolyfill } from './utils/rpc/headers-polyfill'

+ 16 - 0
mini-ui-packages/mini-shared-ui-components/src/utils/rpc/headers-polyfill.d.ts

@@ -0,0 +1,16 @@
+declare class Headers {
+  constructor(init?: HeadersInit)
+
+  append(name: string, value: string): void
+  set(name: string, value: string): void
+  get(name: string): string | null
+  has(name: string): boolean
+  delete(name: string): void
+  forEach(callback: (value: string, name: string, headers: Headers) => void): void
+  raw(): Record<string, string>
+
+  _normalizeName(name: string): string
+}
+
+export declare function registerGlobalHeadersPolyfill(): void
+export default Headers

+ 87 - 0
mini-ui-packages/mini-shared-ui-components/src/utils/rpc/headers-polyfill.js

@@ -0,0 +1,87 @@
+class Headers {
+  constructor(init = {}) {
+    this._headers = {};
+
+    if (init instanceof Headers) {
+      // 如果传入的是另一个 Headers 实例,复制其内容
+      init.forEach((value, name) => {
+        this.append(name, value);
+      });
+    } else if (init) {
+      // 处理普通对象或数组
+      Object.entries(init).forEach(([name, value]) => {
+        if (Array.isArray(value)) {
+          // 处理数组值(如 ['value1', 'value2'])
+          value.forEach(v => this.append(name, v));
+        } else {
+          this.set(name, value);
+        }
+      });
+    }
+  }
+
+  // 添加头(可重复添加同名头)
+  append(name, value) {
+    const normalizedName = this._normalizeName(name);
+    if (this._headers[normalizedName]) {
+      this._headers[normalizedName] += `, ${value}`;
+    } else {
+      this._headers[normalizedName] = String(value);
+    }
+  }
+
+  // 设置头(覆盖同名头)
+  set(name, value) {
+    this._headers[this._normalizeName(name)] = String(value);
+  }
+
+  // 获取头
+  get(name) {
+    return this._headers[this._normalizeName(name)] || null;
+  }
+
+  // 检查是否存在头
+  has(name) {
+    return this._normalizeName(name) in this._headers;
+  }
+
+  // 删除头
+  delete(name) {
+    delete this._headers[this._normalizeName(name)];
+  }
+
+  // 遍历头
+  forEach(callback) {
+    Object.entries(this._headers).forEach(([name, value]) => {
+      callback(value, name, this);
+    });
+  }
+
+  // 获取所有头(原始对象)
+  raw() {
+    return { ...this._headers };
+  }
+
+  // 规范化头名称(转为小写)
+  _normalizeName(name) {
+    if (typeof name !== 'string') {
+      throw new TypeError('Header name must be a string');
+    }
+    return name.toLowerCase();
+  }
+}
+
+// 可选的全局注册函数
+export function registerGlobalHeadersPolyfill() {
+  if (typeof globalThis.Headers === 'undefined') {
+    globalThis.Headers = Headers;
+  }
+}
+
+// 默认导出Headers类
+export default Headers;
+
+// 自动注册(如果环境需要)
+if (typeof globalThis.Headers === 'undefined') {
+  globalThis.Headers = Headers;
+}

+ 93 - 0
mini-ui-packages/mini-shared-ui-components/src/utils/rpc/response-polyfill.ts

@@ -0,0 +1,93 @@
+// 在全局注册Headers(如果不存在)
+if (typeof globalThis.Headers === 'undefined') {
+  // 这里会由headers-polyfill.js注册
+}
+
+export class ResponsePolyfill {
+  constructor(
+    public body: string | ArrayBuffer | null,
+    public init: {
+      status?: number
+      statusText?: string
+      headers?: Record<string, string>
+    } = {}
+  ) {}
+
+  get ok(): boolean {
+    return this.status >= 200 && this.status < 300
+  }
+
+  get status(): number {
+    return this.init.status || 200
+  }
+
+  get statusText(): string {
+    return this.init.statusText || 'OK'
+  }
+
+  get headers(): Headers {
+    return new Headers(this.init.headers || {})
+  }
+
+  get bodyUsed(): boolean {
+    return false // 小程序环境简单实现
+  }
+
+  async arrayBuffer(): Promise<ArrayBuffer> {
+    if (this.body instanceof ArrayBuffer) {
+      return this.body
+    }
+    throw new Error('Not implemented')
+  }
+
+  async text(): Promise<string> {
+    if (typeof this.body === 'string') {
+      return this.body
+    }
+    throw new Error('Not implemented')
+  }
+
+  async json<T = any>(): Promise<T> {
+    if (typeof this.body === 'string') {
+      try {
+        return JSON.parse(this.body)
+      } catch (e) {
+        throw new Error('Invalid JSON')
+      }
+    }
+    throw new Error('Not implemented')
+  }
+
+  clone(): ResponsePolyfill {
+    return new ResponsePolyfill(this.body, { ...this.init })
+  }
+
+  static json(data: any, init?: ResponseInit): ResponsePolyfill {
+    const headers = new Headers(init && 'headers' in init ? init.headers : undefined)
+    if (!headers.has('Content-Type')) {
+      headers.set('Content-Type', 'application/json')
+    }
+    return new ResponsePolyfill(JSON.stringify(data), {
+      ...init,
+      headers: (headers as any).raw ? (headers as any).raw() : {}
+    })
+  }
+
+  static error(): ResponsePolyfill {
+    return new ResponsePolyfill(null, { status: 0, statusText: 'Network Error' })
+  }
+
+  static redirect(url: string, status: number): ResponsePolyfill {
+    return new ResponsePolyfill(null, {
+      status,
+      headers: { Location: url }
+    })
+  }
+}
+
+// 可选的全局注册函数
+export function registerGlobalResponsePolyfill(): void {
+  if (typeof globalThis.Response === 'undefined') {
+    globalThis.Response = ResponsePolyfill as any
+  }
+}

+ 222 - 0
mini-ui-packages/mini-shared-ui-components/src/utils/rpc/rpc-client.ts

@@ -0,0 +1,222 @@
+import Taro from '@tarojs/taro'
+import { hc } from 'hono/client'
+import { ResponsePolyfill } from './response-polyfill'
+
+export interface RpcClientConfig {
+  /** API基础URL,例如: http://localhost:3000 */
+  apiBaseUrl: string
+  /** token存储键名,默认: 'token' */
+  tokenStorageKey?: string
+  /** 刷新token存储键名,默认: 'refresh_token' */
+  refreshTokenStorageKey?: string
+  /** 刷新token的API路径,默认: '/api/v1/auth/refresh-token' */
+  refreshTokenPath?: string
+  /** 登录页面路径,token刷新失败时跳转,默认: '/pages/login/index' */
+  loginPagePath?: string
+  /** 用户信息存储键名,默认: 'userInfo' */
+  userInfoStorageKey?: string
+  /** 认证类型前缀,默认: 'Bearer ' */
+  authPrefix?: string
+}
+
+// 默认配置
+const defaultConfig: RpcClientConfig = {
+  apiBaseUrl: process.env.TARO_APP_API_BASE_URL || 'http://localhost:3000',
+  tokenStorageKey: 'token',
+  refreshTokenStorageKey: 'refresh_token',
+  refreshTokenPath: '/api/v1/auth/refresh-token',
+  loginPagePath: '/pages/login/index',
+  userInfoStorageKey: 'userInfo',
+  authPrefix: 'Bearer '
+}
+
+// 刷新token的管理器
+class TokenRefreshManager {
+  private isRefreshing = false
+  private refreshSubscribers: ((token: string | null) => void)[] = []
+
+  constructor(private config: RpcClientConfig) {}
+
+  async refreshToken(): Promise<string | null> {
+    if (this.isRefreshing) {
+      // 如果已经在刷新,等待结果
+      return new Promise((resolve) => {
+        this.refreshSubscribers.push((token) => {
+          resolve(token)
+        })
+      })
+    }
+
+    this.isRefreshing = true
+    try {
+      const refreshToken = Taro.getStorageSync(this.config.refreshTokenStorageKey!)
+      if (!refreshToken) {
+        throw new Error('未找到刷新token')
+      }
+
+      // 调用刷新token接口
+      const response = await Taro.request({
+        url: `${this.config.apiBaseUrl}${this.config.refreshTokenPath}`,
+        method: 'POST',
+        header: {
+          'Content-Type': 'application/json',
+          'Authorization': `${this.config.authPrefix}${refreshToken}`
+        }
+      })
+
+      if (response.statusCode === 200) {
+        const { token, refresh_token: newRefreshToken } = response.data
+        Taro.setStorageSync(this.config.tokenStorageKey!, token)
+        if (newRefreshToken) {
+          Taro.setStorageSync(this.config.refreshTokenStorageKey!, newRefreshToken)
+        }
+
+        // 通知所有等待的请求
+        this.refreshSubscribers.forEach(callback => callback(token))
+        this.refreshSubscribers = []
+        return token
+      } else {
+        throw new Error('刷新token失败')
+      }
+    } catch (error) {
+      console.error('刷新token失败:', error)
+      // 清除token,跳转到登录页
+      this.clearAuthData()
+
+      // 跳转到登录页
+      Taro.showToast({
+        title: '登录已过期,请重新登录',
+        icon: 'none'
+      })
+      setTimeout(() => {
+        Taro.redirectTo({
+          url: this.config.loginPagePath!
+        })
+      }, 1500)
+
+      // 通知所有等待的请求刷新失败
+      this.refreshSubscribers.forEach(callback => callback(null))
+      this.refreshSubscribers = []
+      return null
+    } finally {
+      this.isRefreshing = false
+    }
+  }
+
+  clearAuthData(): void {
+    Taro.removeStorageSync(this.config.tokenStorageKey!)
+    Taro.removeStorageSync(this.config.refreshTokenStorageKey!)
+    Taro.removeStorageSync(this.config.userInfoStorageKey!)
+  }
+
+  getToken(): string | null {
+    return Taro.getStorageSync(this.config.tokenStorageKey!)
+  }
+}
+
+// 创建自定义fetch函数,适配Taro.request,支持token自动刷新
+const createTaroFetch = (config: RpcClientConfig, tokenManager: TokenRefreshManager) => {
+  return async (input: RequestInfo | URL, init?: RequestInit): Promise<any> => {
+    const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url
+    const method = init?.method || 'GET'
+    const requestHeaders: Record<string, string> = { ...init?.headers } as Record<string, string>
+
+    // 确保有Content-Type
+    const keyOfContentType = Object.keys(requestHeaders).find(item => item.toLowerCase() === 'content-type')
+    if (!keyOfContentType) {
+      requestHeaders['content-type'] = 'application/json'
+    }
+
+    // 构建Taro请求选项
+    const options: Taro.request.Option = {
+      url,
+      method: method as any,
+      data: init?.body,
+      header: requestHeaders
+    }
+
+    // 添加token
+    let token = tokenManager.getToken()
+    if (token) {
+      options.header = {
+        ...options.header,
+        'Authorization': `${config.authPrefix}${token}`
+      }
+    }
+
+    // 发送请求
+    const sendRequest = async (): Promise<any> => {
+      try {
+        console.log('API请求:', options.url)
+        const response = await Taro.request(options)
+
+        const responseHeaders = response.header;
+
+        // 处理204 No Content响应,不设置body
+        const body = response.statusCode === 204
+          ? null
+          : responseHeaders['content-type']!.includes('application/json')
+            ? JSON.stringify(response.data)
+            : response.data;
+
+        return new ResponsePolyfill(
+          body,
+          {
+            status: response.statusCode,
+            statusText: response.errMsg || 'OK',
+            headers: responseHeaders
+          }
+        )
+      } catch (error) {
+        console.error('API Error:', error)
+        throw error
+      }
+    }
+
+    try {
+      let response = await sendRequest()
+
+      // 检查是否为401错误,尝试刷新token
+      if (response.status === 401 && token) {
+        console.log('检测到401错误,尝试刷新token...')
+        const newToken = await tokenManager.refreshToken()
+
+        if (newToken) {
+          // 更新请求header中的token
+          options.header = {
+            ...options.header,
+            'Authorization': `${config.authPrefix}${newToken}`
+          }
+
+          // 重试原始请求
+          response = await sendRequest()
+        } else {
+          // 刷新失败,返回原始401响应
+          return response
+        }
+      }
+
+      return response
+    } catch (error: any) {
+      console.error('API请求失败:', error)
+      Taro.showToast({
+        title: error.message || '网络错误',
+        icon: 'none'
+      })
+      throw error
+    }
+  }
+}
+
+// 创建Hono RPC客户端
+export const createRpcClient = <T extends any>(customConfig?: Partial<RpcClientConfig>) => {
+  const config = { ...defaultConfig, ...customConfig }
+  const tokenManager = new TokenRefreshManager(config)
+  const fetch = createTaroFetch(config, tokenManager)
+
+  // @ts-ignore
+  return hc<T>(`${config.apiBaseUrl}`, { fetch })
+}
+
+// 导出类型
+export type RpcClient<T> = ReturnType<typeof createRpcClient<T>>

+ 2 - 15
mini/src/utils/cn.ts

@@ -1,15 +1,2 @@
-import { type ClassValue } from 'clsx'
-import { create } from '@weapp-tailwindcss/merge';
-import Taro from '@tarojs/taro';
-
-// 根据当前环境判断是否需要转义
-const isWeapp = Taro.getEnv() === Taro.ENV_TYPE.WEAPP;
-
-const { twMerge } = create({
-  // 仅在小程序环境下启用转义,H5环境禁用
-  disableEscape: !isWeapp
-});
-
-export function cn(...inputs: ClassValue[]) {
-  return twMerge(inputs)
-}
+// 从共享UI组件包重新导出cn函数
+export { cn } from '@d8d/mini-shared-ui-components'

+ 2 - 16
mini/src/utils/platform.ts

@@ -1,16 +1,2 @@
-import Taro from '@tarojs/taro'
-
-// 获取当前平台
-export const getPlatform = () => {
-  return Taro.getEnv()
-}
-
-// 是否为小程序
-export const isWeapp = (): boolean => {
-  return getPlatform() === Taro.ENV_TYPE.WEAPP
-}
-
-// 是否为H5
-export const isH5 = (): boolean => {
-  return getPlatform() === Taro.ENV_TYPE.WEB
-}
+// 从共享UI组件包重新导出平台工具函数
+export { getPlatform, isWeapp, isH5 } from '@d8d/mini-shared-ui-components'

+ 3 - 0
pnpm-lock.yaml

@@ -1319,6 +1319,9 @@ importers:
       clsx:
         specifier: ^2.1.1
         version: 2.1.1
+      hono:
+        specifier: 4.8.5
+        version: 4.8.5
       react:
         specifier: ^18.0.0
         version: 18.3.1