jaystar ed4caca2fb feat: 实现微博超话签到小程序功能
- 实现超话列表页面,显示关注超话和签到状态
- 实现一键批量签到功能,可一次性签到所有未签到超话
- 实现签到记录页面,包含日历视图和历史记录
- 实现个人中心页面,显示用户信息和签到统计
- 后端实现超话列表、签到、记录查询、用户信息四个接口
- 使用 Supabase 存储签到记录数据
- 采用微博风格设计,橙色主题 + 白色背景
- 完成所有接口测试和前后端匹配验证
- 通过 ESLint 检查和编译验证
2026-03-16 11:59:58 +08:00
2026-03-16 11:17:17 +08:00
2026-03-16 11:17:17 +08:00

Coze Mini Program

这是一个基于 Taro 4 + Nest.js 的前后端分离项目,由扣子编程 CLI 创建。

技术栈

  • 整体框架: Taro 4.1.9
  • 语言: TypeScript 5.4.5
  • 渲染: React 18.0.0
  • 样式: TailwindCSS 4.1.18
  • Tailwind 适配层: weapp-tailwindcss 4.9.2
  • 状态管理: Zustand 5.0.9
  • 图标库: lucide-react-taro latest
  • 工程化: Vite 4.2.0
  • 包管理: pnpm
  • 运行时: Node.js >= 18
  • 服务端: NestJS 10.4.15
  • 数据库 ORM: Drizzle ORM 0.45.1
  • 类型校验: Zod 4.3.5

项目结构

├── .cozeproj/                # Coze 平台配置
│   └── scripts/              # 构建和运行脚本
├── config/                   # Taro 构建配置
│   ├── index.ts              # 主配置文件
│   ├── dev.ts                # 开发环境配置
│   └── prod.ts               # 生产环境配置
├── server/                   # NestJS 后端服务
│   └── src/
│       ├── main.ts           # 服务入口
│       ├── app.module.ts     # 根模块
│       ├── app.controller.ts # 应用控制器
│       └── app.service.ts    # 应用服务
├── src/                      # 前端源码
│   ├── pages/                # 页面组件
│   ├── presets/              # 框架预置逻辑(无需读取,如无必要不改动)
│   ├── utils/                # 工具函数
│   ├── network.ts            # 封装好的网络请求工具
│   ├── app.ts                # 应用入口
│   ├── app.config.ts         # 应用配置
│   └── app.css               # 全局样式
├── types/                    # TypeScript 类型定义
├── key/                      # 小程序密钥CI 上传用)
├── .env.local                # 环境变量
└── project.config.json       # 微信小程序项目配置

快速开始

安装依赖

pnpm install

本地开发

同时启动 H5 前端和 NestJS 后端:

pnpm dev

单独启动:

pnpm dev:web      # 仅 H5 前端
pnpm dev:weapp    # 仅微信小程序
pnpm dev:server   # 仅后端服务

构建

pnpm build        # 构建所有H5 + 小程序 + 后端)
pnpm build:web    # 仅构建 H5输出到 dist-web
pnpm build:weapp  # 仅构建微信小程序,输出到 dist
pnpm build:server # 仅构建后端

预览小程序

pnpm preview:weapp # 构建并生成预览小程序二维码

前端核心开发规范

新建页面流程

  1. 在 `src/pages/` 下创建页面目录
  2. 创建 `index.tsx`(页面组件)
  3. 创建 `index.config.ts`(页面配置)
  4. 创建 `index.css`(页面样式,可选)
  5. 在 `src/app.config.ts` 的 `pages` 数组中注册页面路径

或使用 Taro 脚手架命令:

pnpm new      # 交互式创建页面/组件

常用 Taro 组件

引入方式

import { Text } from '@tarojs/components'
  • 基础组件
    • Text
    • Icon
    • Progress
    • RichText
  • 表单组件
    • Button
    • Checkbox
    • CheckboxGroup
    • Editor
    • Form
    • Input
    • Label
    • Picker
    • PickerView
    • PickerViewColumn
    • Radio
    • RadioGroup
    • Slider
    • Switch
    • Textarea
  • 导航组件
    • FunctionalPageNavigator
    • NavigationBar
    • Navigator
    • TabItem
    • Tabs
  • 媒体组件
    • Camera
    • Image
    • Video
  • 视图容器
    • ScrollView
    • Swiper
    • SwiperItem
    • View

路径别名

