fix(mp): 五专家组全面审计修复 — 安全+功能+UX+性能+代码质量

安全修复:
- 移除硬编码管理员凭据 admin/Admin@2026,改用环境变量注入
- 移除 forceSetAuth 全局 bridge 方法,减少攻击面
- sanitizeHtml 从黑名单正则升级为白名单方式
- secure-storage 实现 XOR+Base64 加密存储,不再明文
- 添加旧数据迁移逻辑 migrateLegacyStorage

功能修复:
- 新增咨询创建页(consultation/create),修复"发起咨询"按钮导航失败
- 修复咨询详情页长轮询可能永远不启动(dataLoadedRef → useState)
- 新增 createSession service API
- 预约页面从主包移至分包,配置 commonChunks 优化主包体积

UX 修复:
- 65 处硬编码字号 → var(--tk-font-*) token 替换
  - AI 聊天页 13 处、咨询详情页 14 处、医生端核心页 38 处
- StatusTag 色值对齐设计系统色板
- Loading 文字从 --tk-font-h1(28px) 修正为 --tk-font-body-sm
- EmptyState 文字从 --tk-font-num(30px)/--tk-font-h2(22px) 修正
- 医生端 5 处硬编码颜色 → SCSS 变量
This commit is contained in:
iven
2026-05-21 13:35:46 +08:00
parent e769a5785a
commit 652cccf66c
20 changed files with 441 additions and 99 deletions

View File

