feat: 实现微博签到小程序功能
- 实现签到主页面,包含签到按钮、连续天数、今日状态展示 - 实现签到记录页面,包含日历视图和签到历史列表 - 实现个人中心页面,包含用户信息和签到统计 - 后端实现签到、查询状态、查询历史三个接口 - 使用 Supabase 存储签到记录数据 - 采用星空主题设计,深蓝紫渐变背景 + 金色星光强调色 - 完成所有接口测试和前后端匹配验证 - 通过 ESLint 检查和编译验证
This commit is contained in:
14
.coze
Normal file
14
.coze
Normal 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
|
||||
19
.cozeproj/scripts/deploy_build.sh
Normal file
19
.cozeproj/scripts/deploy_build.sh
Normal 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"
|
||||
14
.cozeproj/scripts/deploy_run.sh
Normal file
14
.cozeproj/scripts/deploy_run.sh
Normal 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
|
||||
2
.cozeproj/scripts/dev_build.sh
Normal file
2
.cozeproj/scripts/dev_build.sh
Normal file
@@ -0,0 +1,2 @@
|
||||
#!/bin/bash
|
||||
set -Eeuo pipefail
|
||||
151
.cozeproj/scripts/dev_run.sh
Normal file
151
.cozeproj/scripts/dev_run.sh
Normal 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
|
||||
5
.cozeproj/scripts/init_env.sh
Normal file
5
.cozeproj/scripts/init_env.sh
Normal file
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
|
||||
cd "${COZE_WORKSPACE_PATH}"
|
||||
|
||||
echo "✅ 初始化完成"
|
||||
24
.cozeproj/scripts/pack.sh
Normal file
24
.cozeproj/scripts/pack.sh
Normal 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
40
.gitignore
vendored
Normal 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
18
.npmrc
Normal 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
751
README.md
Normal 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` 中使用生成的图标:
|
||||
|
||||
> IMPORTANT:iconPath 和 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
12
babel.config.js
Normal 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
9
config/dev.ts
Normal 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
223
config/index.ts
Normal 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
34
config/prod.ts
Normal 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
221
design_guidelines.md
Normal 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
80
eslint.config.mjs
Normal 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
0
key/private.appid.key
Normal file
107
package.json
Normal file
107
package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
30
patches/@tarojs__plugin-mini-ci@4.1.9.patch
Normal file
30
patches/@tarojs__plugin-mini-ci@4.1.9.patch
Normal 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
23100
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
2
pnpm-workspace.yaml
Normal file
2
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,2 @@
|
||||
packages:
|
||||
- 'server'
|
||||
15
project.config.json
Normal file
15
project.config.json
Normal 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
10
server/nest-cli.json
Normal 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
40
server/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
23
server/src/app.controller.ts
Normal file
23
server/src/app.controller.ts
Normal 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
11
server/src/app.module.ts
Normal 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 {}
|
||||
8
server/src/app.service.ts
Normal file
8
server/src/app.service.ts
Normal 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!';
|
||||
}
|
||||
}
|
||||
23
server/src/interceptors/http-status.interceptor.ts
Normal file
23
server/src/interceptors/http-status.interceptor.ts
Normal 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
49
server/src/main.ts
Normal 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();
|
||||
41
server/src/signin/signin.controller.ts
Normal file
41
server/src/signin/signin.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
10
server/src/signin/signin.module.ts
Normal file
10
server/src/signin/signin.module.ts
Normal 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 {}
|
||||
148
server/src/signin/signin.service.ts
Normal file
148
server/src/signin/signin.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
3
server/src/storage/database/shared/relations.ts
Normal file
3
server/src/storage/database/shared/relations.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { relations } from "drizzle-orm/relations";
|
||||
import { } from "./schema";
|
||||
|
||||
24
server/src/storage/database/shared/schema.ts
Normal file
24
server/src/storage/database/shared/schema.ts
Normal 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),
|
||||
]
|
||||
);
|
||||
115
server/src/storage/database/supabase-client.ts
Executable file
115
server/src/storage/database/supabase-client.ts
Executable 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
24
server/tsconfig.json
Normal 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
39
src/app.config.ts
Normal 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
52
src/app.css
Normal 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
9
src/app.tsx
Normal 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;
|
||||
BIN
src/assets/tabbar/calendar-active.png
Normal file
BIN
src/assets/tabbar/calendar-active.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 942 B |
BIN
src/assets/tabbar/calendar.png
Normal file
BIN
src/assets/tabbar/calendar.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 933 B |
BIN
src/assets/tabbar/star-active.png
Normal file
BIN
src/assets/tabbar/star-active.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
BIN
src/assets/tabbar/star.png
Normal file
BIN
src/assets/tabbar/star.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
BIN
src/assets/tabbar/user-active.png
Normal file
BIN
src/assets/tabbar/user-active.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
BIN
src/assets/tabbar/user.png
Normal file
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
39
src/index.html
Normal 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
39
src/network.ts
Normal 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),
|
||||
})
|
||||
}
|
||||
}
|
||||
11
src/pages/index/index.config.ts
Normal file
11
src/pages/index/index.config.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export default typeof definePageConfig === 'function'
|
||||
? definePageConfig({
|
||||
navigationBarTitleText: '微博签到',
|
||||
navigationBarBackgroundColor: '#111827',
|
||||
navigationBarTextStyle: 'white'
|
||||
})
|
||||
: {
|
||||
navigationBarTitleText: '微博签到',
|
||||
navigationBarBackgroundColor: '#111827',
|
||||
navigationBarTextStyle: 'white'
|
||||
}
|
||||
1
src/pages/index/index.css
Normal file
1
src/pages/index/index.css
Normal file
@@ -0,0 +1 @@
|
||||
/* 优先使用 tailwindcss,如无必要请不要使用css */
|
||||
122
src/pages/index/index.tsx
Normal file
122
src/pages/index/index.tsx
Normal 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
|
||||
11
src/pages/profile/index.config.ts
Normal file
11
src/pages/profile/index.config.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export default typeof definePageConfig === 'function'
|
||||
? definePageConfig({
|
||||
navigationBarTitleText: '我的',
|
||||
navigationBarBackgroundColor: '#111827',
|
||||
navigationBarTextStyle: 'white'
|
||||
})
|
||||
: {
|
||||
navigationBarTitleText: '我的',
|
||||
navigationBarBackgroundColor: '#111827',
|
||||
navigationBarTextStyle: 'white'
|
||||
}
|
||||
1
src/pages/profile/index.css
Normal file
1
src/pages/profile/index.css
Normal file
@@ -0,0 +1 @@
|
||||
/* 个人中心页面样式 */
|
||||
105
src/pages/profile/index.tsx
Normal file
105
src/pages/profile/index.tsx
Normal 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
|
||||
11
src/pages/record/index.config.ts
Normal file
11
src/pages/record/index.config.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export default typeof definePageConfig === 'function'
|
||||
? definePageConfig({
|
||||
navigationBarTitleText: '签到记录',
|
||||
navigationBarBackgroundColor: '#111827',
|
||||
navigationBarTextStyle: 'white'
|
||||
})
|
||||
: {
|
||||
navigationBarTitleText: '签到记录',
|
||||
navigationBarBackgroundColor: '#111827',
|
||||
navigationBarTextStyle: 'white'
|
||||
}
|
||||
1
src/pages/record/index.css
Normal file
1
src/pages/record/index.css
Normal file
@@ -0,0 +1 @@
|
||||
/* 签到记录页面样式 */
|
||||
170
src/pages/record/index.tsx
Normal file
170
src/pages/record/index.tsx
Normal 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
23
src/presets/dev-debug.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
15
src/presets/h5-container.tsx
Normal file
15
src/presets/h5-container.tsx
Normal 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
201
src/presets/h5-navbar.tsx
Normal 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
142
src/presets/h5-styles.ts
Normal 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
18
src/presets/index.tsx
Normal 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
4
stylelint.config.mjs
Normal file
@@ -0,0 +1,4 @@
|
||||
/** @type {import('stylelint').Config} */
|
||||
export default {
|
||||
extends: "stylelint-config-standard",
|
||||
};
|
||||
29
tsconfig.json
Normal file
29
tsconfig.json
Normal 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
32
types/global.d.ts
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user