feat: 实现微博签到小程序功能

- 实现签到主页面,包含签到按钮、连续天数、今日状态展示
- 实现签到记录页面,包含日历视图和签到历史列表
- 实现个人中心页面,包含用户信息和签到统计
- 后端实现签到、查询状态、查询历史三个接口
- 使用 Supabase 存储签到记录数据
- 采用星空主题设计,深蓝紫渐变背景 + 金色星光强调色
- 完成所有接口测试和前后端匹配验证
- 通过 ESLint 检查和编译验证
This commit is contained in:
jaystar
2026-03-16 11:17:17 +08:00
commit e209fe02a4
64 changed files with 26475 additions and 0 deletions

14
.coze Normal file
View File

@@ -0,0 +1,14 @@
[project]
entrypoint = "server.js"
requires = ["nodejs-24"]
[dev]
build = ["bash", ".cozeproj/scripts/dev_build.sh"]
run = ["bash", ".cozeproj/scripts/dev_run.sh"]
deps = ["git"] # -> apt install git
pack = ["bash", ".cozeproj/scripts/pack.sh"]
[deploy]
build = ["bash", ".cozeproj/scripts/deploy_build.sh"]
run = ["bash", ".cozeproj/scripts/deploy_run.sh"]
deps = ["git"] # -> apt install git

View File

@@ -0,0 +1,19 @@
#!/bin/bash
set -Eeuo pipefail
cd "${COZE_WORKSPACE_PATH}"
if [ -f "./.cozeproj/scripts/init_env.sh" ]; then
echo "⚙️ Initializing environment..."
# 使用 bash 执行,确保即使没有 x 权限也能跑
bash ./.cozeproj/scripts/init_env.sh
else
echo "⚠️ Warning: init_env.sh not found, skipping environment init."
fi
echo "Installing dependencies..."
# 安装所有依赖(包含 Taro 核心和 React
pnpm install
echo "Building the Taro project..."
pnpm build
echo "Build completed successfully! Assets are in /dist"

View File

@@ -0,0 +1,14 @@
#!/bin/bash
set -Eeuo pipefail
start_service() {
cd "${COZE_WORKSPACE_PATH}/server/dist"
local port="${DEPLOY_RUN_PORT:-3000}"
echo "Starting Static File Server on port ${port} for deploy..."
node ./main.js -p "${port}"
}
echo "Starting HTTP service for deploy..."
start_service

View File

@@ -0,0 +1,2 @@
#!/bin/bash
set -Eeuo pipefail

View File

@@ -0,0 +1,151 @@
#!/bin/bash
echo "⚙️ dev_run.sh 开始运行"
set -Eeuo pipefail
cd "${COZE_WORKSPACE_PATH}"
# ---------------------------------------------------------
# PID 文件,用于追踪上一次启动的进程树
# ---------------------------------------------------------
PID_FILE="/tmp/coze-dev-run.pid"
# ---------------------------------------------------------
# 工具函数
# ---------------------------------------------------------
kill_process_tree() {
local pid=$1
local children
children=$(pgrep -P "${pid}" 2>/dev/null || true)
for child in ${children}; do
kill_process_tree "${child}"
done
if kill -0 "${pid}" 2>/dev/null; then
echo "Killing PID ${pid}"
kill -9 "${pid}" 2>/dev/null || true
fi
}
kill_port_if_listening() {
local port=$1
local pids
pids=$(ss -H -lntp 2>/dev/null | awk -v port="${port}" '$4 ~ ":"port"$"' | grep -o 'pid=[0-9]*' | cut -d= -f2 | sort -u | paste -sd' ' - || true)
if [[ -z "${pids}" ]]; then
echo "Port ${port} is free."
return
fi
echo "Port ${port} in use by PIDs: ${pids}"
for pid in ${pids}; do
kill_process_tree "${pid}"
done
sleep 1
pids=$(ss -H -lntp 2>/dev/null | awk -v port="${port}" '$4 ~ ":"port"$"' | grep -o 'pid=[0-9]*' | cut -d= -f2 | sort -u | paste -sd' ' - || true)
if [[ -n "${pids}" ]]; then
echo "Warning: port ${port} still busy after cleanup, PIDs: ${pids}"
else
echo "Port ${port} cleared."
fi
}
# ---------------------------------------------------------
# 1. 清理上一次运行残留的整棵进程树
# ---------------------------------------------------------
cleanup_previous_run() {
# 1a. 通过 PID 文件清理上次的进程树
if [[ -f "${PID_FILE}" ]]; then
local old_pid
old_pid=$(cat "${PID_FILE}" 2>/dev/null || true)
if [[ -n "${old_pid}" ]] && kill -0 "${old_pid}" 2>/dev/null; then
echo "🧹 Killing previous dev process tree (root PID: ${old_pid})..."
kill_process_tree "${old_pid}"
fi
rm -f "${PID_FILE}"
fi
# 1b. 兜底:按特征匹配清理所有残留的相关进程(排除自身)
echo "🧹 Cleaning up any orphaned dev processes..."
local patterns=(
"pnpm dev"
"concurrently.*dev:web.*dev:server"
"nest start --watch"
"taro build --type h5 --watch"
"node --enable-source-maps.*/workspace/projects/server/dist/main"
"esbuild --service.*--ping"
)
for pattern in "${patterns[@]}"; do
local pids
pids=$(pgrep -f "${pattern}" 2>/dev/null || true)
for pid in ${pids}; do
# 不杀自己和自己的父进程链
if [[ "${pid}" != "$$" ]] && [[ "${pid}" != "${PPID}" ]]; then
echo " Killing orphan PID ${pid} matching '${pattern}'"
kill -9 "${pid}" 2>/dev/null || true
fi
done
done
sleep 1
}
# ---------------------------------------------------------
# 2. 安装依赖
# ---------------------------------------------------------
echo "📦 Installing dependencies..."
pnpm install
echo "✅ Dependencies installed successfully!"
# ---------------------------------------------------------
# 3. 清理旧进程 + 端口
# ---------------------------------------------------------
SERVER_PORT=3000
cleanup_previous_run
echo "Clearing port ${DEPLOY_RUN_PORT} (web) before start."
kill_port_if_listening "${DEPLOY_RUN_PORT}"
echo "Clearing port ${SERVER_PORT} (server) before start."
kill_port_if_listening "${SERVER_PORT}"
# ---------------------------------------------------------
# 4. 退出时自动清理子进程(信号 trap
# ---------------------------------------------------------
cleanup_on_exit() {
echo "🛑 dev_run.sh exiting, cleaning up child processes..."
# 杀掉当前脚本的所有子进程
kill -- -$$ 2>/dev/null || true
rm -f "${PID_FILE}"
exit 0
}
trap cleanup_on_exit EXIT INT TERM HUP
# ---------------------------------------------------------
# 5. 启动服务
# ---------------------------------------------------------
start_service() {
cd "${COZE_WORKSPACE_PATH}"
# 动态注入环境变量
if [ -n "${COZE_PROJECT_DOMAIN_DEFAULT:-}" ]; then
export PROJECT_DOMAIN="$COZE_PROJECT_DOMAIN_DEFAULT"
echo "✅ 环境变量已动态注入: PROJECT_DOMAIN=$PROJECT_DOMAIN"
else
echo "⚠️ 警告: COZE_PROJECT_DOMAIN_DEFAULT 未设置,使用 .env.local 中的配置"
fi
# 启动 Taro H5 和 NestJS Server
echo "Starting Taro H5 Dev Server and NestJS Server..."
export PORT=${DEPLOY_RUN_PORT}
rm -f /tmp/coze-logs/dev.log
mkdir -p /tmp/coze-logs
# 后台启动并记录 PID
pnpm dev 2>&1 | tee /tmp/coze-logs/dev.log &
local dev_pid=$!
echo "${dev_pid}" > "${PID_FILE}"
echo "📝 Dev process started with PID: ${dev_pid} (saved to ${PID_FILE})"
# 前台等待,保证 trap 能正常捕获信号
wait "${dev_pid}" || true
}
echo "Starting HTTP services on port ${DEPLOY_RUN_PORT} (web) and ${SERVER_PORT} (server)..."
start_service

View File

@@ -0,0 +1,5 @@
#!/bin/bash
cd "${COZE_WORKSPACE_PATH}"
echo "✅ 初始化完成"

24
.cozeproj/scripts/pack.sh Normal file
View File

@@ -0,0 +1,24 @@
# build_weapp.sh - 通过 PID 文件精确杀掉自己上次的构建进程
export OUTPUT_ROOT=dist
PID_FILE="/tmp/coze-build_weapp.pid"
# 杀掉上次的构建进程组
if [ -f "$PID_FILE" ]; then
OLD_PID=$(cat "$PID_FILE")
if kill -0 "$OLD_PID" 2>/dev/null; then
echo "正在终止上次的构建进程组 (PID: $OLD_PID)..."
# 关键kill 负数 PID = 杀掉整个进程组
kill -9 -"$OLD_PID" 2>/dev/null
sleep 1
fi
rm -f "$PID_FILE"
fi
# 用 setsid 创建新的进程组,方便下次整组杀掉
setsid pnpm build:weapp &
echo $! > "$PID_FILE"
echo "构建已启动 (PID: $(cat $PID_FILE))"
wait $!
rm -f "$PID_FILE"

40
.gitignore vendored Normal file
View File

@@ -0,0 +1,40 @@
# Dependencies
node_modules/
.pnpm-store/
# Production
dist/
build/
dist-*/
# Misc
.DS_Store
.env
# .env.local
.env.development.local
.env.test.local
.env.production.local
# Logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Editor directories and files
.idea/
.vscode/
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Taro specific
.taro/
# OS X
.DS_Store
# Key
# key/

18
.npmrc Normal file
View File

@@ -0,0 +1,18 @@
loglevel=error
registry=https://registry.npmmirror.com
strictStorePkgContentCheck=false
verifyStoreIntegrity=false
network-concurrency=16
fetch-retries=3
fetch-timeout=60000
strict-peer-dependencies=false
auto-install-peers=true
lockfile=true
prefer-frozen-lockfile=true
resolution-mode=highest

751
README.md Normal file
View File

