feat: Iteration 1 — 审计日志IP记录、文件上传、医护端API、小程序角色切换
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

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:
iven
2026-04-26 13:13:25 +08:00
parent 1326b3e504
commit a0b72b0f73
21 changed files with 679 additions and 12 deletions

View File

@@ -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',

View 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;
}
}

View 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>
);
}

View File

@@ -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' });
}

View File

@@ -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' });
},
}));

View File

@@ -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;