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:
@@ -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: {
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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; };
|
||||
}, []);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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> = ({
|
||||
|
||||
68
apps/miniprogram/src/pages/consultation/create/index.scss
Normal file
68
apps/miniprogram/src/pages/consultation/create/index.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
127
apps/miniprogram/src/pages/consultation/create/index.tsx
Normal file
127
apps/miniprogram/src/pages/consultation/create/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user