@@ -0,0 +1,751 @@
# Coze Mini Program
这是一个基于 [Taro 4](https://docs.taro.zone/docs/) + [Nest.js](https://nestjs.com/) 的前后端分离项目,由扣子编程 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 # 微信小程序项目配置
```
## 快速开始
### 安装依赖
```bash
pnpm install
```
### 本地开发
同时启动 H5 前端和 NestJS 后端:
```bash
pnpm dev
```
- 前端地址http://localhost:5000
- 后端地址http://localhost:3000
单独启动:
```bash
pnpm dev:web # 仅 H5 前端
pnpm dev:weapp # 仅微信小程序
pnpm dev:server # 仅后端服务
```
### 构建
```bash
pnpm build # 构建所有H5 + 小程序 + 后端)
pnpm build:web # 仅构建 H5输出到 dist-web
pnpm build:weapp # 仅构建微信小程序,输出到 dist
pnpm build:server # 仅构建后端
```
### 预览小程序
```bash
pnpm preview:weapp # 构建并生成预览小程序二维码
```
## 前端核心开发规范
### 新建页面流程
1. 在 \`src/pages/\` 下创建页面目录
2. 创建 \`index.tsx\`(页面组件)
3. 创建 \`index.config.ts\`(页面配置)
4. 创建 \`index.css\`(页面样式,可选)
5. 在 \`src/app.config.ts\` 的 \`pages\` 数组中注册页面路径
或使用 Taro 脚手架命令:
```bash
pnpm new # 交互式创建页面/组件
```
### 常用 Taro 组件
引入方式
```typescript
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/*`
```typescript
import { SomeComponent } from '@/components/SomeComponent'
import { useUserStore } from '@/stores/user'
```
### 代码模板
#### 页面组件 (TypeScript + React)
```tsx
// 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
```
#### 页面配置
```typescript
// src/pages/example/index.config.ts
import { definePageConfig } from '@tarojs/taro'
export default definePageConfig({
navigationBarTitleText: '示例页面',
enablePullDownRefresh: true,
backgroundTextStyle: 'dark',
})
```
#### 应用配置
```typescript
// 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 一致。
✅ 正确使用方式
```typescript
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'
})
```
❌ 错误用法
```typescript
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 状态管理
```typescript
// 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
```typescript
import {
useLoad, // 页面加载 (onLoad)
useReady, // 页面初次渲染完成 (onReady)
useDidShow, // 页面显示 (onShow)
useDidHide, // 页面隐藏 (onHide)
usePullDownRefresh, // 下拉刷新 (onPullDownRefresh)
useReachBottom, // 触底加载 (onReachBottom)
useShareAppMessage, // 分享 (onShareAppMessage)
useRouter, // 获取路由参数
} from '@tarojs/taro'
```
### 路由导航
```typescript
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 一致:
```tsx
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 图标:
```bash
# 生成带选中状态的图标
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 必须以 `./` 开头,否则图标无法渲染
```typescript
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>
```
### 性能优化
#### 图片懒加载
```tsx
import { Image } from '@tarojs/components'
<Image src={imageUrl} lazyLoad mode="aspectFill" />
```
#### 虚拟列表
```tsx
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>
)}
/>
```
#### 分包加载
```typescript
// src/app.config.ts
export default defineAppConfig({
pages: ['pages/index/index'],
subPackages: [
{
root: 'packageA',
pages: ['pages/detail/index'],
},
],
})
```
### 小程序限制
| 限制项 | 说明 |
| -------- | ---------------------------------------- |
| 主包体积 | ≤ 2MB |
| 总包体积 | ≤ 20MB |
| 域名配置 | 生产环境需在小程序后台配置合法域名 |
| 本地开发 | 需在微信开发者工具开启「不校验合法域名」 |
### 权限配置
```typescript
// src/app.config.ts
export default defineAppConfig({
// ...其他配置
permission: {
'scope.userLocation': {
desc: '你的位置信息将用于小程序位置接口的效果展示'
}
},
requiredPrivateInfos: ['getLocation', 'chooseAddress']
})
```
### 位置服务
```typescript
// 需先在 app.config.ts 中配置 permission
async function getLocation(): Promise<Taro.getLocation.SuccessCallbackResult> {
return await Taro.getLocation({ type: 'gcj02' })
}
```
## 后端核心开发规范
本项目后端基于 NestJS + TypeScript 构建,提供高效、可扩展的服务端能力。
### 项目结构
```sh
.
├── server/ # NestJS 后端服务
│ └── src/
│ ├── main.ts # 服务入口
│ ├── app.module.ts # 根模块
│ ├── app.controller.ts # 根控制器
│ └── app.service.ts # 根服务
```
### 开发命令
```sh
pnpm dev:server // 启动开发服务 (热重载, 默认端口 3000)
pnpm build:server // 构建生产版本
```
### 新建模块流程 (CLI)
快速生成样板代码:
```bash
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 文件:
```sh
## 服务端口
PORT=3000
## 微信小程序配置
WX_APP_ID=你的AppID
WX_APP_SECRET=你的AppSecret
## JWT 密钥
JWT_SECRET=your-super-secret-key
```
在代码中使用 @nestjs/config 读取环境变量:
```typescript
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 响应格式:
```typeScript
// 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 中全局注册:
```typescript
app.useGlobalInterceptors(new TransformInterceptor());
```
### 微信登录后端实现
```typescript
// 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) 统一错误响应:
```typescript
// 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](https://orm.drizzle.team/),已预安装。
### 类型校验 (Zod)
项目集成了 [Zod](https://zod.dev/) 用于运行时类型校验。
#### 定义 Schema
```typescript
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>;
```
#### 请求校验
```typescript
// 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);
}
```

12
babel.config.js Normal file
View File

@@ -0,0 +1,12 @@
// babel-preset-taro 更多选项和默认值:
// https://docs.taro.zone/docs/next/babel-config
module.exports = {
presets: [
['taro', {
framework: 'react',
ts: true,
compiler: 'vite',
useBuiltIns: false
}]
]
}

9
config/dev.ts Normal file
View File

@@ -0,0 +1,9 @@
import type { UserConfigExport } from "@tarojs/cli"
export default {
mini: {
debugReact: true,
},
h5: {}
} satisfies UserConfigExport<'vite'>

223
config/index.ts Normal file
View File

@@ -0,0 +1,223 @@
import fs from 'node:fs';
import path from 'node:path';
import tailwindcss from '@tailwindcss/postcss';
import { UnifiedViteWeappTailwindcssPlugin } from 'weapp-tailwindcss/vite';
import { defineConfig, type UserConfigExport } from '@tarojs/cli';
import type { PluginItem } from '@tarojs/taro/types/compile/config/project';
import dotenv from 'dotenv';
import devConfig from './dev';
import prodConfig from './prod';
import pkg from '../package.json';
dotenv.config({ path: path.resolve(__dirname, '../.env.local') });
const generateTTProjectConfig = () => {
const config = {
miniprogramRoot: './',
projectname: 'coze-mini-program',
appid: process.env.TARO_APP_TT_APPID || '',
setting: {
urlCheck: false,
es6: false,
postcss: false,
minified: false,
},
};
const outputDir = path.resolve(__dirname, '../dist-tt');
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
fs.writeFileSync(
path.resolve(outputDir, 'project.config.json'),
JSON.stringify(config, null, 2),
);
};
// https://taro-docs.jd.com/docs/next/config#defineconfig-辅助函数
export default defineConfig<'vite'>(async (merge, _env) => {
const outputRootMap: Record<string, string> = {
weapp: 'dist',
tt: 'dist-tt',
h5: 'dist-web',
};
const outputRoot = outputRootMap[process.env.TARO_ENV || ''] || 'dist-web';
const buildMiniCIPluginConfig = () => {
const hasWeappConfig = !!process.env.TARO_APP_WEAPP_APPID
const hasTTConfig = !!process.env.TARO_APP_TT_EMAIL
if (!hasWeappConfig && !hasTTConfig) {
return []
}
const miniCIConfig: Record<string, any> = {
version: pkg.version,
desc: pkg.description,
}
if (hasWeappConfig) {
miniCIConfig.weapp = {
appid: process.env.TARO_APP_WEAPP_APPID,
privateKeyPath: 'key/private.appid.key',
}
}
if (hasTTConfig) {
miniCIConfig.tt = {
email: process.env.TARO_APP_TT_EMAIL,
password: process.env.TARO_APP_TT_PASSWORD,
setting: {
skipDomainCheck: true,
},
}
}
return [['@tarojs/plugin-mini-ci', miniCIConfig]] as PluginItem[]
}
const baseConfig: UserConfigExport<'vite'> = {
projectName: 'coze-mini-program',
date: '2026-1-13',
alias: {
'@': path.resolve(__dirname, '..', 'src'),
},
designWidth: 750,
deviceRatio: {
640: 2.34 / 2,
750: 1,
375: 2,
828: 1.81 / 2,
},
sourceRoot: 'src',
outputRoot,
plugins: ['@tarojs/plugin-generator', ...buildMiniCIPluginConfig()],
defineConstants: {
PROJECT_DOMAIN: JSON.stringify(
process.env.PROJECT_DOMAIN ||
process.env.COZE_PROJECT_DOMAIN_DEFAULT ||
'',
),
TARO_ENV: JSON.stringify(process.env.TARO_ENV),
},
copy: {
patterns: [],
options: {},
},
...(process.env.TARO_ENV === 'tt' && {
tt: {
appid: process.env.TARO_APP_TT_APPID,
projectName: 'coze-mini-program',
},
}),
jsMinimizer: 'esbuild',
framework: 'react',
compiler: {
type: 'vite',
vitePlugins: [
{
name: 'postcss-config-loader-plugin',
config(config) {
// 通过 postcss 配置注册 tailwindcss 插件
if (typeof config.css?.postcss === 'object') {
config.css?.postcss.plugins?.unshift(tailwindcss());
}
},
},
{
name: 'hmr-config-plugin',
config() {
if (!process.env.PROJECT_DOMAIN) {
return;
}
return {
server: {
hmr: {
overlay: true,
path: '/hot/vite-hmr',
port: 6000,
clientPort: 443,
timeout: 30000,
},
},
};
},
},
UnifiedViteWeappTailwindcssPlugin({
rem2rpx: true,
cssEntries: [path.resolve(__dirname, '../src/app.css')],
}),
...(process.env.TARO_ENV === 'tt'
? [
{
name: 'generate-tt-project-config',
closeBundle() {
generateTTProjectConfig();
},
},
]
: []),
],
},
mini: {
postcss: {
pxtransform: {
enable: true,
config: {},
},
cssModules: {
enable: false, // 默认为 false如需使用 css modules 功能,则设为 true
config: {
namingPattern: 'module', // 转换模式,取值为 global/module
generateScopedName: '[name]__[local]___[hash:base64:5]',
},
},
},
},
h5: {
publicPath: '/',
staticDirectory: 'static',
devServer: {
port: 5000,
host: '0.0.0.0',
open: false,
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
},
},
},
miniCssExtractPluginOption: {
ignoreOrder: true,
filename: 'css/[name].[hash].css',
chunkFilename: 'css/[name].[chunkhash].css',
},
postcss: {
autoprefixer: {
enable: true,
config: {},
},
cssModules: {
enable: false, // 默认为 false如需使用 css modules 功能,则设为 true
config: {
namingPattern: 'module', // 转换模式,取值为 global/module
generateScopedName: '[name]__[local]___[hash:base64:5]',
},
},
},
},
rn: {
appName: 'coze-mini-program',
postcss: {
cssModules: {
enable: false, // 默认为 false如需使用 css modules 功能,则设为 true
},
},
},
};
process.env.BROWSERSLIST_ENV = process.env.NODE_ENV;
if (process.env.NODE_ENV === 'development') {
// 本地开发构建配置(不混淆压缩)
return merge({}, baseConfig, devConfig);
}
// 生产构建配置(默认开启压缩混淆等)
return merge({}, baseConfig, prodConfig);
});

34
config/prod.ts Normal file
View File

@@ -0,0 +1,34 @@
import type { UserConfigExport } from '@tarojs/cli';
export default {
mini: {},
h5: {
legacy: false,
/**
* WebpackChain 插件配置
* @docs https://github.com/neutrinojs/webpack-chain
*/
// webpackChain (chain) {
// /**
// * 如果 h5 端编译后体积过大,可以使用 webpack-bundle-analyzer 插件对打包体积进行分析。
// * @docs https://github.com/webpack-contrib/webpack-bundle-analyzer
// */
// chain.plugin('analyzer')
// .use(require('webpack-bundle-analyzer').BundleAnalyzerPlugin, [])
// /**
// * 如果 h5 端首屏加载时间过长,可以使用 prerender-spa-plugin 插件预加载首页。
// * @docs https://github.com/chrisvfritz/prerender-spa-plugin
// */
// const path = require('path')
// const Prerender = require('prerender-spa-plugin')
// const staticDir = path.join(__dirname, '..', 'dist')
// chain
// .plugin('prerender')
// .use(new Prerender({
// staticDir,
// routes: [ '/pages/index/index' ],
// postProcess: (context) => ({ ...context, outputPath: path.join(staticDir, 'index.html') })
// }))
// }
},
} satisfies UserConfigExport<'vite'>;