项目配置了 @/* 路径别名指向 src/*

import { SomeComponent } from '@/components/SomeComponent'
import { useUserStore } from '@/stores/user'

代码模板

页面组件 (TypeScript + React)

// src/pages/example/index.tsx
import { View, Text } from '@tarojs/components'
import { useLoad, useDidShow } from '@tarojs/taro'
import type { FC } from 'react'
import './index.css'

const ExamplePage: FC = () => {
  useLoad(() => {
    console.log('Page loaded.')
  })

  useDidShow(() => {
    console.log('Page showed.')
  })

  return (
    <View className="flex flex-col items-center p-4">
      <Text className="text-lg font-bold">Hello Taro!</Text>
    </View>
  )
}

export default ExamplePage

页面配置

// src/pages/example/index.config.ts
import { definePageConfig } from '@tarojs/taro'

export default definePageConfig({
  navigationBarTitleText: '示例页面',
  enablePullDownRefresh: true,
  backgroundTextStyle: 'dark',
})

应用配置

// src/app.config.ts
import { defineAppConfig } from '@tarojs/taro'

export default defineAppConfig({
  pages: [
    'pages/index/index',
    'pages/example/index',
  ],
  window: {
    backgroundTextStyle: 'light',
    navigationBarBackgroundColor: '#fff',
    navigationBarTitleText: 'App',
    navigationBarTextStyle: 'black',
  },
  // TabBar 配置 (可选)
  // tabBar: {
  //   list: [
  //     { pagePath: 'pages/index/index', text: '首页' },
  //   ],
  // },
})

发送请求

IMPORTANT: 禁止直接使用 Taro.request、Taro.uploadFile、Taro.downloadFile使用 Network.request、Network.uploadFile、Network.downloadFile 替代。

Network 是对 Taro.request、Taro.uploadFile、Taro.downloadFile 的封装,自动添加项目域名前缀,参数与 Taro 一致。

正确使用方式

import { Network } from '@/network'

// GET 请求
const data = await Network.request({
  url: '/api/hello'
})

// POST 请求
const result = await Network.request({
  url: '/api/user/login',
  method: 'POST',
  data: { username, password }
})

// 文件上传
await Network.uploadFile({
  url: '/api/upload',
  filePath: tempFilePath,
  name: 'file'
})

// 文件下载
await Network.downloadFile({
  url: '/api/download/file.pdf'
})

错误用法

import Taro from '@tarojs/taro'

// ❌ 会导致自动域名拼接无法生效,除非是特殊指定域名
const data = await Network.request({
  url: 'http://localhost/api/hello'
})

// ❌ 不要直接使用 Taro.request
await Taro.request({ url: '/api/hello' })

// ❌ 不要直接使用 Taro.uploadFile
await Taro.uploadFile({ url: '/api/upload', filePath, name: 'file' })

Zustand 状态管理

// src/stores/user.ts
import { create } from 'zustand'

interface UserState {
  userInfo: UserInfo | null
  token: string
  setUserInfo: (info: UserInfo) => void
  setToken: (token: string) => void
  logout: () => void
}

interface UserInfo {
  id: string
  name: string
  avatar: string
}

export const useUserStore = create<UserState>((set) => ({
  userInfo: null,
  token: '',
  setUserInfo: (info) => set({ userInfo: info }),
  setToken: (token) => set({ token }),
  logout: () => set({ userInfo: null, token: '' }),
}))

Taro 生命周期 Hooks

import {
  useLoad,             // 页面加载 (onLoad)
  useReady,            // 页面初次渲染完成 (onReady)
  useDidShow,          // 页面显示 (onShow)
  useDidHide,          // 页面隐藏 (onHide)
  usePullDownRefresh,  // 下拉刷新 (onPullDownRefresh)
  useReachBottom,      // 触底加载 (onReachBottom)
  useShareAppMessage,  // 分享 (onShareAppMessage)
  useRouter,           // 获取路由参数
} from '@tarojs/taro'

路由导航

import Taro from '@tarojs/taro'

// 保留当前页面,跳转到新页面
Taro.navigateTo({ url: '/pages/detail/index?id=1' })

// 关闭当前页面,跳转到新页面
Taro.redirectTo({ url: '/pages/detail/index' })

// 跳转到 tabBar 页面
Taro.switchTab({ url: '/pages/index/index' })

// 返回上一页
Taro.navigateBack({ delta: 1 })

// 获取路由参数
const router = useRouter()
const { id } = router.params

图标使用 (lucide-react-taro)

IMPORTANT: 禁止使用 lucide-react必须使用 lucide-react-taro 替代。

lucide-react-taro 是 Lucide 图标库的 Taro 适配版本专为小程序环境优化API 与 lucide-react 一致:

import { View } from '@tarojs/components'
import { House, Settings, User, Search, Camera, Zap } from 'lucide-react-taro'

const IconDemo = () => {
  return (
    <View className="flex gap-4">
      {/* 基本用法 */}
      <House />
      {/* 自定义尺寸和颜色 */}
      <Settings size={32} color="#1890ff" />
      {/* 自定义描边宽度 */}
      <User size={24} strokeWidth={1.5} />
      {/* 绝对描边宽度(描边不随 size 缩放) */}
      <Camera size={48} strokeWidth={2} absoluteStrokeWidth />
      {/* 组合使用 */}
      <Zap size={32} color="#ff6b00" strokeWidth={1.5} className="my-icon" />
    </View>
  )
}

