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

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

View File

@@ -1,61 +1,61 @@
# 微博签到小程序设计指南 # 微博超话签到小程序设计指南
## 品牌定位 ## 品牌定位
**应用定位**:微博签到打卡工具,帮助用户养成持续签到的习惯 **应用定位**:微博超话签到工具,帮助用户快速完成关注的超话签到
**设计风格**星空主题、仪式感、成就感 **设计风格**简洁高效、微博风格、橙色调
**目标用户**:微博用户,希望通过签到获得奖励的用户 **目标用户**:微博用户,需要每日签到多个超话的用户
**核心意象**点亮星空——每次签到都是点亮夜空中的一颗星,连续签到让星空更加璀璨 **核心功能**
- 获取用户关注的超话列表
- 一键批量签到所有超话
- 查看签到状态和历史记录
--- ---
## 配色方案 ## 配色方案
### 主色板 ### 主色板(微博风格)
| 颜色名称 | Tailwind 类名 | 色值 | 意象来源 | | 颜色名称 | Tailwind 类名 | 色值 | 用途 |
|---------|--------------|------|---------| |---------|--------------|------|------|
| 星空蓝(主色) | `bg-blue-600` | #2563eb | 深夜星空的底色 | | 微博橙(主色) | `bg-orange-500` | #f97316 | 主按钮、强调元素 |
| 星光金(强调色) | `text-yellow-400` | #facc15 | 星星的光芒 | | 微博橙深 | `bg-orange-600` | #ea580c | 按钮hover态 |
| 月光蓝(辅助色) | `text-blue-300` | #93c5fd | 柔和的月光 | | 橙色浅 | `text-orange-100` | #ffedd5 | 浅色背景 |
### 中性色 ### 中性色
| 颜色名称 | Tailwind 类名 | 色值 | 用途 | | 颜色名称 | Tailwind 类名 | 色值 | 用途 |
|---------|--------------|------|------| |---------|--------------|------|------|
| 深空灰 | `bg-gray-900` | #111827 | 页面背景 | | 白色背景 | `bg-white` | #ffffff | 页面背景 |
| 夜空灰 | `bg-gray-800` | #1f2937 | 卡片背景 | | 浅灰背景 | `bg-gray-50` | #f9fafb | 卡片背景 |
| 星云灰 | `bg-gray-700` | #374151 | 次级背景 | | 边框灰 | `border-gray-200` | #e5e7eb | 分割线 |
| 银河白 | `text-white` | #ffffff | 主要文字 | | 主文字 | `text-gray-900` | #111827 | 主要文字 |
| 星尘灰 | `text-gray-400` | #9ca3af | 次要文字 | | 次文字 | `text-gray-600` | #4b5563 | 次要文字 |
| 辅助文字 | `text-gray-400` | #9ca3af | 说明文字 |
### 语义色 ### 语义色
| 状态 | Tailwind 类名 | 色值 | | 状态 | Tailwind 类名 | 色值 |
|-----|--------------|------| |-----|--------------|------|
| 成功/已签到 | `text-green-400` | #4ade80 | | 成功/已签到 | `text-green-500` | #22c55e |
| 警告/未签到 | `text-orange-400` | #fb923c | | 失败/未签到 | `text-red-500` | #ef4444 |
| 信息/提示 | `text-blue-400` | #60a5fa | | 进行中 | `text-blue-500` | #3b82f6 |
| 警告 | `text-yellow-500` | #eab308 |
--- ---
## 字体规范 ## 字体规范
### 字体选择
- **标题字体**:使用系统默认字体(微信小程序限制)
- **正文字体**:使用系统默认字体
### 字体层级 ### 字体层级
| 层级 | Tailwind 类名 | 字号 | 行高 | 用途 | | 层级 | Tailwind 类名 | 字号 | 用途 |
|-----|--------------|------|------|------| |-----|--------------|------|------|
| H1 | `text-3xl font-bold` | 30px | 36px | 签到主标题 | | H1 | `text-2xl font-bold` | 24px | 页面标题 |
| H2 | `text-2xl font-bold` | 24px | 32px | 页面标题 | | H2 | `text-lg font-semibold` | 18px | 卡片标题 |
| H3 | `text-xl font-semibold` | 20px | 28px | 卡片标题 | | Body | `text-base` | 16px | 正文内容 |
| Body | `text-base` | 16px | 24px | 正文内容 | | Caption | `text-sm` | 14px | 说明文字 |
| Caption | `text-sm` | 14px | 20px | 说明文字 | | Small | `text-xs` | 12px | 辅助信息 |
--- ---
@@ -64,58 +64,74 @@
### 页面边距 ### 页面边距
- 页面左右边距:`px-4`16px - 页面左右边距:`px-4`16px
- 页面上下边距:`py-6`24px - 页面上下边距:`py-4`16px
### 组件间距 ### 组件间距
- 卡片间距:`gap-4`16px - 卡片间距:`gap-3`12px
- 列表项间距:`gap-3`12px - 列表项间距:`gap-2`8px
- 按钮组间距:`gap-2`8px - 按钮组间距:`gap-2`8px
--- ---
## 组件规范 ## 组件规范
### 1. 签到按钮(核心组件) ### 1. 超话卡片(核心组件)
```tsx ```tsx
// 主签到按钮 <View className="bg-white rounded-lg p-4 mb-3 shadow-sm border border-gray-100">
<View className="flex justify-center items-center"> <View className="flex flex-row 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> <Image className="w-12 h-12 rounded-lg" src={coverUrl} />
{/* 超话信息 */}
<View className="flex-1 ml-3">
<Text className="text-gray-900 font-semibold text-base">{name}</Text>
<Text className="text-gray-400 text-xs mt-1">{memberCount} </Text>
</View>
{/* 签到状态 */}
<View className={`px-3 py-1 rounded-full ${isSignedIn ? 'bg-green-100' : 'bg-orange-100'}`}>
<Text className={`text-xs ${isSignedIn ? 'text-green-600' : 'text-orange-600'}`}>
{isSignedIn ? '已签到' : '未签到'}
</Text>
</View>
</View> </View>
</View> </View>
```
// 已签到状态 ### 2. 一键签到按钮
<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> ```tsx
<View className="px-4">
<View className="bg-orange-500 rounded-full py-3 flex items-center justify-center">
<Text className="text-white text-base font-semibold"></Text>
</View>
</View> </View>
``` ```
### 2. 卡片容器 ### 3. 统计卡片
```tsx ```tsx
<View className="bg-gray-800 rounded-2xl p-4 shadow-lg"> <View className="flex flex-row gap-3 mb-4">
{/* 卡片内容 */} <View className="flex-1 bg-orange-50 rounded-lg p-3">
<Text className="text-gray-600 text-xs"></Text>
<Text className="text-orange-600 text-2xl font-bold">{total}</Text>
</View>
<View className="flex-1 bg-green-50 rounded-lg p-3">
<Text className="text-gray-600 text-xs"></Text>
<Text className="text-green-600 text-2xl font-bold">{signedIn}</Text>
</View>
<View className="flex-1 bg-gray-50 rounded-lg p-3">
<Text className="text-gray-600 text-xs"></Text>
<Text className="text-gray-600 text-2xl font-bold">{notSignedIn}</Text>
</View>
</View> </View>
``` ```
### 3. 列表项 ### 4. 加载状态
```tsx ```tsx
<View className="flex flex-row items-center gap-3 py-3 border-b border-gray-700"> <View className="flex justify-center py-8">
<View className="w-2 h-2 rounded-full bg-blue-500" /> <Text className="text-gray-400">...</Text>
<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> </View>
``` ```
@@ -123,8 +139,8 @@
```tsx ```tsx
<View className="flex flex-col items-center justify-center py-20"> <View className="flex flex-col items-center justify-center py-20">
<Text className="text-gray-500 text-lg"></Text> <Text className="text-gray-400 text-base"></Text>
<Text className="text-gray-600 text-sm mt-2"></Text> <Text className="text-gray-300 text-sm mt-2"></Text>
</View> </View>
``` ```
@@ -138,19 +154,19 @@
{ {
tabBar: { tabBar: {
color: '#9ca3af', color: '#9ca3af',
selectedColor: '#2563eb', selectedColor: '#f97316',
backgroundColor: '#111827', backgroundColor: '#ffffff',
borderStyle: 'black', borderStyle: 'black',
list: [ list: [
{ {
pagePath: 'pages/index/index', pagePath: 'pages/index/index',
text: '签到', text: '超话列表',
iconPath: './assets/tabbar/star.png', iconPath: './assets/tabbar/list.png',
selectedIconPath: './assets/tabbar/star-active.png' selectedIconPath: './assets/tabbar/list-active.png'
}, },
{ {
pagePath: 'pages/record/index', pagePath: 'pages/record/index',
text: '记录', text: '签到记录',
iconPath: './assets/tabbar/calendar.png', iconPath: './assets/tabbar/calendar.png',
selectedIconPath: './assets/tabbar/calendar-active.png' selectedIconPath: './assets/tabbar/calendar-active.png'
}, },
@@ -165,57 +181,86 @@
} }
``` ```
### 页面跳转规范 ---
- TabBar 页面跳转:使用 `Taro.switchTab()` ## 页面结构
- 普通页面跳转:使用 `Taro.navigateTo()`
### 1. 超话列表页(首页)
**顶部区域**
- 页面标题"微博超话签到"
- 一键签到按钮
**统计区域**
- 已关注数量
- 已签到数量
- 未签到数量
**超话列表**
- 超话封面
- 超话名称
- 成员数量
- 签到状态标签
### 2. 签到记录页
**日历视图**
- 当月日历
- 签到日期标记
**统计信息**
- 连续签到天数
- 累计签到天数
### 3. 个人中心页
**用户信息**
- 微博昵称
- 微博头像
**配置选项**
- 自动签到开关
- 签到时间设置
- 清除缓存
---
## 交互规范
### 签到流程
1. 用户点击"一键签到"
2. 显示loading状态
3. 依次调用签到接口
4. 实时更新签到状态
5. 完成后显示结果统计
### 刷新机制
- 下拉刷新超话列表
- 自动获取最新签到状态
--- ---
## 小程序约束 ## 小程序约束
### 包体积限制 ### 权限说明
- 主包大小:不超过 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
```
--- ---
## 设计禁忌 ## 设计禁忌
**禁止使用** **禁止使用**
-微博橙色默认配色 -深色背景(不符合微博风格)
-纯白色背景(不符合星空主题) -过于复杂的动画
-过于功能导向的冷冰冰设计 -与微博风格冲突的配色
-缺乏情感共鸣的界面元素 -过长的加载时间
- ❌ 复杂的动画效果(影响性能)

View File

@@ -0,0 +1,18 @@
-- Current sql file was generated after introspecting the database
-- If you want to run this migration please uncomment this code before executing migrations
/*
CREATE TABLE "sign_in_records" (
"id" serial PRIMARY KEY NOT NULL,
"user_id" varchar(128) NOT NULL,
"sign_date" date NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "health_check" (
"id" serial NOT NULL,
"updated_at" timestamp with time zone DEFAULT now()
);
--> statement-breakpoint
CREATE INDEX "sign_in_records_sign_date_idx" ON "sign_in_records" USING btree ("sign_date" date_ops);--> statement-breakpoint
CREATE INDEX "sign_in_records_user_id_idx" ON "sign_in_records" USING btree ("user_id" text_ops);
*/

View File

@@ -0,0 +1,119 @@
{
"id": "00000000-0000-0000-0000-000000000000",
"prevId": "",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.sign_in_records": {
"name": "sign_in_records",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "varchar(128)",
"primaryKey": false,
"notNull": true
},
"sign_date": {
"name": "sign_date",
"type": "date",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"sign_in_records_sign_date_idx": {
"name": "sign_in_records_sign_date_idx",
"columns": [
{
"expression": "sign_date",
"asc": true,
"nulls": "last",
"opclass": "date_ops",
"isExpression": false
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"sign_in_records_user_id_idx": {
"name": "sign_in_records_user_id_idx",
"columns": [
{
"expression": "user_id",
"asc": true,
"nulls": "last",
"opclass": "text_ops",
"isExpression": false
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {},
"policies": {},
"isRLSEnabled": false
},
"public.health_check": {
"name": "health_check",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {},
"policies": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"tables": {}
}
}

View File

@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1773633411873,
"tag": "0000_polite_annihilus",
"breakpoints": true
}
]
}

3
drizzle/relations.ts Normal file
View File

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

19
drizzle/schema.ts Normal file
View File

@@ -0,0 +1,19 @@
import { pgTable, index, serial, varchar, date, timestamp } from "drizzle-orm/pg-core"
import { sql } from "drizzle-orm"
export const signInRecords = pgTable("sign_in_records", {
id: serial().primaryKey().notNull(),
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_sign_date_idx").using("btree", table.signDate.asc().nullsLast().op("date_ops")),
index("sign_in_records_user_id_idx").using("btree", table.userId.asc().nullsLast().op("text_ops")),
]);
export const healthCheck = pgTable("health_check", {
id: serial().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true, mode: 'string' }).defaultNow(),
});

View File

@@ -1,10 +1,11 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AppController } from '@/app.controller'; import { AppController } from '@/app.controller';
import { AppService } from '@/app.service'; import { AppService } from '@/app.service';
import { SignInModule } from '@/signin/signin.module'; import { SuperTopicModule } from '@/super-topic/super-topic.module';
import { UserModule } from '@/user/user.module';
@Module({ @Module({
imports: [SignInModule], imports: [SuperTopicModule, UserModule],
controllers: [AppController], controllers: [AppController],
providers: [AppService], providers: [AppService],
}) })

View File

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

View File

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

View File

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

View File

@@ -6,19 +6,21 @@ export const healthCheck = pgTable("health_check", {
updatedAt: timestamp("updated_at", { withTimezone: true, mode: 'string' }).defaultNow(), updatedAt: timestamp("updated_at", { withTimezone: true, mode: 'string' }).defaultNow(),
}); });
// 签到记录表 // 超话签到记录表
export const signInRecords = pgTable( export const superTopicSignin = pgTable(
"sign_in_records", "super_topic_signin",
{ {
id: serial("id").primaryKey(), id: serial("id").primaryKey(),
userId: varchar("user_id", { length: 128 }).notNull(), userId: varchar("user_id", { length: 128 }).notNull(),
topicId: varchar("topic_id", { length: 128 }).notNull(),
signDate: date("sign_date").notNull(), signDate: date("sign_date").notNull(),
createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }) createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' })
.defaultNow() .defaultNow()
.notNull(), .notNull(),
}, },
(table) => [ (table) => [
index("sign_in_records_user_id_idx").on(table.userId), index("super_topic_signin_user_id_idx").on(table.userId),
index("sign_in_records_sign_date_idx").on(table.signDate), index("super_topic_signin_topic_id_idx").on(table.topicId),
index("super_topic_signin_sign_date_idx").on(table.signDate),
] ]
); );

View File

@@ -0,0 +1,41 @@
import { Controller, Get, Post, Body, Query } from '@nestjs/common';
import { SuperTopicService } from './super-topic.service';
@Controller('super-topics')
export class SuperTopicController {
constructor(private readonly superTopicService: SuperTopicService) {}
/**
* 获取超话列表
* GET /api/super-topics
*/
@Get()
async getTopics() {
// 临时使用固定用户ID实际应从 token 中获取
const userId = 'default_user';
return await this.superTopicService.getSuperTopics(userId);
}
/**
* 超话签到
* POST /api/super-topics/signin
*/
@Post('signin')
async signIn(@Body() body: { topicId: string }) {
// 临时使用固定用户ID实际应从 token 中获取
const userId = 'default_user';
return await this.superTopicService.signIn(userId, body.topicId);
}
/**
* 获取签到记录
* GET /api/super-topics/records
*/
@Get('records')
async getRecords(@Query('limit') limit?: string) {
// 临时使用固定用户ID实际应从 token 中获取
const userId = 'default_user';
const limitNum = limit ? parseInt(limit, 10) : 30;
return await this.superTopicService.getRecords(userId, limitNum);
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { SuperTopicController } from './super-topic.controller';
import { SuperTopicService } from './super-topic.service';
@Module({
controllers: [SuperTopicController],
providers: [SuperTopicService],
exports: [SuperTopicService],
})
export class SuperTopicModule {}

View File

@@ -0,0 +1,195 @@
import { Injectable } from '@nestjs/common';
import { getSupabaseClient } from '@/storage/database/supabase-client';
@Injectable()
export class SuperTopicService {
/**
* 获取超话列表(模拟数据)
* TODO: 接入真实微博API
*/
async getSuperTopics(userId: string) {
// 模拟数据 - 实际应调用微博API获取用户关注的超话列表
const mockTopics = [
{
id: '100808100',
name: '王一博超话',
cover: 'https://picsum.photos/seed/topic1/200/200',
memberCount: 1234567,
isSignedIn: false,
},
{
id: '100808101',
name: '肖站超话',
cover: 'https://picsum.photos/seed/topic2/200/200',
memberCount: 2345678,
isSignedIn: true,
},
{
id: '100808102',
name: '杨幂超话',
cover: 'https://picsum.photos/seed/topic3/200/200',
memberCount: 987654,
isSignedIn: false,
},
{
id: '100808103',
name: '迪丽热巴超话',
cover: 'https://picsum.photos/seed/topic4/200/200',
memberCount: 1567890,
isSignedIn: true,
},
{
id: '100808104',
name: '蔡徐坤超话',
cover: 'https://picsum.photos/seed/topic5/200/200',
memberCount: 3456789,
isSignedIn: false,
},
];
// 从数据库获取今日签到状态
const client = getSupabaseClient();
const today = new Date().toISOString().split('T')[0];
const { data: todayRecords } = await client
.from('super_topic_signin')
.select('topic_id')
.eq('user_id', userId)
.eq('sign_date', today);
const signedTopicIds = todayRecords?.map(r => r.topic_id) || [];
// 合并签到状态
const topics = mockTopics.map(topic => ({
...topic,
isSignedIn: signedTopicIds.includes(topic.id),
}));
return {
code: 200,
msg: 'success',
data: {
topics,
},
};
}
/**
* 超话签到
*/
async signIn(userId: string, topicId: string) {
const client = getSupabaseClient();
const today = new Date().toISOString().split('T')[0];
// 检查是否已签到
const { data: existingRecord } = await client
.from('super_topic_signin')
.select('*')
.eq('user_id', userId)
.eq('topic_id', topicId)
.eq('sign_date', today)
.single();
if (existingRecord) {
return {
code: 400,
msg: '今日已签到该超话',
data: null,
};
}
// 创建签到记录
const { data, error } = await client
.from('super_topic_signin')
.insert({
user_id: userId,
topic_id: topicId,
sign_date: today,
})
.select()
.single();
if (error) {
console.error('签到失败:', error);
console.error('错误详情:', JSON.stringify(error, null, 2));
return {
code: 500,
msg: `签到失败: ${error.message || '未知错误'}`,
data: null,
};
}
return {
code: 200,
msg: '签到成功',
data,
};
}
/**
* 获取签到记录
*/
async getRecords(userId: string, limit: number = 30) {
const client = getSupabaseClient();
// 获取最近N天的签到记录
const { data, error } = await client
.from('super_topic_signin')
.select('sign_date')
.eq('user_id', userId)
.order('sign_date', { ascending: false })
.limit(limit * 10); // 假设每天最多签到10个超话
if (error) {
console.error('获取签到记录失败:', error);
console.error('错误详情:', JSON.stringify(error, null, 2));
return {
code: 500,
msg: `获取签到记录失败: ${error.message || '未知错误'}`,
data: null,
};
}
// 按日期分组统计
const recordsMap = new Map<string, { date: string; count: number; topicIds: string[] }>();
data?.forEach(record => {
const date = record.sign_date;
if (!recordsMap.has(date)) {
recordsMap.set(date, { date, count: 0, topicIds: [] });
}
const item = recordsMap.get(date)!;
item.count++;
});
const records = Array.from(recordsMap.values()).slice(0, limit);
// 计算连续签到天数
const dates = Array.from(recordsMap.keys()).sort().reverse();
const today = new Date().toISOString().split('T')[0];
let continuousDays = 0;
let checkDate = new Date(today);
for (const date of dates) {
const dateObj = new Date(date);
const diffTime = checkDate.getTime() - dateObj.getTime();
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
if (diffDays === 0 || diffDays === 1) {
continuousDays++;
checkDate = dateObj;
} else {
break;
}
}
return {
code: 200,
msg: 'success',
data: {
records,
continuousDays,
totalDays: recordsMap.size,
},
};
}
}

View File

@@ -0,0 +1,18 @@
import { Controller, Get } from '@nestjs/common';
import { UserService } from './user.service';
@Controller('user')
export class UserController {
constructor(private readonly userService: UserService) {}
/**
* 获取用户信息
* GET /api/user/info
*/
@Get('info')
async getUserInfo() {
// 临时使用固定用户ID实际应从 token 中获取
const userId = 'default_user';
return await this.userService.getUserInfo(userId);
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { UserController } from './user.controller';
import { UserService } from './user.service';
@Module({
controllers: [UserController],
providers: [UserService],
exports: [UserService],
})
export class UserModule {}

View File

@@ -0,0 +1,59 @@
import { Injectable } from '@nestjs/common';
import { getSupabaseClient } from '@/storage/database/supabase-client';
@Injectable()
export class UserService {
/**
* 获取用户信息(模拟数据)
* TODO: 接入真实微博API
*/
async getUserInfo(userId: string) {
const client = getSupabaseClient();
// 获取签到统计
const { data: signinData } = await client
.from('super_topic_signin')
.select('sign_date, topic_id')
.eq('user_id', userId);
// 计算统计数据
const uniqueDates = new Set(signinData?.map(r => r.sign_date) || []);
const uniqueTopics = new Set(signinData?.map(r => r.topic_id) || []);
// 计算最长连续签到天数
let maxContinuousDays = 0;
if (uniqueDates.size > 0) {
const sortedDates = Array.from(uniqueDates).sort().reverse();
let currentStreak = 0;
let checkDate = new Date();
for (const date of sortedDates) {
const dateObj = new Date(date);
const diffTime = checkDate.getTime() - dateObj.getTime();
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
if (diffDays <= 1) {
currentStreak++;
checkDate = dateObj;
} else {
break;
}
}
maxContinuousDays = currentStreak;
}
// 模拟用户信息 - 实际应从微博API获取
return {
code: 200,
msg: 'success',
data: {
nickname: '微博用户',
avatar: '',
userId: userId,
totalTopics: uniqueTopics.size || 5, // 模拟关注超话数
totalDays: uniqueDates.size || 0,
maxContinuousDays,
},
};
}
}

View File

@@ -6,25 +6,25 @@ export default defineAppConfig({
], ],
window: { window: {
backgroundTextStyle: 'dark', backgroundTextStyle: 'dark',
navigationBarBackgroundColor: '#111827', navigationBarBackgroundColor: '#ffffff',
navigationBarTitleText: '微博签到', navigationBarTitleText: '微博超话签到',
navigationBarTextStyle: 'white' navigationBarTextStyle: 'black'
}, },
tabBar: { tabBar: {
color: '#9ca3af', color: '#9ca3af',
selectedColor: '#2563eb', selectedColor: '#f97316',
backgroundColor: '#111827', backgroundColor: '#ffffff',
borderStyle: 'black', borderStyle: 'black',
list: [ list: [
{ {
pagePath: 'pages/index/index', pagePath: 'pages/index/index',
text: '签到', text: '超话列表',
iconPath: './assets/tabbar/star.png', iconPath: './assets/tabbar/list.png',
selectedIconPath: './assets/tabbar/star-active.png' selectedIconPath: './assets/tabbar/list-active.png'
}, },
{ {
pagePath: 'pages/record/index', pagePath: 'pages/record/index',
text: '记录', text: '签到记录',
iconPath: './assets/tabbar/calendar.png', iconPath: './assets/tabbar/calendar.png',
selectedIconPath: './assets/tabbar/calendar-active.png' selectedIconPath: './assets/tabbar/calendar-active.png'
}, },

Binary file not shown.

Before

Width:  |  Height:  |  Size: 942 B

After

Width:  |  Height:  |  Size: 929 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 620 B

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 626 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -1,11 +1,13 @@
export default typeof definePageConfig === 'function' export default typeof definePageConfig === 'function'
? definePageConfig({ ? definePageConfig({
navigationBarTitleText: '微博签到', navigationBarTitleText: '微博超话签到',
navigationBarBackgroundColor: '#111827', navigationBarBackgroundColor: '#ffffff',
navigationBarTextStyle: 'white' navigationBarTextStyle: 'black',
enablePullDownRefresh: true
}) })
: { : {
navigationBarTitleText: '微博签到', navigationBarTitleText: '微博超话签到',
navigationBarBackgroundColor: '#111827', navigationBarBackgroundColor: '#ffffff',
navigationBarTextStyle: 'white' navigationBarTextStyle: 'black',
enablePullDownRefresh: true
} }

View File

@@ -1,120 +1,211 @@
import { View, Text } from '@tarojs/components' import { View, Text, Image } from '@tarojs/components'
import { useDidShow } from '@tarojs/taro' import { useDidShow, usePullDownRefresh } from '@tarojs/taro'
import { FC, useState } from 'react' import { FC, useState } from 'react'
import { Network } from '@/network' import { Network } from '@/network'
import './index.css' import './index.css'
interface SignInStatus { interface SuperTopic {
todaySignedIn: boolean id: string
continuousDays: number name: string
totalDays: number cover: string
memberCount: number
isSignedIn: boolean
signPoints?: number
}
interface SigninStatus {
total: number
signedIn: number
notSignedIn: number
} }
/** /**
* 签到主页面 * 超话列表页面
*/ */
const IndexPage: FC = () => { const IndexPage: FC = () => {
const [signInStatus, setSignInStatus] = useState<SignInStatus>({ const [topics, setTopics] = useState<SuperTopic[]>([])
todaySignedIn: false, const [status, setStatus] = useState<SigninStatus>({
continuousDays: 0, total: 0,
totalDays: 0 signedIn: 0,
notSignedIn: 0
}) })
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(true)
const [signingIn, setSigningIn] = useState(false)
// 页面显示时获取签到状态 // 页面显示时获取超话列表
useDidShow(async () => { useDidShow(async () => {
await fetchSignInStatus() await fetchTopics()
}) })
// 获取签到状态 // 下拉刷新
const fetchSignInStatus = async () => { usePullDownRefresh(async () => {
await fetchTopics()
})
// 获取超话列表
const fetchTopics = async () => {
try { try {
setLoading(true)
const res = await Network.request({ const res = await Network.request({
url: '/api/signin/status', url: '/api/super-topics',
method: 'GET' method: 'GET'
}) })
console.log('签到状态:', res.data) console.log('超话列表:', res.data)
if (res.data?.code === 200 && res.data?.data) { if (res.data?.code === 200 && res.data?.data) {
setSignInStatus(res.data.data) const topicList = res.data.data.topics || []
setTopics(topicList)
setStatus({
total: topicList.length,
signedIn: topicList.filter((t: SuperTopic) => t.isSignedIn).length,
notSignedIn: topicList.filter((t: SuperTopic) => !t.isSignedIn).length
})
} }
} catch (error) { } catch (error) {
console.error('获取签到状态失败:', 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 { } finally {
setLoading(false) setLoading(false)
} }
} }
// 一键签到所有超话
const handleSignAll = async () => {
if (signingIn) return
setSigningIn(true)
const unsignedTopics = topics.filter(t => !t.isSignedIn)
if (unsignedTopics.length === 0) {
setSigningIn(false)
return
}
let successCount = 0
for (const topic of unsignedTopics) {
try {
const res = await Network.request({
url: '/api/super-topics/signin',
method: 'POST',
data: { topicId: topic.id }
})
if (res.data?.code === 200) {
successCount++
// 更新该超话的状态
setTopics(prev => prev.map(t =>
t.id === topic.id ? { ...t, isSignedIn: true } : t
))
}
} catch (error) {
console.error(`签到失败 [${topic.name}]:`, error)
}
// 添加延迟避免请求过快
await new Promise(resolve => setTimeout(resolve, 300))
}
// 更新统计
setStatus(prev => ({
...prev,
signedIn: prev.signedIn + successCount,
notSignedIn: prev.notSignedIn - successCount
}))
setSigningIn(false)
}
// 格式化成员数量
const formatMemberCount = (count: number) => {
if (count >= 10000) {
return `${(count / 10000).toFixed(1)}`
}
return count.toString()
}
return ( 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="min-h-screen bg-gray-50 px-4 py-4">
{/* 顶部标题 */} {/* 统计区域 */}
<View className="text-center mb-8"> <View className="flex flex-row gap-3 mb-4">
<Text className="block text-white text-3xl font-bold"></Text> <View className="flex-1 bg-orange-50 rounded-lg p-3">
<Text className="block text-blue-300 text-sm mt-2"></Text> <Text className="block text-gray-600 text-xs"></Text>
</View> <Text className="block text-orange-600 text-2xl font-bold">{status.total}</Text>
{/* 统计卡片 */}
<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>
<View className="flex-1 bg-gray-800 rounded-2xl p-4"> <View className="flex-1 bg-green-50 rounded-lg p-3">
<Text className="block text-gray-400 text-xs"></Text> <Text className="block text-gray-600 text-xs"></Text>
<Text className="block text-white text-3xl font-bold mt-1">{signInStatus.totalDays}</Text> <Text className="block text-green-600 text-2xl font-bold">{status.signedIn}</Text>
<Text className="block text-gray-400 text-xs mt-1"></Text> </View>
<View className="flex-1 bg-gray-100 rounded-lg p-3">
<Text className="block text-gray-600 text-xs"></Text>
<Text className="block text-gray-600 text-2xl font-bold">{status.notSignedIn}</Text>
</View> </View>
</View> </View>
{/* 签到按钮 */} {/* 一键签到按钮 */}
<View className="flex justify-center items-center mb-8"> {status.notSignedIn > 0 && (
<View <View className="mb-4">
className={`w-40 h-40 rounded-full flex items-center justify-center shadow-lg ${ <View
signInStatus.todaySignedIn className={`rounded-full py-3 flex items-center justify-center ${
? 'bg-gradient-to-br from-green-500 to-green-600 shadow-green-500/50' signingIn ? 'bg-orange-300' : 'bg-orange-500'
: 'bg-gradient-to-br from-blue-500 to-purple-600 shadow-blue-500/50' }`}
} ${loading ? 'opacity-50' : ''}`} onClick={handleSignAll}
onClick={handleSignIn} >
> <Text className="text-white text-base font-semibold">
{loading ? ( {signingIn ? '签到中...' : '一键签到全部超话'}
<Text className="text-white text-xl font-bold">...</Text> </Text>
) : signInStatus.todaySignedIn ? ( </View>
<Text className="text-white text-xl font-bold"></Text>
) : (
<Text className="text-white text-2xl font-bold"></Text>
)}
</View> </View>
</View> )}
{/* 提示文字 */} {/* 超话列表 */}
<View className="text-center"> {loading ? (
{signInStatus.todaySignedIn ? ( <View className="flex justify-center py-8">
<Text className="block text-green-400 text-sm"></Text> <Text className="text-gray-400">...</Text>
) : ( </View>
<Text className="block text-yellow-400 text-sm"></Text> ) : topics.length === 0 ? (
)} <View className="flex flex-col items-center justify-center py-20">
</View> <Text className="text-gray-400 text-base"></Text>
<Text className="text-gray-300 text-sm mt-2"></Text>
</View>
) : (
<View className="flex flex-col gap-3">
{topics.map((topic) => (
<View
key={topic.id}
className="bg-white rounded-lg p-4 shadow-sm border border-gray-100"
>
<View className="flex flex-row items-center">
{/* 超话封面 */}
<Image
className="w-12 h-12 rounded-lg bg-gray-200"
src={topic.cover}
mode="aspectFill"
/>
{/* 超话信息 */}
<View className="flex-1 ml-3">
<Text className="block text-gray-900 font-semibold text-base">
{topic.name}
</Text>
<Text className="block text-gray-400 text-xs mt-1">
{formatMemberCount(topic.memberCount)}
</Text>
</View>
{/* 签到状态 */}
<View
className={`px-3 py-1 rounded-full ${
topic.isSignedIn ? 'bg-green-100' : 'bg-orange-100'
}`}
>
<Text
className={`text-xs ${
topic.isSignedIn ? 'text-green-600' : 'text-orange-600'
}`}
>
{topic.isSignedIn ? '已签到' : '未签到'}
</Text>
</View>
</View>
</View>
))}
</View>
)}
</View> </View>
) )
} }

View File

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

View File

@@ -4,98 +4,125 @@ import { FC, useState } from 'react'
import { Network } from '@/network' import { Network } from '@/network'
import './index.css' import './index.css'
interface SignInStatus { interface UserInfo {
todaySignedIn: boolean nickname: string
continuousDays: number avatar: string
userId: string
}
interface UserStats {
totalTopics: number
totalDays: number totalDays: number
maxContinuousDays: number
} }
/** /**
* 个人中心页面 * 个人中心页面
*/ */
const ProfilePage: FC = () => { const ProfilePage: FC = () => {
const [signInStatus, setSignInStatus] = useState<SignInStatus>({ const [userInfo, setUserInfo] = useState<UserInfo>({
todaySignedIn: false, nickname: '未登录',
continuousDays: 0, avatar: '',
totalDays: 0 userId: ''
})
const [stats, setStats] = useState<UserStats>({
totalTopics: 0,
totalDays: 0,
maxContinuousDays: 0
}) })
// 页面显示时获取签到统计 // 页面显示时获取用户信息
useDidShow(async () => { useDidShow(async () => {
await fetchSignInStatus() await fetchUserInfo()
}) })
// 获取签到状态 // 获取用户信息
const fetchSignInStatus = async () => { const fetchUserInfo = async () => {
try { try {
const res = await Network.request({ const res = await Network.request({
url: '/api/signin/status', url: '/api/user/info',
method: 'GET' method: 'GET'
}) })
console.log('签到状态:', res.data) console.log('用户信息:', res.data)
if (res.data?.code === 200 && res.data?.data) { if (res.data?.code === 200 && res.data?.data) {
setSignInStatus(res.data.data) setUserInfo({
nickname: res.data.data.nickname || '未登录',
avatar: res.data.data.avatar || '',
userId: res.data.data.userId || ''
})
setStats({
totalTopics: res.data.data.totalTopics || 0,
totalDays: res.data.data.totalDays || 0,
maxContinuousDays: res.data.data.maxContinuousDays || 0
})
} }
} catch (error) { } catch (error) {
console.error('获取签到状态失败:', error) console.error('获取用户信息失败:', error)
} }
} }
return ( return (
<View className="min-h-screen bg-gray-900 px-4 py-6"> <View className="min-h-screen bg-gray-50 px-4 py-4">
{/* 用户信息卡片 */} {/* 用户信息卡片 */}
<View className="bg-gradient-to-br from-blue-600 to-purple-600 rounded-2xl p-6 mb-6"> <View className="bg-gradient-to-r from-orange-500 to-orange-400 rounded-lg p-4 mb-4">
<View className="flex flex-row items-center gap-4 mb-4"> <View className="flex flex-row items-center gap-3">
<View className="w-16 h-16 rounded-full bg-white bg-opacity-20 flex items-center justify-center"> <View className="w-14 h-14 rounded-full bg-white bg-opacity-20 flex items-center justify-center overflow-hidden">
<Text className="text-white text-2xl">👤</Text> {userInfo.avatar ? (
<View className="w-full h-full bg-gray-300" />
) : (
<Text className="text-white text-2xl">👤</Text>
)}
</View> </View>
<View className="flex-1"> <View className="flex-1">
<Text className="block text-white text-xl font-bold"></Text> <Text className="block text-white text-lg font-semibold">{userInfo.nickname}</Text>
<Text className="block text-blue-200 text-sm mt-1">ID: default_user</Text> {userInfo.userId && (
<Text className="block text-orange-100 text-xs mt-1">ID: {userInfo.userId}</Text>
)}
</View> </View>
</View> </View>
</View> </View>
{/* 签到统计 */} {/* 统计信息 */}
<View className="mb-6"> <View className="bg-white rounded-lg p-4 mb-4 border border-gray-100">
<Text className="block text-white text-lg font-bold mb-4"></Text> <Text className="block text-gray-900 font-semibold mb-3"></Text>
<View className="flex flex-row gap-3"> <View className="flex flex-row gap-3">
<View className="flex-1 bg-gray-800 rounded-2xl p-4"> <View className="flex-1 text-center">
<Text className="block text-gray-400 text-xs"></Text> <Text className="block text-gray-900 text-2xl font-bold">{stats.totalTopics}</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>
<Text className="block text-gray-400 text-xs mt-1"></Text>
</View> </View>
<View className="flex-1 bg-gray-800 rounded-2xl p-4"> <View className="w-px bg-gray-100" />
<Text className="block text-gray-400 text-xs"></Text> <View className="flex-1 text-center">
<Text className="block text-white text-3xl font-bold mt-1">{signInStatus.totalDays}</Text> <Text className="block text-gray-900 text-2xl font-bold">{stats.totalDays}</Text>
<Text className="block text-gray-400 text-xs mt-1"></Text> <Text className="block text-gray-400 text-xs mt-1"></Text>
</View>
<View className="w-px bg-gray-100" />
<View className="flex-1 text-center">
<Text className="block text-gray-900 text-2xl font-bold">{stats.maxContinuousDays}</Text>
<Text className="block text-gray-400 text-xs mt-1"></Text>
</View> </View>
</View> </View>
</View> </View>
{/* 今日状态 */} {/* 功能菜单 */}
<View className="bg-gray-800 rounded-2xl p-4 mb-6"> <View className="bg-white rounded-lg border border-gray-100 mb-4">
<View className="flex flex-row items-center justify-between"> <View className="flex flex-row items-center justify-between p-4 border-b border-gray-50">
<View> <Text className="text-gray-700 text-sm">使</Text>
<Text className="block text-white text-base font-semibold"></Text> <Text className="text-gray-400 text-xs"></Text>
<Text className="block text-gray-400 text-sm mt-1"> </View>
{signInStatus.todaySignedIn ? '已完成' : '未完成'} <View className="flex flex-row items-center justify-between p-4 border-b border-gray-50">
</Text> <Text className="text-gray-700 text-sm"></Text>
</View> <Text className="text-gray-400 text-xs"></Text>
<View </View>
className={`w-12 h-12 rounded-full flex items-center justify-center ${ <View className="flex flex-row items-center justify-between p-4">
signInStatus.todaySignedIn ? 'bg-green-500' : 'bg-gray-700' <Text className="text-gray-700 text-sm"></Text>
}`} <Text className="text-gray-400 text-xs">v1.0.0</Text>
>
<Text className="text-white text-xl">{signInStatus.todaySignedIn ? '✓' : '○'}</Text>
</View>
</View> </View>
</View> </View>
{/* 提示信息 */} {/* 提示信息 */}
<View className="bg-gray-800 rounded-2xl p-4"> <View className="bg-orange-50 rounded-lg p-4">
<Text className="block text-gray-400 text-sm leading-relaxed"> <Text className="block text-orange-600 text-xs leading-relaxed">
使使
</Text> </Text>
</View> </View>
</View> </View>

View File

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

View File

@@ -6,9 +6,9 @@ import './index.css'
interface SignInRecord { interface SignInRecord {
id: number id: number
user_id: string date: string
sign_date: string count: number
created_at: string details: string[]
} }
/** /**
@@ -16,9 +16,13 @@ interface SignInRecord {
*/ */
const RecordPage: FC = () => { const RecordPage: FC = () => {
const [records, setRecords] = useState<SignInRecord[]>([]) const [records, setRecords] = useState<SignInRecord[]>([])
const [stats, setStats] = useState({
continuousDays: 0,
totalDays: 0
})
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
// 页面显示时获取签到记录 // 页面显示时获取记录
useDidShow(async () => { useDidShow(async () => {
await fetchRecords() await fetchRecords()
}) })
@@ -28,12 +32,16 @@ const RecordPage: FC = () => {
try { try {
setLoading(true) setLoading(true)
const res = await Network.request({ const res = await Network.request({
url: '/api/signin/history', url: '/api/super-topics/records',
method: 'GET' method: 'GET'
}) })
console.log('签到记录:', res.data) console.log('签到记录:', res.data)
if (res.data?.code === 200 && res.data?.data) { if (res.data?.code === 200 && res.data?.data) {
setRecords(res.data.data) setRecords(res.data.data.records || [])
setStats({
continuousDays: res.data.data.continuousDays || 0,
totalDays: res.data.data.totalDays || 0
})
} }
} catch (error) { } catch (error) {
console.error('获取签到记录失败:', error) console.error('获取签到记录失败:', error)
@@ -42,15 +50,14 @@ const RecordPage: FC = () => {
} }
} }
// 格式化日期显示 // 格式化日期
const formatDate = (dateStr: string) => { const formatDate = (dateStr: string) => {
const date = new Date(dateStr) const date = new Date(dateStr)
const year = date.getFullYear()
const month = date.getMonth() + 1 const month = date.getMonth() + 1
const day = date.getDate() const day = date.getDate()
const weekDays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'] const weekDays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
const weekDay = weekDays[date.getDay()] const weekDay = weekDays[date.getDay()]
return `${year}${month}${day}${weekDay}` return `${month}${day}${weekDay}`
} }
// 获取当前月份的天数 // 获取当前月份的天数
@@ -60,19 +67,22 @@ const RecordPage: FC = () => {
const month = now.getMonth() const month = now.getMonth()
const firstDay = new Date(year, month, 1) const firstDay = new Date(year, month, 1)
const lastDay = new Date(year, month + 1, 0) const lastDay = new Date(year, month + 1, 0)
const days: { date: Date; isSigned: boolean }[] = [] const days: { date: Date | null; hasRecord: boolean }[] = []
// 填充前面的空白 // 填充前面的空白
for (let i = 0; i < firstDay.getDay(); i++) { for (let i = 0; i < firstDay.getDay(); i++) {
days.push({ date: null as any, isSigned: false }) days.push({ date: null, hasRecord: false })
} }
// 获取有签到记录的日期
const recordDates = records.map(r => r.date)
// 填充日期 // 填充日期
for (let i = 1; i <= lastDay.getDate(); i++) { for (let i = 1; i <= lastDay.getDate(); i++) {
const date = new Date(year, month, i) const date = new Date(year, month, i)
const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(i).padStart(2, '0')}` const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(i).padStart(2, '0')}`
const isSigned = records.some(r => r.sign_date === dateStr) const hasRecord = recordDates.includes(dateStr)
days.push({ date, isSigned }) days.push({ date, hasRecord })
} }
return days return days
@@ -83,19 +93,32 @@ const RecordPage: FC = () => {
const monthNames = ['一月', '二月', '三月', '四月', '五月', '六月', '七月', '八月', '九月', '十月', '十一月', '十二月'] const monthNames = ['一月', '二月', '三月', '四月', '五月', '六月', '七月', '八月', '九月', '十月', '十一月', '十二月']
return ( return (
<View className="min-h-screen bg-gray-900 px-4 py-6"> <View className="min-h-screen bg-gray-50 px-4 py-4">
{/* 月份标题 */} {/* 统计卡片 */}
<View className="mb-6"> <View className="flex flex-row gap-3 mb-4">
<Text className="block text-white text-xl font-bold">{monthNames[now.getMonth()]} {now.getFullYear()}</Text> <View className="flex-1 bg-orange-50 rounded-lg p-4">
<Text className="block text-gray-600 text-xs"></Text>
<Text className="block text-orange-600 text-3xl font-bold mt-1">{stats.continuousDays}</Text>
<Text className="block text-gray-400 text-xs mt-1"></Text>
</View>
<View className="flex-1 bg-white rounded-lg p-4 border border-gray-100">
<Text className="block text-gray-600 text-xs"></Text>
<Text className="block text-gray-900 text-3xl font-bold mt-1">{stats.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="bg-white rounded-lg p-4 mb-4 border border-gray-100">
<Text className="block text-gray-900 font-semibold mb-3">
{monthNames[now.getMonth()]} {now.getFullYear()}
</Text>
{/* 星期标题 */} {/* 星期标题 */}
<View className="flex flex-row mb-3"> <View className="flex flex-row mb-2">
{['日', '一', '二', '三', '四', '五', '六'].map((day, index) => ( {['日', '一', '二', '三', '四', '五', '六'].map((day, index) => (
<View key={index} className="flex-1 flex justify-center"> <View key={index} className="flex-1 flex justify-center">
<Text className="text-gray-400 text-sm">{day}</Text> <Text className="text-gray-400 text-xs">{day}</Text>
</View> </View>
))} ))}
</View> </View>
@@ -106,20 +129,20 @@ const RecordPage: FC = () => {
<View key={index} className="w-[14.28%] aspect-square flex justify-center items-center mb-1"> <View key={index} className="w-[14.28%] aspect-square flex justify-center items-center mb-1">
{item.date ? ( {item.date ? (
<View <View
className={`w-8 h-8 rounded-full flex items-center justify-center ${ className={`w-6 h-6 rounded-full flex items-center justify-center ${
item.isSigned item.hasRecord
? 'bg-blue-500' ? 'bg-orange-500'
: item.date.toDateString() === now.toDateString() : item.date.toDateString() === now.toDateString()
? 'bg-gray-700 border border-blue-400' ? 'bg-orange-100'
: 'bg-transparent' : 'bg-transparent'
}`} }`}
> >
<Text <Text
className={`text-sm ${ className={`text-xs ${
item.isSigned item.hasRecord
? 'text-white' ? 'text-white'
: item.date.toDateString() === now.toDateString() : item.date.toDateString() === now.toDateString()
? 'text-blue-400' ? 'text-orange-600'
: 'text-gray-400' : 'text-gray-400'
}`} }`}
> >
@@ -135,28 +158,27 @@ const RecordPage: FC = () => {
</View> </View>
{/* 签到记录列表 */} {/* 签到记录列表 */}
<View className="bg-gray-800 rounded-2xl p-4"> <View className="bg-white rounded-lg p-4 border border-gray-100">
<Text className="block text-white text-lg font-bold mb-4"></Text> <Text className="block text-gray-900 font-semibold mb-3"></Text>
{loading ? ( {loading ? (
<View className="flex justify-center py-8"> <View className="flex justify-center py-8">
<Text className="text-gray-500">...</Text> <Text className="text-gray-400">...</Text>
</View> </View>
) : records.length === 0 ? ( ) : records.length === 0 ? (
<View className="flex flex-col items-center justify-center py-8"> <View className="flex flex-col items-center justify-center py-8">
<Text className="text-gray-500 text-base"></Text> <Text className="text-gray-400 text-sm"></Text>
<Text className="text-gray-600 text-sm mt-2"></Text>
</View> </View>
) : ( ) : (
<View className="flex flex-col gap-3"> <View className="flex flex-col gap-2">
{records.map((record) => ( {records.slice(0, 10).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 key={record.id} className="flex flex-row items-center gap-3 py-2 border-b border-gray-50 last:border-b-0">
<View className="w-2 h-2 rounded-full bg-blue-500" /> <View className="w-1.5 h-1.5 rounded-full bg-orange-500" />
<View className="flex-1"> <View className="flex-1">
<Text className="block text-white text-sm">{formatDate(record.sign_date)}</Text> <Text className="block text-gray-700 text-sm">{formatDate(record.date)}</Text>
</View> </View>
<View className="bg-blue-500 rounded-full px-3 py-1"> <View className="bg-orange-50 rounded-full px-2 py-0.5">
<Text className="text-white text-xs"></Text> <Text className="text-orange-600 text-xs">{record.count} </Text>
</View> </View>
</View> </View>
))} ))}