221
design_guidelines.md Normal file
View File

@@ -0,0 +1,221 @@
# 微博签到小程序设计指南
## 品牌定位
**应用定位**:微博签到打卡工具,帮助用户养成持续签到的习惯
**设计风格**:星空主题、仪式感、成就感
**目标用户**:微博用户,希望通过签到获得奖励的用户
**核心意象**:点亮星空——每次签到都是点亮夜空中的一颗星,连续签到让星空更加璀璨
---
## 配色方案
### 主色板
| 颜色名称 | Tailwind 类名 | 色值 | 意象来源 |
|---------|--------------|------|---------|
| 星空蓝(主色) | `bg-blue-600` | #2563eb | 深夜星空的底色 |
| 星光金(强调色) | `text-yellow-400` | #facc15 | 星星的光芒 |
| 月光蓝(辅助色) | `text-blue-300` | #93c5fd | 柔和的月光 |
### 中性色
| 颜色名称 | Tailwind 类名 | 色值 | 用途 |
|---------|--------------|------|------|
| 深空灰 | `bg-gray-900` | #111827 | 页面背景 |
| 夜空灰 | `bg-gray-800` | #1f2937 | 卡片背景 |
| 星云灰 | `bg-gray-700` | #374151 | 次级背景 |
| 银河白 | `text-white` | #ffffff | 主要文字 |
| 星尘灰 | `text-gray-400` | #9ca3af | 次要文字 |
### 语义色
| 状态 | Tailwind 类名 | 色值 |
|-----|--------------|------|
| 成功/已签到 | `text-green-400` | #4ade80 |
| 警告/未签到 | `text-orange-400` | #fb923c |
| 信息/提示 | `text-blue-400` | #60a5fa |
---
## 字体规范
### 字体选择
- **标题字体**:使用系统默认字体(微信小程序限制)
- **正文字体**:使用系统默认字体
### 字体层级
| 层级 | Tailwind 类名 | 字号 | 行高 | 用途 |
|-----|--------------|------|------|------|
| H1 | `text-3xl font-bold` | 30px | 36px | 签到主标题 |
| H2 | `text-2xl font-bold` | 24px | 32px | 页面标题 |
| H3 | `text-xl font-semibold` | 20px | 28px | 卡片标题 |
| Body | `text-base` | 16px | 24px | 正文内容 |
| Caption | `text-sm` | 14px | 20px | 说明文字 |
---
## 间距系统
### 页面边距
- 页面左右边距:`px-4`16px
- 页面上下边距:`py-6`24px
### 组件间距
- 卡片间距:`gap-4`16px
- 列表项间距:`gap-3`12px
- 按钮组间距:`gap-2`8px
---
## 组件规范
### 1. 签到按钮(核心组件)
```tsx
// 主签到按钮
<View className="flex justify-center items-center">
<View className="w-40 h-40 rounded-full bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center shadow-lg shadow-blue-500/50">
<Text className="text-white text-2xl font-bold"></Text>
</View>
</View>
// 已签到状态
<View className="w-40 h-40 rounded-full bg-gradient-to-br from-green-500 to-green-600 flex items-center justify-center shadow-lg shadow-green-500/50">
<Text className="text-white text-2xl font-bold"></Text>
</View>
```
### 2. 卡片容器
```tsx
<View className="bg-gray-800 rounded-2xl p-4 shadow-lg">
{/* 卡片内容 */}
</View>
```
### 3. 列表项
```tsx
<View className="flex flex-row items-center gap-3 py-3 border-b border-gray-700">
<View className="w-2 h-2 rounded-full bg-blue-500" />
<Text className="text-white flex-1"></Text>
<Text className="text-gray-400 text-sm"></Text>
</View>
```
### 4. 统计卡片
```tsx
<View className="bg-gradient-to-br from-blue-600 to-purple-600 rounded-2xl p-6">
<Text className="text-blue-200 text-sm"></Text>
<Text className="text-white text-4xl font-bold mt-2">7 </Text>
</View>
```
### 5. 空状态
```tsx
<View className="flex flex-col items-center justify-center py-20">
<Text className="text-gray-500 text-lg"></Text>
<Text className="text-gray-600 text-sm mt-2"></Text>
</View>
```
---
## 导航结构
### TabBar 配置
```typescript
{
tabBar: {
color: '#9ca3af',
selectedColor: '#2563eb',
backgroundColor: '#111827',
borderStyle: 'black',
list: [
{
pagePath: 'pages/index/index',
text: '签到',
iconPath: './assets/tabbar/star.png',
selectedIconPath: './assets/tabbar/star-active.png'
},
{
pagePath: 'pages/record/index',
text: '记录',
iconPath: './assets/tabbar/calendar.png',
selectedIconPath: './assets/tabbar/calendar-active.png'
},
{
pagePath: 'pages/profile/index',
text: '我的',
iconPath: './assets/tabbar/user.png',
selectedIconPath: './assets/tabbar/user-active.png'
}
]
}
}
```
### 页面跳转规范
- TabBar 页面跳转:使用 `Taro.switchTab()`
- 普通页面跳转:使用 `Taro.navigateTo()`
---
## 小程序约束
### 包体积限制
- 主包大小:不超过 2MB
- 分包大小:单个分包不超过 2MB
- 总大小:不超过 20MB
### 图片策略
- 使用 CDN 图片链接
- 本地图标使用 PNG 格式TabBar 必须是本地 PNG
- 图片压缩后使用
### 性能优化
- 避免过深的组件嵌套
- 列表使用虚拟滚动(长列表场景)
- 图片懒加载
- 避免频繁的 `setState`
---
## 跨端兼容注意事项
### H5/小程序兼容
- Text 换行:垂直排列的 Text 必须添加 `block`
- Input 样式:必须用 View 包裹,样式放外层
- Fixed 布局:使用 inline style避免 Tailwind fixed+flex 失效
### 平台检测
```tsx
const isWeapp = Taro.getEnv() === Taro.ENV_TYPE.WEAPP
```
---
## 设计禁忌
**禁止使用**
- ❌ 微博橙色默认配色
- ❌ 纯白色背景(不符合星空主题)
- ❌ 过于功能导向的冷冰冰设计
- ❌ 缺乏情感共鸣的界面元素
- ❌ 复杂的动画效果(影响性能)

80
eslint.config.mjs Normal file
View File