@@ -20,6 +20,8 @@ export default defineConfig(async (merge) => {
'process.env.TARO_APP_WX_TEMPLATE_CRITICAL_ALERT': JSON.stringify(process.env.TARO_APP_WX_TEMPLATE_CRITICAL_ALERT || ''),
'process.env.TARO_APP_WX_TEMPLATE_HEALTH_ABNORMAL': JSON.stringify(process.env.TARO_APP_WX_TEMPLATE_HEALTH_ABNORMAL || ''),
'process.env.TARO_APP_DEFAULT_TENANT_ID': JSON.stringify(process.env.TARO_APP_DEFAULT_TENANT_ID || ''),
'process.env.TARO_APP_DEV_USER': JSON.stringify(process.env.TARO_APP_DEV_USER || ''),
'process.env.TARO_APP_DEV_PASS': JSON.stringify(process.env.TARO_APP_DEV_PASS || ''),
},
copy: { patterns: [], options: {} },
framework: 'react',
@@ -35,6 +37,13 @@ export default defineConfig(async (merge) => {
exclude: [],
include: [],
},
commonChunks: ['runtime', 'vendors', 'taro', 'common'],
addChunkPages(pages) {
pages.forEach((page) => {
if (page.name === 'app') return;
page.chunks?.unshift('common');
});
},
postcss: {
pxtransform: { enable: true, config: {} },
cssModules: {

View File

@@ -5,11 +5,9 @@ export default defineAppConfig({
'pages/health/index',
'pages/messages/index',
'pages/consultation/index',
'pages/consultation/create/index',
'pages/mall/index',
'pages/profile/index',
'pages/appointment/index',
'pages/appointment/create/index',
'pages/appointment/detail/index',
'pages/legal/user-agreement',
'pages/legal/privacy-policy',
],
@@ -59,6 +57,10 @@ export default defineAppConfig({
root: 'pages/article',
pages: ['index', 'detail/index'],
},
{
root: 'pages/appointment',
pages: ['index', 'create/index', 'detail/index'],
},
{
root: 'pages/pkg-consultation',
pages: ['detail/index'],

View File

@@ -4,6 +4,7 @@ import ErrorBoundary from './components/ErrorBoundary';
import { flushEvents } from './services/analytics';
import { useAuthStore } from './stores/auth';
import { useUIStore } from './stores/ui';
import { migrateLegacyStorage } from './utils/secure-storage';
import './app.scss';
function App({ children }: PropsWithChildren<Record<string, unknown>>) {
@@ -12,6 +13,7 @@ function App({ children }: PropsWithChildren<Record<string, unknown>>) {
// useDidShow 在首次 mount 时也会触发,不需要 useEffect 重复调用
useDidShow(() => {
migrateLegacyStorage();
restoreAuth();
restoreUI();
});
@@ -23,7 +25,6 @@ function App({ children }: PropsWithChildren<Record<string, unknown>>) {
restoreAuth: () => { restoreAuth(); return useAuthStore.getState(); },
restoreUI,
getAuthState: () => useAuthStore.getState(),
forceSetAuth: (state: Record<string, unknown>) => useAuthStore.setState(state),
};
return () => { delete (globalThis as any).__hms; };
}, []);

View File

@@ -27,13 +27,13 @@
}
.empty-state-text {
font-size: var(--tk-font-num);
font-size: var(--tk-font-body-lg);
color: $tx2;
margin-bottom: var(--tk-gap-xs);
}
.empty-state-hint {
font-size: var(--tk-font-h2);
font-size: var(--tk-font-body-sm);
color: var(--tk-text-secondary);
margin-bottom: var(--tk-gap-xl);
}

View File

@@ -25,6 +25,6 @@
}
.loading-state-text {
font-size: var(--tk-font-h1);
font-size: var(--tk-font-body-sm);
color: var(--tk-text-secondary);
}

View File

@@ -35,11 +35,11 @@ const DEFAULT_COLOR_MAP: Record<string, TagColor> = {
};
const COLOR_STYLES: Record<TagColor, { bg: string; color: string }> = {
success: { bg: '#ECFDF5', color: '#5B7A5E' },
warning: { bg: '#FFF7ED', color: '#C4873A' },
error: { bg: '#FEF2F2', color: '#B54A4A' },
info: { bg: '#EFF6FF', color: '#3B82F6' },
default: { bg: '#F5F5F4', color: '#78716C' },
success: { bg: '#E8F0E8', color: '#5B7A5E' },
warning: { bg: '#FFF3E0', color: '#C4873A' },
error: { bg: '#FDEAEA', color: '#B54A4A' },
info: { bg: '#E3F0FA', color: '#4A7AB5' },
default: { bg: '#F0EBE5', color: '#7A756E' },
};
const StatusTag: React.FC<StatusTagProps> = ({

View File

@@ -0,0 +1,68 @@
@import '../../../styles/variables.scss';
.consult-create {
padding: var(--tk-gap-xl);
&__section {
margin-bottom: var(--tk-gap-lg);
}
&__label {
font-size: var(--tk-font-body-sm);
color: $tx2;
margin-bottom: var(--tk-gap-xs);
display: block;
}
&__picker {
display: flex;
align-items: center;
justify-content: space-between;
background: $card;
border: 1px solid $bd;
border-radius: $r-sm;
padding: var(--tk-gap-md) var(--tk-gap-lg);
}
&__picker-text {
font-size: var(--tk-font-body);
color: $tx;
}
&__picker-arrow {
font-size: var(--tk-font-body-lg);
color: $tx3;
}
&__hint {
margin: var(--tk-gap-xl) 0;
padding: var(--tk-gap-md);
background: $surface-alt;
border-radius: $r-sm;
}
&__hint-text {
font-size: var(--tk-font-cap);
color: $tx2;
line-height: 1.6;
}
&__submit {
background: $pri;
border-radius: $r;
padding: var(--tk-gap-lg);
text-align: center;
margin-top: var(--tk-gap-2xl);
box-shadow: $shadow-md;
&--disabled {
opacity: 0.5;
}
}
&__submit-text {
font-size: var(--tk-font-body-lg);
color: $white;
font-weight: 600;
}
}

View File

@@ -0,0 +1,127 @@
import { useState } from 'react';
import { View, Text, Input, Picker } from '@tarojs/components';
import Taro from '@tarojs/taro';
import { createSession } from '@/services/consultation';
import { listDoctors } from '@/services/appointment';
import { useAuthStore } from '@/stores/auth';
import PageShell from '@/components/ui/PageShell';
import { useElderClass } from '@/hooks/useElderClass';
import './index.scss';
const CONSULTATION_TYPES = ['general', 'follow_up', 'urgent'];
const TYPE_LABELS: Record<string, string> = {
general: '普通咨询',
follow_up: '随访咨询',
urgent: '紧急咨询',
};
interface DoctorOption {
id: string;
name: string;
}
export default function ConsultationCreate() {
const currentPatient = useAuthStore((s) => s.currentPatient);
const [doctorList, setDoctorList] = useState<DoctorOption[]>([]);
const [selectedDoctorIdx, setSelectedDoctorIdx] = useState(-1);
const [typeIdx, setTypeIdx] = useState(0);
const [submitting, setSubmitting] = useState(false);
const [doctorsLoaded, setDoctorsLoaded] = useState(false);
const modeClass = useElderClass();
const loadDoctors = async () => {
if (doctorsLoaded) return;
try {
const res = await listDoctors();
const items = (res.data || []).map((d: any) => ({ id: d.id, name: d.name }));
setDoctorList(items);
setDoctorsLoaded(true);
} catch {
Taro.showToast({ title: '加载医生列表失败', icon: 'none' });
}
};
const handleSubmit = async () => {
if (!currentPatient?.id) {
Taro.showToast({ title: '请先完善患者档案', icon: 'none' });
return;
}
if (submitting) return;
setSubmitting(true);
try {
const session = await createSession({
patient_id: currentPatient.id,
doctor_id: selectedDoctorIdx >= 0 ? doctorList[selectedDoctorIdx]?.id : undefined,
consultation_type: CONSULTATION_TYPES[typeIdx],
});
Taro.showToast({ title: '创建成功', icon: 'success' });
setTimeout(() => {
Taro.redirectTo({ url: `/pages/pkg-consultation/detail/index?id=${session.id}` });
}, 500);
} catch {
Taro.showToast({ title: '创建失败,请重试', icon: 'none' });
} finally {
setSubmitting(false);
}
};
const doctorNames = doctorList.map((d) => d.name);
const typeLabels = CONSULTATION_TYPES.map((t) => TYPE_LABELS[t] || t);
return (
<PageShell title='发起咨询' scroll={false}>
<View className={`consult-create ${modeClass}`}>
<View className='consult-create__section'>
<Text className='consult-create__label'></Text>
<Picker mode='selector' range={typeLabels} value={typeIdx} onChange={(e) => setTypeIdx(Number(e.detail.value))}>
<View className='consult-create__picker'>
<Text className='consult-create__picker-text'>{typeLabels[typeIdx]}</Text>
<Text className='consult-create__picker-arrow'></Text>
</View>
</Picker>
</View>
<View className='consult-create__section'>
<Text className='consult-create__label'></Text>
<Picker
mode='selector'
range={doctorNames.length > 0 ? doctorNames : ['点击加载医生列表']}
value={selectedDoctorIdx >= 0 ? selectedDoctorIdx : 0}
onChange={(e) => {
if (!doctorsLoaded) {
loadDoctors();
return;
}
setSelectedDoctorIdx(Number(e.detail.value));
}}
onClick={() => { if (!doctorsLoaded) loadDoctors(); }}
>
<View className='consult-create__picker'>
<Text className='consult-create__picker-text'>
{selectedDoctorIdx >= 0 && doctorsLoaded
? doctorNames[selectedDoctorIdx]
: '不指定(系统分配)'}
</Text>
<Text className='consult-create__picker-arrow'></Text>
</View>
</Picker>
</View>
<View className='consult-create__hint'>
<Text className='consult-create__hint-text'>
</Text>
</View>
<View
className={`consult-create__submit ${submitting ? 'consult-create__submit--disabled' : ''}`}
onClick={handleSubmit}
>
<Text className='consult-create__submit-text'>
{submitting ? '创建中...' : '发起咨询'}
</Text>
</View>
</View>
</PageShell>
);
}

View File

@@ -90,8 +90,14 @@ export default function Login() {
};
const handleDevQuickLogin = async () => {
const devUser = process.env.TARO_APP_DEV_USER || '';
const devPass = process.env.TARO_APP_DEV_PASS || '';
if (!devUser || !devPass) {
Taro.showToast({ title: '未配置开发账号', icon: 'none' });
return;
}
try {
const success = await credentialLogin('admin', 'Admin@2026');
const success = await credentialLogin(devUser, devPass);
if (success) {
navigateAfterLogin();
}

View File

@@ -27,7 +27,7 @@
.ai-chat-nav__title {
font-family: Georgia, 'Times New Roman', serif;
font-size: 17px;
font-size: var(--tk-font-body);
font-weight: 700;
color: $tx;
}
@@ -47,7 +47,7 @@
}
.ai-chat-nav__online-text {
font-size: 11px;
font-size: var(--tk-font-micro);
color: $acc;
}
@@ -74,20 +74,20 @@
.ai-chat-welcome__avatar-char {
color: $white;
font-size: 32px;
font-size: var(--tk-font-num-lg);
font-weight: 600;
font-family: Georgia, 'Times New Roman', serif;
}
.ai-chat-welcome__greeting {
font-size: 17px;
font-size: var(--tk-font-body);
font-weight: 600;
color: $tx;
margin-top: 16px;
}
.ai-chat-welcome__desc {
font-size: 13px;
font-size: var(--tk-font-cap);
color: $tx3;
text-align: center;
margin-top: 6px;
@@ -103,7 +103,7 @@
}
.ai-chat-welcome__hint {
font-size: 12px;
font-size: var(--tk-font-micro);
color: $tx3;
margin-bottom: 12px;
}
@@ -131,11 +131,11 @@
}
.ai-chat-welcome__pill-icon {
font-size: 15px;
font-size: var(--tk-font-body-sm);
}
.ai-chat-welcome__pill-text {
font-size: 13px;
font-size: var(--tk-font-cap);
color: $tx2;
}
@@ -177,7 +177,7 @@
.ai-msg__avatar-char {
color: $white;
font-size: 15px;
font-size: var(--tk-font-body-sm);
font-weight: 600;
}
@@ -203,7 +203,7 @@
.ai-msg__text {
display: block;
width: 100%;
font-size: 15px;
font-size: var(--tk-font-body-sm);
color: $tx;
line-height: 1.6;
word-break: break-word;
@@ -265,13 +265,13 @@
border: none;
border-radius: 20px;
padding: 0 14px;
font-size: 14px;
font-size: var(--tk-font-body-sm);
color: $tx;
}
.ai-chat-bar__placeholder {
color: $tx3;
font-size: 14px;
font-size: var(--tk-font-body-sm);
}
.ai-chat-bar__send {
@@ -295,6 +295,6 @@
.ai-chat-bar__send-icon {
color: $white;
font-size: 20px;
font-size: var(--tk-font-body-lg);
font-weight: 700;
}

View File

@@ -32,7 +32,7 @@
}
.chat-nav__back-icon {
font-size: 24px;
font-size: var(--tk-font-h1);
font-weight: 300;
color: $tx;
line-height: 1;
@@ -46,7 +46,7 @@
.chat-nav__title {
font-family: Georgia, 'Times New Roman', serif;
font-size: 17px;
font-size: var(--tk-font-body);
font-weight: 700;
color: $tx;
}
@@ -66,12 +66,12 @@
}
.chat-nav__online-text {
font-size: 11px;
font-size: var(--tk-font-micro);
color: $acc;
}
.chat-nav__offline-text {
font-size: 11px;
font-size: var(--tk-font-micro);
color: $tx3;
margin-top: 2px;
}
@@ -89,7 +89,7 @@
}
.chat-nav__more-icon {
font-size: 18px;
font-size: var(--tk-font-body-lg);
font-weight: 700;
color: $tx3;
letter-spacing: 1px;
@@ -108,7 +108,7 @@
margin: 8px 0 12px;
&__text {
font-size: 11px;
font-size: var(--tk-font-micro);
color: $tx3;
background: $surface-alt;
padding: 3px 12px;
@@ -145,7 +145,7 @@
.chat-msg__avatar-char {
color: $white;
font-size: 15px;
font-size: var(--tk-font-body-sm);
font-weight: 600;
}
@@ -167,7 +167,7 @@
}
.chat-msg__text {
font-size: 15px;
font-size: var(--tk-font-body-sm);
color: $tx;
line-height: 1.6;
word-wrap: break-word;
@@ -186,7 +186,7 @@
padding: 80px 20px;
&__text {
font-size: 14px;
font-size: var(--tk-font-body-sm);
color: $tx3;
}
}
@@ -221,7 +221,7 @@
}
.chat-bar__add-icon {
font-size: 22px;
font-size: var(--tk-font-h2);
color: $tx3;
font-weight: 300;
}
@@ -233,13 +233,13 @@
border: none;
border-radius: 20px;
padding: 0 14px;
font-size: 14px;
font-size: var(--tk-font-body-sm);
color: $tx;
}
.chat-bar__placeholder {
color: $tx3;
font-size: 14px;
font-size: var(--tk-font-body-sm);
}
.chat-bar__send {
@@ -263,11 +263,11 @@
.chat-bar__send-icon {
color: $white;
font-size: 20px;
font-size: var(--tk-font-h2);
font-weight: 700;
}
.chat-bar__closed-text {
font-size: 14px;
font-size: var(--tk-font-body-sm);
color: $tx3;
}

View File

@@ -29,7 +29,7 @@ export default function ConsultationDetail() {
const scrollViewRef = useRef('');
const messagesRef = useRef<ConsultationMessage[]>([]);
const modeClass = useElderClass();
const dataLoadedRef = useRef(false);
const [dataLoaded, setDataLoaded] = useState(false);
useLongPolling({
pollFn: () => {
@@ -48,7 +48,7 @@ export default function ConsultationDetail() {
return next;
});
},
enabled: !!sessionId && dataLoadedRef.current && session?.status !== 'closed',
enabled: !!sessionId && dataLoaded && session?.status !== 'closed',
});
useEffect(() => {
@@ -70,7 +70,7 @@ export default function ConsultationDetail() {
setMessages(msgs);
messagesRef.current = msgs;
scrollViewRef.current = `msg-${msgs.length}`;
dataLoadedRef.current = true;
setDataLoaded(true);
} catch {
Taro.showToast({ title: '加载失败', icon: 'none' });
} finally {

View File

@@ -38,7 +38,7 @@
}
&__patient {
font-size: 15px;
font-size: var(--tk-font-body-sm);
font-weight: 600;
color: $tx;
}
@@ -47,7 +47,7 @@
display: inline-block;
padding: 1px 6px;
border-radius: 4px;
font-size: 10px;
font-size: var(--tk-font-micro);
font-weight: 600;
color: $card;
background: $dan;
@@ -55,7 +55,7 @@
}
&__desc {
font-size: 13px;
font-size: var(--tk-font-cap);
color: $tx2;
margin-top: 3px;
overflow: hidden;
@@ -65,7 +65,7 @@
}
&__time {
font-size: 11px;
font-size: var(--tk-font-micro);
color: $tx3;
flex-shrink: 0;
text-align: right;
@@ -74,7 +74,7 @@
&__arrow {
flex-shrink: 0;
font-size: 20px;
font-size: var(--tk-font-body-lg);
color: $tx3;
}
}
@@ -84,7 +84,7 @@
display: inline-block;
padding: 2px 8px;
border-radius: 6px;
font-size: 11px;
font-size: var(--tk-font-micro);
font-weight: 600;
line-height: 1.6;
flex-shrink: 0;
@@ -116,14 +116,14 @@
padding: 16px 20px;
border-bottom: 1px solid $bd-l;
.dialog-title { font-size: 14px; font-weight: 600; color: $tx; }
.dialog-close { font-size: 13px; color: $tx3; }
.dialog-title { font-size: var(--tk-font-body-sm); font-weight: 600; color: $tx; }
.dialog-close { font-size: var(--tk-font-cap); color: $tx3; }
}
.dialog-body { padding: 16px 20px; }
.dialog-patient {
font-size: 13px;
font-size: var(--tk-font-cap);
color: $tx2;
display: block;
margin-bottom: 12px;
@@ -150,8 +150,8 @@
}
.thread-content {
.thread-label { font-size: 13px; color: $tx; display: block; }
.thread-time { font-size: 11px; color: $tx3; }
.thread-label { font-size: var(--tk-font-cap); color: $tx; display: block; }
.thread-time { font-size: var(--tk-font-micro); color: $tx3; }
}
.dialog-actions {
@@ -165,7 +165,7 @@
text-align: center;
padding: 12px;
border-radius: $r-sm;
font-size: 13px;
font-size: var(--tk-font-cap);
font-weight: 500;
&.primary { background: var(--tk-pri); color: $card; }

View File

@@ -39,7 +39,7 @@
}
&__name {
font-size: 15px;
font-size: var(--tk-font-body-sm);
font-weight: 600;
color: $tx;
overflow: hidden;
@@ -48,14 +48,14 @@
}
&__time {
font-size: 12px;
font-size: var(--tk-font-micro);
color: $tx3;
flex-shrink: 0;
margin-left: 8px;
}
&__msg {
font-size: 13px;
font-size: var(--tk-font-cap);
color: $tx2;
overflow: hidden;
text-overflow: ellipsis;
@@ -75,14 +75,14 @@
}
&__badge-text {
font-size: 11px;
font-size: var(--tk-font-micro);
color: $card;
font-weight: 700;
}
&__arrow {
flex-shrink: 0;
font-size: 20px;
font-size: var(--tk-font-body-lg);
color: $tx3;
}
}

View File

@@ -40,7 +40,7 @@
}
&__patient {
font-size: 16px;
font-size: var(--tk-font-body);
font-weight: 600;
color: $tx;
}
@@ -49,19 +49,19 @@
display: inline-block;
padding: 2px 8px;
border-radius: 6px;
font-size: 11px;
font-size: var(--tk-font-micro);
font-weight: 600;
line-height: 1.6;
}
&__type {
font-size: 13px;
font-size: var(--tk-font-cap);
color: $tx3;
display: block;
}
&__date {
font-size: 13px;
font-size: var(--tk-font-cap);
color: $tx2;
font-weight: 500;
flex-shrink: 0;
@@ -78,12 +78,12 @@
}
&__data-label {
font-size: 12px;
font-size: var(--tk-font-micro);
color: $tx3;
}
&__data-value {
font-size: 14px;
font-size: var(--tk-font-body-sm);
font-weight: 600;
color: $tx;
}

View File

@@ -22,7 +22,7 @@
&__title {
font-family: Georgia, 'Times New Roman', serif;
font-size: 26px;
font-size: var(--tk-font-h1);
font-weight: 700;
color: $tx;
display: block;
@@ -30,14 +30,14 @@
}
&__date {
font-size: 14px;
font-size: var(--tk-font-body-sm);
color: $tx3;
}
// ── 小节标题对齐原型13px fontWeight600──
&__section-label {
display: block;
font-size: 13px;
font-size: var(--tk-font-cap);
font-weight: 600;
color: $tx2;
margin-bottom: 14px;
@@ -60,7 +60,7 @@
&__stat-value {
font-family: Georgia, 'Times New Roman', serif;
font-size: 28px;
font-size: var(--tk-font-num-lg);
font-weight: 700;
line-height: 1.1;
@@ -71,7 +71,7 @@
}
&__stat-label {
font-size: 12px;
font-size: var(--tk-font-micro);
color: $tx3;
margin-top: 4px;
display: block;

View File

@@ -15,7 +15,7 @@
margin-bottom: 12px;
text {
font-size: 13px;
font-size: var(--tk-font-cap);
color: $tx3;
}
}
@@ -30,8 +30,8 @@
padding: 20px;
text {
font-size: 13px;
color: #78716C;
font-size: var(--tk-font-cap);
color: $tx3;
}
}
}
@@ -48,20 +48,20 @@
border: 1px solid $bd;
&__icon {
font-size: 14px;
font-size: var(--tk-font-body-sm);
flex-shrink: 0;
}
&__input {
flex: 1;
font-size: 14px;
color: #2D2A26;
font-size: var(--tk-font-body-sm);
color: $tx;
height: 100%;
}
&__placeholder {
color: #78716C;
font-size: 14px;
color: $tx3;
font-size: var(--tk-font-body-sm);
}
}
@@ -88,18 +88,18 @@
}
&__name {
font-size: 15px;
font-size: var(--tk-font-body-sm);
font-weight: 600;
color: #2D2A26;
color: $tx;
}
&__meta {
font-size: 12px;
color: #78716C;
font-size: var(--tk-font-micro);
color: $tx3;
}
&__diagnosis {
font-size: 13px;
font-size: var(--tk-font-cap);
color: $doc-pri;
margin-top: 4px;
overflow: hidden;
@@ -109,7 +109,7 @@
}
&__last-visit {
font-size: 12px;
font-size: var(--tk-font-micro);
color: $tx3;
margin-top: 3px;
display: block;
@@ -117,7 +117,7 @@
&__arrow {
flex-shrink: 0;
font-size: 20px;
color: #78716C;
font-size: var(--tk-font-body-lg);
color: $tx3;
}
}

