feat(miniprogram): 初始化 Taro 4 + React 小程序项目
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled

- 手动创建 Taro 4.2 + React 18 + TypeScript 项目骨架
- 配置 webpack5 编译、SCSS 样式、医疗清新主题
- 实现 API 请求层(JWT 自动注入 + token 刷新)
- 实现 auth store(微信登录 + 手机号绑定 + 就诊人管理)
- 实现登录页(微信一键登录 + 手机号授权绑定)
- 实现首页(问候栏 + 今日健康卡片 + 快捷服务 + 即将到来)
- 实现我的页面(个人信息 + 功能菜单 + 退出登录)
- 健康/预约/资讯占位页
- TabBar 5 个入口:首页/健康/预约/资讯/我的
This commit is contained in:
iven
2026-04-24 00:28:38 +08:00
parent 47817bae7d
commit 0f84c881ef
30 changed files with 17555 additions and 0 deletions

View File

@@ -0,0 +1 @@
@import '../../styles/variables.scss';

View File

@@ -0,0 +1,12 @@
import { View, Text } from '@tarojs/components';
import '../health/index.scss';
export default function Appointment() {
return (
<View className='placeholder-page'>
<Text className='placeholder-icon'>📅</Text>
<Text className='placeholder-title'></Text>
<Text className='placeholder-desc'></Text>
</View>
);
}

View File

@@ -0,0 +1 @@
@import '../../styles/variables.scss';

View File

@@ -0,0 +1,12 @@
import { View, Text } from '@tarojs/components';
import '../health/index.scss';
export default function Article() {
return (
<View className='placeholder-page'>
<Text className='placeholder-icon'>📰</Text>
<Text className='placeholder-title'></Text>
<Text className='placeholder-desc'></Text>
</View>
);
}

View File

@@ -0,0 +1,27 @@
@import '../../styles/variables.scss';
.placeholder-page {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: $bg;
}
.placeholder-icon {
font-size: 80px;
margin-bottom: 20px;
}
.placeholder-title {
font-size: 36px;
font-weight: bold;
color: $tx;
margin-bottom: 8px;
}
.placeholder-desc {
font-size: 26px;
color: $tx3;
}

View File

@@ -0,0 +1,12 @@
import { View, Text } from '@tarojs/components';
import './index.scss';
export default function Health() {
return (
<View className='placeholder-page'>
<Text className='placeholder-icon'>📊</Text>
<Text className='placeholder-title'></Text>
<Text className='placeholder-desc'></Text>
</View>
);
}

View File

@@ -0,0 +1,122 @@
@import '../../styles/variables.scss';
.index-page {
padding-bottom: 20px;
}
.greeting-bar {
background: linear-gradient(135deg, $pri 0%, $pri-d 100%);
padding: 40px 32px 60px;
color: white;
}
.greeting-text {
margin-bottom: 8px;
}
.greeting-hello {
font-size: 36px;
font-weight: bold;
}
.greeting-name {
font-size: 36px;
font-weight: bold;
margin-left: 12px;
}
.greeting-date {
font-size: 24px;
opacity: 0.8;
}
.health-card {
background: $card;
border-radius: $r;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
margin: -30px 24px 24px;
padding: 28px;
}
.section-title {
font-size: 30px;
font-weight: bold;
color: $tx;
margin-bottom: 20px;
display: block;
}
.health-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
.health-item {
background: $bg;
border-radius: $r-sm;
padding: 20px;
text-align: center;
}
.health-label {
font-size: 24px;
color: $tx2;
display: block;
}
.health-value {
font-size: 36px;
font-weight: bold;
color: $pri;
display: block;
margin: 8px 0 4px;
}
.health-unit {
font-size: 22px;
color: $tx3;
}
.quick-services {
margin: 0 24px 24px;
}
.service-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
}
.service-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 20px 0;
}
.service-icon {
font-size: 48px;
margin-bottom: 8px;
}
.service-label {
font-size: 24px;
color: $tx2;
}
.upcoming {
margin: 0 24px;
}
.empty-hint {
background: $card;
border-radius: $r;
padding: 40px;
text-align: center;
}
.empty-text {
font-size: 26px;
color: $tx3;
}