@@ -0,0 +1,80 @@
import { FlatCompat } from '@eslint/eslintrc';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
export default [
...compat.extends('taro/react'),
{
rules: {
'react/jsx-uses-react': 'off',
'react/react-in-jsx-scope': 'off',
'jsx-quotes': ['error', 'prefer-double'],
'react-hooks/exhaustive-deps': 'off',
},
},
{
files: ['src/**/*.{js,jsx,ts,tsx}'],
ignores: ['src/network.ts'],
rules: {
'no-restricted-syntax': [
'error',
{
selector:
"MemberExpression[object.name='process'][property.name='env']",
message:
'请勿在 src 目录下直接使用 process.env\n如需获取 URL 请求前缀,请使用已经注入全局的 PROJECT_DOMAIN',
},
{
selector:
":matches(ExportNamedDeclaration, ExportDefaultDeclaration) :matches([id.name='Network'], [declaration.id.name='Network'])",
message:
"禁止自行定义 Network项目已提供 src/network.ts请直接使用: import { Network } from '@/network'",
},
],
'no-restricted-properties': [
'error',
{
object: 'Taro',
property: 'request',
message:
"请使用 Network.request 替代 Taro.request导入方式: import { Network } from '@/network'",
},
{
object: 'Taro',
property: 'uploadFile',
message:
"请使用 Network.uploadFile 替代 Taro.uploadFile导入方式: import { Network } from '@/network'",
},
{
object: 'Taro',
property: 'downloadFile',
message:
"请使用 Network.downloadFile 替代 Taro.downloadFile导入方式: import { Network } from '@/network'",
},
],
},
},
{
files: ['src/pages/index/index.tsx'],
rules: {
'no-restricted-syntax': [
'error',
{
selector: 'JSXText[value=/应用开发中/]',
message:
'检测到首页 (src/pages/index/index) 仍为默认占位页面,这会导致用户无法进入新增页面,请根据用户需求开发实际的首页功能和界面。如果已经开发了新的首页,也需要删除旧首页,并更新 src/app.config.ts 文件',
},
],
},
},
{
ignores: ['dist/**', 'dist-*/**', 'node_modules/**'],
},
];

0
key/private.appid.key Normal file
View File

107
package.json Normal file
View File

@@ -0,0 +1,107 @@
{
"name": "coze-mini-program",
"version": "1.0.0",
"private": true,
"description": "Coze Mini Program Application",
"scripts": {
"build": "pnpm exec concurrently --kill-others-on-fail --kill-signal SIGKILL -n lint,tsc,web,weapp,tt,server -c red,blue,green,yellow,cyan,magenta \"pnpm lint:build\" \"pnpm tsc\" \"pnpm build:web\" \"pnpm build:weapp\" \"pnpm build:tt\" \"pnpm build:server\"",
"build:server": "pnpm --filter server build",
"build:tt": "taro build --type tt",
"build:weapp": "taro build --type weapp",
"build:web": "taro build --type h5",
"dev": "pnpm exec concurrently --kill-others --kill-signal SIGKILL -n web,server -c blue,green \"pnpm dev:web\" \"pnpm dev:server\"",
"dev:server": "pnpm --filter server dev",
"dev:tt": "taro build --type tt --watch",
"dev:weapp": "taro build --type weapp --watch",
"dev:web": "taro build --type h5 --watch",
"preinstall": "npx only-allow pnpm",
"postinstall": "weapp-tw patch",
"kill:all": "pkill -9 -f 'concurrently' 2>/dev/null || true; pkill -9 -f 'nest start' 2>/dev/null || true; pkill -9 -f 'taro build' 2>/dev/null || true; pkill -9 -f 'node.*dev' 2>/dev/null || true; echo 'All dev processes cleaned'",
"lint": "eslint \"src/**/*.{js,jsx,ts,tsx}\"",
"lint:build": "eslint \"src/**/*.{js,jsx,ts,tsx}\" --max-warnings=0",
"lint:fix": "eslint \"src/**/*.{js,jsx,ts,tsx}\" --fix",
"new": "taro new",
"preview:tt": "taro build --type tt --preview",
"preview:weapp": "taro build --type weapp --preview",
"tsc": "npx tsc --noEmit --skipLibCheck",
"validate": "pnpm exec concurrently --kill-others-on-fail --kill-signal SIGKILL -n lint,tsc -c red,blue \"pnpm lint:build\" \"pnpm tsc\""
},
"lint-staged": {
"src/**/*.{js,jsx,ts,tsx}": [
"eslint"
]
},
"browserslist": [
"last 3 versions",
"Android >= 4.1",
"ios >= 8"
],
"dependencies": {
"@babel/runtime": "^7.24.4",
"@tarojs/components": "4.1.9",
"@tarojs/helper": "4.1.9",
"@tarojs/plugin-framework-react": "4.1.9",
"@tarojs/plugin-platform-h5": "4.1.9",
"@tarojs/plugin-platform-tt": "4.1.9",
"@tarojs/plugin-platform-weapp": "4.1.9",
"@tarojs/react": "4.1.9",
"@tarojs/runtime": "4.1.9",
"@tarojs/shared": "4.1.9",
"@tarojs/taro": "4.1.9",
"drizzle-kit": "^0.31.8",
"lucide-react-taro": "^1.2.0",
"react": "^18.0.0",
"react-dom": "^18.0.0",
"zustand": "^5.0.9"
},
"devDependencies": {
"@babel/core": "^7.24.4",
"@babel/plugin-transform-class-properties": "7.25.9",
"@babel/preset-react": "^7.24.1",
"@eslint/eslintrc": "^3.3.1",
"@tailwindcss/postcss": "^4.1.18",
"@tarojs/cli": "4.1.9",
"@tarojs/plugin-generator": "4.1.9",
"@tarojs/plugin-mini-ci": "^4.1.9",
"@tarojs/vite-runner": "4.1.9",
"@types/minimatch": "^5",
"@types/react": "^18.0.0",
"@vitejs/plugin-react": "^4.3.0",
"babel-preset-taro": "4.1.9",
"concurrently": "^9.2.1",
"dotenv": "^17.2.3",
"eslint": "^8.57.0",
"eslint-config-taro": "4.1.9",
"eslint-plugin-react": "^7.34.1",
"eslint-plugin-react-hooks": "^4.4.0",
"less": "^4.2.0",
"lint-staged": "^16.1.2",
"miniprogram-ci": "^2.1.26",
"only-allow": "^1.2.2",
"postcss": "^8.5.6",
"react-refresh": "^0.14.0",
"stylelint": "^16.4.0",
"stylelint-config-standard": "^38.0.0",
"tailwindcss": "^4.1.18",
"terser": "^5.30.4",
"tt-ide-cli": "^0.1.31",
"typescript": "^5.4.5",
"vite": "^4.2.0",
"weapp-tailwindcss": "^4.9.2"
},
"packageManager": "pnpm@9.0.0",
"engines": {
"pnpm": ">=9.0.0"
},
"pnpm": {
"patchedDependencies": {
"@tarojs/plugin-mini-ci@4.1.9": "patches/@tarojs__plugin-mini-ci@4.1.9.patch"
}
},
"templateInfo": {
"name": "default",
"typescript": true,
"css": "Less",
"framework": "React"
}
}

View File

@@ -0,0 +1,30 @@
diff --git a/dist/TTCI.js b/dist/TTCI.js
index e5fe6a5f9a0ca8670ffa2b7e14cdfd8d49d0d792..6f24020a3388ea8fc7d1cbc41c1df104a8c37cb8 100644
--- a/dist/TTCI.js
+++ b/dist/TTCI.js
@@ -63,7 +63,10 @@ class TTCI extends BaseCi_1.default {
output: previewQrcodePath,
},
copyToClipboard: true,
- cache: true
+ cache: true,
+ ideConfig: {
+ skipDomainCheck: this.pluginOpts.tt && this.pluginOpts.tt.setting && this.pluginOpts.tt.setting.skipDomainCheck
+ }
});
console.log(chalk.green(`开发版上传成功 ${new Date().toLocaleString()}\n`));
const qrContent = previewResult.shortUrl;
diff --git a/dist/index.js b/dist/index.js
index bb245e34a477bcd738b18fdd754b252a456efe4f..588fe56131be52de2d798ee959ef257b4fa53313 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -43,7 +43,8 @@ exports.default = (ctx, _pluginOpts) => {
/** 字节跳动小程序上传配置 */
tt: joi.object({
email: joi.string().required(),
- password: joi.string().required()
+ password: joi.string().required(),
+ setting: joi.object()
}),
/** 阿里小程序上传配置 */
alipay: joi.alternatives().try(joi.object({

23100
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

2
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,2 @@
packages:
- 'server'

15
project.config.json Normal file
View File

@@ -0,0 +1,15 @@
{
"miniprogramRoot": "./dist",
"projectname": "coze-mini-program",
"description": "test",
"appid": "touristappid",
"setting": {
"urlCheck": true,
"es6": false,
"enhance": false,
"compileHotReLoad": false,
"postcss": false,
"minified": false
},
"compileType": "miniprogram"
}

10
server/nest-cli.json Normal file
View File

@@ -0,0 +1,10 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true,
"exclude": ["node_modules", "dist", ".git", "../dist", "../src"]
},
"webpack": true
}

40
server/package.json Normal file
View File

@@ -0,0 +1,40 @@
{
"name": "server",
"version": "1.0.0",
"private": true,
"description": "NestJS server application",
"scripts": {
"build": "nest build",
"dev": "nest start --watch",
"start": "nest start",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.958.0",
"@aws-sdk/lib-storage": "^3.958.0",
"@nestjs/common": "^10.4.15",
"@nestjs/core": "^10.4.15",
"@nestjs/platform-express": "^10.4.15",
"@supabase/supabase-js": "2.95.3",
"better-sqlite3": "^11.9.1",
"coze-coding-dev-sdk": "^0.7.16",
"dotenv": "^17.2.3",
"drizzle-kit": "^0.31.8",
"drizzle-orm": "^0.45.1",
"drizzle-zod": "^0.8.3",
"express": "5.2.1",
"pg": "^8.16.3",
"rxjs": "^7.8.1",
"zod": "^4.3.5"
},
"devDependencies": {
"@nestjs/cli": "^10.4.9",
"@nestjs/schematics": "^10.2.3",
"@types/better-sqlite3": "^7.6.13",
"@types/express": "5.0.6",
"@types/node": "^22.10.2",
"drizzle-kit": "^0.31.8",
"typescript": "^5.7.2"
}
}

View File

@@ -0,0 +1,23 @@
import { Controller, Get } from '@nestjs/common';
import { AppService } from '@/app.service';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get('hello')
getHello(): { status: string; data: string } {
return {
status: 'success',
data: this.appService.getHello()
};
}
@Get('health')
getHealth(): { status: string; data: string } {
return {
status: 'success',
data: new Date().toISOString(),
};
}
}

11
server/src/app.module.ts Normal file
View File

@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { AppController } from '@/app.controller';
import { AppService } from '@/app.service';
import { SignInModule } from '@/signin/signin.module';
@Module({
imports: [SignInModule],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}

View File

@@ -0,0 +1,8 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
getHello(): string {
return 'Hello, welcome to coze coding mini-program server!';
}
}

View File

@@ -0,0 +1,23 @@
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
@Injectable()
export class HttpStatusInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const request = context.switchToHttp().getRequest();
const response = context.switchToHttp().getResponse();
// 如果是 POST 请求且状态码为 201改为 200
if (request.method === 'POST' && response.statusCode === 201) {
response.status(200);
}
return next.handle();
}
}

