diff --git a/design_guidelines.md b/design_guidelines.md index 2746120..32661de 100644 --- a/design_guidelines.md +++ b/design_guidelines.md @@ -1,61 +1,61 @@ -# 微博签到小程序设计指南 +# 微博超话签到小程序设计指南 ## 品牌定位 -**应用定位**:微博签到打卡工具,帮助用户养成持续签到的习惯 -**设计风格**:星空主题、仪式感、成就感 -**目标用户**:微博用户,希望通过签到获得奖励的用户 +**应用定位**:微博超话签到工具,帮助用户快速完成关注的超话签到 +**设计风格**:简洁高效、微博风格、橙色调 +**目标用户**:微博用户,需要每日签到多个超话的用户 -**核心意象**:点亮星空——每次签到都是点亮夜空中的一颗星,连续签到让星空更加璀璨 +**核心功能**: +- 获取用户关注的超话列表 +- 一键批量签到所有超话 +- 查看签到状态和历史记录 --- ## 配色方案 -### 主色板 +### 主色板(微博风格) -| 颜色名称 | Tailwind 类名 | 色值 | 意象来源 | -|---------|--------------|------|---------| -| 星空蓝(主色) | `bg-blue-600` | #2563eb | 深夜星空的底色 | -| 星光金(强调色) | `text-yellow-400` | #facc15 | 星星的光芒 | -| 月光蓝(辅助色) | `text-blue-300` | #93c5fd | 柔和的月光 | +| 颜色名称 | Tailwind 类名 | 色值 | 用途 | +|---------|--------------|------|------| +| 微博橙(主色) | `bg-orange-500` | #f97316 | 主按钮、强调元素 | +| 微博橙深 | `bg-orange-600` | #ea580c | 按钮hover态 | +| 橙色浅 | `text-orange-100` | #ffedd5 | 浅色背景 | ### 中性色 | 颜色名称 | Tailwind 类名 | 色值 | 用途 | |---------|--------------|------|------| -| 深空灰 | `bg-gray-900` | #111827 | 页面背景 | -| 夜空灰 | `bg-gray-800` | #1f2937 | 卡片背景 | -| 星云灰 | `bg-gray-700` | #374151 | 次级背景 | -| 银河白 | `text-white` | #ffffff | 主要文字 | -| 星尘灰 | `text-gray-400` | #9ca3af | 次要文字 | +| 白色背景 | `bg-white` | #ffffff | 页面背景 | +| 浅灰背景 | `bg-gray-50` | #f9fafb | 卡片背景 | +| 边框灰 | `border-gray-200` | #e5e7eb | 分割线 | +| 主文字 | `text-gray-900` | #111827 | 主要文字 | +| 次文字 | `text-gray-600` | #4b5563 | 次要文字 | +| 辅助文字 | `text-gray-400` | #9ca3af | 说明文字 | ### 语义色 | 状态 | Tailwind 类名 | 色值 | |-----|--------------|------| -| 成功/已签到 | `text-green-400` | #4ade80 | -| 警告/未签到 | `text-orange-400` | #fb923c | -| 信息/提示 | `text-blue-400` | #60a5fa | +| 成功/已签到 | `text-green-500` | #22c55e | +| 失败/未签到 | `text-red-500` | #ef4444 | +| 进行中 | `text-blue-500` | #3b82f6 | +| 警告 | `text-yellow-500` | #eab308 | --- ## 字体规范 -### 字体选择 - -- **标题字体**:使用系统默认字体(微信小程序限制) -- **正文字体**:使用系统默认字体 - ### 字体层级 -| 层级 | 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 | 说明文字 | +| 层级 | Tailwind 类名 | 字号 | 用途 | +|-----|--------------|------|------| +| H1 | `text-2xl font-bold` | 24px | 页面标题 | +| H2 | `text-lg font-semibold` | 18px | 卡片标题 | +| Body | `text-base` | 16px | 正文内容 | +| Caption | `text-sm` | 14px | 说明文字 | +| Small | `text-xs` | 12px | 辅助信息 | --- @@ -64,58 +64,74 @@ ### 页面边距 - 页面左右边距:`px-4`(16px) -- 页面上下边距:`py-6`(24px) +- 页面上下边距:`py-4`(16px) ### 组件间距 -- 卡片间距:`gap-4`(16px) -- 列表项间距:`gap-3`(12px) +- 卡片间距:`gap-3`(12px) +- 列表项间距:`gap-2`(8px) - 按钮组间距:`gap-2`(8px) --- ## 组件规范 -### 1. 签到按钮(核心组件) +### 1. 超话卡片(核心组件) ```tsx -// 主签到按钮 - - - 签到 + + + {/* 超话封面 */} + + {/* 超话信息 */} + + {name} + {memberCount} 成员 + + {/* 签到状态 */} + + + {isSignedIn ? '已签到' : '未签到'} + + +``` -// 已签到状态 - - 已签到 +### 2. 一键签到按钮 + +```tsx + + + 一键签到全部超话 + ``` -### 2. 卡片容器 +### 3. 统计卡片 ```tsx - - {/* 卡片内容 */} + + + 已关注 + {total} + + + 已签到 + {signedIn} + + + 未签到 + {notSignedIn} + ``` -### 3. 列表项 +### 4. 加载状态 ```tsx - - - 内容 - 时间 - -``` - -### 4. 统计卡片 - -```tsx - - 连续签到 - 7 天 + + 加载中... ``` @@ -123,8 +139,8 @@ ```tsx - 暂无签到记录 - 开始你的第一次签到吧 + 暂无关注的超话 + 去微博关注一些超话吧 ``` @@ -138,19 +154,19 @@ { tabBar: { color: '#9ca3af', - selectedColor: '#2563eb', - backgroundColor: '#111827', + selectedColor: '#f97316', + backgroundColor: '#ffffff', borderStyle: 'black', list: [ { pagePath: 'pages/index/index', - text: '签到', - iconPath: './assets/tabbar/star.png', - selectedIconPath: './assets/tabbar/star-active.png' + text: '超话列表', + iconPath: './assets/tabbar/list.png', + selectedIconPath: './assets/tabbar/list-active.png' }, { pagePath: 'pages/record/index', - text: '记录', + text: '签到记录', iconPath: './assets/tabbar/calendar.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 -``` --- ## 设计禁忌 **禁止使用**: -- ❌ 微博橙色默认配色 -- ❌ 纯白色背景(不符合星空主题) -- ❌ 过于功能导向的冷冰冰设计 -- ❌ 缺乏情感共鸣的界面元素 -- ❌ 复杂的动画效果(影响性能) +- ❌ 深色背景(不符合微博风格) +- ❌ 过于复杂的动画 +- ❌ 与微博风格冲突的配色 +- ❌ 过长的加载时间 diff --git a/drizzle/0000_polite_annihilus.sql b/drizzle/0000_polite_annihilus.sql new file mode 100644 index 0000000..952e86d --- /dev/null +++ b/drizzle/0000_polite_annihilus.sql @@ -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); +*/ \ No newline at end of file diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..d0b67e0 --- /dev/null +++ b/drizzle/meta/0000_snapshot.json @@ -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": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json new file mode 100644 index 0000000..0aedda2 --- /dev/null +++ b/drizzle/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1773633411873, + "tag": "0000_polite_annihilus", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/drizzle/relations.ts b/drizzle/relations.ts new file mode 100644 index 0000000..80768e2 --- /dev/null +++ b/drizzle/relations.ts @@ -0,0 +1,3 @@ +import { relations } from "drizzle-orm/relations"; +import { } from "./schema"; + diff --git a/drizzle/schema.ts b/drizzle/schema.ts new file mode 100644 index 0000000..0d11894 --- /dev/null +++ b/drizzle/schema.ts @@ -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(), +}); diff --git a/server/src/app.module.ts b/server/src/app.module.ts index 0f39e06..6ff7fc8 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -1,10 +1,11 @@ import { Module } from '@nestjs/common'; import { AppController } from '@/app.controller'; 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({ - imports: [SignInModule], + imports: [SuperTopicModule, UserModule], controllers: [AppController], providers: [AppService], }) diff --git a/server/src/signin/signin.controller.ts b/server/src/signin/signin.controller.ts deleted file mode 100644 index d312b98..0000000 --- a/server/src/signin/signin.controller.ts +++ /dev/null @@ -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); - } -} diff --git a/server/src/signin/signin.module.ts b/server/src/signin/signin.module.ts deleted file mode 100644 index 7445d4a..0000000 --- a/server/src/signin/signin.module.ts +++ /dev/null @@ -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 {} diff --git a/server/src/signin/signin.service.ts b/server/src/signin/signin.service.ts deleted file mode 100644 index 1acd61c..0000000 --- a/server/src/signin/signin.service.ts +++ /dev/null @@ -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, - }; - } -} diff --git a/server/src/storage/database/shared/schema.ts b/server/src/storage/database/shared/schema.ts index 239ab7e..82112bc 100644 --- a/server/src/storage/database/shared/schema.ts +++ b/server/src/storage/database/shared/schema.ts @@ -6,19 +6,21 @@ export const healthCheck = pgTable("health_check", { updatedAt: timestamp("updated_at", { withTimezone: true, mode: 'string' }).defaultNow(), }); -// 签到记录表 -export const signInRecords = pgTable( - "sign_in_records", +// 超话签到记录表 +export const superTopicSignin = pgTable( + "super_topic_signin", { id: serial("id").primaryKey(), userId: varchar("user_id", { length: 128 }).notNull(), + topicId: varchar("topic_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), + index("super_topic_signin_user_id_idx").on(table.userId), + index("super_topic_signin_topic_id_idx").on(table.topicId), + index("super_topic_signin_sign_date_idx").on(table.signDate), ] ); diff --git a/server/src/super-topic/super-topic.controller.ts b/server/src/super-topic/super-topic.controller.ts new file mode 100644 index 0000000..225bbc8 --- /dev/null +++ b/server/src/super-topic/super-topic.controller.ts @@ -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); + } +} diff --git a/server/src/super-topic/super-topic.module.ts b/server/src/super-topic/super-topic.module.ts new file mode 100644 index 0000000..69eb915 --- /dev/null +++ b/server/src/super-topic/super-topic.module.ts @@ -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 {} diff --git a/server/src/super-topic/super-topic.service.ts b/server/src/super-topic/super-topic.service.ts new file mode 100644 index 0000000..cbc8fea --- /dev/null +++ b/server/src/super-topic/super-topic.service.ts @@ -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(); + 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, + }, + }; + } +} diff --git a/server/src/user/user.controller.ts b/server/src/user/user.controller.ts new file mode 100644 index 0000000..7b7f5b3 --- /dev/null +++ b/server/src/user/user.controller.ts @@ -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); + } +} diff --git a/server/src/user/user.module.ts b/server/src/user/user.module.ts new file mode 100644 index 0000000..ca5060e --- /dev/null +++ b/server/src/user/user.module.ts @@ -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 {} diff --git a/server/src/user/user.service.ts b/server/src/user/user.service.ts new file mode 100644 index 0000000..d8a2f69 --- /dev/null +++ b/server/src/user/user.service.ts @@ -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, + }, + }; + } +} diff --git a/src/app.config.ts b/src/app.config.ts index e83fa52..61388e6 100644 --- a/src/app.config.ts +++ b/src/app.config.ts @@ -6,25 +6,25 @@ export default defineAppConfig({ ], window: { backgroundTextStyle: 'dark', - navigationBarBackgroundColor: '#111827', - navigationBarTitleText: '微博签到', - navigationBarTextStyle: 'white' + navigationBarBackgroundColor: '#ffffff', + navigationBarTitleText: '微博超话签到', + navigationBarTextStyle: 'black' }, tabBar: { color: '#9ca3af', - selectedColor: '#2563eb', - backgroundColor: '#111827', + selectedColor: '#f97316', + backgroundColor: '#ffffff', borderStyle: 'black', list: [ { pagePath: 'pages/index/index', - text: '签到', - iconPath: './assets/tabbar/star.png', - selectedIconPath: './assets/tabbar/star-active.png' + text: '超话列表', + iconPath: './assets/tabbar/list.png', + selectedIconPath: './assets/tabbar/list-active.png' }, { pagePath: 'pages/record/index', - text: '记录', + text: '签到记录', iconPath: './assets/tabbar/calendar.png', selectedIconPath: './assets/tabbar/calendar-active.png' }, diff --git a/src/assets/tabbar/calendar-active.png b/src/assets/tabbar/calendar-active.png index 1c0f272..72a4c68 100644 Binary files a/src/assets/tabbar/calendar-active.png and b/src/assets/tabbar/calendar-active.png differ diff --git a/src/assets/tabbar/list-active.png b/src/assets/tabbar/list-active.png new file mode 100644 index 0000000..31f7287 Binary files /dev/null and b/src/assets/tabbar/list-active.png differ diff --git a/src/assets/tabbar/list.png b/src/assets/tabbar/list.png new file mode 100644 index 0000000..b3cc1ea Binary files /dev/null and b/src/assets/tabbar/list.png differ diff --git a/src/assets/tabbar/user-active.png b/src/assets/tabbar/user-active.png index 2ff7406..6056da3 100644 Binary files a/src/assets/tabbar/user-active.png and b/src/assets/tabbar/user-active.png differ diff --git a/src/pages/index/index.config.ts b/src/pages/index/index.config.ts index fbefe2a..7de7864 100644 --- a/src/pages/index/index.config.ts +++ b/src/pages/index/index.config.ts @@ -1,11 +1,13 @@ export default typeof definePageConfig === 'function' ? definePageConfig({ - navigationBarTitleText: '微博签到', - navigationBarBackgroundColor: '#111827', - navigationBarTextStyle: 'white' + navigationBarTitleText: '微博超话签到', + navigationBarBackgroundColor: '#ffffff', + navigationBarTextStyle: 'black', + enablePullDownRefresh: true }) : { - navigationBarTitleText: '微博签到', - navigationBarBackgroundColor: '#111827', - navigationBarTextStyle: 'white' + navigationBarTitleText: '微博超话签到', + navigationBarBackgroundColor: '#ffffff', + navigationBarTextStyle: 'black', + enablePullDownRefresh: true } diff --git a/src/pages/index/index.tsx b/src/pages/index/index.tsx index c10dba9..30fe9ca 100644 --- a/src/pages/index/index.tsx +++ b/src/pages/index/index.tsx @@ -1,120 +1,211 @@ -import { View, Text } from '@tarojs/components' -import { useDidShow } from '@tarojs/taro' +import { View, Text, Image } from '@tarojs/components' +import { useDidShow, usePullDownRefresh } from '@tarojs/taro' import { FC, useState } from 'react' import { Network } from '@/network' import './index.css' -interface SignInStatus { - todaySignedIn: boolean - continuousDays: number - totalDays: number +interface SuperTopic { + id: string + name: string + cover: string + memberCount: number + isSignedIn: boolean + signPoints?: number +} + +interface SigninStatus { + total: number + signedIn: number + notSignedIn: number } /** - * 签到主页面 + * 超话列表页面 */ const IndexPage: FC = () => { - const [signInStatus, setSignInStatus] = useState({ - todaySignedIn: false, - continuousDays: 0, - totalDays: 0 + const [topics, setTopics] = useState([]) + const [status, setStatus] = useState({ + total: 0, + signedIn: 0, + notSignedIn: 0 }) - const [loading, setLoading] = useState(false) + const [loading, setLoading] = useState(true) + const [signingIn, setSigningIn] = useState(false) - // 页面显示时获取签到状态 + // 页面显示时获取超话列表 useDidShow(async () => { - await fetchSignInStatus() + await fetchTopics() }) - // 获取签到状态 - const fetchSignInStatus = async () => { + // 下拉刷新 + usePullDownRefresh(async () => { + await fetchTopics() + }) + + // 获取超话列表 + const fetchTopics = async () => { try { + setLoading(true) const res = await Network.request({ - url: '/api/signin/status', + url: '/api/super-topics', method: 'GET' }) - console.log('签到状态:', res.data) + console.log('超话列表:', res.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) { - 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) + console.error('获取超话列表失败:', error) } finally { 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 ( - - {/* 顶部标题 */} - - 微博签到 - 每天签到,点亮星空 - - - {/* 统计卡片 */} - - - 连续签到 - {signInStatus.continuousDays} - + + {/* 统计区域 */} + + + 已关注 + {status.total} - - 累计签到 - {signInStatus.totalDays} - + + 已签到 + {status.signedIn} + + + 未签到 + {status.notSignedIn} - {/* 签到按钮 */} - - - {loading ? ( - 签到中... - ) : signInStatus.todaySignedIn ? ( - 已签到 - ) : ( - 签到 - )} + {/* 一键签到按钮 */} + {status.notSignedIn > 0 && ( + + + + {signingIn ? '签到中...' : '一键签到全部超话'} + + - + )} - {/* 提示文字 */} - - {signInStatus.todaySignedIn ? ( - 今日已签到,明天继续加油! - ) : ( - 点击签到按钮,开启今日签到 - )} - + {/* 超话列表 */} + {loading ? ( + + 加载中... + + ) : topics.length === 0 ? ( + + 暂无关注的超话 + 去微博关注一些超话吧 + + ) : ( + + {topics.map((topic) => ( + + + {/* 超话封面 */} + + {/* 超话信息 */} + + + {topic.name} + + + {formatMemberCount(topic.memberCount)} 成员 + + + {/* 签到状态 */} + + + {topic.isSignedIn ? '已签到' : '未签到'} + + + + + ))} + + )} ) } diff --git a/src/pages/profile/index.config.ts b/src/pages/profile/index.config.ts index 04f75f4..727dfc6 100644 --- a/src/pages/profile/index.config.ts +++ b/src/pages/profile/index.config.ts @@ -1,11 +1,11 @@ export default typeof definePageConfig === 'function' ? definePageConfig({ navigationBarTitleText: '我的', - navigationBarBackgroundColor: '#111827', - navigationBarTextStyle: 'white' + navigationBarBackgroundColor: '#ffffff', + navigationBarTextStyle: 'black' }) : { navigationBarTitleText: '我的', - navigationBarBackgroundColor: '#111827', - navigationBarTextStyle: 'white' + navigationBarBackgroundColor: '#ffffff', + navigationBarTextStyle: 'black' } diff --git a/src/pages/profile/index.tsx b/src/pages/profile/index.tsx index e84a0ce..fd40d92 100644 --- a/src/pages/profile/index.tsx +++ b/src/pages/profile/index.tsx @@ -4,98 +4,125 @@ import { FC, useState } from 'react' import { Network } from '@/network' import './index.css' -interface SignInStatus { - todaySignedIn: boolean - continuousDays: number +interface UserInfo { + nickname: string + avatar: string + userId: string +} + +interface UserStats { + totalTopics: number totalDays: number + maxContinuousDays: number } /** * 个人中心页面 */ const ProfilePage: FC = () => { - const [signInStatus, setSignInStatus] = useState({ - todaySignedIn: false, - continuousDays: 0, - totalDays: 0 + const [userInfo, setUserInfo] = useState({ + nickname: '未登录', + avatar: '', + userId: '' + }) + const [stats, setStats] = useState({ + totalTopics: 0, + totalDays: 0, + maxContinuousDays: 0 }) - // 页面显示时获取签到统计 + // 页面显示时获取用户信息 useDidShow(async () => { - await fetchSignInStatus() + await fetchUserInfo() }) - // 获取签到状态 - const fetchSignInStatus = async () => { + // 获取用户信息 + const fetchUserInfo = async () => { try { const res = await Network.request({ - url: '/api/signin/status', + url: '/api/user/info', method: 'GET' }) - console.log('签到状态:', res.data) + console.log('用户信息:', res.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) { - console.error('获取签到状态失败:', error) + console.error('获取用户信息失败:', error) } } return ( - + {/* 用户信息卡片 */} - - - - 👤 + + + + {userInfo.avatar ? ( + + ) : ( + 👤 + )} - 微博用户 - ID: default_user + {userInfo.nickname} + {userInfo.userId && ( + ID: {userInfo.userId} + )} - {/* 签到统计 */} - - 签到统计 + {/* 统计信息 */} + + 我的统计 - - 连续签到 - {signInStatus.continuousDays} - + + {stats.totalTopics} + 关注超话 - - 累计签到 - {signInStatus.totalDays} - + + + {stats.totalDays} + 累计签到 + + + + {stats.maxContinuousDays} + 最长连续 - {/* 今日状态 */} - - - - 今日签到 - - {signInStatus.todaySignedIn ? '已完成' : '未完成'} - - - - {signInStatus.todaySignedIn ? '✓' : '○'} - + {/* 功能菜单 */} + + + 使用说明 + + + + 关于我们 + + + + 版本信息 + v1.0.0 {/* 提示信息 */} - - - 温馨提示:每天签到可以点亮一颗星星,连续签到让星空更加璀璨。坚持签到,养成好习惯! + + + 温馨提示:本工具需要登录微博账号才能使用。签到功能依赖于微博官方接口,请合理使用。 diff --git a/src/pages/record/index.config.ts b/src/pages/record/index.config.ts index d7d3b73..7b41a70 100644 --- a/src/pages/record/index.config.ts +++ b/src/pages/record/index.config.ts @@ -1,11 +1,11 @@ export default typeof definePageConfig === 'function' ? definePageConfig({ navigationBarTitleText: '签到记录', - navigationBarBackgroundColor: '#111827', - navigationBarTextStyle: 'white' + navigationBarBackgroundColor: '#ffffff', + navigationBarTextStyle: 'black' }) : { navigationBarTitleText: '签到记录', - navigationBarBackgroundColor: '#111827', - navigationBarTextStyle: 'white' + navigationBarBackgroundColor: '#ffffff', + navigationBarTextStyle: 'black' } diff --git a/src/pages/record/index.tsx b/src/pages/record/index.tsx index 4c07d08..101a720 100644 --- a/src/pages/record/index.tsx +++ b/src/pages/record/index.tsx @@ -6,9 +6,9 @@ import './index.css' interface SignInRecord { id: number - user_id: string - sign_date: string - created_at: string + date: string + count: number + details: string[] } /** @@ -16,9 +16,13 @@ interface SignInRecord { */ const RecordPage: FC = () => { const [records, setRecords] = useState([]) + const [stats, setStats] = useState({ + continuousDays: 0, + totalDays: 0 + }) const [loading, setLoading] = useState(true) - // 页面显示时获取签到记录 + // 页面显示时获取记录 useDidShow(async () => { await fetchRecords() }) @@ -28,12 +32,16 @@ const RecordPage: FC = () => { try { setLoading(true) const res = await Network.request({ - url: '/api/signin/history', + url: '/api/super-topics/records', method: 'GET' }) console.log('签到记录:', res.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) { console.error('获取签到记录失败:', error) @@ -42,15 +50,14 @@ const RecordPage: FC = () => { } } - // 格式化日期显示 + // 格式化日期 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}` + return `${month}月${day}日 ${weekDay}` } // 获取当前月份的天数 @@ -60,19 +67,22 @@ const RecordPage: FC = () => { 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 }[] = [] + const days: { date: Date | null; hasRecord: boolean }[] = [] // 填充前面的空白 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++) { 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 }) + const hasRecord = recordDates.includes(dateStr) + days.push({ date, hasRecord }) } return days @@ -83,19 +93,32 @@ const RecordPage: FC = () => { const monthNames = ['一月', '二月', '三月', '四月', '五月', '六月', '七月', '八月', '九月', '十月', '十一月', '十二月'] return ( - - {/* 月份标题 */} - - {monthNames[now.getMonth()]} {now.getFullYear()} + + {/* 统计卡片 */} + + + 连续签到 + {stats.continuousDays} + + + + 累计签到 + {stats.totalDays} + + {/* 日历视图 */} - + + + {monthNames[now.getMonth()]} {now.getFullYear()} + + {/* 星期标题 */} - + {['日', '一', '二', '三', '四', '五', '六'].map((day, index) => ( - {day} + {day} ))} @@ -106,20 +129,20 @@ const RecordPage: FC = () => { {item.date ? ( @@ -135,28 +158,27 @@ const RecordPage: FC = () => { {/* 签到记录列表 */} - - 签到记录 + + 签到历史 {loading ? ( - 加载中... + 加载中... ) : records.length === 0 ? ( - 暂无签到记录 - 开始你的第一次签到吧 + 暂无签到记录 ) : ( - - {records.map((record) => ( - - + + {records.slice(0, 10).map((record) => ( + + - {formatDate(record.sign_date)} + {formatDate(record.date)} - - 已签到 + + {record.count} 个超话 ))}