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

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

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

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

View File

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

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

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

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

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

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

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