49
server/src/main.ts Normal file
View File

@@ -0,0 +1,49 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from '@/app.module';
import * as express from 'express';
import { HttpStatusInterceptor } from '@/interceptors/http-status.interceptor';
function parsePort(): number {
const args = process.argv.slice(2);
const portIndex = args.indexOf('-p');
if (portIndex !== -1 && args[portIndex + 1]) {
const port = parseInt(args[portIndex + 1], 10);
if (!isNaN(port) && port > 0 && port < 65536) {
return port;
}
}
return 3000;
}
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableCors({
origin: true,
credentials: true,
});
app.setGlobalPrefix('api');
app.use(express.json({ limit: '50mb' }));
app.use(express.urlencoded({ limit: '50mb', extended: true }));
// 全局拦截器:统一将 POST 请求的 201 状态码改为 200
app.useGlobalInterceptors(new HttpStatusInterceptor());
// 1. 开启优雅关闭 Hooks (关键!)
app.enableShutdownHooks();
// 2. 解析端口
const port = parsePort();
try {
await app.listen(port);
console.log(`Server running on http://localhost:${port}`);
} catch (err) {
if (err.code === 'EADDRINUSE') {
console.error(`❌ 端口 \({port} 被占用! 请运行 'npx kill-port \){port}' 然后重试。`);
process.exit(1);
} else {
throw err;
}
}
console.log(`Application is running on: http://localhost:3000`);
}
bootstrap();

View File

@@ -0,0 +1,41 @@
import { Controller, Post, Get, Query } from '@nestjs/common';
import { SignInService } from './signin.service';
@Controller('signin')
export class SignInController {
constructor(private readonly signInService: SignInService) {}
/**
* 用户签到
* POST /api/signin
*/
@Post()
async signIn() {
// 临时使用固定用户ID实际应从 token 中获取
const userId = 'default_user';
return await this.signInService.signIn(userId);
}
/**
* 获取签到状态
* GET /api/signin/status
*/
@Get('status')
async getStatus() {
// 临时使用固定用户ID实际应从 token 中获取
const userId = 'default_user';
return await this.signInService.getSignInStatus(userId);
}
/**
* 获取签到历史记录
* GET /api/signin/history
*/
@Get('history')
async getHistory(@Query('limit') limit?: string) {
// 临时使用固定用户ID实际应从 token 中获取
const userId = 'default_user';
const limitNum = limit ? parseInt(limit, 10) : 30;
return await this.signInService.getSignInHistory(userId, limitNum);
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { SignInController } from './signin.controller';
import { SignInService } from './signin.service';
@Module({
controllers: [SignInController],
providers: [SignInService],
exports: [SignInService],
})
export class SignInModule {}

View File

@@ -0,0 +1,148 @@
import { Injectable } from '@nestjs/common';
import { getSupabaseClient } from '@/storage/database/supabase-client';
@Injectable()
export class SignInService {
/**
* 用户签到
*/
async signIn(userId: string) {
const client = getSupabaseClient();
const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD
// 检查今日是否已签到
const { data: existingRecord } = await client
.from('sign_in_records')
.select('*')
.eq('user_id', userId)
.eq('sign_date', today)
.single();
if (existingRecord) {
return {
code: 400,
msg: '今日已签到',
data: null,
};
}
// 创建签到记录
const { data, error } = await client
.from('sign_in_records')
.insert({
user_id: userId,
sign_date: today,
})
.select()
.single();
if (error) {
console.error('签到失败:', error);
return {
code: 500,
msg: '签到失败',
data: null,
};
}
return {
code: 200,
msg: '签到成功',
data,
};
}
/**
* 获取签到状态
*/
async getSignInStatus(userId: string) {
const client = getSupabaseClient();
const today = new Date().toISOString().split('T')[0];
// 检查今日是否已签到
const { data: todayRecord } = await client
.from('sign_in_records')
.select('*')
.eq('user_id', userId)
.eq('sign_date', today)
.single();
// 获取所有签到记录
const { data: allRecords } = await client
.from('sign_in_records')
.select('sign_date')
.eq('user_id', userId)
.order('sign_date', { ascending: false });
const totalDays = allRecords?.length || 0;
// 计算连续签到天数
let continuousDays = 0;
if (allRecords && allRecords.length > 0) {
const dates = allRecords.map((r) => r.sign_date).sort().reverse();
continuousDays = this.calculateContinuousDays(dates, today);
}
return {
code: 200,
msg: 'success',
data: {
todaySignedIn: !!todayRecord,
continuousDays,
totalDays,
},
};
}
/**
* 计算连续签到天数
*/
private calculateContinuousDays(dates: string[], today: string): number {
let continuousDays = 0;
let checkDate = new Date(today);
for (const dateStr of dates) {
const date = new Date(dateStr);
const diffTime = checkDate.getTime() - date.getTime();
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
if (diffDays === 0 || diffDays === 1) {
continuousDays++;
checkDate = date;
} else {
break;
}
}
return continuousDays;
}
/**
* 获取签到历史记录
*/
async getSignInHistory(userId: string, limit: number = 30) {
const client = getSupabaseClient();
const { data, error } = await client
.from('sign_in_records')
.select('*')
.eq('user_id', userId)
.order('sign_date', { ascending: false })
.limit(limit);
if (error) {
console.error('获取签到历史失败:', error);
return {
code: 500,
msg: '获取签到历史失败',
data: null,
};
}
return {
code: 200,
msg: 'success',
data,
};
}
}

View File

@@ -0,0 +1,3 @@
import { relations } from "drizzle-orm/relations";
import { } from "./schema";

View File

@@ -0,0 +1,24 @@
import { pgTable, serial, timestamp, varchar, date, index } from "drizzle-orm/pg-core"
import { sql } from "drizzle-orm"
export const healthCheck = pgTable("health_check", {
id: serial().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true, mode: 'string' }).defaultNow(),
});
// 签到记录表
export const signInRecords = pgTable(
"sign_in_records",
{
id: serial("id").primaryKey(),
userId: varchar("user_id", { length: 128 }).notNull(),
signDate: date("sign_date").notNull(),
createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' })
.defaultNow()
.notNull(),
},
(table) => [
index("sign_in_records_user_id_idx").on(table.userId),
index("sign_in_records_sign_date_idx").on(table.signDate),
]
);

View File

@@ -0,0 +1,115 @@
import { createClient, SupabaseClient } from '@supabase/supabase-js';
import { execSync } from 'child_process';
let envLoaded = false;
interface SupabaseCredentials {
url: string;
anonKey: string;
}
function loadEnv(): void {
if (envLoaded || (process.env.COZE_SUPABASE_URL && process.env.COZE_SUPABASE_ANON_KEY)) {
return;
}
try {
try {
require('dotenv').config();
if (process.env.COZE_SUPABASE_URL && process.env.COZE_SUPABASE_ANON_KEY) {
envLoaded = true;
return;
}
} catch {
// dotenv not available
}
const pythonCode = `
import os
import sys
try:
from coze_workload_identity import Client
client = Client()
env_vars = client.get_project_env_vars()
client.close()
for env_var in env_vars:
print(f"{env_var.key}={env_var.value}")
except Exception as e:
print(f"# Error: {e}", file=sys.stderr)
`;
const output = execSync(`python3 -c '${pythonCode.replace(/'/g, "'\"'\"'")}'`, {
encoding: 'utf-8',
timeout: 10000,
stdio: ['pipe', 'pipe', 'pipe'],
});
const lines = output.trim().split('\n');
for (const line of lines) {
if (line.startsWith('#')) continue;
const eqIndex = line.indexOf('=');
if (eqIndex > 0) {
const key = line.substring(0, eqIndex);
let value = line.substring(eqIndex + 1);
if ((value.startsWith("'") && value.endsWith("'")) ||
(value.startsWith('"') && value.endsWith('"'))) {
value = value.slice(1, -1);
}
if (!process.env[key]) {
process.env[key] = value;
}
}
}
envLoaded = true;
} catch {
// Silently fail
}
}
function getSupabaseCredentials(): SupabaseCredentials {
loadEnv();
const url = process.env.COZE_SUPABASE_URL;
const anonKey = process.env.COZE_SUPABASE_ANON_KEY;
if (!url) {
throw new Error('COZE_SUPABASE_URL is not set');
}
if (!anonKey) {
throw new Error('COZE_SUPABASE_ANON_KEY is not set');
}
return { url, anonKey };
}
function getSupabaseClient(token?: string): SupabaseClient {
const { url, anonKey } = getSupabaseCredentials();
if (token) {
return createClient(url, anonKey, {
global: {
headers: { Authorization: `Bearer ${token}` },
},
db: {
timeout: 60000,
},
auth: {
autoRefreshToken: false,
persistSession: false,
},
});
}
return createClient(url, anonKey, {
db: {
timeout: 60000,
},
auth: {
autoRefreshToken: false,
persistSession: false,
},
});
}
export { loadEnv, getSupabaseCredentials, getSupabaseClient };

24
server/tsconfig.json Normal file
View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"noImplicitAny": false,
"strictBindCallApply": false,
"forceConsistentCasingInFileNames": false,
"noFallthroughCasesInSwitch": false,
"paths": {
"@/*": ["./src/*"]
}
}
}

39
src/app.config.ts Normal file
View File

