feat(miniprogram): 初始化 Taro 4 + React 小程序项目
- 手动创建 Taro 4.2 + React 18 + TypeScript 项目骨架 - 配置 webpack5 编译、SCSS 样式、医疗清新主题 - 实现 API 请求层(JWT 自动注入 + token 刷新) - 实现 auth store(微信登录 + 手机号绑定 + 就诊人管理) - 实现登录页(微信一键登录 + 手机号授权绑定) - 实现首页(问候栏 + 今日健康卡片 + 快捷服务 + 即将到来) - 实现我的页面(个人信息 + 功能菜单 + 退出登录) - 健康/预约/资讯占位页 - TabBar 5 个入口:首页/健康/预约/资讯/我的
This commit is contained in:
1
apps/miniprogram/.gitignore
vendored
Normal file
1
apps/miniprogram/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
node_modules/\ndist/
|
||||
Binary file not shown.
8
apps/miniprogram/babel.config.js
Normal file
8
apps/miniprogram/babel.config.js
Normal file
@@ -0,0 +1,8 @@
|
||||
module.exports = {
|
||||
presets: [
|
||||
['taro', {
|
||||
framework: 'react',
|
||||
ts: true,
|
||||
}],
|
||||
],
|
||||
};
|
||||
7
apps/miniprogram/config/dev.ts
Normal file
7
apps/miniprogram/config/dev.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { UserConfigExport } from '@tarojs/cli';
|
||||
|
||||
export default {
|
||||
logger: { quiet: false },
|
||||
mini: {},
|
||||
h5: {},
|
||||
} satisfies UserConfigExport;
|
||||
51
apps/miniprogram/config/index.ts
Normal file
51
apps/miniprogram/config/index.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { defineConfig } from '@tarojs/cli';
|
||||
|
||||
export default defineConfig(async (merge) => {
|
||||
const baseConfig = {
|
||||
projectName: 'hms-miniprogram',
|
||||
date: '2026-4-23',
|
||||
designWidth: 750,
|
||||
deviceRatio: { 640: 2.34 / 2, 750: 1, 375: 2, 828: 1.81 / 2 },
|
||||
sourceRoot: 'src',
|
||||
outputRoot: 'dist',
|
||||
plugins: [],
|
||||
defineConstants: {},
|
||||
copy: { patterns: [], options: {} },
|
||||
framework: 'react',
|
||||
compiler: 'webpack5',
|
||||
sass: {
|
||||
resource: ['src/styles/variables.scss'],
|
||||
},
|
||||
mini: {
|
||||
compile: {
|
||||
exclude: [],
|
||||
},
|
||||
postcss: {
|
||||
pxtransform: { enable: true, config: {} },
|
||||
cssModules: {
|
||||
enable: false,
|
||||
config: { namingPattern: 'module', generateScopedName: '[name]__[local]___[hash:base64:5]' },
|
||||
},
|
||||
},
|
||||
},
|
||||
h5: {
|
||||
publicPath: '/',
|
||||
staticDirectory: 'static',
|
||||
postcss: {
|
||||
autoprefixer: { enable: true, config: {} },
|
||||
cssModules: {
|
||||
enable: false,
|
||||
config: { namingPattern: 'module', generateScopedName: '[name]__[local]___[hash:base64:5]' },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const devConfig = (await import('./dev')).default;
|
||||
const prodConfig = (await import('./prod')).default;
|
||||
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
return merge({}, baseConfig, prodConfig);
|
||||
}
|
||||
return merge({}, baseConfig, devConfig);
|
||||
});
|
||||
12
apps/miniprogram/config/prod.ts
Normal file
12
apps/miniprogram/config/prod.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { UserConfigExport } from '@tarojs/cli';
|
||||
|
||||
export default {
|
||||
logger: { quiet: false },
|
||||
mini: { miniCssExtractPluginOption: { ignoreOrder: true } },
|
||||
h5: {
|
||||
miniCssExtractPluginOption: {
|
||||
ignoreOrder: true,
|
||||
cssLoaderOption: { importLoaders: 1, esModule: false },
|
||||
},
|
||||
},
|
||||
} satisfies UserConfigExport;
|
||||
16559
apps/miniprogram/package-lock.json
generated
Normal file
16559
apps/miniprogram/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
41
apps/miniprogram/package.json
Normal file
41
apps/miniprogram/package.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "hms-miniprogram",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "HMS 健康管理平台患者小程序",
|
||||
"scripts": {
|
||||
"build:weapp": "taro build --type weapp",
|
||||
"dev:weapp": "taro build --type weapp --watch"
|
||||
},
|
||||
"browserslist": [
|
||||
"last 3 versions",
|
||||
"Android >= 4.1",
|
||||
"ios >= 8"
|
||||
],
|
||||
"dependencies": {
|
||||
"@babel/preset-env": "^7.29.2",
|
||||
"@babel/preset-react": "^7.28.5",
|
||||
"@babel/preset-typescript": "^7.28.5",
|
||||
"@tarojs/components": "4.2.0",
|
||||
"@tarojs/helper": "4.2.0",
|
||||
"@tarojs/plugin-framework-react": "4.2.0",
|
||||
"@tarojs/plugin-platform-weapp": "4.2.0",
|
||||
"@tarojs/react": "4.2.0",
|
||||
"@tarojs/runtime": "4.2.0",
|
||||
"@tarojs/shared": "4.2.0",
|
||||
"@tarojs/taro": "4.2.0",
|
||||
"babel-preset-taro": "^4.2.0",
|
||||
"react": "^18.3.0",
|
||||
"react-dom": "^18.3.0",
|
||||
"zustand": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/runtime": "^7.27.0",
|
||||
"@tarojs/cli": "4.2.0",
|
||||
"@tarojs/webpack5-runner": "4.2.0",
|
||||
"@types/react": "^18.3.0",
|
||||
"sass": "^1.87.0",
|
||||
"typescript": "^5.8.0",
|
||||
"webpack": "~5.95.0"
|
||||
}
|
||||
}
|
||||
16
apps/miniprogram/project.config.json
Normal file
16
apps/miniprogram/project.config.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"miniprogramRoot": "dist/",
|
||||
"projectname": "hms-miniprogram",
|
||||
"description": "HMS 健康管理平台患者小程序",
|
||||
"appid": "touristappid",
|
||||
"setting": {
|
||||
"urlCheck": false,
|
||||
"es6": false,
|
||||
"enhance": true,
|
||||
"compileHotReLoad": false,
|
||||
"postcss": false,
|
||||
"minified": false,
|
||||
"bundle": false
|
||||
},
|
||||
"compileType": "miniprogram"
|
||||
}
|
||||
29
apps/miniprogram/src/app.config.ts
Normal file
29
apps/miniprogram/src/app.config.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export default defineAppConfig({
|
||||
pages: [
|
||||
'pages/index/index',
|
||||
'pages/health/index',
|
||||
'pages/appointment/index',
|
||||
'pages/article/index',
|
||||
'pages/profile/index',
|
||||
'pages/login/index',
|
||||
],
|
||||
tabBar: {
|
||||
color: '#94A3B8',
|
||||
selectedColor: '#0891B2',
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderStyle: 'white',
|
||||
list: [
|
||||
{ pagePath: 'pages/index/index', text: '首页' },
|
||||
{ pagePath: 'pages/health/index', text: '健康' },
|
||||
{ pagePath: 'pages/appointment/index', text: '预约' },
|
||||
{ pagePath: 'pages/article/index', text: '资讯' },
|
||||
{ pagePath: 'pages/profile/index', text: '我的' },
|
||||
],
|
||||
},
|
||||
window: {
|
||||
backgroundTextStyle: 'light',
|
||||
navigationBarBackgroundColor: '#0891B2',
|
||||
navigationBarTitleText: '健康管理',
|
||||
navigationBarTextStyle: 'white',
|
||||
},
|
||||
});
|
||||
10
apps/miniprogram/src/app.scss
Normal file
10
apps/miniprogram/src/app.scss
Normal file
@@ -0,0 +1,10 @@
|
||||
@import './styles/variables.scss';
|
||||
|
||||
page {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', Helvetica, 'PingFang SC',
|
||||
'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
|
||||
font-size: 28px;
|
||||
color: $tx;
|
||||
background-color: $bg;
|
||||
line-height: 1.5;
|
||||
}
|
||||
8
apps/miniprogram/src/app.tsx
Normal file
8
apps/miniprogram/src/app.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import { PropsWithChildren } from 'react';
|
||||
import './app.scss';
|
||||
|
||||
function App({ children }: PropsWithChildren<any>) {
|
||||
return children;
|
||||
}
|
||||
|
||||
export default App;
|
||||
1
apps/miniprogram/src/pages/appointment/index.scss
Normal file
1
apps/miniprogram/src/pages/appointment/index.scss
Normal file
@@ -0,0 +1 @@
|
||||
@import '../../styles/variables.scss';
|
||||
12
apps/miniprogram/src/pages/appointment/index.tsx
Normal file
12
apps/miniprogram/src/pages/appointment/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1
apps/miniprogram/src/pages/article/index.scss
Normal file
1
apps/miniprogram/src/pages/article/index.scss
Normal file
@@ -0,0 +1 @@
|
||||
@import '../../styles/variables.scss';
|
||||
12
apps/miniprogram/src/pages/article/index.tsx
Normal file
12
apps/miniprogram/src/pages/article/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
27
apps/miniprogram/src/pages/health/index.scss
Normal file
27
apps/miniprogram/src/pages/health/index.scss
Normal 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;
|
||||
}
|
||||
12
apps/miniprogram/src/pages/health/index.tsx
Normal file
12
apps/miniprogram/src/pages/health/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
122
apps/miniprogram/src/pages/index/index.scss
Normal file
122
apps/miniprogram/src/pages/index/index.scss
Normal 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;
|
||||
}
|
||||
85
apps/miniprogram/src/pages/index/index.tsx
Normal file
85
apps/miniprogram/src/pages/index/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
65
apps/miniprogram/src/pages/login/index.scss
Normal file
65
apps/miniprogram/src/pages/login/index.scss
Normal 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%;
|
||||
}
|
||||
70
apps/miniprogram/src/pages/login/index.tsx
Normal file
70
apps/miniprogram/src/pages/login/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
86
apps/miniprogram/src/pages/profile/index.scss
Normal file
86
apps/miniprogram/src/pages/profile/index.scss
Normal 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;
|
||||
}
|
||||
41
apps/miniprogram/src/pages/profile/index.tsx
Normal file
41
apps/miniprogram/src/pages/profile/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
44
apps/miniprogram/src/services/auth.ts
Normal file
44
apps/miniprogram/src/services/auth.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { api } from './request';
|
||||
|
||||
export interface UserInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
avatar?: string;
|
||||
tenant_id: string;
|
||||
}
|
||||
|
||||
export interface LoginResp {
|
||||
bound: boolean;
|
||||
openid: string;
|
||||
token?: {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
expires_in: number;
|
||||
user: { id: string; username: string; display_name?: string; phone?: string; avatar_url?: string };
|
||||
};
|
||||
}
|
||||
|
||||
export interface PatientInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
gender?: string;
|
||||
birthday?: string;
|
||||
relation: string;
|
||||
}
|
||||
|
||||
export async function wechatLogin(code: string): Promise<LoginResp> {
|
||||
return api.post('/auth/wechat/login', { code });
|
||||
}
|
||||
|
||||
export async function wechatBindPhone(openid: string, encryptedData: string, iv: string) {
|
||||
return api.post('/auth/wechat/bind-phone', {
|
||||
openid,
|
||||
encrypted_data: encryptedData,
|
||||
iv,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getPatients() {
|
||||
return api.get<PatientInfo[]>('/health/patients');
|
||||
}
|
||||
65
apps/miniprogram/src/services/request.ts
Normal file
65
apps/miniprogram/src/services/request.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import Taro from '@tarojs/taro';
|
||||
|
||||
const BASE_URL = process.env.TARO_APP_API_URL || 'http://localhost:3000/api/v1';
|
||||
|
||||
interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
async function getHeaders(): Promise<Record<string, string>> {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
const token = Taro.getStorageSync('access_token');
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
const patientId = Taro.getStorageSync('current_patient_id');
|
||||
if (patientId) headers['X-Patient-Id'] = patientId;
|
||||
const tenantId = Taro.getStorageSync('tenant_id');
|
||||
if (tenantId) headers['X-Tenant-Id'] = tenantId;
|
||||
return headers;
|
||||
}
|
||||
|
||||
async function tryRefreshToken(): Promise<boolean> {
|
||||
const refreshToken = Taro.getStorageSync('refresh_token');
|
||||
if (!refreshToken) return false;
|
||||
try {
|
||||
const res = await Taro.request({
|
||||
url: `${BASE_URL}/auth/refresh`,
|
||||
method: 'POST',
|
||||
data: { refresh_token: refreshToken },
|
||||
});
|
||||
if (res.statusCode === 200 && res.data?.success) {
|
||||
Taro.setStorageSync('access_token', res.data.data.access_token);
|
||||
Taro.setStorageSync('refresh_token', res.data.data.refresh_token);
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
Taro.removeStorageSync('access_token');
|
||||
Taro.removeStorageSync('refresh_token');
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function request<T>(method: string, path: string, data?: unknown): Promise<T> {
|
||||
const headers = await getHeaders();
|
||||
const res = await Taro.request({ url: `${BASE_URL}${path}`, method, data, header: headers });
|
||||
|
||||
if (res.statusCode === 401) {
|
||||
const refreshed = await tryRefreshToken();
|
||||
if (refreshed) return request<T>(method, path, data);
|
||||
Taro.redirectTo({ url: '/pages/login/index' });
|
||||
throw new Error('登录已过期');
|
||||
}
|
||||
|
||||
const body = res.data as ApiResponse<T>;
|
||||
if (!body.success) throw new Error(body.message || '请求失败');
|
||||
return body.data as T;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(path: string) => request<T>('GET', path),
|
||||
post: <T>(path: string, data?: unknown) => request<T>('POST', path, data),
|
||||
put: <T>(path: string, data?: unknown) => request<T>('PUT', path, data),
|
||||
delete: <T>(path: string) => request<T>('DELETE', path),
|
||||
};
|
||||
101
apps/miniprogram/src/stores/auth.ts
Normal file
101
apps/miniprogram/src/stores/auth.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { create } from 'zustand';
|
||||
import Taro from '@tarojs/taro';
|
||||
import * as authApi from '../services/auth';
|
||||
|
||||
interface AuthState {
|
||||
token: string | null;
|
||||
refreshToken: string | null;
|
||||
user: { id: string; username: string; display_name?: string; phone?: string } | null;
|
||||
currentPatient: authApi.PatientInfo | null;
|
||||
patients: authApi.PatientInfo[];
|
||||
loading: boolean;
|
||||
|
||||
login: (code: string) => Promise<boolean>;
|
||||
bindPhone: (openid: string, encryptedData: string, iv: string) => Promise<boolean>;
|
||||
setCurrentPatient: (patient: authApi.PatientInfo) => void;
|
||||
loadPatients: () => Promise<void>;
|
||||
logout: () => void;
|
||||
restore: () => void;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
token: null,
|
||||
refreshToken: null,
|
||||
user: null,
|
||||
currentPatient: null,
|
||||
patients: [],
|
||||
loading: false,
|
||||
|
||||
restore: () => {
|
||||
const token = Taro.getStorageSync('access_token') || null;
|
||||
const refreshToken = Taro.getStorageSync('refresh_token') || null;
|
||||
const user = Taro.getStorageSync('user') || null;
|
||||
const currentPatient = Taro.getStorageSync('current_patient') || null;
|
||||
set({ token, refreshToken, user, currentPatient });
|
||||
},
|
||||
|
||||
login: async (code: string) => {
|
||||
set({ loading: true });
|
||||
try {
|
||||
const resp = await authApi.wechatLogin(code);
|
||||
if (resp.bound && resp.token) {
|
||||
const { access_token, refresh_token, user } = resp.token;
|
||||
Taro.setStorageSync('access_token', access_token);
|
||||
Taro.setStorageSync('refresh_token', refresh_token);
|
||||
Taro.setStorageSync('user', user);
|
||||
Taro.setStorageSync('tenant_id', user.tenant_id || '');
|
||||
set({ token: access_token, refreshToken: refresh_token, user, loading: false });
|
||||
return true;
|
||||
}
|
||||
set({ loading: false });
|
||||
return false;
|
||||
} catch {
|
||||
set({ loading: false });
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
bindPhone: async (openid: string, encryptedData: string, iv: string) => {
|
||||
set({ loading: true });
|
||||
try {
|
||||
const resp: any = await authApi.wechatBindPhone(openid, encryptedData, iv);
|
||||
const { access_token, refresh_token, user } = resp;
|
||||
Taro.setStorageSync('access_token', access_token);
|
||||
Taro.setStorageSync('refresh_token', refresh_token);
|
||||
Taro.setStorageSync('user', user);
|
||||
set({ token: access_token, refreshToken: refresh_token, user, loading: false });
|
||||
return true;
|
||||
} catch {
|
||||
set({ loading: false });
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
setCurrentPatient: (patient) => {
|
||||
Taro.setStorageSync('current_patient_id', patient.id);
|
||||
Taro.setStorageSync('current_patient', patient);
|
||||
set({ currentPatient: patient });
|
||||
},
|
||||
|
||||
loadPatients: async () => {
|
||||
try {
|
||||
const patients = await authApi.getPatients();
|
||||
set({ patients });
|
||||
if (patients.length > 0 && !get().currentPatient) {
|
||||
get().setCurrentPatient(patients[0]);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
},
|
||||
|
||||
logout: () => {
|
||||
Taro.removeStorageSync('access_token');
|
||||
Taro.removeStorageSync('refresh_token');
|
||||
Taro.removeStorageSync('user');
|
||||
Taro.removeStorageSync('current_patient');
|
||||
Taro.removeStorageSync('current_patient_id');
|
||||
set({ token: null, refreshToken: null, user: null, currentPatient: null, patients: [] });
|
||||
Taro.redirectTo({ url: '/pages/login/index' });
|
||||
},
|
||||
}));
|
||||
20
apps/miniprogram/src/styles/mixins.scss
Normal file
20
apps/miniprogram/src/styles/mixins.scss
Normal file
@@ -0,0 +1,20 @@
|
||||
@import './variables.scss';
|
||||
|
||||
@mixin card {
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
padding: 24px;
|
||||
margin: 0 24px 20px;
|
||||
}
|
||||
|
||||
@mixin flex-center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@mixin safe-bottom {
|
||||
padding-bottom: constant(safe-area-inset-bottom);
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
21
apps/miniprogram/src/styles/variables.scss
Normal file
21
apps/miniprogram/src/styles/variables.scss
Normal file
@@ -0,0 +1,21 @@
|
||||
// 医疗清新主题 — 青色主调
|
||||
$pri: #0891B2;
|
||||
$pri-l: #E0F7FA;
|
||||
$pri-d: #065A73;
|
||||
$pri-surface: #ECFEFF;
|
||||
$acc: #059669;
|
||||
$acc-l: #D1FAE5;
|
||||
$bg: #F0FDFA;
|
||||
$card: #FFFFFF;
|
||||
$tx: #134E4A;
|
||||
$tx2: #6B7280;
|
||||
$tx3: #94A3B8;
|
||||
$bd: #E5E7EB;
|
||||
$bd-l: #F3F4F6;
|
||||
$dan: #DC2626;
|
||||
$dan-l: #FEE2E2;
|
||||
$wrn: #D97706;
|
||||
$wrn-l: #FEF3C7;
|
||||
$r: 12px;
|
||||
$r-sm: 8px;
|
||||
$r-lg: 16px;
|
||||
28
apps/miniprogram/tsconfig.json
Normal file
28
apps/miniprogram/tsconfig.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"module": "commonjs",
|
||||
"removeComments": false,
|
||||
"preserveConstEnums": true,
|
||||
"moduleResolution": "node",
|
||||
"experimentalDecorators": true,
|
||||
"noImplicitAny": false,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"outDir": "lib",
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"strictNullChecks": true,
|
||||
"sourceMap": true,
|
||||
"rootDir": "src",
|
||||
"jsx": "react",
|
||||
"allowJs": true,
|
||||
"resolveJsonModule": true,
|
||||
"typeRoots": ["node_modules/@types"],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"baseUrl": "."
|
||||
},
|
||||
"include": ["src"],
|
||||
"compileOnSave": false
|
||||
}
|
||||
Reference in New Issue
Block a user