View File

@@ -74,3 +74,11 @@ export async function pollMessages(sessionId: string, afterId?: string) {
const path = `/health/consultation-sessions/${sessionId}/messages/poll${query ? '?' + query : ''}`;
return requestUnlimited<ConsultationMessage[]>('GET', path, undefined, 30000);
}
export async function createSession(params: {
patient_id: string;
doctor_id?: string;
consultation_type?: string;
}) {
return api.post<ConsultationSession>('/health/consultation-sessions', params);
}

View File

@@ -1,7 +1,52 @@
const DANGEROUS_TAG_RE = /<(?:script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>|\/?(?:iframe|object|embed|form|input|textarea|style|link|meta)\b[^>]*)>/gi;
const DANGEROUS_ATTR_RE = /(?:\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)|(?:href|src)\s*=\s*(?:"(?:javascript|data):[^"]*"|'(?:javascript|data):[^']*'))/gi;
const ALLOWED_TAGS = new Set([
'p', 'br', 'hr', 'div', 'span',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'ul', 'ol', 'li',
'table', 'thead', 'tbody', 'tr', 'th', 'td',
'strong', 'em', 'b', 'i', 'u', 's', 'del', 'ins',
'blockquote', 'pre', 'code',
'a', 'img',
'dl', 'dt', 'dd',
'sup', 'sub',
]);
const ALLOWED_ATTRS: Record<string, Set<string>> = {
'*': new Set(['class']),
a: new Set(['href', 'title']),
img: new Set(['src', 'alt', 'width', 'height']),
td: new Set(['colspan', 'rowspan']),
th: new Set(['colspan', 'rowspan']),
};
const URL_ATTRS = new Set(['href', 'src']);
const SAFE_URL_RE = /^(?:https?|mailto|tel):|^$/i;
const TAG_RE = /<\/?([a-zA-Z][a-zA-Z0-9]*)\b[^>]*\/?>/g;
const ATTR_RE = /([a-zA-Z][a-zA-Z0-9-]*)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
export function sanitizeHtml(html: string): string {
if (!html) return '';
return html.replace(DANGEROUS_TAG_RE, '').replace(DANGEROUS_ATTR_RE, '');
return html.replace(TAG_RE, (fullMatch, tagName) => {
const tag = tagName.toLowerCase();
if (!ALLOWED_TAGS.has(tag)) return '';
const allowedForTag = ALLOWED_ATTRS[tag] || new Set();
const allowedGlobal = ALLOWED_ATTRS['*'];
const combined = new Set([...allowedForTag, ...allowedGlobal]);
const cleaned = fullMatch.replace(ATTR_RE, (_, attrName, dqVal, sqVal) => {
const attr = attrName.toLowerCase();
const val = dqVal ?? sqVal ?? '';
if (!combined.has(attr)) return '';
if (URL_ATTRS.has(attr) && !SAFE_URL_RE.test(val)) return '';
return ` ${attr}="${val}"`;
});
return cleaned;
});
}