@@ -0,0 +1,39 @@
export default defineAppConfig({
pages: [
'pages/index/index',
'pages/record/index',
'pages/profile/index'
],
window: {
backgroundTextStyle: 'dark',
navigationBarBackgroundColor: '#111827',
navigationBarTitleText: '微博签到',
navigationBarTextStyle: 'white'
},
tabBar: {
color: '#9ca3af',
selectedColor: '#2563eb',
backgroundColor: '#111827',
borderStyle: 'black',
list: [
{
pagePath: 'pages/index/index',
text: '签到',
iconPath: './assets/tabbar/star.png',
selectedIconPath: './assets/tabbar/star-active.png'
},
{
pagePath: 'pages/record/index',
text: '记录',
iconPath: './assets/tabbar/calendar.png',
selectedIconPath: './assets/tabbar/calendar-active.png'
},
{
pagePath: 'pages/profile/index',
text: '我的',
iconPath: './assets/tabbar/user.png',
selectedIconPath: './assets/tabbar/user-active.png'
}
]
}
})

52
src/app.css Normal file
View File

@@ -0,0 +1,52 @@
@import url('tailwindcss');
/*
* H5 端 rem 适配:与小程序 rpx 缩放一致
* 375px 屏幕1rem = 16px小程序 32rpx = 16px
*/
html {
font-size: 4vw !important;
}
/* 小程序页面容器高度设置,确保垂直居中生效 */
/* stylelint-disable-next-line selector-type-no-unknown */
page {
height: 100%;
}
/* H5 端组件默认样式修复 */
taro-view-core {
display: block;
}
taro-text-core {
display: inline;
}
taro-input-core {
display: block;
width: 100%;
}
taro-input-core input {
width: 100%;
background: transparent;
border: none;
outline: none;
}
/* 全局按钮样式重置 */
taro-button-core,
button {
margin: 0 !important;
padding: 0 !important;
line-height: inherit;
display: flex;
align-items: center;
justify-content: center;
}
taro-button-core::after,
button::after {
border: none;
}

9
src/app.tsx Normal file
View File

@@ -0,0 +1,9 @@
import { PropsWithChildren } from 'react';
import '@/app.css';
import { Preset } from './presets';
const App = ({ children }: PropsWithChildren) => {
return <Preset>{children}</Preset>;
};
export default App;

Binary file not shown.

After

Width:  |  Height:  |  Size: 942 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 933 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

BIN
src/assets/tabbar/star.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
src/assets/tabbar/user.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

39
src/index.html Normal file
View File

@@ -0,0 +1,39 @@
<!DOCTYPE html>
<html>
<head>
<meta content="text/html; charset=utf-8" http-equiv="Content-Type">
<meta content="width=device-width,initial-scale=1,user-scalable=no" name="viewport">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-touch-fullscreen" content="yes">
<meta name="format-detection" content="telephone=no,address=no">
<meta name="apple-mobile-web-app-status-bar-style" content="white">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<title>coze-mini-program</title>
<script><%= htmlWebpackPlugin.options.script %></script>
</head>
<body>
<div id="app">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="lab(2.86037 0.455312 0.568903)"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="
position: fixed;
inset: 0;
margin: auto;
width: 24px;
height: 24px;
animation: __app-loading-spin 1s linear infinite;
">
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
<style>
@keyframes __app-loading-spin {
to {
transform: rotate(360deg);
}
}
</style>
</div>
</body>
</html>

39
src/network.ts Normal file
View File

@@ -0,0 +1,39 @@
import Taro from '@tarojs/taro'
/**
* 网络请求模块
* 封装 Taro.request、Taro.uploadFile、Taro.downloadFile自动添加项目域名前缀
* 如果请求的 url 以 http:// 或 https:// 开头,则不会添加域名前缀
*
* IMPORTANT: 项目已经全局注入 PROJECT_DOMAIN
* IMPORTANT: 除非你需要添加全局参数,如给所有请求加上 header否则不能修改此文件
*/
export namespace Network {
const createUrl = (url: string): string => {
if (url.startsWith('http://') || url.startsWith('https://')) {
return url
}
return `${PROJECT_DOMAIN}${url}`
}
export const request: typeof Taro.request = option => {
return Taro.request({
...option,
url: createUrl(option.url),
})
}
export const uploadFile: typeof Taro.uploadFile = option => {
return Taro.uploadFile({
...option,
url: createUrl(option.url),
})
}
export const downloadFile: typeof Taro.downloadFile = option => {
return Taro.downloadFile({
...option,
url: createUrl(option.url),
})
}
}

View File

@@ -0,0 +1,11 @@
export default typeof definePageConfig === 'function'
? definePageConfig({
navigationBarTitleText: '微博签到',
navigationBarBackgroundColor: '#111827',
navigationBarTextStyle: 'white'
})
: {
navigationBarTitleText: '微博签到',
navigationBarBackgroundColor: '#111827',
navigationBarTextStyle: 'white'
}

View File

@@ -0,0 +1 @@
/* 优先使用 tailwindcss如无必要请不要使用css */

122
src/pages/index/index.tsx Normal file
View File

@@ -0,0 +1,122 @@
import { View, Text } from '@tarojs/components'
import { useDidShow } from '@tarojs/taro'
import { FC, useState } from 'react'
import { Network } from '@/network'
import './index.css'
interface SignInStatus {
todaySignedIn: boolean
continuousDays: number
totalDays: number
}
/**
* 签到主页面
*/
const IndexPage: FC = () => {
const [signInStatus, setSignInStatus] = useState<SignInStatus>({
todaySignedIn: false,
continuousDays: 0,
totalDays: 0
})
const [loading, setLoading] = useState(false)
// 页面显示时获取签到状态
useDidShow(async () => {
await fetchSignInStatus()
})
// 获取签到状态
const fetchSignInStatus = async () => {
try {
const res = await Network.request({
url: '/api/signin/status',
method: 'GET'
})
console.log('签到状态:', res.data)
if (res.data?.code === 200 && res.data?.data) {
setSignInStatus(res.data.data)
}
} catch (error) {
console.error('获取签到状态失败:', error)
}
}
// 签到
const handleSignIn = async () => {
if (loading || signInStatus.todaySignedIn) return
setLoading(true)
try {
const res = await Network.request({
url: '/api/signin',
method: 'POST'
})
console.log('签到结果:', res.data)
if (res.data?.code === 200) {
// 更新签到状态
await fetchSignInStatus()
}
} catch (error) {
console.error('签到失败:', error)
} finally {
setLoading(false)
}
}
return (
<View className="min-h-screen bg-gradient-to-b from-gray-900 via-blue-900 to-gray-900 px-4 py-6">
{/* 顶部标题 */}
<View className="text-center mb-8">
<Text className="block text-white text-3xl font-bold"></Text>
<Text className="block text-blue-300 text-sm mt-2"></Text>
</View>
{/* 统计卡片 */}
<View className="flex flex-row gap-3 mb-8">
<View className="flex-1 bg-gradient-to-br from-blue-600 to-purple-600 rounded-2xl p-4">
<Text className="block text-blue-200 text-xs"></Text>
<Text className="block text-white text-3xl font-bold mt-1">{signInStatus.continuousDays}</Text>
<Text className="block text-blue-200 text-xs mt-1"></Text>
</View>
<View className="flex-1 bg-gray-800 rounded-2xl p-4">
<Text className="block text-gray-400 text-xs"></Text>
<Text className="block text-white text-3xl font-bold mt-1">{signInStatus.totalDays}</Text>
<Text className="block text-gray-400 text-xs mt-1"></Text>
</View>
</View>
{/* 签到按钮 */}
<View className="flex justify-center items-center mb-8">
<View
className={`w-40 h-40 rounded-full flex items-center justify-center shadow-lg ${
signInStatus.todaySignedIn
? 'bg-gradient-to-br from-green-500 to-green-600 shadow-green-500/50'
: 'bg-gradient-to-br from-blue-500 to-purple-600 shadow-blue-500/50'
} ${loading ? 'opacity-50' : ''}`}
onClick={handleSignIn}
>
{loading ? (
<Text className="text-white text-xl font-bold">...</Text>
) : signInStatus.todaySignedIn ? (
<Text className="text-white text-xl font-bold"></Text>
) : (
<Text className="text-white text-2xl font-bold"></Text>
)}
</View>
</View>
{/* 提示文字 */}
<View className="text-center">
{signInStatus.todaySignedIn ? (
<Text className="block text-green-400 text-sm"></Text>
) : (
<Text className="block text-yellow-400 text-sm"></Text>
)}
</View>
</View>
)
}
export default IndexPage

View File

@@ -0,0 +1,11 @@
export default typeof definePageConfig === 'function'
? definePageConfig({
navigationBarTitleText: '我的',
navigationBarBackgroundColor: '#111827',
navigationBarTextStyle: 'white'
})
: {
navigationBarTitleText: '我的',
navigationBarBackgroundColor: '#111827',
navigationBarTextStyle: 'white'
}

View File

@@ -0,0 +1 @@
/* 个人中心页面样式 */

105
src/pages/profile/index.tsx Normal file
View File