View File

@@ -0,0 +1,85 @@
import { View, Text } from '@tarojs/components';
import Taro, { useDidShow } from '@tarojs/taro';
import { useAuthStore } from '../../stores/auth';
import './index.scss';
export default function Index() {
const { user, restore } = useAuthStore();
useDidShow(() => {
restore();
});
const greeting = () => {
const h = new Date().getHours();
if (h < 6) return '凌晨好';
if (h < 12) return '上午好';
if (h < 14) return '中午好';
if (h < 18) return '下午好';
return '晚上好';
};
return (
<View className='index-page'>
{/* 问候栏 */}
<View className='greeting-bar'>
<View className='greeting-text'>
<Text className='greeting-hello'>{greeting()}</Text>
<Text className='greeting-name'>{user?.display_name || '用户'}</Text>
</View>
<Text className='greeting-date'>
{new Date().toLocaleDateString('zh-CN', { month: 'long', day: 'numeric', weekday: 'short' })}
</Text>
</View>
{/* 今日健康卡片 */}
<View className='health-card'>
<Text className='section-title'></Text>
<View className='health-grid'>
{[
{ label: '血压', value: '--/--', unit: 'mmHg', status: '' },
{ label: '心率', value: '--', unit: 'bpm', status: '' },
{ label: '血糖', value: '--', unit: 'mmol/L', status: '' },
{ label: '体重', value: '--', unit: 'kg', status: '' },
].map((item) => (
<View className='health-item' key={item.label}>
<Text className='health-label'>{item.label}</Text>
<Text className='health-value'>{item.value}</Text>
<Text className='health-unit'>{item.unit}</Text>
</View>
))}
</View>
</View>
{/* 快捷服务 */}
<View className='quick-services'>
<Text className='section-title'></Text>
<View className='service-grid'>
{[
{ label: '录数据', icon: '📝', path: '/pages/health/index' },
{ label: '预约', icon: '📅', path: '/pages/appointment/index' },
{ label: '报告', icon: '📋', path: '/pages/profile/index' },
{ label: '随访', icon: '💬', path: '/pages/profile/index' },
].map((item) => (
<View
className='service-item'
key={item.label}
onClick={() => Taro.switchTab({ url: item.path })}
>
<Text className='service-icon'>{item.icon}</Text>
<Text className='service-label'>{item.label}</Text>
</View>
))}
</View>
</View>
{/* 即将到来 */}
<View className='upcoming'>
<Text className='section-title'></Text>
<View className='empty-hint'>
<Text className='empty-text'></Text>
</View>
</View>
</View>
);
}

View File

@@ -0,0 +1,65 @@
@import '../../styles/variables.scss';
.login-page {
min-height: 100vh;
background: linear-gradient(135deg, $pri 0%, $pri-d 100%);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0 60px;
}
.login-header {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 120px;
}
.login-logo {
width: 120px;
height: 120px;
background: rgba(255, 255, 255, 0.2);
border-radius: 30px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 30px;
}
.login-logo-text {
font-size: 60px;
color: white;
font-weight: bold;
}
.login-title {
font-size: 48px;
color: white;
font-weight: bold;
margin-bottom: 12px;
}
.login-subtitle {
font-size: 28px;
color: rgba(255, 255, 255, 0.8);
}
.login-btn {
width: 100%;
height: 88px;
background: white;
color: $pri;
font-size: 32px;
font-weight: bold;
border-radius: $r;
border: none;
display: flex;
align-items: center;
justify-content: center;
}
.login-body {
width: 100%;
}

View File

