feat: Iteration 1 — 审计日志IP记录、文件上传、医护端API、小程序角色切换
Iteration 1 六项任务全部完成: 1. 审计日志IP记录 — task_local RequestInfo 自动注入 IP/user_agent 2. 文件上传服务 — multipart 上传 + ServeDir 静态文件服务 3. 医护端后端API — 医生工作台仪表盘 + 患者标签CRUD + 会话已读 4. 小程序角色切换 — 登录后根据角色跳转医护台/患者首页 5. 小程序安全加固 — secure-storage 开发模式警告 6. 讨论记录归档 — docs/discussions/
This commit is contained in:
@@ -29,6 +29,7 @@ export default defineAppConfig({
|
||||
'pages/profile/settings/index',
|
||||
'pages/legal/user-agreement',
|
||||
'pages/legal/privacy-policy',
|
||||
'pages/doctor/index',
|
||||
],
|
||||
tabBar: {
|
||||
color: '#94A3B8',
|
||||
|
||||
71
apps/miniprogram/src/pages/doctor/index.scss
Normal file
71
apps/miniprogram/src/pages/doctor/index.scss
Normal file
@@ -0,0 +1,71 @@
|
||||
.doctor-home {
|
||||
min-height: 100vh;
|
||||
background: #f0f4f8;
|
||||
padding: 32px;
|
||||
|
||||
&__header {
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 40px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
display: block;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
&__greeting {
|
||||
font-size: 28px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
&__section {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
&__section-title {
|
||||
font-size: 30px;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
display: block;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
&__grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
&__card {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
&__card-num {
|
||||
font-size: 48px;
|
||||
font-weight: 700;
|
||||
color: #0891b2;
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
&__card-label {
|
||||
font-size: 24px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
&__footer {
|
||||
margin-top: 80px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__logout {
|
||||
color: #ef4444;
|
||||
font-size: 28px;
|
||||
}
|
||||
}
|
||||
48
apps/miniprogram/src/pages/doctor/index.tsx
Normal file
48
apps/miniprogram/src/pages/doctor/index.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import Taro from '@tarojs/taro';
|
||||
import './index.scss';
|
||||
|
||||
export default function DoctorHome() {
|
||||
const { user, logout } = useAuthStore();
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
Taro.redirectTo({ url: '/pages/login/index' });
|
||||
};
|
||||
|
||||
return (
|
||||
<View className='doctor-home'>
|
||||
<View className='doctor-home__header'>
|
||||
<Text className='doctor-home__title'>医护工作台</Text>
|
||||
<Text className='doctor-home__greeting'>{user?.display_name || user?.username || '医生'},您好</Text>
|
||||
</View>
|
||||
|
||||
<View className='doctor-home__section'>
|
||||
<Text className='doctor-home__section-title'>工作概览</Text>
|
||||
<View className='doctor-home__grid'>
|
||||
<View className='doctor-home__card' onClick={() => Taro.showToast({ title: '开发中', icon: 'none' })}>
|
||||
<Text className='doctor-home__card-num'>-</Text>
|
||||
<Text className='doctor-home__card-label'>待回复咨询</Text>
|
||||
</View>
|
||||
<View className='doctor-home__card' onClick={() => Taro.showToast({ title: '开发中', icon: 'none' })}>
|
||||
<Text className='doctor-home__card-num'>-</Text>
|
||||
<Text className='doctor-home__card-label'>待处理随访</Text>
|
||||
</View>
|
||||
<View className='doctor-home__card' onClick={() => Taro.showToast({ title: '开发中', icon: 'none' })}>
|
||||
<Text className='doctor-home__card-num'>-</Text>
|
||||
<Text className='doctor-home__card-label'>今日咨询</Text>
|
||||
</View>
|
||||
<View className='doctor-home__card' onClick={() => Taro.showToast({ title: '开发中', icon: 'none' })}>
|
||||
<Text className='doctor-home__card-num'>-</Text>
|
||||
<Text className='doctor-home__card-label'>我的患者</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className='doctor-home__footer'>
|
||||
<Text className='doctor-home__logout' onClick={handleLogout}>退出登录</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,17 @@ export default function Login() {
|
||||
const [agreed, setAgreed] = useState(false);
|
||||
const { login, bindPhone, loading } = useAuthStore();
|
||||
|
||||
const { login, bindPhone, loading, isMedicalStaff } = useAuthStore();
|
||||
|
||||
/** 登录/绑定成功后根据角色跳转 */
|
||||
const navigateAfterLogin = () => {
|
||||
if (isMedicalStaff()) {
|
||||
Taro.redirectTo({ url: '/pages/doctor/index' });
|
||||
} else {
|
||||
Taro.switchTab({ url: '/pages/index/index' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleWechatLogin = async () => {
|
||||
if (!agreed) {
|
||||
Taro.showToast({ title: '请先阅读并同意用户协议', icon: 'none' });
|
||||
@@ -18,7 +29,7 @@ export default function Login() {
|
||||
const { code } = await Taro.login();
|
||||
const result = await login(code);
|
||||
if (result) {
|
||||
Taro.switchTab({ url: '/pages/index/index' });
|
||||
navigateAfterLogin();
|
||||
} else {
|
||||
setNeedBind(true);
|
||||
Taro.showToast({ title: '请授权手机号完成绑定', icon: 'none' });
|
||||
@@ -41,7 +52,7 @@ export default function Login() {
|
||||
const { encryptedData, iv } = e.detail;
|
||||
const success = await bindPhone(encryptedData, iv);
|
||||
if (success) {
|
||||
Taro.switchTab({ url: '/pages/index/index' });
|
||||
navigateAfterLogin();
|
||||
} else {
|
||||
Taro.showToast({ title: '绑定失败,请重试', icon: 'none' });
|
||||
}
|
||||
|
||||
@@ -12,7 +12,8 @@ interface BindPhoneResp {
|
||||
interface AuthState {
|
||||
token: string | null;
|
||||
refreshToken: string | null;
|
||||
user: { id: string; username: string; display_name?: string; phone?: string } | null;
|
||||
user: { id: string; username: string; display_name?: string; phone?: string; tenant_id?: string } | null;
|
||||
roles: string[];
|
||||
currentPatient: authApi.PatientInfo | null;
|
||||
patients: authApi.PatientInfo[];
|
||||
loading: boolean;
|
||||
@@ -23,22 +24,30 @@ interface AuthState {
|
||||
loadPatients: () => Promise<void>;
|
||||
logout: () => void;
|
||||
restore: () => void;
|
||||
isMedicalStaff: () => boolean;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
token: null,
|
||||
refreshToken: null,
|
||||
user: null,
|
||||
roles: [],
|
||||
currentPatient: null,
|
||||
patients: [],
|
||||
loading: false,
|
||||
|
||||
isMedicalStaff: () => {
|
||||
const { roles } = get();
|
||||
return roles.some((r) => r === 'doctor' || r === 'nurse' || r === 'admin');
|
||||
},
|
||||
|
||||
restore: () => {
|
||||
const token = secureGet('access_token') || null;
|
||||
const refreshToken = secureGet('refresh_token') || null;
|
||||
const user = Taro.getStorageSync('user') || null;
|
||||
const roles = Taro.getStorageSync('user_roles') || [];
|
||||
const currentPatient = Taro.getStorageSync('current_patient') || null;
|
||||
set({ token, refreshToken, user, currentPatient });
|
||||
set({ token, refreshToken, user, roles, currentPatient });
|
||||
},
|
||||
|
||||
login: async (code: string) => {
|
||||
@@ -47,11 +56,13 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
const resp = await authApi.wechatLogin(code);
|
||||
if (resp.bound && resp.token) {
|
||||
const { access_token, refresh_token, user } = resp.token;
|
||||
const roles = (resp as any).roles?.map((r: any) => r.code || r.name || r) || [];
|
||||
secureSet('access_token', access_token);
|
||||
secureSet('refresh_token', refresh_token);
|
||||
Taro.setStorageSync('user', user);
|
||||
Taro.setStorageSync('user_roles', roles);
|
||||
Taro.setStorageSync('tenant_id', (user as any).tenant_id || '');
|
||||
set({ token: access_token, refreshToken: refresh_token, user, loading: false });
|
||||
set({ token: access_token, refreshToken: refresh_token, user, roles, loading: false });
|
||||
return true;
|
||||
}
|
||||
Taro.setStorageSync('wechat_openid', resp.openid);
|
||||
@@ -71,14 +82,16 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
set({ loading: false });
|
||||
return false;
|
||||
}
|
||||
const resp = await authApi.wechatBindPhone(openid, encryptedData, iv) as BindPhoneResp;
|
||||
const resp = await authApi.wechatBindPhone(openid, encryptedData, iv) as any;
|
||||
const { access_token, refresh_token, user } = resp;
|
||||
const roles = resp.roles?.map((r: any) => r.code || r.name || r) || [];
|
||||
secureSet('access_token', access_token);
|
||||
secureSet('refresh_token', refresh_token);
|
||||
Taro.setStorageSync('user', user);
|
||||
Taro.setStorageSync('user_roles', roles);
|
||||
Taro.setStorageSync('tenant_id', user.tenant_id || '');
|
||||
Taro.removeStorageSync('wechat_openid');
|
||||
set({ token: access_token, refreshToken: refresh_token, user, loading: false });
|
||||
set({ token: access_token, refreshToken: refresh_token, user, roles, loading: false });
|
||||
return true;
|
||||
} catch {
|
||||
set({ loading: false });
|
||||
@@ -108,9 +121,10 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
secureRemove('access_token');
|
||||
secureRemove('refresh_token');
|
||||
Taro.removeStorageSync('user');
|
||||
Taro.removeStorageSync('user_roles');
|
||||
Taro.removeStorageSync('current_patient');
|
||||
Taro.removeStorageSync('current_patient_id');
|
||||
set({ token: null, refreshToken: null, user: null, currentPatient: null, patients: [] });
|
||||
set({ token: null, refreshToken: null, user: null, roles: [], currentPatient: null, patients: [] });
|
||||
Taro.redirectTo({ url: '/pages/login/index' });
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -2,6 +2,11 @@ import Taro from '@tarojs/taro';
|
||||
import CryptoJS from 'crypto-js';
|
||||
|
||||
const ENCRYPTION_KEY = process.env.TARO_APP_ENCRYPTION_KEY || '';
|
||||
const IS_DEV = process.env.NODE_ENV !== 'production';
|
||||
|
||||
if (!ENCRYPTION_KEY && IS_DEV) {
|
||||
console.warn('[secure-storage] TARO_APP_ENCRYPTION_KEY 未设置,敏感数据将以明文存储');
|
||||
}
|
||||
|
||||
function encrypt(plaintext: string): string {
|
||||
if (!ENCRYPTION_KEY) return plaintext;
|
||||
|
||||
Reference in New Issue
Block a user