@@ -0,0 +1,105 @@
import { View, Text } from '@tarojs/components'
import { useDidShow } from '@tarojs/taro'
import { FC, useState } from 'react'
import { Network } from '@/network'
import './index.css'
interface SignInStatus {
todaySignedIn: boolean
continuousDays: number
totalDays: number
}
/**
* 个人中心页面
*/
const ProfilePage: FC = () => {
const [signInStatus, setSignInStatus] = useState<SignInStatus>({
todaySignedIn: false,
continuousDays: 0,
totalDays: 0
})
// 页面显示时获取签到统计
useDidShow(async () => {
await fetchSignInStatus()
})
// 获取签到状态
const fetchSignInStatus = async () => {
try {
const res = await Network.request({
url: '/api/signin/status',
method: 'GET'
})
console.log('签到状态:', res.data)
if (res.data?.code === 200 && res.data?.data) {
setSignInStatus(res.data.data)
}
} catch (error) {
console.error('获取签到状态失败:', error)
}
}
return (
<View className="min-h-screen bg-gray-900 px-4 py-6">
{/* 用户信息卡片 */}
<View className="bg-gradient-to-br from-blue-600 to-purple-600 rounded-2xl p-6 mb-6">
<View className="flex flex-row items-center gap-4 mb-4">
<View className="w-16 h-16 rounded-full bg-white bg-opacity-20 flex items-center justify-center">
<Text className="text-white text-2xl">👤</Text>
</View>
<View className="flex-1">
<Text className="block text-white text-xl font-bold"></Text>
<Text className="block text-blue-200 text-sm mt-1">ID: default_user</Text>
</View>
</View>
</View>
{/* 签到统计 */}
<View className="mb-6">
<Text className="block text-white text-lg font-bold mb-4"></Text>
<View className="flex flex-row gap-3">
<View className="flex-1 bg-gray-800 rounded-2xl p-4">
<Text className="block text-gray-400 text-xs"></Text>
<Text className="block text-white text-3xl font-bold mt-1">{signInStatus.continuousDays}</Text>
<Text className="block text-gray-400 text-xs mt-1"></Text>
</View>
<View className="flex-1 bg-gray-800 rounded-2xl p-4">
<Text className="block text-gray-400 text-xs"></Text>
<Text className="block text-white text-3xl font-bold mt-1">{signInStatus.totalDays}</Text>
<Text className="block text-gray-400 text-xs mt-1"></Text>
</View>
</View>
</View>
{/* 今日状态 */}
<View className="bg-gray-800 rounded-2xl p-4 mb-6">
<View className="flex flex-row items-center justify-between">
<View>
<Text className="block text-white text-base font-semibold"></Text>
<Text className="block text-gray-400 text-sm mt-1">
{signInStatus.todaySignedIn ? '已完成' : '未完成'}
</Text>
</View>
<View
className={`w-12 h-12 rounded-full flex items-center justify-center ${
signInStatus.todaySignedIn ? 'bg-green-500' : 'bg-gray-700'
}`}
>
<Text className="text-white text-xl">{signInStatus.todaySignedIn ? '✓' : '○'}</Text>
</View>
</View>
</View>
{/* 提示信息 */}
<View className="bg-gray-800 rounded-2xl p-4">
<Text className="block text-gray-400 text-sm leading-relaxed">
</Text>
</View>
</View>
)
}
export default ProfilePage

View File

@@ -0,0 +1,11 @@
export default typeof definePageConfig === 'function'
? definePageConfig({
navigationBarTitleText: '签到记录',
navigationBarBackgroundColor: '#111827',
navigationBarTextStyle: 'white'
})
: {
navigationBarTitleText: '签到记录',
navigationBarBackgroundColor: '#111827',
navigationBarTextStyle: 'white'
}

View File

@@ -0,0 +1 @@
/* 签到记录页面样式 */

170
src/pages/record/index.tsx Normal file
View File