常用属性:

  • size - 图标大小(默认 24
  • color - 图标颜色(默认 currentColor小程序中建议显式设置
  • strokeWidth - 线条粗细(默认 2
  • absoluteStrokeWidth - 绝对描边宽度,启用后描边不随 size 缩放
  • className / style - 自定义样式

更多图标请访问:https://lucide.dev/icons

TabBar 图标生成 (CLI 工具)

IMPORTANT: 微信小程序的 TabBar 不支持 base64 或 SVG 图片,必须使用本地 PNG 文件。

lucide-react-taro 提供了 CLI 工具来生成 TabBar 所需的 PNG 图标:

# 生成带选中状态的图标
npx taro-lucide-tabbar House Settings User -c "#999999" -a "#1890ff"

# 指定输出目录和尺寸
npx taro-lucide-tabbar House Settings User -c "#999999" -a "#1890ff" -o ./src/assets/tabbar -s 81

CLI 参数:

  • --color, -c (默认 #000000): 图标颜色
  • --active-color, -a: 选中状态颜色
  • --size, -s (默认 81): 图标尺寸
  • --output, -o (默认 ./tabbar-icons): 输出目录
  • --stroke-width (默认 2): 描边宽度

app.config.ts 中使用生成的图标:

IMPORTANTiconPath 和 selectedIconPath 必须以 ./ 开头,否则图标无法渲染

export default defineAppConfig({
  tabBar: {
    color: '#999999',
    selectedColor: '#1890ff',
    backgroundColor: '#ffffff',
    borderStyle: 'black',
    list: [
      {
        pagePath: 'pages/index/index',
        text: '首页',
        iconPath: './assets/tabbar/house.png',
        selectedIconPath: './assets/tabbar/house-active.png',
      },
      {
        pagePath: 'pages/settings/index',
        text: '设置',
        iconPath: './assets/tabbar/settings.png',
        selectedIconPath: './assets/tabbar/settings-active.png',
      },
      {
        pagePath: 'pages/user/index',
        text: '用户',
        iconPath: './assets/tabbar/user.png',
        selectedIconPath: './assets/tabbar/user-active.png',
      },
    ],
  },
})

### Tailwind CSS 样式开发

IMPORTANT:必须使用 tailwindcss 实现样式,只有在必要情况下才能 fallback  css / less

> 项目已集成 Tailwind CSS 4.x + weapp-tailwindcss,支持跨端原子化样式:

```tsx
<View className="flex flex-col items-center justify-center min-h-screen bg-gray-100">
  <Text className="text-2xl font-bold text-blue-600 mb-4">标题</Text>
  <View className="w-full px-4">
    <Button className="w-full bg-blue-500 text-white rounded-lg py-3">
      按钮
    </Button>
  </View>
</View>

性能优化

图片懒加载

import { Image } from '@tarojs/components'

<Image src={imageUrl} lazyLoad mode="aspectFill" />

虚拟列表

import { VirtualList } from '@tarojs/components'

<VirtualList
  height={500}
  itemData={list}
  itemCount={list.length}
  itemSize={100}
  renderItem={({ index, style, data }) => (
    <View style={style}>{data[index].name}</View>
  )}
/>

分包加载

// src/app.config.ts
export default defineAppConfig({
  pages: ['pages/index/index'],
  subPackages: [
    {
      root: 'packageA',
      pages: ['pages/detail/index'],
    },
  ],
})

小程序限制

限制项 说明
主包体积 ≤ 2MB
总包体积 ≤ 20MB
域名配置 生产环境需在小程序后台配置合法域名
本地开发 需在微信开发者工具开启「不校验合法域名」

权限配置

// src/app.config.ts
export default defineAppConfig({
  // ...其他配置
  permission: {
    'scope.userLocation': {
      desc: '你的位置信息将用于小程序位置接口的效果展示'
    }
  },
  requiredPrivateInfos: ['getLocation', 'chooseAddress']
})

位置服务

// 需先在 app.config.ts 中配置 permission
async function getLocation(): Promise<Taro.getLocation.SuccessCallbackResult> {
  return await Taro.getLocation({ type: 'gcj02' })
}

后端核心开发规范

本项目后端基于 NestJS + TypeScript 构建,提供高效、可扩展的服务端能力。

项目结构

.
├── server/                   # NestJS 后端服务
│   └── src/
│       ├── main.ts           # 服务入口
│       ├── app.module.ts     # 根模块
│       ├── app.controller.ts # 根控制器
│       └── app.service.ts    # 根服务

开发命令

pnpm dev:server // 启动开发服务 (热重载, 默认端口 3000)
pnpm build:server // 构建生产版本

新建模块流程 (CLI)

快速生成样板代码:

cd server

# 生成完整的 CRUD 资源 (包含 Module, Controller, Service, DTO, Entity)
npx nest g resource modules/product

# 仅生成特定部分
npx nest g module modules/order
npx nest g controller modules/order
npx nest g service modules/order

环境变量配置

在 server/ 根目录创建 .env 文件:

## 服务端口
PORT=3000

## 微信小程序配置
WX_APP_ID=你的AppID
WX_APP_SECRET=你的AppSecret

## JWT 密钥
JWT_SECRET=your-super-secret-key

在代码中使用 @nestjs/config 读取环境变量:

import { ConfigService } from '@nestjs/config';

// 在 Service 中注入
constructor(private configService: ConfigService) {}

getWxConfig() {
  return {
    appId: this.configService.get<string>('WX_APP_ID'),
    secret: this.configService.get<string>('WX_APP_SECRET'),
  };
}

标准响应封装

建议使用拦截器 (Interceptor) 统一 API 响应格式:

// src/common/interceptors/transform.interceptor.ts
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';

export interface Response<T> {
  code: number;
  data: T;
  message: string;
}

@Injectable()
export class TransformInterceptor<T> implements NestInterceptor<T, Response<T>> {
  intercept(context: ExecutionContext, next: CallHandler): Observable<Response<T>> {
    return next.handle().pipe(
      map((data) => ({
        code: 200,
        data,
        message: 'success',
      })),
    );
  }
}

在 main.ts 中全局注册:

app.useGlobalInterceptors(new TransformInterceptor());

微信登录后端实现

// src/modules/auth/auth.service.ts
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { HttpService } from '@nestjs/axios';
import { ConfigService } from '@nestjs/config';
import { lastValueFrom } from 'rxjs';

@Injectable()
export class AuthService {
  constructor(
    private httpService: HttpService,
    private configService: ConfigService,
  ) {}

  async code2Session(code: string) {
    const appId = this.configService.get('WX_APP_ID');
    const secret = this.configService.get('WX_APP_SECRET');
    const url = `https://api.weixin.qq.com/sns/jscode2session?appid=${appId}&secret=${secret}&js_code=${code}&grant_type=authorization_code`;

    const { data } = await lastValueFrom(this.httpService.get(url));

    if (data.errcode) {
      throw new UnauthorizedException(`微信登录失败: ${data.errmsg}`);
    }

    return data; // 包含 openid, session_key
  }
}

异常处理

使用全局异常过滤器 (Filter) 统一错误响应:

// src/common/filters/http-exception.filter.ts
import { ExceptionFilter, Catch, ArgumentsHost, HttpException } from '@nestjs/common';
import { Response } from 'express';

@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
  catch(exception: HttpException, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();
    const status = exception.getStatus();
    const exceptionResponse = exception.getResponse();

    response.status(status).json({
      code: status,
      message: typeof exceptionResponse === 'string' ? exceptionResponse : (exceptionResponse as any).message,
      data: null,
    });
  }
}

在 main.ts 中注册:

app.useGlobalFilters(new HttpExceptionFilter());

数据库 (Drizzle ORM)

推荐使用 Drizzle ORM,已预安装。

类型校验 (Zod)

项目集成了 Zod 用于运行时类型校验。

定义 Schema

import { z } from 'zod';

// 基础类型
const userSchema = z.object({
  id: z.number(),
  name: z.string().min(1).max(50),
  email: z.string().email(),
  age: z.number().int().positive().optional(),
});

// 从 schema 推导 TypeScript 类型
type User = z.infer<typeof userSchema>;

请求校验

// src/modules/user/dto/create-user.dto.ts
import { z } from 'zod';

export const createUserSchema = z.object({
  nickname: z.string().min(1, '昵称不能为空').max(20, '昵称最多20个字符'),
  avatar: z.string().url('头像必须是有效的URL').optional(),
  phone: z.string().regex(/^1[3-9]\d{9}$/, '手机号格式不正确').optional(),
});

export type CreateUserDto = z.infer<typeof createUserSchema>;

// 在 Controller 中使用
@Post()
create(@Body() body: unknown) {
  const result = createUserSchema.safeParse(body);
  if (!result.success) {
    throw new BadRequestException(result.error.errors);
  }
  return this.userService.create(result.data);
}
Description
No description provided
Readme 358 KiB
Languages
TypeScript 88.2%
Shell 6.7%
JavaScript 2.9%
HTML 1.2%
CSS 1%