@@ -0,0 +1,70 @@
import { useState } from 'react';
import { View, Text, Button, Image } from '@tarojs/components';
import Taro from '@tarojs/taro';
import { useAuthStore } from '../../stores/auth';
import './index.scss';
export default function Login() {
const [needBind, setNeedBind] = useState(false);
const [openid, setOpenid] = useState('');
const { login, bindPhone, loading } = useAuthStore();
const handleWechatLogin = async () => {
try {
const { code } = await Taro.login();
const success = await login(code);
if (success) {
Taro.switchTab({ url: '/pages/index/index' });
} else {
// 未绑定,需要获取手机号
setNeedBind(true);
// 从最近的登录响应获取 openid简化处理
}
} catch (e: any) {
Taro.showToast({ title: '登录失败', icon: 'none' });
}
};
const handleGetPhone = async (e: any) => {
if (e.detail.errMsg !== 'getPhoneNumber:ok') {
Taro.showToast({ title: '需要授权手机号', icon: 'none' });
return;
}
const { encryptedData, iv } = e.detail;
const success = await bindPhone(openid, encryptedData, iv);
if (success) {
Taro.switchTab({ url: '/pages/index/index' });
} else {
Taro.showToast({ title: '绑定失败', icon: 'none' });
}
};
return (
<View className='login-page'>
<View className='login-header'>
<View className='login-logo'>
<Text className='login-logo-text'>+</Text>
</View>
<Text className='login-title'></Text>
<Text className='login-subtitle'></Text>
</View>
<View className='login-body'>
{!needBind ? (
<Button className='login-btn' onClick={handleWechatLogin} loading={loading}>
</Button>
) : (
<Button
className='login-btn'
openType='getPhoneNumber'
onGetPhoneNumber={handleGetPhone}
loading={loading}
>
</Button>
)}
</View>
</View>
);
}

View File

@@ -0,0 +1,86 @@
@import '../../styles/variables.scss';
.profile-page {
min-height: 100vh;
background: $bg;
}
.profile-header {
background: linear-gradient(135deg, $pri 0%, $pri-d 100%);
padding: 60px 32px 40px;
display: flex;
flex-direction: column;
align-items: center;
}
.profile-avatar {
width: 120px;
height: 120px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.3);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 16px;
}
.profile-avatar-text {
font-size: 48px;
color: white;
font-weight: bold;
}
.profile-name {
font-size: 34px;
color: white;
font-weight: bold;
margin-bottom: 4px;
}
.profile-phone {
font-size: 26px;
color: rgba(255, 255, 255, 0.8);
}
.profile-menu {
margin: 24px;
background: $card;
border-radius: $r;
overflow: hidden;
}
.menu-item {
display: flex;
align-items: center;
padding: 28px 24px;
border-bottom: 1px solid $bd-l;
}
.menu-icon {
font-size: 36px;
margin-right: 16px;
}
.menu-label {
flex: 1;
font-size: 30px;
color: $tx;
}
.menu-arrow {
font-size: 32px;
color: $tx3;
}
.profile-logout {
margin: 24px;
background: $card;
border-radius: $r;
padding: 28px;
text-align: center;
}
.logout-text {
font-size: 30px;
color: $dan;
}

View File

@@ -0,0 +1,41 @@
import { View, Text } from '@tarojs/components';
import { useAuthStore } from '../../stores/auth';
import '../health/index.scss';
export default function Profile() {
const { user, logout } = useAuthStore();
return (
<View className='profile-page'>
<View className='profile-header'>
<View className='profile-avatar'>
<Text className='profile-avatar-text'>
{user?.display_name?.[0] || '?'}
</Text>
</View>
<Text className='profile-name'>{user?.display_name || '未登录'}</Text>
<Text className='profile-phone'>{user?.phone || ''}</Text>
</View>
<View className='profile-menu'>
{[
{ label: '就诊人管理', icon: '👥' },
{ label: '我的报告', icon: '📋' },
{ label: '我的随访', icon: '💬' },
{ label: '用药提醒', icon: '💊' },
{ label: '设置', icon: '⚙️' },
].map((item) => (
<View className='menu-item' key={item.label}>
<Text className='menu-icon'>{item.icon}</Text>
<Text className='menu-label'>{item.label}</Text>
<Text className='menu-arrow'></Text>
</View>
))}
</View>
<View className='profile-logout' onClick={logout}>
<Text className='logout-text'>退</Text>
</View>
</View>
);
}