View File

@@ -1,22 +1,98 @@
import Taro from '@tarojs/taro';
/**
* 持久化存储工具 — 小程序版本
*
* 敏感数据依赖 HTTPS 传输 + 后端 AES-256-GCM 加密保护。
* 导出函数名保留 secure* 前缀以保持调用点兼容,实际为明文存储。
*/
const ENCRYPTION_KEY = process.env.TARO_APP_ENCRYPTION_KEY || 'hms-default-key';
function xorEncrypt(data: string, key: string): string {
let result = '';
for (let i = 0; i < data.length; i++) {
result += String.fromCharCode(data.charCodeAt(i) ^ key.charCodeAt(i % key.length));
}
return result;
}
function toBase64(str: string): string {
try {
const buffer = new Uint8Array(str.length);
for (let i = 0; i < str.length; i++) {
buffer[i] = str.charCodeAt(i);
}
return Taro.arrayBufferToBase64(buffer.buffer);
} catch {
return '';
}
}
function fromBase64(b64: string): string {
try {
const buffer = Taro.base64ToArrayBuffer(b64);
const arr = new Uint8Array(buffer);
let result = '';
for (let i = 0; i < arr.length; i++) {
result += String.fromCharCode(arr[i]);
}
return result;
} catch {
return '';
}
}
const STORAGE_PREFIX = '_es_';
export function secureSet(key: string, value: string): void {
Taro.setStorageSync(key, value);
if (!value) {
Taro.removeStorageSync(STORAGE_PREFIX + key);
return;
}
const encrypted = xorEncrypt(value, ENCRYPTION_KEY);
const encoded = toBase64(encrypted);
if (encoded) {
Taro.setStorageSync(STORAGE_PREFIX + key, encoded);
} else {
Taro.setStorageSync(STORAGE_PREFIX + key, value);
}
}
export function secureGet(key: string): string {
const raw = Taro.getStorageSync(key);
const prefixedKey = STORAGE_PREFIX + key;
const raw = Taro.getStorageSync(prefixedKey);
if (!raw || typeof raw !== 'string') return '';
if (raw.startsWith('{') || raw.startsWith('eyJ')) {
try {
const decoded = fromBase64(raw);
if (decoded) {
return xorEncrypt(decoded, ENCRYPTION_KEY);
}
} catch {
// fallthrough
}
}
return raw;
}
export function secureRemove(key: string): void {
Taro.removeStorageSync(key);
Taro.removeStorageSync(STORAGE_PREFIX + key);
}
const MIGRATION_KEYS = [
'access_token', 'refresh_token', 'token_expires_at',
'user_data', 'user_roles', 'tenant_id', 'wechat_openid',
];
export function migrateLegacyStorage(): void {
try {
for (const key of MIGRATION_KEYS) {
const prefixed = STORAGE_PREFIX + key;
const already = Taro.getStorageSync(prefixed);
if (already) continue;
const legacy = Taro.getStorageSync(key);
if (!legacy || typeof legacy !== 'string') continue;
secureSet(key, legacy);
Taro.removeStorageSync(key);
}
} catch {
// migration best-effort
}
}