@@ -0,0 +1,170 @@
import { View, Text } from '@tarojs/components'
import { useDidShow } from '@tarojs/taro'
import { FC, useState } from 'react'
import { Network } from '@/network'
import './index.css'
interface SignInRecord {
id: number
user_id: string
sign_date: string
created_at: string
}
/**
* 签到记录页面
*/
const RecordPage: FC = () => {
const [records, setRecords] = useState<SignInRecord[]>([])
const [loading, setLoading] = useState(true)
// 页面显示时获取签到记录
useDidShow(async () => {
await fetchRecords()
})
// 获取签到记录
const fetchRecords = async () => {
try {
setLoading(true)
const res = await Network.request({
url: '/api/signin/history',
method: 'GET'
})
console.log('签到记录:', res.data)
if (res.data?.code === 200 && res.data?.data) {
setRecords(res.data.data)
}
} catch (error) {
console.error('获取签到记录失败:', error)
} finally {
setLoading(false)
}
}
// 格式化日期显示
const formatDate = (dateStr: string) => {
const date = new Date(dateStr)
const year = date.getFullYear()
const month = date.getMonth() + 1
const day = date.getDate()
const weekDays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
const weekDay = weekDays[date.getDay()]
return `${year}${month}${day}${weekDay}`
}
// 获取当前月份的天数
const getCurrentMonthDays = () => {
const now = new Date()
const year = now.getFullYear()
const month = now.getMonth()
const firstDay = new Date(year, month, 1)
const lastDay = new Date(year, month + 1, 0)
const days: { date: Date; isSigned: boolean }[] = []
// 填充前面的空白
for (let i = 0; i < firstDay.getDay(); i++) {
days.push({ date: null as any, isSigned: false })
}
// 填充日期
for (let i = 1; i <= lastDay.getDate(); i++) {
const date = new Date(year, month, i)
const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(i).padStart(2, '0')}`
const isSigned = records.some(r => r.sign_date === dateStr)
days.push({ date, isSigned })
}
return days
}
const monthDays = getCurrentMonthDays()
const now = new Date()
const monthNames = ['一月', '二月', '三月', '四月', '五月', '六月', '七月', '八月', '九月', '十月', '十一月', '十二月']
return (
<View className="min-h-screen bg-gray-900 px-4 py-6">
{/* 月份标题 */}
<View className="mb-6">
<Text className="block text-white text-xl font-bold">{monthNames[now.getMonth()]} {now.getFullYear()}</Text>
</View>
{/* 日历视图 */}
<View className="bg-gray-800 rounded-2xl p-4 mb-6">
{/* 星期标题 */}
<View className="flex flex-row mb-3">
{['日', '一', '二', '三', '四', '五', '六'].map((day, index) => (
<View key={index} className="flex-1 flex justify-center">
<Text className="text-gray-400 text-sm">{day}</Text>
</View>
))}
</View>
{/* 日期网格 */}
<View className="flex flex-row flex-wrap">
{monthDays.map((item, index) => (
<View key={index} className="w-[14.28%] aspect-square flex justify-center items-center mb-1">
{item.date ? (
<View
className={`w-8 h-8 rounded-full flex items-center justify-center ${
item.isSigned
? 'bg-blue-500'
: item.date.toDateString() === now.toDateString()
? 'bg-gray-700 border border-blue-400'
: 'bg-transparent'
}`}
>
<Text
className={`text-sm ${
item.isSigned
? 'text-white'
: item.date.toDateString() === now.toDateString()
? 'text-blue-400'
: 'text-gray-400'
}`}
>
{item.date.getDate()}
</Text>
</View>
) : (
<View />
)}
</View>
))}
</View>
</View>
{/* 签到记录列表 */}
<View className="bg-gray-800 rounded-2xl p-4">
<Text className="block text-white text-lg font-bold mb-4"></Text>
{loading ? (
<View className="flex justify-center py-8">
<Text className="text-gray-500">...</Text>
</View>
) : records.length === 0 ? (
<View className="flex flex-col items-center justify-center py-8">
<Text className="text-gray-500 text-base"></Text>
<Text className="text-gray-600 text-sm mt-2"></Text>
</View>
) : (
<View className="flex flex-col gap-3">
{records.map((record) => (
<View key={record.id} className="flex flex-row items-center gap-3 py-3 border-b border-gray-700 last:border-b-0">
<View className="w-2 h-2 rounded-full bg-blue-500" />
<View className="flex-1">
<Text className="block text-white text-sm">{formatDate(record.sign_date)}</Text>
</View>
<View className="bg-blue-500 rounded-full px-3 py-1">
<Text className="text-white text-xs"></Text>
</View>
</View>
))}
</View>
)}
</View>
</View>
)
}
export default RecordPage

23
src/presets/dev-debug.ts Normal file
View File

@@ -0,0 +1,23 @@
import Taro from '@tarojs/taro';
/**
* 小程序调试工具
* 在开发版/体验版自动开启调试模式
* 支持微信小程序和抖音小程序
*/
export function devDebug() {
const env = Taro.getEnv();
if (env === Taro.ENV_TYPE.WEAPP || env === Taro.ENV_TYPE.TT) {
try {
const accountInfo = Taro.getAccountInfoSync();
const envVersion = accountInfo.miniProgram.envVersion;
console.log('[Debug] envVersion:', envVersion);
if (envVersion !== 'release') {
Taro.setEnableDebug({ enableDebug: true });
}
} catch (error) {
console.error('[Debug] 开启调试模式失败:', error);
}
}
}

View File

@@ -0,0 +1,15 @@
import { PropsWithChildren } from 'react';
import { H5NavBar } from './h5-navbar';
export const H5Container = ({ children }: PropsWithChildren) => {
if (TARO_ENV !== 'h5') {
return <>{children}</>;
}
return (
<>
<H5NavBar />
{children}
</>
);
};

201
src/presets/h5-navbar.tsx Normal file
View File

@@ -0,0 +1,201 @@
import { View, Text } from '@tarojs/components';
import Taro, { useDidShow, usePageScroll } from '@tarojs/taro';
import { useState, useEffect, useCallback } from 'react';
import { ChevronLeft, House } from 'lucide-react-taro';
interface NavConfig {
navigationBarTitleText?: string;
navigationBarBackgroundColor?: string;
navigationBarTextStyle?: 'black' | 'white';
navigationStyle?: 'default' | 'custom';
transparentTitle?: 'none' | 'always' | 'auto';
}
enum LeftIcon {
Back = 'back',
Home = 'home',
None = 'none',
}
interface NavState {
visible: boolean;
title: string;
bgColor: string;
textStyle: 'black' | 'white';
navStyle: 'default' | 'custom';
transparent: 'none' | 'always' | 'auto';
leftIcon: LeftIcon;
}
const DEFAULT_NAV_STATE: NavState = {
visible: false,
title: '',
bgColor: '#ffffff',
textStyle: 'black',
navStyle: 'default',
transparent: 'none',
leftIcon: LeftIcon.None,
};
const getGlobalWindowConfig = (): Partial<NavConfig> => {
const app = Taro.getApp();
return app?.config?.window || {};
};
const getTabBarPages = (): Set<string> => {
const tabBar = Taro.getApp()?.config?.tabBar;
return new Set(
tabBar?.list?.map((item: { pagePath: string }) => item.pagePath) || [],
);
};
const computeLeftIcon = (
route: string,
tabBarPages: Set<string>,
historyLength: number,
): LeftIcon => {
if (!route) return LeftIcon.None;
const isHomePage =
route === 'pages/index/index' || route === '/pages/index/index';
const isTabBarPage = tabBarPages.has(route);
const hasHistory = historyLength > 1;
if (isTabBarPage || isHomePage) return LeftIcon.None;
if (hasHistory) return LeftIcon.Back;
return LeftIcon.Home;
};
export const H5NavBar = () => {
const [navState, setNavState] = useState<NavState>(DEFAULT_NAV_STATE);
const [scrollOpacity, setScrollOpacity] = useState(0);
const updateNavState = useCallback(() => {
const pages = Taro.getCurrentPages();
const currentPage = pages[pages.length - 1];
const route = currentPage?.route || '';
const pageConfig: NavConfig = (currentPage as any)?.config || {};
const globalConfig = getGlobalWindowConfig();
const tabBarPages = getTabBarPages();
const isHomePage =
route === 'pages/index/index' || route === '/pages/index/index';
const isTabBarPage = tabBarPages.has(route);
const shouldHideNav =
tabBarPages.size <= 1 &&
pages.length <= 1 &&
(isHomePage || isTabBarPage);
setNavState({
visible: !shouldHideNav,
title:
pageConfig.navigationBarTitleText ||
globalConfig.navigationBarTitleText ||
'',
bgColor:
pageConfig.navigationBarBackgroundColor ||
globalConfig.navigationBarBackgroundColor ||
'#ffffff',
textStyle:
pageConfig.navigationBarTextStyle ||
globalConfig.navigationBarTextStyle ||
'black',
navStyle:
pageConfig.navigationStyle || globalConfig.navigationStyle || 'default',
transparent:
pageConfig.transparentTitle || globalConfig.transparentTitle || 'none',
leftIcon: shouldHideNav
? LeftIcon.None
: computeLeftIcon(route, tabBarPages, pages.length),
});
}, []);
useDidShow(() => {
updateNavState();
});
usePageScroll(({ scrollTop }) => {
if (navState.transparent === 'auto') {
setScrollOpacity(Math.min(scrollTop / 100, 1));
}
});
useEffect(() => {
if (TARO_ENV !== 'h5') return;
const titleEl = document.querySelector('title') || document.head;
const observer = new MutationObserver(() => updateNavState());
observer.observe(titleEl, {
subtree: true,
childList: true,
characterData: true,
});
return () => observer.disconnect();
}, [updateNavState]);
const shouldRender =
TARO_ENV === 'h5' && navState.visible && navState.navStyle !== 'custom';
useEffect(() => {
if (TARO_ENV !== 'h5') return;
if (shouldRender) {
document.body.classList.add('h5-navbar-visible');
} else {
document.body.classList.remove('h5-navbar-visible');
}
}, [shouldRender]);
if (!shouldRender) {
return <></>;
}
const iconColor = navState.textStyle === 'white' ? '#fff' : '#333';
const textColorClass =
navState.textStyle === 'white' ? 'text-white' : 'text-gray-800';
const getBgStyle = () => {
if (navState.transparent === 'always') {
return { backgroundColor: 'transparent' };
}
if (navState.transparent === 'auto') {
return { backgroundColor: navState.bgColor, opacity: scrollOpacity };
}
return { backgroundColor: navState.bgColor };
};
const handleBack = () => Taro.navigateBack();
const handleGoHome = () => Taro.reLaunch({ url: '/pages/index/index' });
return (
<>
<View
className="fixed top-0 left-0 right-0 h-11 flex items-center justify-center z-1000"
style={getBgStyle()}
>
{navState.leftIcon === LeftIcon.Back && (
<View
className="absolute left-2 top-1/2 -translate-y-1/2 p-1 flex items-center justify-center"
onClick={handleBack}
>
<ChevronLeft size={24} color={iconColor} />
</View>
)}
{navState.leftIcon === LeftIcon.Home && (
<View
className="absolute left-2 top-1/2 -translate-y-1/2 p-1 flex items-center justify-center"
onClick={handleGoHome}
>
<House size={22} color={iconColor} />
</View>
)}
<Text
className={`text-base font-medium max-w-3/5 truncate ${textColorClass}`}
>
{navState.title}
</Text>
</View>
<View className="h-11 shrink-0" />
</>
);
};

142
src/presets/h5-styles.ts Normal file
View File

@@ -0,0 +1,142 @@
/**
* H5 端特殊样式注入
* 如无必要,请勿修改本文件
*/
const H5_BASE_STYLES = `
/* H5 端隐藏 TabBar 空图标(只隐藏没有 src 的图标) */
.weui-tabbar__icon:not([src]),
.weui-tabbar__icon[src=''] {
display: none !important;
}
.weui-tabbar__item:has(.weui-tabbar__icon:not([src])) .weui-tabbar__label,
.weui-tabbar__item:has(.weui-tabbar__icon[src='']) .weui-tabbar__label {
margin-top: 0 !important;
}
/* Vite 错误覆盖层无法选择文本的问题 */
vite-error-overlay {
/* stylelint-disable-next-line property-no-vendor-prefix */
-webkit-user-select: text !important;
}
vite-error-overlay::part(window) {
max-width: 90vw;
padding: 10px;
}
.taro_page {
overflow: auto;
}
::-webkit-scrollbar {
width: 4px;
height: 4px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.2);
border-radius: 2px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.3);
}
/* H5 导航栏页面自动添加顶部间距 */
body.h5-navbar-visible .taro_page {
padding-top: 44px;
}
`;
const PC_WIDESCREEN_STYLES = `
/* PC 宽屏适配 - 基础布局 */
@media (min-width: 769px) {
html {
font-size: 15px !important;
}
body {
background-color: #f3f4f6 !important;
display: flex !important;
justify-content: center !important;
align-items: center !important;
min-height: 100vh !important;
}
}
`;
const PC_WIDESCREEN_PHONE_FRAME = `
/* PC 宽屏适配 - 手机框样式(有 TabBar 页面) */
@media (min-width: 769px) {
.taro-tabbar__container {
width: 375px !important;
max-width: 375px !important;
height: calc(100vh - 40px) !important;
max-height: 900px !important;
background-color: #fff !important;
transform: translateX(0) !important;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.1) !important;
border-radius: 20px !important;
overflow: hidden !important;
position: relative !important;
}
.taro-tabbar__panel {
height: 100% !important;
overflow: auto !important;
}
}
/* PC 宽屏适配 - 手机框样式(无 TabBar 页面,通过 JS 添加 no-tabbar 类) */
@media (min-width: 769px) {
body.no-tabbar #app {
width: 375px !important;
max-width: 375px !important;
height: calc(100vh - 40px) !important;
max-height: 900px !important;
background-color: #fff !important;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.1) !important;
border-radius: 20px !important;
overflow: hidden !important;
position: relative !important;
transform: translateX(0) !important;
}
body.no-tabbar #app .taro_router {
height: 100% !important;
overflow: auto !important;
}
}
`;
function injectStyles() {
const style = document.createElement('style');
style.innerHTML =
H5_BASE_STYLES + PC_WIDESCREEN_STYLES + PC_WIDESCREEN_PHONE_FRAME;
document.head.appendChild(style);
}
function setupTabbarDetection() {
const checkTabbar = () => {
const hasTabbar = !!document.querySelector('.taro-tabbar__container');
document.body.classList.toggle('no-tabbar', !hasTabbar);
};
checkTabbar();
const observer = new MutationObserver(checkTabbar);
observer.observe(document.body, { childList: true, subtree: true });
}
export function injectH5Styles() {
if (TARO_ENV !== 'h5') return;
injectStyles();
setupTabbarDetection();
}

18
src/presets/index.tsx Normal file
View File

@@ -0,0 +1,18 @@
import { useLaunch } from '@tarojs/taro';
import { PropsWithChildren } from 'react';
import { injectH5Styles } from './h5-styles';
import { devDebug } from './dev-debug';
import { H5Container } from './h5-container';
export const Preset = ({ children }: PropsWithChildren) => {
useLaunch(() => {
devDebug();
injectH5Styles();
});
if (TARO_ENV === 'h5') {
return <H5Container>{children}</H5Container>;
}
return <>{children}</>;
};

4
stylelint.config.mjs Normal file
View File

@@ -0,0 +1,4 @@
/** @type {import('stylelint').Config} */
export default {
extends: "stylelint-config-standard",
};

29
tsconfig.json Normal file
View File

@@ -0,0 +1,29 @@
{
"compilerOptions": {
"target": "es2017",
"module": "ESNext",
"removeComments": false,
"preserveConstEnums": true,
"moduleResolution": "bundler",
"experimentalDecorators": true,
"noImplicitAny": false,
"allowSyntheticDefaultImports": true,
"outDir": "lib",
"noUnusedLocals": true,
"noUnusedParameters": true,
"strictNullChecks": true,
"sourceMap": true,
"rootDir": ".",
"jsx": "react-jsx",
"allowJs": true,
"resolveJsonModule": true,
"typeRoots": [
"node_modules/@types"
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["./src", "./types", "./config"],
"compileOnSave": false
}

32
types/global.d.ts vendored Normal file
View File

@@ -0,0 +1,32 @@
/// <reference types="@tarojs/taro" />
declare const PROJECT_DOMAIN: string | undefined;
declare const TARO_ENV: "weapp" | "h5" | undefined;
declare module '*.png';
declare module '*.gif';
declare module '*.jpg';
declare module '*.jpeg';
declare module '*.svg';
declare module '*.css';
declare module '*.less';
declare module '*.scss';
declare module '*.sass';
declare module '*.styl';
declare namespace NodeJS {
interface ProcessEnv {
/** NODE 内置环境变量, 会影响到最终构建生成产物 */
NODE_ENV: 'development' | 'production',
/** 当前构建的平台 */
TARO_ENV: 'weapp' | 'swan' | 'alipay' | 'h5' | 'rn' | 'tt' | 'qq' | 'jd' | 'harmony' | 'jdrn'
/**
* 当前构建的小程序 appid
* @description 若不同环境有不同的小程序,可通过在 env 文件中配置环境变量`TARO_APP_ID`来方便快速切换 appid 而不必手动去修改 dist/project.config.json 文件
* @see https://taro-docs.jd.com/docs/next/env-mode-config#特殊环境变量-taro_app_id
*/
TARO_APP_ID: string
}
}