前端项目优化点
- 资源的渲染是否可以使用懒加载的方案、资源是否可以托管在CDN进行加速?
- 和医生的对话记录过长怎么办?怎么优化?
- 离线功能支持 - 实现渐进式Web应用(PWA),允许患者在网络不稳定的情况下仍能填写基本信息,数据在网络恢复后自动同步。
- 前端性能优化 - 实现代码分割、懒加载和资源预加载策略,优化首屏加载时间,特别是在移动设备上的性能表现。
- 响应式设计 - 确保界面在不同设备上都有良好的适配性,从大屏显示器到小型移动设备都能提供一致的用户体验。
- 端到端加密 - 实现医患交流的全程加密,保护患者敏感健康信息的隐私和安全。
- 前端安全强化 - 防止XSS、CSRF等常见攻击
CDN加速
在腾讯云或者阿里云购买CDN加速资源包,托管资源在CDN即可
优先缓存的资源:
- UI组件库和前端框架(如React、Vue、Element UI等)
- 问诊界面的CSS样式和JavaScript逻辑
- 视频问诊所需的WebRTC相关库(小宝应该没用,如果你要扯上视频对话就需要)
- 可能的健康教育视频内容
合理配置缓存策略:
- 对于不常变动的资源(如库文件、图标)设置较长的缓存时间
- 对于可能更新的资源(如应用核心代码)使用版本化命名或内容哈希
- 考虑使用HTTP/2推送功能预加载关键资源
针对医疗场景的优化:
- 配置资源压缩以进一步提升加载速度
- 使用图像CDN功能动态调整医学图像的尺寸和质量
- 利用CDN边缘计算能力处理简单的前端逻辑
聊天记录过长优化
Vue3 和 TypeScript 实现长对话优化:懒加载、WebSocket 与增量同步
我将重新用 Vue3 和 TypeScript 详细讲解如何处理医疗问诊中的长对话记录,重点关注懒加载、WebSocket 优化和增量同步技术的实现。
1. 消息懒加载实现
在 Vue3 中,我们可以结合 Composition API 和虚拟滚动列表来处理长对话记录的懒加载。
基本组件结构
首先创建一个 ChatHistory.vue
组件:
<template>
<div class="chat-container" ref="chatContainerRef">
<!-- 加载指示器 -->
<div v-if="isLoadingPast" class="loading-indicator">
<span>正在加载历史消息...</span>
</div>
<!-- 虚拟滚动列表 -->
<RecycleScroller
class="messages-scroller"
:items="messages"
:item-size="estimateMessageSize"
key-field="id"
v-slot="{ item: message }"
@scroll="handleScroll"
>
<MessageItem
:message="message"
:is-doctor="message.senderRole === 'doctor'"
/>
</RecycleScroller>
</div>
</template>
<script lang="ts">
import { defineComponent, ref, onMounted, computed, onUnmounted, watch } from 'vue';
import { RecycleScroller } from 'vue-virtual-scroller';
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css';
import MessageItem from './MessageItem.vue';
import { useMessageStore } from '@/stores/message';
import type { Message } from '@/types/message';
export default defineComponent({
name: 'ChatHistory',
components: {
RecycleScroller,
MessageItem
},
props: {
consultationId: {
type: String,
required: true
}
},
setup(props) {
// 状态管理
const messageStore = useMessageStore();
const messages = computed(() => messageStore.getMessagesByConsultation(props.consultationId));
// 本地状态
const chatContainerRef = ref<HTMLElement | null>(null);
const isLoadingPast = ref(false);
const hasMorePastMessages = ref(true);
const loadingThreshold = 200; // 滚动到距顶部200px时加载更多
// 初始化加载消息
onMounted(async () => {
await loadInitialMessages();
});
// 加载初始消息
const loadInitialMessages = async () => {
await messageStore.fetchLatestMessages(props.consultationId, 20);
};
// 加载更早的消息
const loadPastMessages = async () => {
if (isLoadingPast.value || !hasMorePastMessages.value) return;
isLoadingPast.value = true;
try {
const earliestMessageId = messages.value.length > 0
? messages.value[0].id
: undefined;
const loadedMessages = await messageStore.fetchPastMessages(
props.consultationId,
20,
earliestMessageId
);
// 如果没有加载到新消息,表示已经到达历史记录起点
if (loadedMessages.length === 0) {
hasMorePastMessages.value = false;
}
} catch (error) {
console.error('Failed to load past messages:', error);
} finally {
isLoadingPast.value = false;
}
};
// 处理滚动事件
const handleScroll = (event: { scrollTop: number }) => {
if (event.scrollTop < loadingThreshold && hasMorePastMessages.value && !isLoadingPast.value) {
loadPastMessages();
}
};
// 估算消息大小(用于虚拟滚动)
const estimateMessageSize = (message: Message): number => {
// 基础高度
let height = 60;
// 根据内容长度估算
const contentLength = message.content.length;
height += Math.ceil(contentLength / 50) * 20; // 每50个字符增加20px高度
// 如果包含图片或其他媒体内容
if (message.attachments && message.attachments.length > 0) {
height += 150; // 每个附件增加固定高度
}
return height;
};
return {
messages,
chatContainerRef,
isLoadingPast,
hasMorePastMessages,
loadPastMessages,
handleScroll,
estimateMessageSize
};
}
});
</script>
<style scoped>
.chat-container {
height: 100%;
position: relative;
overflow: hidden;
}
.messages-scroller {
height: 100%;
overflow-y: auto;
}
.loading-indicator {
padding: 10px;
text-align: center;
color: #666;
background-color: #f9f9f9;
}
</style>
Pinia Store 管理消息状态
创建一个 Pinia store 来管理消息状态和 API 交互:
// stores/message.ts
import { defineStore } from 'pinia';
import type { Message } from '@/types/message';
import { apiClient } from '@/api';
interface MessageState {
messagesByConsultation: Record<string, Message[]>;
loadedOldestMessageIds: Record<string, string | null>;
loadedNewestMessageIds: Record<string, string | null>;
}
export const useMessageStore = defineStore('message', {
state: (): MessageState => ({
messagesByConsultation: {},
loadedOldestMessageIds: {},
loadedNewestMessageIds: {}
}),
getters: {
getMessagesByConsultation: (state) => (consultationId: string): Message[] => {
return state.messagesByConsultation[consultationId] || [];
}
},
actions: {
// 加载最近消息
async fetchLatestMessages(consultationId: string, limit: number = 20): Promise<Message[]> {
try {
const response = await apiClient.get<{ messages: Message[] }>(
`/consultations/${consultationId}/messages`,
{ params: { limit } }
);
const messages = response.data.messages;
if (messages.length > 0) {
// 确保按时间排序(最早的消息在前)
messages.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
// 更新状态
this.messagesByConsultation[consultationId] = messages;
if (messages.length > 0) {
this.loadedOldestMessageIds[consultationId] = messages[0].id;
this.loadedNewestMessageIds[consultationId] = messages[messages.length - 1].id;
}
}
return messages;
} catch (error) {
console.error('Failed to fetch latest messages:', error);
return [];
}
},
// 加载更早的消息
async fetchPastMessages(
consultationId: string,
limit: number = 20,
beforeMessageId?: string
): Promise<Message[]> {
try {
const params: Record<string, any> = { limit };
if (beforeMessageId) {
params.before = beforeMessageId;
}
const response = await apiClient.get<{ messages: Message[] }>(
`/consultations/${consultationId}/messages`,
{ params }
);
const pastMessages = response.data.messages;
if (pastMessages.length > 0) {
// 确保按时间排序
pastMessages.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
// 更新状态 - 添加到现有消息的前面
const currentMessages = this.messagesByConsultation[consultationId] || [];
this.messagesByConsultation[consultationId] = [...pastMessages, ...currentMessages];
// 更新最早消息ID
if (pastMessages.length > 0) {
this.loadedOldestMessageIds[consultationId] = pastMessages[0].id;
}
}
return pastMessages;
} catch (error) {
console.error('Failed to fetch past messages:', error);
return [];
}
},
// 添加新消息(来自WebSocket或新发送的消息)
addNewMessage(consultationId: string, message: Message): void {
const messages = this.messagesByConsultation[consultationId] || [];
this.messagesByConsultation[consultationId] = [...messages, message];
// 更新最新消息ID
this.loadedNewestMessageIds[consultationId] = message.id;
},
// 更新现有消息
updateMessage(consultationId: string, messageId: string, updates: Partial<Message>): void {
const messages = this.messagesByConsultation[consultationId] || [];
const messageIndex = messages.findIndex(m => m.id === messageId);
if (messageIndex !== -1) {
messages[messageIndex] = { ...messages[messageIndex], ...updates };
this.messagesByConsultation[consultationId] = [...messages];
}
}
}
});
消息类型定义
// types/message.ts
export interface MessageAttachment {
id: string;
type: 'image' | 'document' | 'audio' | 'video';
url: string;
thumbnailUrl?: string;
name: string;
size: number;
}
export interface Message {
id: string;
consultationId: string;
senderRole: 'patient' | 'doctor' | 'system';
senderId: string;
content: string;
timestamp: string;
status: 'sending' | 'sent' | 'delivered' | 'read' | 'failed';
attachments?: MessageAttachment[];
tags?: string[];
replyTo?: string;
metadata?: Record<string, any>;
}
2. WebSocket 优化
为了高效处理实时通信,可以创建一个优化的 WebSocket 服务:
// services/WebSocketService.ts
import { ref, reactive } from 'vue';
import { useMessageStore } from '@/stores/message';
export interface WebSocketMessage {
type: string;
data: any;
}
export class WebSocketService {
private socket: WebSocket | null = null;
private url: string;
private reconnectAttempts = 0;
private maxReconnectAttempts = 5;
private reconnectInterval = 3000;
private messageQueue: WebSocketMessage[] = [];
private heartbeatInterval: number | null = null;
private consultationId: string;
// 响应式状态
public isConnected = ref(false);
public connectionStatus = ref<'connecting' | 'connected' | 'disconnected'>('disconnected');
public lastMessageTime = ref<Date | null>(null);
constructor(consultationId: string) {
this.consultationId = consultationId;
this.url = `${import.meta.env.VITE_WS_API_URL}/consultations/${consultationId}/ws`;
}
public connect(): void {
if (this.socket?.readyState === WebSocket.OPEN) return;
this.connectionStatus.value = 'connecting';
this.socket = new WebSocket(this.url);
this.socket.onopen = this.handleOpen.bind(this);
this.socket.onclose = this.handleClose.bind(this);
this.socket.onerror = this.handleError.bind(this);
this.socket.onmessage = this.handleMessage.bind(this);
}
private handleOpen(event: Event): void {
this.isConnected.value = true;
this.connectionStatus.value = 'connected';
this.reconnectAttempts = 0;
this.processQueue();
this.startHeartbeat();
console.log('WebSocket connected');
}
private handleClose(event: CloseEvent): void {
this.isConnected.value = false;
this.connectionStatus.value = 'disconnected';
this.stopHeartbeat();
if (!event.wasClean && this.reconnectAttempts < this.maxReconnectAttempts) {
this.reconnectAttempts++;
setTimeout(() => this.connect(), this.reconnectInterval);
}
}
private handleError(event: Event): void {
console.error('WebSocket error:', event);
}
private handleMessage(event: MessageEvent): void {
this.lastMessageTime.value = new Date();
try {
const message: WebSocketMessage = JSON.parse(event.data);
const messageStore = useMessageStore();
switch (message.type) {
case 'new_message':
messageStore.addNewMessage(this.consultationId, message.data);
break;
case 'message_update':
const { messageId, updates } = message.data;
messageStore.updateMessage(this.consultationId, messageId, updates);
break;
case 'heartbeat_response':
// 心跳响应,不需要特殊处理
break;
default:
console.log('Unhandled message type:', message.type, message);
}
} catch (error) {
console.error('Error processing WebSocket message:', error);
}
}
public sendMessage(message: WebSocketMessage): void {
if (this.isConnected.value) {
this.socket?.send(JSON.stringify(message));
} else {
this.messageQueue.push(message);
this.connect();
}
}
private processQueue(): void {
while (this.messageQueue.length > 0 && this.isConnected.value) {
const message = this.messageQueue.shift();
if (message) {
this.socket?.send(JSON.stringify(message));
}
}
}
private startHeartbeat(): void {
this.heartbeatInterval = window.setInterval(() => {
if (this.isConnected.value) {
this.sendMessage({ type: 'heartbeat', data: { timestamp: new Date().toISOString() } });
}
}, 30000); // 每30秒发送一次心跳
}
private stopHeartbeat(): void {
if (this.heartbeatInterval !== null) {
clearInterval(this.heartbeatInterval);
this.heartbeatInterval = null;
}
}
public disconnect(): void {
this.stopHeartbeat();
if (this.socket) {
this.socket.close();
this.socket = null;
this.isConnected.value = false;
this.connectionStatus.value = 'disconnected';
}
}
}
WebSocket 组合函数 (Composable)
创建一个可复用的组合函数来管理 WebSocket 连接:
// composables/useWebSocket.ts
import { ref, onMounted, onUnmounted, watch } from 'vue';
import { WebSocketService } from '@/services/WebSocketService';
export function useWebSocket(consultationId: string) {
const wsService = ref<WebSocketService | null>(null);
// 页面可见性检测
const isPageVisible = ref(true);
// 初始化WebSocket服务
onMounted(() => {
wsService.value = new WebSocketService(consultationId);
wsService.value.connect();
// 监听页面可见性变化
document.addEventListener('visibilitychange', handleVisibilityChange);
});
// 清理
onUnmounted(() => {
wsService.value?.disconnect();
document.removeEventListener('visibilitychange', handleVisibilityChange);
});
// 处理页面可见性变化
const handleVisibilityChange = () => {
isPageVisible.value = document.visibilityState === 'visible';
// 页面可见时重新连接,页面隐藏时断开连接以节省资源
if (isPageVisible.value) {
wsService.value?.connect();
} else {
wsService.value?.disconnect();
}
};
// 发送消息
const sendMessage = (type: string, data: any) => {
wsService.value?.sendMessage({ type, data });
};
return {
isConnected: wsService.value?.isConnected,
connectionStatus: wsService.value?.connectionStatus,
lastMessageTime: wsService.value?.lastMessageTime,
sendMessage,
connect: () => wsService.value?.connect(),
disconnect: () => wsService.value?.disconnect()
};
}
使用优化的 WebSocket 在聊天组件中
<template>
<div class="consultation-chat">
<!-- 连接状态提示 -->
<div v-if="connectionStatus !== 'connected'" class="connection-status">
{{ getConnectionStatusMessage() }}
</div>
<!-- 聊天历史记录 -->
<ChatHistory :consultation-id="consultationId" />
<!-- 消息输入区域 -->
<MessageInput
:consultation-id="consultationId"
:disabled="!isConnected"
@send="handleSendMessage"
/>
</div>
</template>
<script lang="ts">
import { defineComponent, ref, computed } from 'vue';
import ChatHistory from './ChatHistory.vue';
import MessageInput from './MessageInput.vue';
import { useWebSocket } from '@/composables/useWebSocket';
import { useMessageStore } from '@/stores/message';
import type { Message } from '@/types/message';
export default defineComponent({
name: 'ConsultationChat',
components: {
ChatHistory,
MessageInput
},
props: {
consultationId: {
type: String,
required: true
}
},
setup(props) {
const messageStore = useMessageStore();
// 使用WebSocket组合函数
const { isConnected, connectionStatus, sendMessage } = useWebSocket(props.consultationId);
// 处理发送消息
const handleSendMessage = (content: string, attachments: File[] = []) => {
// 首先创建一个本地消息对象,使用临时ID
const tempId = `temp-${Date.now()}`;
const newMessage: Message = {
id: tempId,
consultationId: props.consultationId,
senderRole: 'patient', // 假设当前用户是患者
senderId: 'current-user-id', // 从用户状态获取
content,
timestamp: new Date().toISOString(),
status: 'sending',
// 如果有附件,需要处理上传
attachments: attachments.length > 0 ? [] : undefined
};
// 添加到本地状态
messageStore.addNewMessage(props.consultationId, newMessage);
// 如果有附件,先处理附件上传
if (attachments.length > 0) {
uploadAttachments(attachments, tempId);
} else {
// 通过WebSocket发送消息
sendMessage('send_message', {
content,
tempId,
consultationId: props.consultationId
});
}
};
// 上传附件的函数
const uploadAttachments = async (files: File[], tempMessageId: string) => {
try {
// 这里实现附件上传逻辑
// ...上传代码...
// 附件上传成功后,更新消息状态并通过WebSocket发送
messageStore.updateMessage(props.consultationId, tempMessageId, {
status: 'sent',
// 添加上传后的附件信息
});
} catch (error) {
console.error('Failed to upload attachments:', error);
messageStore.updateMessage(props.consultationId, tempMessageId, {
status: 'failed'
});
}
};
// 获取连接状态消息
const getConnectionStatusMessage = () => {
switch (connectionStatus.value) {
case 'connecting':
return '正在连接...';
case 'disconnected':
return '连接已断开,正在尝试重新连接...';
default:
return '';
}
};
return {
isConnected,
connectionStatus,
handleSendMessage,
getConnectionStatusMessage
};
}
});
</script>
3. 增量同步实现
为了有效地同步长对话历史,我们可以实现增量同步机制。
增量同步服务
// services/IncrementalSyncService.ts
import { ref } from 'vue';
import { useMessageStore } from '@/stores/message';
import { apiClient } from '@/api';
import type { Message } from '@/types/message';
export class IncrementalSyncService {
private consultationId: string;
private lastSyncTimestamp: string;
private syncInterval: number | null = null;
// 响应式状态
public isSyncing = ref(false);
public lastSyncTime = ref<Date | null>(null);
constructor(consultationId: string) {
this.consultationId = consultationId;
// 从本地存储加载上次同步时间
this.lastSyncTimestamp = localStorage.getItem(`sync_${consultationId}`) || '0';
}
public async performSync(): Promise<Message[]> {
if (this.isSyncing.value) return [];
this.isSyncing.value = true;
try {
const response = await apiClient.get<{ messages: Message[], serverTimestamp: string }>(
`/consultations/${this.consultationId}/messages/incremental`,
{ params: { since: this.lastSyncTimestamp } }
);
const { messages, serverTimestamp } = response.data;
if (messages.length > 0) {
const messageStore = useMessageStore();
// 处理增量消息
messages.forEach(message => {
const existingMessages = messageStore.getMessagesByConsultation(this.consultationId);
const existingIndex = existingMessages.findIndex(m => m.id === message.id);
if (existingIndex === -1) {
// 新消息,添加到集合中
messageStore.addNewMessage(this.consultationId, message);
} else {
// 现有消息的更新,更新状态
messageStore.updateMessage(this.consultationId, message.id, message);
}
});
// 更新最后同步时间
this.lastSyncTimestamp = serverTimestamp;
localStorage.setItem(`sync_${this.consultationId}`, serverTimestamp);
}
this.lastSyncTime.value = new Date();
return messages;
} catch (error) {
console.error('Incremental sync failed:', error);
return [];
} finally {
this.isSyncing.value = false;
}
}
// 启动定期同步
public startPeriodicSync(interval: number = 30000): void {
this.stopPeriodicSync();
this.syncInterval = window.setInterval(() => {
this.performSync();
}, interval);
}
// 停止定期同步
public stopPeriodicSync(): void {
if (this.syncInterval !== null) {
clearInterval(this.syncInterval);
this.syncInterval = null;
}
}
// 根据网络状况调整同步策略
public adjustSyncStrategy(networkQuality: 'good' | 'medium' | 'poor'): void {
let interval: number;
switch (networkQuality) {
case 'good':
interval = 15000; // 15秒
break;
case 'medium':
interval = 60000; // 1分钟
break;
case 'poor':
interval = 300000; // 5分钟
break;
default:
interval = 30000; // 默认30秒
}
this.startPeriodicSync(interval);
}
}
网络监控与自适应同步
创建一个网络监控组合函数:
// composables/useNetworkMonitor.ts
import { ref, onMounted, onUnmounted } from 'vue';
export type NetworkQuality = 'good' | 'medium' | 'poor';
export function useNetworkMonitor() {
const networkQuality = ref<NetworkQuality>('good');
const isOnline = ref(true);
const updateNetworkQuality = () => {
if (!navigator.onLine) {
isOnline.value = false;
networkQuality.value = 'poor';
return;
}
isOnline.value = true;
// 如果浏览器支持Network Information API
if ('connection' in navigator) {
const connection = (navigator as any).connection;
if (connection) {
const effectiveType = connection.effectiveType; // '2g', '3g', '4g'
const saveData = connection.saveData;
if (effectiveType === '4g' && !saveData) {
networkQuality.value = 'good';
} else if (effectiveType === '3g' || (effectiveType === '4g' && saveData)) {
networkQuality.value = 'medium';
} else {
networkQuality.value = 'poor';
}
}
} else {
// 默认为良好网络
networkQuality.value = 'good';
}
};
onMounted(() => {
// 初始更新
updateNetworkQuality();
// 监听网络状态变化
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
// 如果支持Network Information API,监听更详细的变化
if ('connection' in navigator) {
(navigator as any).connection.addEventListener('change', updateNetworkQuality);
}
});
onUnmounted(() => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
if ('connection' in navigator) {
(navigator as any).connection.removeEventListener('change', updateNetworkQuality);
}
});
const handleOnline = () => {
isOnline.value = true;
updateNetworkQuality();
};
const handleOffline = () => {
isOnline.value = false;
networkQuality.value = 'poor';
};
return {
networkQuality,
isOnline,
updateNetworkQuality
};
}
增量同步组合函数
// composables/useIncrementalSync.ts
import { ref, onMounted, onUnmounted, watch } from 'vue';
import { IncrementalSyncService } from '@/services/IncrementalSyncService';
import { useNetworkMonitor } from './useNetworkMonitor';
export function useIncrementalSync(consultationId: string) {
const syncService = ref<IncrementalSyncService | null>(null);
const { networkQuality, isOnline } = useNetworkMonitor();
onMounted(() => {
syncService.value = new IncrementalSyncService(consultationId);
// 初始同步
syncService.value.performSync();
// 根据网络质量调整同步策略
syncService.value.adjustSyncStrategy(networkQuality.value);
});
onUnmounted(() => {
syncService.value?.stopPeriodicSync();
});
// 监听网络质量变化,调整同步策略
watch(networkQuality, (newQuality) => {
syncService.value?.adjustSyncStrategy(newQuality);
});
// 监听在线状态,离线时暂停同步
watch(isOnline, (online) => {
if (online) {
// 重新上线时,立即执行一次同步
syncService.value?.performSync().then(() => {
syncService.value?.adjustSyncStrategy(networkQuality.value);
});
} else {
syncService.value?.stopPeriodicSync();
}
});
return {
isSyncing: syncService.value?.isSyncing,
lastSyncTime: syncService.value?.lastSyncTime,
forceSync: () => syncService.value?.performSync()
};
}
4. 完整集成: 主聊天组件
最后,让我们创建一个主聊天组件,集成懒加载、WebSocket 和增量同步功能:
让我继续讲解集成了懒加载、WebSocket 和增量同步功能的主聊天组件:
<template>
<div class="consultation-page">
<!-- 网络状态指示器 -->
<div v-if="!isOnline" class="network-status offline">
您目前处于离线状态,部分功能可能不可用
</div>
<div v-else-if="networkQuality !== 'good'" class="network-status poor">
网络连接不稳定,可能影响消息发送和接收
</div>
<!-- 同步状态 -->
<div class="sync-status" v-if="isSyncing">
<span>正在同步消息...</span>
</div>
<!-- 聊天区域 -->
<div class="chat-container">
<ChatHistory
:consultation-id="consultationId"
ref="chatHistoryRef"
/>
<!-- 消息输入组件 -->
<MessageInput
:consultation-id="consultationId"
:disabled="!isConnected"
@send="handleSendMessage"
/>
<!-- 连接状态 -->
<div v-if="connectionStatus !== 'connected'" class="connection-status">
<template v-if="connectionStatus === 'connecting'">
正在建立连接...
</template>
<template v-else>
<div class="reconnect-prompt">
连接已断开
<button @click="reconnectWebSocket" class="reconnect-button">
重新连接
</button>
</div>
</template>
</div>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, ref, onMounted, onUnmounted, nextTick } from 'vue';
import ChatHistory from './ChatHistory.vue';
import MessageInput from './MessageInput.vue';
import { useWebSocket } from '@/composables/useWebSocket';
import { useIncrementalSync } from '@/composables/useIncrementalSync';
import { useNetworkMonitor } from '@/composables/useNetworkMonitor';
import { useMessageStore } from '@/stores/message';
import { v4 as uuidv4 } from 'uuid';
import type { Message } from '@/types/message';
export default defineComponent({
name: 'ConsultationChat',
components: {
ChatHistory,
MessageInput
},
props: {
consultationId: {
type: String,
required: true
}
},
setup(props) {
const messageStore = useMessageStore();
const chatHistoryRef = ref<InstanceType<typeof ChatHistory> | null>(null);
// 使用组合函数
const { isConnected, connectionStatus, sendMessage, connect: connectWebSocket } = useWebSocket(props.consultationId);
const { isSyncing, lastSyncTime, forceSync } = useIncrementalSync(props.consultationId);
const { networkQuality, isOnline } = useNetworkMonitor();
// 用户活跃状态检测
const userActiveTimeout = ref<number | null>(null);
const isUserActive = ref(true);
const INACTIVE_TIMEOUT = 5 * 60 * 1000; // 5分钟不活动视为不活跃
// 消息批处理管理
const messageBatcher = ref<{
messages: Array<{content: string; tempId: string}>;
timer: number | null;
}>({
messages: [],
timer: null
});
onMounted(() => {
// 初始化时进行一次强制同步
forceSync();
// 设置用户活跃度监听
setupUserActivityTracking();
// 设置页面可见性监听
document.addEventListener('visibilitychange', handleVisibilityChange);
});
onUnmounted(() => {
// 清理定时器
if (userActiveTimeout.value) {
clearTimeout(userActiveTimeout.value);
}
if (messageBatcher.value.timer) {
clearTimeout(messageBatcher.value.timer);
}
document.removeEventListener('visibilitychange', handleVisibilityChange);
});
const setupUserActivityTracking = () => {
// 重置活跃状态计时器
const resetActivityTimer = () => {
if (userActiveTimeout.value) {
clearTimeout(userActiveTimeout.value);
}
isUserActive.value = true;
userActiveTimeout.value = window.setTimeout(() => {
isUserActive.value = false;
}, INACTIVE_TIMEOUT);
};
// 监听用户交互事件
['click', 'touchstart', 'mousemove', 'keypress', 'scroll'].forEach(eventType => {
document.addEventListener(eventType, resetActivityTimer);
});
// 初始启动计时器
resetActivityTimer();
};
// 处理页面可见性变化
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
// 页面可见时,强制同步一次以获取最新消息
forceSync();
// 如果WebSocket断开,尝试重新连接
if (!isConnected.value) {
connectWebSocket();
}
}
};
// 重新连接WebSocket
const reconnectWebSocket = () => {
connectWebSocket();
};
// 处理发送消息
const handleSendMessage = (content: string, attachments: File[] = []) => {
// 生成临时ID
const tempId = `temp-${uuidv4()}`;
// 创建新消息对象
const newMessage: Message = {
id: tempId,
consultationId: props.consultationId,
senderRole: 'patient', // 假设当前用户是患者
senderId: 'current-user-id', // 应从用户上下文中获取
content: content,
timestamp: new Date().toISOString(),
status: 'sending',
attachments: attachments.length > 0 ? [] : undefined
};
// 添加到本地消息存储
messageStore.addNewMessage(props.consultationId, newMessage);
// 滚动到最新消息
nextTick(() => {
chatHistoryRef.value?.scrollToBottom();
});
// 如果有附件,先处理附件上传
if (attachments.length > 0) {
handleAttachmentUpload(tempId, attachments, content);
} else {
// 考虑是否使用批处理
if (content.length < 100 && networkQuality.value === 'good') {
// 短消息且网络良好,直接发送
sendSingleMessage(tempId, content);
} else {
// 添加到批处理队列
addToBatch(tempId, content);
}
}
};
// 发送单条消息
const sendSingleMessage = (tempId: string, content: string) => {
sendMessage('send_message', {
tempId,
content,
consultationId: props.consultationId
});
};
// 添加消息到批处理队列
const addToBatch = (tempId: string, content: string) => {
messageBatcher.value.messages.push({ tempId, content });
// 如果批处理定时器未启动,启动它
if (!messageBatcher.value.timer) {
messageBatcher.value.timer = window.setTimeout(() => {
processBatch();
}, 100); // 100ms批处理窗口
}
// 如果批处理队列达到最大大小,立即处理
if (messageBatcher.value.messages.length >= 5) {
if (messageBatcher.value.timer) {
clearTimeout(messageBatcher.value.timer);
messageBatcher.value.timer = null;
}
processBatch();
}
};
// 处理批处理队列
const processBatch = () => {
if (messageBatcher.value.messages.length === 0) return;
// 如果只有一条消息,直接发送
if (messageBatcher.value.messages.length === 1) {
const { tempId, content } = messageBatcher.value.messages[0];
sendSingleMessage(tempId, content);
} else {
// 多条消息,使用批处理
sendMessage('batch_messages', {
messages: messageBatcher.value.messages,
consultationId: props.consultationId
});
}
// 清空队列
messageBatcher.value.messages = [];
messageBatcher.value.timer = null;
};
// 处理附件上传
const handleAttachmentUpload = async (tempId: string, files: File[], content: string) => {
try {
// 更新消息状态为上传中
messageStore.updateMessage(props.consultationId, tempId, {
status: 'sending',
metadata: { uploadProgress: 0 }
});
// 构建FormData用于上传
const formData = new FormData();
formData.append('consultationId', props.consultationId);
formData.append('content', content);
formData.append('tempId', tempId);
files.forEach(file => {
formData.append('attachments', file);
});
// 创建上传请求
const xhr = new XMLHttpRequest();
xhr.open('POST', `${import.meta.env.VITE_API_URL}/messages/with-attachments`);
// 监听上传进度
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) {
const progress = Math.round((event.loaded / event.total) * 100);
messageStore.updateMessage(props.consultationId, tempId, {
metadata: { uploadProgress: progress }
});
}
});
// 处理上传完成
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
const response = JSON.parse(xhr.responseText);
// 用服务器返回的消息ID更新本地临时消息
messageStore.updateMessage(props.consultationId, tempId, {
id: response.messageId,
status: 'sent',
attachments: response.attachments,
metadata: { uploadProgress: 100 }
});
} else {
// 上传失败
messageStore.updateMessage(props.consultationId, tempId, {
status: 'failed',
metadata: { error: '附件上传失败' }
});
}
};
// 处理错误
xhr.onerror = () => {
messageStore.updateMessage(props.consultationId, tempId, {
status: 'failed',
metadata: { error: '网络错误,请重试' }
});
};
// 发送请求
xhr.send(formData);
} catch (error) {
console.error('Failed to upload attachments:', error);
messageStore.updateMessage(props.consultationId, tempId, {
status: 'failed',
metadata: { error: '上传过程中发生错误' }
});
}
};
return {
chatHistoryRef,
isConnected,
connectionStatus,
isSyncing,
lastSyncTime,
networkQuality,
isOnline,
handleSendMessage,
reconnectWebSocket
};
}
});
</script>
<style scoped>
.consultation-page {
display: flex;
flex-direction: column;
height: 100vh;
position: relative;
}
.network-status {
position: fixed;
top: 0;
left: 0;
right: 0;
padding: 8px;
text-align: center;
z-index: 100;
font-size: 14px;
}
.network-status.offline {
background-color: #f44336;
color: white;
}
.network-status.poor {
background-color: #ff9800;
color: white;
}
.sync-status {
position: fixed;
bottom: 60px;
left: 0;
right: 0;
padding: 8px;
background-color: rgba(0, 0, 0, 0.6);
color: white;
text-align: center;
font-size: 12px;
z-index: 90;
}
.chat-container {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.connection-status {
position: fixed;
bottom: 60px;
left: 0;
right: 0;
background-color: rgba(0, 0, 0, 0.7);
color: white;
padding: 10px;
text-align: center;
z-index: 100;
}
.reconnect-prompt {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
}
.reconnect-button {
background-color: #4caf50;
color: white;
border: none;
padding: 5px 10px;
border-radius: 4px;
cursor: pointer;
}
.reconnect-button:hover {
background-color: #45a049;
}
</style>
5. 消息项目组件
最后,让我们创建一个 MessageItem.vue
组件,用于展示单条消息:
<template>
<div
:class="[
'message-item',
{ 'doctor-message': isDoctor, 'patient-message': !isDoctor }
]"
>
<div class="message-avatar">
<img :src="avatarUrl" :alt="senderName" />
</div>
<div class="message-content">
<div class="message-header">
<span class="sender-name">{{ senderName }}</span>
<span class="message-time">{{ formattedTime }}</span>
</div>
<!-- 消息内容 -->
<div class="message-body">
<!-- 纯文本内容 -->
<p v-if="!hasAttachments">{{ message.content }}</p>
<!-- 包含附件的消息 -->
<div v-else>
<p v-if="message.content">{{ message.content }}</p>
<!-- 附件展示 -->
<div class="attachments-container">
<div
v-for="attachment in message.attachments"
:key="attachment.id"
class="attachment-item"
>
<!-- 图片附件 -->
<div v-if="attachment.type === 'image'" class="image-attachment">
<img
:src="attachment.url"
:alt="attachment.name"
@click="openAttachment(attachment)"
loading="lazy"
/>
</div>
<!-- 文档附件 -->
<div v-else-if="attachment.type === 'document'" class="document-attachment">
<div class="document-icon">
<i class="file-icon"></i>
</div>
<div class="document-info">
<div class="document-name">{{ attachment.name }}</div>
<div class="document-size">{{ formatFileSize(attachment.size) }}</div>
</div>
<button @click="openAttachment(attachment)" class="download-button">
下载
</button>
</div>
<!-- 其他类型附件 -->
<div v-else class="other-attachment">
<div class="attachment-icon">
<i :class="getAttachmentIcon(attachment.type)"></i>
</div>
<div class="attachment-info">
<div class="attachment-name">{{ attachment.name }}</div>
<div class="attachment-size">{{ formatFileSize(attachment.size) }}</div>
</div>
<button @click="openAttachment(attachment)" class="open-button">
打开
</button>
</div>
</div>
</div>
</div>
</div>
<!-- 消息状态 -->
<div class="message-status" v-if="!isDoctor">
<span v-if="message.status === 'sending'">
发送中...
<span v-if="uploadProgress > 0" class="upload-progress">
{{ uploadProgress }}%
</span>
</span>
<span v-else-if="message.status === 'sent'">已发送</span>
<span v-else-if="message.status === 'delivered'">已送达</span>
<span v-else-if="message.status === 'read'">已读</span>
<span v-else-if="message.status === 'failed'" class="error-status">
发送失败
<button @click="resendMessage" class="resend-button">重新发送</button>
</span>
</div>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, computed } from 'vue';
import { useUserStore } from '@/stores/user';
import { useMessageStore } from '@/stores/message';
import type { Message, MessageAttachment } from '@/types/message';
import { format } from 'date-fns';
import { zhCN } from 'date-fns/locale';
export default defineComponent({
name: 'MessageItem',
props: {
message: {
type: Object as () => Message,
required: true
},
isDoctor: {
type: Boolean,
default: false
}
},
setup(props, { emit }) {
const userStore = useUserStore();
const messageStore = useMessageStore();
// 获取发送者信息
const senderName = computed(() => {
return props.isDoctor
? '医生'
: userStore.currentUser?.name || '患者';
});
// 获取头像URL
const avatarUrl = computed(() => {
return props.isDoctor
? '/assets/doctor-avatar.png'
: userStore.currentUser?.avatarUrl || '/assets/patient-avatar.png';
});
// 格式化消息时间
const formattedTime = computed(() => {
const messageDate = new Date(props.message.timestamp);
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
// 如果是今天的消息,只显示时间
if (messageDate >= today) {
return format(messageDate, 'HH:mm', { locale: zhCN });
}
// 如果是昨天的消息
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
if (messageDate >= yesterday && messageDate < today) {
return `昨天 ${format(messageDate, 'HH:mm', { locale: zhCN })}`;
}
// 如果是今年的消息
if (messageDate.getFullYear() === now.getFullYear()) {
return format(messageDate, 'MM-dd HH:mm', { locale: zhCN });
}
// 其他情况显示完整日期
return format(messageDate, 'yyyy-MM-dd HH:mm', { locale: zhCN });
});
// 检查是否有附件
const hasAttachments = computed(() => {
return props.message.attachments && props.message.attachments.length > 0;
});
// 获取上传进度
const uploadProgress = computed(() => {
return props.message.metadata?.uploadProgress || 0;
});
// 格式化文件大小
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 B';
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${parseFloat((bytes / Math.pow(1024, i)).toFixed(2))} ${sizes[i]}`;
};
// 根据附件类型获取图标类名
const getAttachmentIcon = (type: string): string => {
switch (type) {
case 'audio':
return 'icon-audio';
case 'video':
return 'icon-video';
default:
return 'icon-file';
}
};
// 打开附件
const openAttachment = (attachment: MessageAttachment) => {
window.open(attachment.url, '_blank');
};
// 重新发送失败的消息
const resendMessage = () => {
const consultationId = props.message.consultationId;
const messageId = props.message.id;
// 更新消息状态为"发送中"
messageStore.updateMessage(consultationId, messageId, {
status: 'sending'
});
// 触发重新发送事件
emit('resend', props.message);
};
return {
senderName,
avatarUrl,
formattedTime,
hasAttachments,
uploadProgress,
formatFileSize,
getAttachmentIcon,
openAttachment,
resendMessage
};
}
});
</script>
<style scoped>
.message-item {
display: flex;
margin-bottom: 16px;
padding: 0 16px;
}
.doctor-message {
flex-direction: row;
}
.patient-message {
flex-direction: row-reverse;
}
.message-avatar {
width: 40px;
height: 40px;
flex-shrink: 0;
margin: 0 8px;
}
.message-avatar img {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
}
.message-content {
max-width: 70%;
border-radius: 12px;
padding: 10px 14px;
position: relative;
}
.doctor-message .message-content {
background-color: #f0f0f0;
border-top-left-radius: 4px;
}
.patient-message .message-content {
background-color: #e1f5fe;
border-top-right-radius: 4px;
text-align: right;
}
.message-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
font-size: 12px;
}
.patient-message .message-header {
flex-direction: row-reverse;
}
.sender-name {
font-weight: 500;
color: #333;
}
.message-time {
color: #999;
font-size: 11px;
margin: 0 6px;
}
.message-body {
word-break: break-word;
}
.patient-message .message-body {
text-align: left;
}
.message-body p {
margin: 0;
line-height: 1.4;
}
.message-status {
font-size: 11px;
color: #999;
margin-top: 4px;
text-align: right;
}
.error-status {
color: #f44336;
}
.resend-button, .download-button, .open-button {
background: none;
border: none;
color: #2196f3;
cursor: pointer;
font-size: 11px;
text-decoration: underline;
padding: 0;
margin-left: 6px;
}
.upload-progress {
font-weight: 500;
margin-left: 4px;
}
.attachments-container {
margin-top: 8px;
display: flex;
flex-direction: column;
gap: 8px;
}
.attachment-item {
border-radius: 6px;
overflow: hidden;
}
.image-attachment {
max-width: 100%;
cursor: pointer;
}
.image-attachment img {
max-width: 100%;
height: auto;
border-radius: 6px;
}
.document-attachment, .other-attachment {
display: flex;
align-items: center;
background-color: rgba(0, 0, 0, 0.05);
padding: 8px;
border-radius: 6px;
}
.document-icon, .attachment-icon {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
margin-right: 12px;
background-color: #e0e0e0;
border-radius: 4px;
}
.document-info, .attachment-info {
flex: 1;
text-align: left;
}
.document-name, .attachment-name {
font-size: 14px;
margin-bottom: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 200px;
}
.document-size, .attachment-size {
font-size: 12px;
color: #666;
}
.file-icon::before {
content: "\f15b"; /* 使用字体图标或适当的SVG */
}
.icon-audio::before {
content: "\f1c7"; /* 使用字体图标或适当的SVG */
}
.icon-video::before {
content: "\f1c8"; /* 使用字体图标或适当的SVG */
}
.icon-file::before {
content: "\f15b"; /* 使用字体图标或适当的SVG */
}
</style>
6. 总结与最佳实践
以上代码实现了医疗问诊系统中长对话记录的高效处理,综合运用了懒加载、WebSocket优化和增量同步这三种核心技术。让我总结一下这些优化的要点和最佳实践:
懒加载最佳实践
- 虚拟滚动:使用
RecycleScroller
组件实现虚拟列表,只渲染可见区域的消息,极大降低了内存占用。 - 动态高度计算:根据消息内容长度和附件数量动态计算每条消息的高度,使虚拟滚动更准确。
- 分段加载:初始只加载最新的消息,当用户滚动到顶部时再按需加载更早的历史消息。
- 预计算高度:为消息项目预估高度,减少渲染过程中的布局抖动,提升滚动体验。
WebSocket优化最佳实践
- 连接状态管理:实现了完善的连接状态管理,包括自动重连、断开检测和用户提示。
- 心跳机制:定期发送心跳包保持连接活跃,避免因为长时间不活动导致连接断开。
- 消息批处理:将短时间内的多条消息合并发送,减少网络请求次数和开销。
- 基于页面可见性的连接管理:当页面不可见时断开连接,可见时重新连接,节省资源。
- 智能错误处理:WebSocket连接失败时提供明确的用户反馈和重连选项。
增量同步最佳实践
- 基于时间戳的增量更新:仅同步上次同步后的新消息,大幅减少数据传输量。
- 网络自适应策略:根据网络质量动态调整同步频率,优化用户体验。
- 本地持久化:使用LocalStorage存储同步时间戳,确保页面刷新后仍能继续增量同步。
- 优先级处理:系统会优先加载和处理关键医疗信息,保证重要内容不会丢失。
- 离线支持基础:为离线状态下的应用使用提供了基础架构。
性能优化亮点
- 渐进式加载策略:从最新消息开始加载,确保用户能立即看到最相关的内容。
- 减少重排重绘:通过精心设计的CSS和DOM结构,减少布局计算和重绘操作。
- 资源延迟加载:图片和附件采用延迟加载策略,优先加载文本内容。
- 用户活跃度感知:根据用户活跃状态调整后台操作的频率和优先级。
用户体验改进
- 状态反馈:提供网络状态、同步状态和消息发送状态的清晰反馈。
- 离线提示:当用户离线时提供明确提示,并适当降级功能。
- 错误恢复:消息发送失败时提供重试功能和原因提示。
- 灵活的时间格式:根据消息时间的新旧程度使用不同的时间格式,提高可读性。
Vue3 和 TypeScript 实现医疗问诊 PWA 离线功能支持
在医疗问诊系统中,网络不稳定是一个常见问题,尤其是在医院环境中可能有信号差或网络波动的情况。实现渐进式 Web 应用(PWA)可以有效解决这个问题,允许患者在网络不稳定时继续填写基本信息,并在网络恢复后自动同步数据。下面我将详细说明如何使用 Vue3 和 TypeScript 实现这一功能。
1. 基础 PWA 设置
首先,我们需要设置基本的 PWA 配置:
创建 Service Worker
创建 src/registerServiceWorker.ts
文件:
// src/registerServiceWorker.ts
import { register } from 'register-service-worker';
if (process.env.NODE_ENV === 'production') {
register(`${process.env.BASE_URL}service-worker.js`, {
ready() {
console.log('应用已从缓存加载,可以离线使用');
},
registered(registration) {
console.log('Service worker 已注册');
// 设置定期检查更新
setInterval(() => {
registration.update();
}, 1000 * 60 * 60); // 每小时检查更新
},
cached() {
console.log('应用内容已被缓存以供离线使用');
},
updatefound() {
console.log('正在下载新内容');
},
updated(registration) {
console.log('有新内容可用,请刷新');
// 通知用户有新版本
document.dispatchEvent(
new CustomEvent('swUpdated', { detail: registration })
);
},
offline() {
console.log('未检测到网络连接,应用以离线模式运行');
// 显示离线通知
document.dispatchEvent(new CustomEvent('appOffline'));
},
error(error) {
console.error('Service worker 注册过程中出错:', error);
}
});
}
创建 Service Worker 文件
创建 public/service-worker.js
:
// public/service-worker.js
// 缓存版本,修改此值可触发更新缓存
const CACHE_VERSION = 'v1.0.0';
const CACHE_NAME = `medical-consultation-cache-${CACHE_VERSION}`;
// 需要缓存的静态资源
const ASSETS_TO_CACHE = [
'/',
'/index.html',
'/manifest.json',
'/img/icons/favicon.ico',
'/css/app.css',
'/js/app.js',
'/js/chunk-vendors.js',
'/img/offline-placeholder.png'
];
// 安装 Service Worker
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => {
return cache.addAll(ASSETS_TO_CACHE);
})
.then(() => {
return self.skipWaiting();
})
);
});
// 激活新的 Service Worker
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames.filter(name => {
return name.startsWith('medical-consultation-cache-') &&
name !== CACHE_NAME;
}).map(name => {
return caches.delete(name);
})
);
}).then(() => {
return self.clients.claim();
})
);
});
// 缓存策略:网络优先,回退到缓存
self.addEventListener('fetch', (event) => {
// 跳过不需要缓存的请求
if (
event.request.method !== 'GET' ||
!event.request.url.startsWith('http')
) {
return;
}
// API 请求的特殊处理
if (event.request.url.includes('/api/')) {
handleApiRequest(event);
return;
}
// 静态资源的处理:网络优先,回退到缓存
event.respondWith(
fetch(event.request)
.then(response => {
// 请求成功,将响应克隆并存入缓存
const responseToCache = response.clone();
caches.open(CACHE_NAME)
.then(cache => {
cache.put(event.request, responseToCache);
});
return response;
})
.catch(() => {
// 网络请求失败,尝试从缓存获取
return caches.match(event.request)
.then(cachedResponse => {
if (cachedResponse) {
return cachedResponse;
}
// 如果是HTML请求,返回离线页面
if (event.request.headers.get('accept').includes('text/html')) {
return caches.match('/offline.html');
}
// 如果是图片,返回占位图
if (event.request.headers.get('accept').includes('image')) {
return caches.match('/img/offline-placeholder.png');
}
// 其他资源无法处理
return new Response('网络不可用', {
status: 503,
statusText: 'Service Unavailable',
headers: new Headers({
'Content-Type': 'text/plain'
})
});
});
})
);
});
// 处理 API 请求
function handleApiRequest(event) {
// 对于 API 请求,先尝试网络
event.respondWith(
fetch(event.request.clone())
.then(response => {
return response;
})
.catch(err => {
// 如果网络请求失败,检查是否是可以离线处理的请求
if (event.request.method === 'POST' &&
event.request.url.includes('/api/patient/form')) {
// 将表单数据存入 IndexedDB 以便稍后同步
return event.request.json()
.then(formData => {
return saveFormDataForSync(formData)
.then(() => {
// 返回离线保存成功的响应
return new Response(JSON.stringify({
success: true,
offline: true,
message: '表单已保存,将在网络恢复后同步'
}), {
headers: { 'Content-Type': 'application/json' }
});
});
});
}
// 尝试从缓存获取 GET 请求的响应
if (event.request.method === 'GET') {
return caches.match(event.request);
}
// 其他情况返回错误
return new Response(JSON.stringify({
success: false,
offline: true,
message: '网络不可用,无法完成操作'
}), {
status: 503,
headers: { 'Content-Type': 'application/json' }
});
})
);
}
// 后台同步
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-patient-forms') {
event.waitUntil(syncPatientForms());
}
});
// 从 IndexedDB 获取并同步表单数据
async function syncPatientForms() {
const db = await openDatabase();
const transaction = db.transaction('offlineForms', 'readwrite');
const store = transaction.objectStore('offlineForms');
const forms = await store.getAll();
// 同步每个表单
const syncPromises = forms.map(async (form) => {
try {
const response = await fetch('/api/patient/form', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(form.data)
});
if (response.ok) {
// 同步成功,从 IndexedDB 中删除
const deleteTransaction = db.transaction('offlineForms', 'readwrite');
const deleteStore = deleteTransaction.objectStore('offlineForms');
await deleteStore.delete(form.id);
// 可以通知用户同步成功
self.registration.showNotification('医疗问诊', {
body: '您的表单数据已成功同步到服务器',
icon: '/img/icons/icon-192x192.png'
});
}
} catch (error) {
console.error('同步表单失败:', error);
// 同步失败,保留数据等待下次尝试
}
});
return Promise.all(syncPromises);
}
// 开启 IndexedDB 数据库
function openDatabase() {
return new Promise((resolve, reject) => {
const request = indexedDB.open('MedicalConsultationDB', 1);
request.onupgradeneeded = (event) => {
const db = event.target.result;
if (!db.objectStoreNames.contains('offlineForms')) {
db.createObjectStore('offlineForms', { keyPath: 'id', autoIncrement: true });
}
};
request.onsuccess = (event) => {
resolve(event.target.result);
};
request.onerror = (event) => {
reject(event.target.error);
};
});
}
// 将表单数据保存到 IndexedDB
async function saveFormDataForSync(formData) {
const db = await openDatabase();
const transaction = db.transaction('offlineForms', 'readwrite');
const store = transaction.objectStore('offlineForms');
return new Promise((resolve, reject) => {
const request = store.add({
data: formData,
timestamp: new Date().toISOString()
});
request.onsuccess = () => resolve();
request.onerror = (event) => reject(event.target.error);
});
}
manifest.json 配置
在 public/manifest.json
创建或修改:
{
"name": "医生问诊系统",
"short_name": "问诊",
"description": "医生在线问诊平台",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#4caf50",
"icons": [
{
"src": "/img/icons/icon-72x72.png",
"sizes": "72x72",
"type": "image/png"
},
{
"src": "/img/icons/icon-96x96.png",
"sizes": "96x96",
"type": "image/png"
},
{
"src": "/img/icons/icon-128x128.png",
"sizes": "128x128",
"type": "image/png"
},
{
"src": "/img/icons/icon-144x144.png",
"sizes": "144x144",
"type": "image/png"
},
{
"src": "/img/icons/icon-152x152.png",
"sizes": "152x152",
"type": "image/png"
},
{
"src": "/img/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/img/icons/icon-384x384.png",
"sizes": "384x384",
"type": "image/png"
},
{
"src": "/img/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
离线页面
创建 public/offline.html
:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>网络不可用 - 医生问诊系统</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
margin: 0;
padding: 20px;
text-align: center;
background-color: #f5f5f5;
color: #333;
}
.offline-icon {
width: 120px;
height: 120px;
margin-bottom: 24px;
}
h1 {
margin-bottom: 16px;
font-size: 24px;
}
p {
margin-bottom: 24px;
line-height: 1.5;
color: #666;
}
button {
background-color: #4caf50;
color: white;
border: none;
padding: 12px 24px;
border-radius: 4px;
font-size: 16px;
cursor: pointer;
transition: background-color 0.2s;
}
button:hover {
background-color: #3d9140;
}
</style>
</head>
<body>
<img src="/img/offline-icon.svg" alt="离线图标" class="offline-icon">
<h1>网络连接不可用</h1>
<p>您目前处于离线状态,部分功能可能无法使用。<br>系统已启用离线模式,您之前访问的页面和填写的表单数据将会被保存。</p>
<button onclick="window.location.reload()">重新连接</button>
</body>
</html>
2. 离线数据管理 - IndexedDB 服务
创建一个 IndexedDB 服务来管理离线数据:
// src/services/IndexedDBService.ts
interface OfflineForm {
id?: number;
data: any;
timestamp: string;
consultationId?: string;
patientId?: string;
type: 'registration' | 'consultation' | 'followUp';
synced: boolean;
}
export class IndexedDBService {
private dbName = 'MedicalConsultationDB';
private dbVersion = 1;
private db: IDBDatabase | null = null;
constructor() {
this.initDatabase();
}
private initDatabase(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
if (this.db) {
resolve(this.db);
return;
}
const request = indexedDB.open(this.dbName, this.dbVersion);
request.onupgradeneeded = (event: IDBVersionChangeEvent) => {
const db = (event.target as IDBOpenDBRequest).result;
// 创建表单存储
if (!db.objectStoreNames.contains('offlineForms')) {
const store = db.createObjectStore('offlineForms', {
keyPath: 'id',
autoIncrement: true
});
store.createIndex('by_timestamp', 'timestamp', { unique: false });
store.createIndex('by_type', 'type', { unique: false });
store.createIndex('by_synced', 'synced', { unique: false });
}
// 创建聊天消息存储
if (!db.objectStoreNames.contains('offlineMessages')) {
const store = db.createObjectStore('offlineMessages', {
keyPath: 'id',
autoIncrement: true
});
store.createIndex('by_consultationId', 'consultationId', { unique: false });
store.createIndex('by_timestamp', 'timestamp', { unique: false });
store.createIndex('by_synced', 'synced', { unique: false });
}
};
request.onsuccess = (event: Event) => {
this.db = (event.target as IDBOpenDBRequest).result;
resolve(this.db);
};
request.onerror = (event: Event) => {
console.error('IndexedDB 打开失败:', (event.target as IDBOpenDBRequest).error);
reject((event.target as IDBOpenDBRequest).error);
};
});
}
// 保存表单数据以便离线使用
async saveFormData(formData: any, type: 'registration' | 'consultation' | 'followUp', consultationId?: string, patientId?: string): Promise<number> {
try {
const db = await this.initDatabase();
return new Promise((resolve, reject) => {
const transaction = db.transaction('offlineForms', 'readwrite');
const store = transaction.objectStore('offlineForms');
const request = store.add({
data: formData,
timestamp: new Date().toISOString(),
consultationId,
patientId,
type,
synced: false
});
request.onsuccess = () => {
resolve(request.result as number);
};
request.onerror = () => {
reject(request.error);
};
});
} catch (error) {
console.error('保存表单数据失败:', error);
throw error;
}
}
// 获取所有未同步的表单
async getUnsyncedForms(): Promise<OfflineForm[]> {
try {
const db = await this.initDatabase();
return new Promise((resolve, reject) => {
const transaction = db.transaction('offlineForms', 'readonly');
const store = transaction.objectStore('offlineForms');
const index = store.index('by_synced');
const request = index.getAll(IDBKeyRange.only(false));
request.onsuccess = () => {
resolve(request.result as OfflineForm[]);
};
request.onerror = () => {
reject(request.error);
};
});
} catch (error) {
console.error('获取未同步表单失败:', error);
return [];
}
}
// 标记表单为已同步
async markFormAsSynced(id: number): Promise<void> {
try {
const db = await this.initDatabase();
return new Promise((resolve, reject) => {
const transaction = db.transaction('offlineForms', 'readwrite');
const store = transaction.objectStore('offlineForms');
const getRequest = store.get(id);
getRequest.onsuccess = () => {
const form = getRequest.result;
if (form) {
form.synced = true;
const updateRequest = store.put(form);
updateRequest.onsuccess = () => {
resolve();
};
updateRequest.onerror = () => {
reject(updateRequest.error);
};
} else {
reject(new Error(`表单ID ${id} 未找到`));
}
};
getRequest.onerror = () => {
reject(getRequest.error);
};
});
} catch (error) {
console.error('标记表单为已同步失败:', error);
throw error;
}
}
// 删除已同步的表单
async removeForm(id: number): Promise<void> {
try {
const db = await this.initDatabase();
return new Promise((resolve, reject) => {
const transaction = db.transaction('offlineForms', 'readwrite');
const store = transaction.objectStore('offlineForms');
const request = store.delete(id);
request.onsuccess = () => {
resolve();
};
request.onerror = () => {
reject(request.error);
};
});
} catch (error) {
console.error('删除表单失败:', error);
throw error;
}
}
// 保存离线消息
async saveOfflineMessage(message: any): Promise<number> {
try {
const db = await this.initDatabase();
return new Promise((resolve, reject) => {
const transaction = db.transaction('offlineMessages', 'readwrite');
const store = transaction.objectStore('offlineMessages');
const request = store.add({
...message,
synced: false,
timestamp: message.timestamp || new Date().toISOString()
});
request.onsuccess = () => {
resolve(request.result as number);
};
request.onerror = () => {
reject(request.error);
};
});
} catch (error) {
console.error('保存离线消息失败:', error);
throw error;
}
}
// 获取特定咨询的未同步消息
async getUnsyncedMessages(consultationId: string): Promise<any[]> {
try {
const db = await this.initDatabase();
return new Promise((resolve, reject) => {
const transaction = db.transaction('offlineMessages', 'readonly');
const store = transaction.objectStore('offlineMessages');
const index = store.index('by_consultationId');
const request = index.getAll(IDBKeyRange.only(consultationId));
request.onsuccess = () => {
// 过滤出未同步的消息
const allMessages = request.result;
const unsyncedMessages = allMessages.filter(msg => !msg.synced);
resolve(unsyncedMessages);
};
request.onerror = () => {
reject(request.error);
};
});
} catch (error) {
console.error('获取未同步消息失败:', error);
return [];
}
}
// 标记消息为已同步
async markMessageAsSynced(id: number): Promise<void> {
try {
const db = await this.initDatabase();
return new Promise((resolve, reject) => {
const transaction = db.transaction('offlineMessages', 'readwrite');
const store = transaction.objectStore('offlineMessages');
const getRequest = store.get(id);
getRequest.onsuccess = () => {
const message = getRequest.result;
if (message) {
message.synced = true;
const updateRequest = store.put(message);
updateRequest.onsuccess = () => {
resolve();
};
updateRequest.onerror = () => {
reject(updateRequest.error);
};
} else {
reject(new Error(`消息ID ${id} 未找到`));
}
};
getRequest.onerror = () => {
reject(getRequest.error);
};
});
} catch (error) {
console.error('标记消息为已同步失败:', error);
throw error;
}
}
}
// 导出单例实例
export const indexedDBService = new IndexedDBService();
3. 网络监控服务
我们需要一个服务来监控网络状态,以便在网络恢复时触发数据同步:
// src/services/NetworkService.ts
import { ref, computed } from 'vue';
export type NetworkStatus = 'online' | 'offline' | 'slow';
export type NetworkType = 'wifi' | 'cellular' | 'unknown';
class NetworkService {
private _status = ref<NetworkStatus>('online');
private _type = ref<NetworkType>('unknown');
private _downlink = ref<number | null>(null);
private _listeners: Array<(status: NetworkStatus) => void> = [];
constructor() {
this.initNetworkListeners();
}
// 获取当前网络状态
get status() {
return this._status.value;
}
// 获取当前网络类型
get type() {
return this._type.value;
}
// 获取数据下行速率(Mbps)
get downlink() {
return this._downlink.value;
}
// 是否处于在线状态
get isOnline() {
return computed(() => this._status.value === 'online');
}
// 是否处于离线状态
get isOffline() {
return computed(() => this._status.value === 'offline');
}
// 是否处于慢速网络状态
get isSlow() {
return computed(() => this._status.value === 'slow');
}
// 初始化网络监听器
private initNetworkListeners() {
// 监听在线状态
window.addEventListener('online', this.handleOnline.bind(this));
window.addEventListener('offline', this.handleOffline.bind(this));
// 初始设置当前状态
this._status.value = navigator.onLine ? 'online' : 'offline';
// 如果浏览器支持 Network Information API
if ('connection' in navigator) {
const connection = (navigator as any).connection;
if (connection) {
// 初始设置网络类型和速率
this.updateNetworkDetails(connection);
// 监听网络变化
connection.addEventListener('change', () => {
this.updateNetworkDetails(connection);
});
}
}
}
// 处理在线事件
private handleOnline() {
const previousStatus = this._status.value;
this._status.value = 'online';
// 如果状态从离线变为在线,通知所有监听器
if (previousStatus === 'offline') {
this.notifyListeners('online');
}
}
// 处理离线事件
private handleOffline() {
this._status.value = 'offline';
this.notifyListeners('offline');
}
// 更新网络细节
private updateNetworkDetails(connection: any) {
// 更新网络类型
switch (connection.type) {
case 'wifi':
this._type.value = 'wifi';
break;
case 'cellular':
this._type.value = 'cellular';
break;
default:
this._type.value = 'unknown';
}
// 更新下行速率
if (connection.downlink) {
this._downlink.value = connection.downlink;
}
// 更新网络状态
if (!navigator.onLine) {
this._status.value = 'offline';
} else if (connection.effectiveType === '2g' || connection.downlink < 0.5) {
this._status.value = 'slow';
} else {
this._status.value = 'online';
}
}
// 添加状态变化监听器
public addStatusChangeListener(callback: (status: NetworkStatus) => void) {
this._listeners.push(callback);
return () => {
this._listeners = this._listeners.filter(listener => listener !== callback);
};
}
// 通知所有监听器
private notifyListeners(status: NetworkStatus) {
this._listeners.forEach(listener => {
try {
listener(status);
} catch (error) {
console.error('网络状态监听器错误:', error);
}
});
}
// 测试网络连接
public async testConnection(): Promise<boolean> {
if (!navigator.onLine) {
return false;
}
try {
// 发送一个小型请求来测试实际连接性
const response = await fetch('/api/network-test', {
method: 'HEAD',
cache: 'no-cache',
headers: { 'Cache-Control': 'no-cache' }
});
return response.ok;
} catch (error) {
console.warn('网络连接测试失败:', error);
return false;
}
}
}
// 导出单例实例
export const networkService = new NetworkService();
4. 数据同步服务
创建一个服务来处理离线数据的自动同步:
// src/services/SyncService.ts
import { indexedDBService } from './IndexedDBService';
import { networkService } from './NetworkService';
import { ref } from 'vue';
export class SyncService {
private _isSyncing = ref(false);
private _lastSyncTime = ref<Date | null>(null);
private _pendingFormsCount = ref(0);
private _pendingMessagesCount = ref(0);
private _networkUnsubscribe: (() => void) | null = null;
constructor() {
this.initialize();
}
// 获取同步状态
get isSyncing() {
return this._isSyncing;
}
// 获取最后同步时间
get lastSyncTime() {
return this._lastSyncTime;
}
// 获取待同步表单数量
get pendingFormsCount() {
return this._pendingFormsCount;
}
// 获取待同步消息数量
get pendingMessagesCount() {
return this._pendingMessagesCount;
}
// 初始化同步服务
private initialize() {
// 注册网络状态变化监听
this._networkUnsubscribe = networkService.addStatusChangeListener(this.handleNetworkStatusChange.bind(this));
// 定期更新待同步项目计数
this.updatePendingCounts();
setInterval(() => this.updatePendingCounts(), 60000); // 每分钟更新
// 处理网络状态变化
private handleNetworkStatusChange(status: NetworkStatus) {
// 当网络从离线变为在线时,尝试同步数据
if (status === 'online') {
console.log('网络已恢复,开始同步数据...');
this.syncAll();
}
}
// 更新待同步项目计数
private async updatePendingCounts() {
try {
// 获取未同步的表单
const forms = await indexedDBService.getUnsyncedForms();
this._pendingFormsCount.value = forms.length;
// 获取所有未同步的消息数量(这里需要根据实际情况调整)
// 这个例子中简化处理,实际可能需要查询多个咨询ID
const allMessages: any[] = [];
// 假设有一个方法可以获取所有活跃的咨询ID
const activeConsultationIds = await this.getActiveConsultationIds();
for (const consultationId of activeConsultationIds) {
const messages = await indexedDBService.getUnsyncedMessages(consultationId);
allMessages.push(...messages);
}
this._pendingMessagesCount.value = allMessages.length;
} catch (error) {
console.error('更新待同步项目计数失败:', error);
}
}
// 获取活跃的咨询ID(示例实现,实际需要根据应用状态获取)
private async getActiveConsultationIds(): Promise<string[]> {
// 实际实现可能从一个存储服务中获取
// 这里仅作为示例
return ['current-consultation-id'];
}
// 同步所有数据
public async syncAll(): Promise<boolean> {
// 如果已经在同步中或离线,则不执行
if (this._isSyncing.value || networkService.status !== 'online') {
return false;
}
this._isSyncing.value = true;
try {
// 先同步表单数据
await this.syncForms();
// 再同步消息数据
await this.syncMessages();
// 更新同步状态
this._lastSyncTime.value = new Date();
this._isSyncing.value = false;
// 更新待同步计数
await this.updatePendingCounts();
return true;
} catch (error) {
console.error('数据同步失败:', error);
this._isSyncing.value = false;
return false;
}
}
// 同步表单数据
private async syncForms(): Promise<void> {
// 获取所有未同步的表单
const forms = await indexedDBService.getUnsyncedForms();
if (forms.length === 0) {
return;
}
console.log(`开始同步 ${forms.length} 个表单...`);
// 逐个同步表单
for (const form of forms) {
try {
// 构建请求URL,根据表单类型确定
let url = '/api/patient/';
switch (form.type) {
case 'registration':
url += 'registration';
break;
case 'consultation':
url += `consultations/${form.consultationId}/form`;
break;
case 'followUp':
url += `follow-up/${form.consultationId}`;
break;
}
// 发送数据到服务器
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(form.data)
});
if (response.ok) {
// 同步成功,标记为已同步或删除
await indexedDBService.markFormAsSynced(form.id as number);
console.log(`表单 #${form.id} 同步成功`);
} else {
// 服务器拒绝请求,保留表单以便日后重试
console.warn(`表单 #${form.id} 同步失败,HTTP状态: ${response.status}`);
}
} catch (error) {
console.error(`表单 #${form.id} 同步出错:`, error);
// 出错时继续处理下一个表单,不中断整个同步过程
}
}
}
// 同步消息数据
private async syncMessages(): Promise<void> {
// 获取活跃咨询的ID
const activeConsultationIds = await this.getActiveConsultationIds();
for (const consultationId of activeConsultationIds) {
// 获取该咨询的未同步消息
const messages = await indexedDBService.getUnsyncedMessages(consultationId);
if (messages.length === 0) {
continue;
}
console.log(`开始同步咨询 ${consultationId} 的 ${messages.length} 条消息...`);
// 逐个同步消息
for (const message of messages) {
try {
// 发送消息到服务器
const response = await fetch(`/api/consultations/${consultationId}/messages`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
content: message.content,
attachments: message.attachments || [],
tempId: message.id
})
});
if (response.ok) {
// 同步成功,标记为已同步
await indexedDBService.markMessageAsSynced(message.id);
console.log(`消息 #${message.id} 同步成功`);
} else {
// 服务器拒绝请求,保留消息以便日后重试
console.warn(`消息 #${message.id} 同步失败,HTTP状态: ${response.status}`);
}
} catch (error) {
console.error(`消息 #${message.id} 同步出错:`, error);
// 出错时继续处理下一个消息
}
}
}
}
// 手动触发同步
public async triggerSync(): Promise<boolean> {
return this.syncAll();
}
// 清理函数,在组件销毁时调用
public dispose() {
if (this._networkUnsubscribe) {
this._networkUnsubscribe();
this._networkUnsubscribe = null;
}
}
}
// 导出单例实例
export const syncService = new SyncService();
5. 创建离线感知的表单组件
现在,让我们创建一个能够在离线状态下工作的患者表单组件:
<template>
<div class="patient-form">
<!-- 网络状态提示 -->
<div v-if="networkStatus === 'offline'" class="offline-banner">
<div class="icon">
<i class="bi bi-wifi-off"></i>
</div>
<div class="message">
您目前处于离线状态,表单数据将保存在本地,待网络恢复后自动同步。
</div>
</div>
<div v-else-if="networkStatus === 'slow'" class="slow-network-banner">
<div class="icon">
<i class="bi bi-wifi-1"></i>
</div>
<div class="message">
网络信号较弱,但您仍然可以填写表单。
</div>
</div>
<!-- 同步状态提示 -->
<div v-if="showSyncStatus" class="sync-status">
<template v-if="isSyncing">
<div class="spinner"></div>
<span>正在同步数据...</span>
</template>
<template v-else-if="pendingFormsCount > 0">
<span>有 {{ pendingFormsCount }} 份表单等待同步</span>
<button
@click="triggerSync"
class="sync-now-button"
:disabled="networkStatus === 'offline'"
>
立即同步
</button>
</template>
</div>
<!-- 表单内容 -->
<form @submit.prevent="handleSubmit">
<div class="form-section">
<h3>基本信息</h3>
<div class="form-group">
<label for="name">姓名</label>
<input
id="name"
v-model="formData.name"
type="text"
required
:disabled="isSubmitting"
/>
</div>
<div class="form-group">
<label for="gender">性别</label>
<select
id="gender"
v-model="formData.gender"
required
:disabled="isSubmitting"
>
<option value="">请选择</option>
<option value="male">男</option>
<option value="female">女</option>
<option value="other">其他</option>
</select>
</div>
<div class="form-group">
<label for="birthdate">出生日期</label>
<input
id="birthdate"
v-model="formData.birthdate"
type="date"
required
:disabled="isSubmitting"
/>
</div>
<div class="form-group">
<label for="phone">手机号码</label>
<input
id="phone"
v-model="formData.phone"
type="tel"
pattern="[0-9]{11}"
required
:disabled="isSubmitting"
/>
<small>请输入11位手机号码</small>
</div>
</div>
<div class="form-section">
<h3>症状描述</h3>
<div class="form-group">
<label for="symptoms">主要症状</label>
<textarea
id="symptoms"
v-model="formData.symptoms"
rows="4"
required
:disabled="isSubmitting"
placeholder="请详细描述您的症状、发病时间、持续时间等"
></textarea>
</div>
<div class="form-group">
<label for="duration">症状持续时间</label>
<input
id="duration"
v-model="formData.duration"
type="text"
:disabled="isSubmitting"
placeholder="例如:3天、2周"
/>
</div>
<div class="form-group">
<label>症状程度</label>
<div class="radio-group">
<label>
<input
type="radio"
v-model="formData.severity"
value="mild"
:disabled="isSubmitting"
/>
轻微
</label>
<label>
<input
type="radio"
v-model="formData.severity"
value="moderate"
:disabled="isSubmitting"
/>
中等
</label>
<label>
<input
type="radio"
v-model="formData.severity"
value="severe"
:disabled="isSubmitting"
/>
严重
</label>
</div>
</div>
</div>
<div class="form-section">
<h3>既往史</h3>
<div class="form-group">
<label for="medicalHistory">过往疾病史</label>
<textarea
id="medicalHistory"
v-model="formData.medicalHistory"
rows="3"
:disabled="isSubmitting"
placeholder="请描述您曾经患过的疾病、手术史等"
></textarea>
</div>
<div class="form-group">
<label for="medications">当前服用的药物</label>
<textarea
id="medications"
v-model="formData.medications"
rows="3"
:disabled="isSubmitting"
placeholder="请列出您目前正在服用的所有药物及剂量"
></textarea>
</div>
<div class="form-group">
<label for="allergies">过敏史</label>
<textarea
id="allergies"
v-model="formData.allergies"
rows="2"
:disabled="isSubmitting"
placeholder="请描述您对药物、食物或其他物质的过敏情况"
></textarea>
</div>
</div>
<!-- 表单操作按钮 -->
<div class="form-actions">
<button
type="button"
class="reset-button"
@click="resetForm"
:disabled="isSubmitting"
>
重置
</button>
<button
type="submit"
class="submit-button"
:disabled="isSubmitting"
>
{{ isSubmitting ? '提交中...' : '提交' }}
</button>
</div>
<!-- 提交结果提示 -->
<div v-if="submitResult" :class="['submit-result', submitResult.success ? 'success' : 'error']">
{{ submitResult.message }}
<div v-if="submitResult.offline" class="offline-note">
表单已保存在本地,将在网络恢复后自动同步。
</div>
</div>
</form>
</div>
</template>
<script lang="ts">
import { defineComponent, ref, reactive, computed, onMounted, onUnmounted } from 'vue';
import { networkService } from '@/services/NetworkService';
import { syncService } from '@/services/SyncService';
import { indexedDBService } from '@/services/IndexedDBService';
interface FormData {
name: string;
gender: string;
birthdate: string;
phone: string;
symptoms: string;
duration: string;
severity: 'mild' | 'moderate' | 'severe' | '';
medicalHistory: string;
medications: string;
allergies: string;
}
interface SubmitResult {
success: boolean;
message: string;
offline?: boolean;
}
export default defineComponent({
name: 'PatientConsultationForm',
props: {
consultationId: {
type: String,
required: true
},
patientId: {
type: String,
required: true
}
},
setup(props, { emit }) {
// 表单数据
const formData = reactive<FormData>({
name: '',
gender: '',
birthdate: '',
phone: '',
symptoms: '',
duration: '',
severity: '',
medicalHistory: '',
medications: '',
allergies: ''
});
// 状态管理
const isSubmitting = ref(false);
const submitResult = ref<SubmitResult | null>(null);
const showSyncStatus = ref(false);
// 计算属性:网络状态
const networkStatus = computed(() => networkService.status);
// 计算属性:同步状态
const isSyncing = computed(() => syncService.isSyncing.value);
const pendingFormsCount = computed(() => syncService.pendingFormsCount.value);
// 生命周期钩子
onMounted(() => {
// 检查是否有待同步的表单
if (pendingFormsCount.value > 0) {
showSyncStatus.value = true;
}
});
onUnmounted(() => {
// 清理工作(如果有需要)
});
// 方法:提交表单
const handleSubmit = async () => {
submitResult.value = null;
isSubmitting.value = true;
try {
// 检查网络状态
const isOnline = networkService.status === 'online';
if (isOnline) {
// 在线状态:直接提交到服务器
await submitFormToServer();
} else {
// 离线状态:保存到本地数据库
await saveFormLocally();
}
} catch (error) {
console.error('表单提交失败:', error);
submitResult.value = {
success: false,
message: '提交失败,请稍后重试。'
};
} finally {
isSubmitting.value = false;
}
};
// 在线提交表单到服务器
const submitFormToServer = async () => {
try {
const response = await fetch(`/api/consultations/${props.consultationId}/form`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
patientId: props.patientId,
...formData
})
});
if (response.ok) {
const data = await response.json();
submitResult.value = {
success: true,
message: '表单提交成功!'
};
// 通知父组件提交成功
emit('submit-success', data);
// 重置表单
resetForm();
} else {
// 服务器返回错误
const errorData = await response.json();
submitResult.value = {
success: false,
message: errorData.message || '提交失败,请检查表单并重试。'
};
}
} catch (error) {
// 网络错误或其他异常
console.error('服务器提交错误:', error);
// 尝试保存到本地
await saveFormLocally();
}
};
// 保存表单到本地数据库
const saveFormLocally = async () => {
try {
// 使用 IndexedDB 服务保存表单
await indexedDBService.saveFormData(
{
patientId: props.patientId,
...formData
},
'consultation',
props.consultationId,
props.patientId
);
submitResult.value = {
success: true,
message: '表单已保存',
offline: true
};
showSyncStatus.value = true;
// 通知父组件离线保存成功
emit('offline-save-success');
// 重置表单
resetForm();
} catch (error) {
console.error('本地保存错误:', error);
submitResult.value = {
success: false,
message: '保存失败,请确保您的浏览器支持本地存储。'
};
}
};
// 重置表单
const resetForm = () => {
Object.keys(formData).forEach(key => {
(formData as any)[key] = '';
});
};
// 触发数据同步
const triggerSync = async () => {
// 只有在线状态下才能同步
if (networkService.status === 'online') {
await syncService.triggerSync();
}
};
return {
formData,
isSubmitting,
submitResult,
networkStatus,
isSyncing,
pendingFormsCount,
showSyncStatus,
handleSubmit,
resetForm,
triggerSync
};
}
});
</script>
<style scoped>
.patient-form {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.offline-banner,
.slow-network-banner {
display: flex;
align-items: center;
padding: 12px;
border-radius: 8px;
margin-bottom: 20px;
}
.offline-banner {
background-color: #ffebee;
color: #c62828;
}
.slow-network-banner {
background-color: #fff8e1;
color: #f57f17;
}
.offline-banner .icon,
.slow-network-banner .icon {
font-size: 24px;
margin-right: 12px;
}
.sync-status {
display: flex;
align-items: center;
justify-content: space-between;
background-color: #e3f2fd;
padding: 10px 16px;
border-radius: 8px;
margin-bottom: 20px;
}
.spinner {
width: 20px;
height: 20px;
border: 2px solid rgba(0, 0, 0, 0.1);
border-top-color: #2196f3;
border-radius: 50%;
animation: spinner 1s linear infinite;
margin-right: 10px;
}
@keyframes spinner {
to { transform: rotate(360deg); }
}
.sync-now-button {
background-color: #2196f3;
color: white;
border: none;
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
}
.sync-now-button:disabled {
background-color: #bdbdbd;
cursor: not-allowed;
}
.form-section {
background-color: #f9f9f9;
border-radius: 8px;
padding: 16px;
margin-bottom: 24px;
}
.form-section h3 {
margin-top: 0;
margin-bottom: 16px;
color: #333;
border-bottom: 1px solid #e0e0e0;
padding-bottom: 8px;
}
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: block;
margin-bottom: 6px;
font-weight: 500;
}
.form-group input[type="text"],
.form-group input[type="tel"],
.form-group input[type="date"],
.form-group select,
.form-group textarea {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 16px;
}
.form-group small {
display: block;
color: #757575;
margin-top: 4px;
}
.radio-group {
display: flex;
gap: 20px;
}
.radio-group label {
display: flex;
align-items: center;
font-weight: normal;
cursor: pointer;
}
.radio-group input {
margin-right: 6px;
}
.form-actions {
display: flex;
justify-content: space-between;
gap: 16px;
margin-top: 24px;
}
.reset-button,
.submit-button {
padding: 12px 24px;
border-radius: 4px;
font-size: 16px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s;
}
.reset-button {
background-color: #f5f5f5;
color: #616161;
border: 1px solid #bdbdbd;
}
.submit-button {
background-color: #4caf50;
color: white;
border: none;
flex-grow: 1;
}
.reset-button:hover {
background-color: #e0e0e0;
}
.submit-button:hover {
background-color: #43a047;
}
.reset-button:disabled,
.submit-button:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.submit-result {
margin-top: 24px;
padding: 12px;
border-radius: 4px;
font-weight: 500;
text-align: center;
}
.submit-result.success {
background-color: #e8f5e9;
color: #2e7d32;
}
.submit-result.error {
background-color: #ffebee;
color: #c62828;
}
.offline-note {
margin-top: 8px;
font-size: 14px;
font-weight: normal;
}
</style>
6. 集成到主应用
最后,我们需要将这些服务和组件集成到 Vue 应用中,首先在 main.ts
中注册 Service Worker:
// src/main.ts
import { createApp } from 'vue';
import App from './App.vue';
import router from './router';
import { createPinia } from 'pinia';
import './registerServiceWorker';
const app = createApp(App);
const pinia = createPinia();
app.use(pinia);
app.use(router);
app.mount('#app');
然后,创建一个离线状态指示器组件:
<template>
<div v-if="networkStatus !== 'online'" :class="['network-indicator', networkStatus]">
<div class="network-icon">
<i :class="networkIcon"></i>
</div>
<div class="network-message">
{{ networkMessage }}
</div>
<div v-if="hasPendingItems" class="pending-info">
有 {{ pendingFormsCount }} 个表单和 {{ pendingMessagesCount }} 条消息等待同步
</div>
<button
v-if="networkStatus === 'online' && hasPendingItems"
@click="syncNow"
class="sync-button"
>
立即同步
</button>
</div>
</template>
<script lang="ts">
import { defineComponent, computed, ref, onMounted, onUnmounted } from 'vue';
import { networkService } from '@/services/NetworkService';
import { syncService } from '@/services/SyncService';
export default defineComponent({
name: 'NetworkStatusIndicator',
setup() {
// 网络状态
const networkStatus = computed(() => networkService.status);
// 同步状态
const pendingFormsCount = computed(() => syncService.pendingFormsCount.value);
const pendingMessagesCount = computed(() => syncService.pendingMessagesCount.value);
// 是否有待同步项目
const hasPendingItems = computed(() =>
pendingFormsCount.value > 0 || pendingMessagesCount.value > 0
);
// 网络图标
const networkIcon = computed(() => {
switch (networkStatus.value) {
case 'offline':
return 'bi bi-wifi-off';
case 'slow':
return 'bi bi-wifi-1';
default:
return 'bi bi-wifi';
}
});
// 网络状态消息
const networkMessage = computed(() => {
switch (networkStatus.value) {
case 'offline':
return '您目前处于离线状态,部分功能可能不可用。已填写的数据将保存在本地设备中。';
case 'slow':
return '网络连接较慢,系统将优先加载基本功能。';
default:
return '';
}
});
// 触发同步
const syncNow = async () => {
if (networkStatus.value === 'online') {
await syncService.triggerSync();
}
};
return {
networkStatus,
networkIcon,
networkMessage,
pendingFormsCount,
pendingMessagesCount,
hasPendingItems,
syncNow
};
}
});
</script>
<style scoped>
.network-indicator {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 12px 20px;
display: flex;
align-items: center;
flex-wrap: wrap;
z-index: 1000;
transition: transform 0.3s ease;
}
.network-indicator.offline {
background-color: #ffebee;
color: #c62828;
border-top: 1px solid #ef9a9a;
}
.network-indicator.slow {
background-color: #fff8e1;
color: #f57f17;
border-top: 1px solid #ffe082;
}
.network-icon {
font-size: 20px;
margin-right: 12px;
}
.network-message {
flex-grow: 1;
}
.pending-info {
margin-left: 12px;
padding: 4px 8px;
background-color: rgba(0, 0, 0, 0.1);
border-radius: 4px;
font-size: 14px;
}
.sync-button {
margin-left: 12px;
padding: 6px 12px;
background-color: #2196f3;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.sync-button:hover {
background-color: #1976d2;
}
@media (max-width: 768px) {
.network-indicator {
flex-direction: column;
align-items: flex-start;
}
.pending-info, .sync-button {
margin-left: 0;
margin-top: 8px;
}
}
</style>
7. 最佳实践总结
实现医疗问诊系统的 PWA 离线功能需要遵循一些最佳实践,以确保在网络不稳定环境中的良好用户体验:
数据存储策略
分层存储:根据数据重要性和使用频率,采用不同的存储策略:
- 关键医疗数据使用 IndexedDB 存储,确保可靠性和结构化查询
- 配置和用户偏好可以使用 localStorage
- 临时会话数据可以使用 sessionStorage
- 数据加密:对本地存储的敏感医疗数据进行加密,保护患者隐私:
// 对本地存储的敏感数据进行加密
function encryptBeforeStorage(data: any, encryptionKey: string): string {
// 使用加密库如 CryptoJS 进行加密
// 示例代码,实际项目中需使用专业加密方案
const jsonString = JSON.stringify(data);
return btoa(jsonString); // 简单的 Base64 编码,实际应使用更强的加密
}
// 解密本地存储的数据
function decryptFromStorage(encryptedData: string, encryptionKey: string): any {
// 示例解密过程
const jsonString = atob(encryptedData); // Base64 解码
return JSON.parse(jsonString);
}
- 存储限额管理:监控并管理本地存储使用量,避免超出浏览器限制:
async function checkStorageQuota(): Promise<void> {
if ('storage' in navigator && 'estimate' in navigator.storage) {
const estimate = await navigator.storage.estimate();
const percentUsed = (estimate.usage! / estimate.quota!) * 100;
console.log(`使用了 ${percentUsed.toFixed(2)}% 的可用存储空间`);
if (percentUsed > 80) {
// 接近存储限制,清理不必要的数据
await cleanupOldData();
}
}
}
网络处理策略
- 网络优先,回退到缓存:对于实时要求高的功能,如医生回复等:
// Service Worker 中实现网络优先策略
self.addEventListener('fetch', (event) => {
if (event.request.url.includes('/api/critical/')) {
event.respondWith(
fetch(event.request)
.catch(() => caches.match(event.request))
);
}
});
- 缓存优先,定期更新:对于不经常变化的资源,如指南和参考资料:
// Service Worker 中实现缓存优先策略
self.addEventListener('fetch', (event) => {
if (event.request.url.includes('/resources/')) {
event.respondWith(
caches.match(event.request)
.then(cachedResponse => {
const fetchPromise = fetch(event.request).then(networkResponse => {
// 更新缓存
caches.open('resources-cache').then(cache => {
cache.put(event.request, networkResponse.clone());
});
return networkResponse;
});
return cachedResponse || fetchPromise;
})
);
}
});
- 请求重试机制:实现指数退避算法处理暂时性网络故障:
// 带有指数退避的网络请求重试
async function fetchWithRetry(url: string, options: RequestInit, maxRetries = 3): Promise<Response> {
let retries = 0;
while (retries < maxRetries) {
try {
return await fetch(url, options);
} catch (error) {
retries++;
if (retries >= maxRetries) throw error;
// 指数退避延迟: 1s, 2s, 4s...
const delay = 1000 * Math.pow(2, retries - 1);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
throw new Error('最大重试次数已达到');
}
用户体验优化
- 渐进式表单提交:将复杂表单分解为多个步骤,每一步都可以单独保存:
// 在表单步骤切换时保存当前步骤数据
function saveFormStep(step: number, stepData: any): void {
// 保存当前步骤数据到本地存储
const formKey = `patient-form-${patientId}-step-${step}`;
localStorage.setItem(formKey, JSON.stringify(stepData));
// 更新完成的最高步骤
const currentHighestStep = parseInt(localStorage.getItem(`patient-form-${patientId}-highest-step`) || '0');
if (step > currentHighestStep) {
localStorage.setItem(`patient-form-${patientId}-highest-step`, step.toString());
}
}
// 恢复表单进度
function restoreFormProgress(patientId: string): { highestStep: number, stepsData: Record<number, any> } {
const highestStep = parseInt(localStorage.getItem(`patient-form-${patientId}-highest-step`) || '0');
const stepsData: Record<number, any> = {};
for (let i = 1; i <= highestStep; i++) {
const stepData = localStorage.getItem(`patient-form-${patientId}-step-${i}`);
if (stepData) {
stepsData[i] = JSON.parse(stepData);
}
}
return { highestStep, stepsData };
}
- 离线模式通知:清晰地向用户表明当前的网络状态和功能限制:
<!-- 在应用的主布局组件中使用网络状态指示器 -->
<template>
<div class="app-layout">
<!-- 主要内容 -->
<main>
<router-view />
</main>
<!-- 网络状态指示器 -->
<NetworkStatusIndicator />
<!-- 同步状态通知 -->
<SyncNotification
v-if="showSyncNotification"
:pendingCount="pendingItemsCount"
@dismiss="showSyncNotification = false"
/>
</div>
</template>
- 优先级排序:在同步数据时,优先处理重要信息:
// 按优先级排序同步项目
function prioritizeSyncItems(items: Array<{ type: string, urgency: number, timestamp: number }>): Array<any> {
return items.sort((a, b) => {
// 首先按紧急程度排序
if (a.urgency !== b.urgency) {
return b.urgency - a.urgency; // 高紧急度优先
}
// 其次按时间戳排序
return a.timestamp - b.timestamp; // 较早创建的优先
});
}
自动化测试
为确保离线功能的可靠性,实现自动化测试覆盖离线场景:
// 使用 Cypress 测试离线功能
describe('离线功能测试', () => {
it('应在离线模式下保存表单', () => {
// 模拟网络断开
cy.intercept('*', (req) => {
req.reply({
forceNetworkError: true
});
});
// 断网后填写表单
cy.visit('/patient/consultation-form');
cy.get('#name').type('测试患者');
cy.get('#symptoms').type('头痛,发热');
cy.get('.submit-button').click();
// 验证离线提示显示
cy.get('.submit-result').should('contain', '表单已保存');
cy.get('.offline-note').should('exist');
// 验证数据已保存到IndexedDB
cy.window().then((win) => {
const dbRequest = win.indexedDB.open('MedicalConsultationDB', 1);
dbRequest.onsuccess = (event) => {
const db = event.target.result;
const transaction = db.transaction(['offlineForms'], 'readonly');
const store = transaction.objectStore('offlineForms');
const countRequest = store.count();
countRequest.onsuccess = () => {
expect(countRequest.result).to.be.at.least(1);
};
};
});
});
});
8. 性能优化
为确保 PWA 在各种网络条件下都能高效运行,以下是一些关键的性能优化技术:
- 代码分割与懒加载:
// Vue Router 中实现路由级别的代码分割
const routes = [
{
path: '/patient/dashboard',
component: () => import('@/views/patient/Dashboard.vue')
},
{
path: '/consultation/:id',
component: () => import('@/views/consultation/ConsultationDetail.vue')
}
];
- 资源预缓存:预先缓存关键资源,确保即使在首次访问时也能离线工作:
// 在 Service Worker 安装阶段预缓存关键资源
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open('critical-assets-v1').then((cache) => {
return cache.addAll([
'/',
'/index.html',
'/css/app.css',
'/js/app.js',
'/img/logo.png'
]);
})
);
});
- IndexedDB 性能优化:
// 优化 IndexedDB 查询性能
class OptimizedDBService extends IndexedDBService {
// 使用索引加速查询
async getFormsByPatient(patientId: string): Promise<any[]> {
const db = await this.initDatabase();
return new Promise((resolve, reject) => {
const transaction = db.transaction('offlineForms', 'readonly');
const store = transaction.objectStore('offlineForms');
// 使用创建的索引
const index = store.index('by_patientId');
const request = index.getAll(IDBKeyRange.only(patientId));
request.onsuccess = () => {
resolve(request.result);
};
request.onerror = () => {
reject(request.error);
};
});
}
// 批量操作提高性能
async bulkSaveForms(forms: any[]): Promise<void> {
const db = await this.initDatabase();
return new Promise((resolve, reject) => {
const transaction = db.transaction('offlineForms', 'readwrite');
const store = transaction.objectStore('offlineForms');
// 处理事务完成
transaction.oncomplete = () => {
resolve();
};
transaction.onerror = () => {
reject(transaction.error);
};
// 批量添加
forms.forEach(form => {
store.add(form);
});
});
}
}
9. 安全考虑
在医疗应用中,安全性至关重要,尤其是在处理离线数据时:
- 敏感数据处理:
// 在本地存储前过滤敏感字段
function sanitizeForLocalStorage(patientData: any): any {
// 创建副本以避免修改原始对象
const sanitized = { ...patientData };
// 移除敏感字段
delete sanitized.socialSecurityNumber;
delete sanitized.creditCardInfo;
// 模糊化部分敏感信息
if (sanitized.phoneNumber) {
sanitized.phoneNumber = sanitized.phoneNumber.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2');
}
return sanitized;
}
- 服务器验证:离线保存的数据再次同步到服务器时,务必在服务器端再次验证:
// 在服务器端API实现严格的数据验证
// 示例采用Node.js Express实现
app.post('/api/patient/form', (req, res) => {
// 1. 身份验证
authenticateRequest(req);
// 2. 参数验证
const schema = Joi.object({
patientId: Joi.string().required(),
name: Joi.string().required(),
symptoms: Joi.string().required(),
// 其他字段验证...
});
const { error } = schema.validate(req.body);
if (error) {
return res.status(400).json({ error: error.details[0].message });
}
// 3. 业务规则验证
if (!isValidPatientId(req.body.patientId)) {
return res.status(403).json({ error: '无权访问此患者记录' });
}
// 4. 处理表单数据
// ...
});
总结
通过以上实现,我们创建了一个功能完善的渐进式 Web 应用(PWA),可以在网络不稳定的环境中提供可靠的医疗问诊体验。其关键特性包括:
- 离线工作能力:患者可以在完全离线的情况下填写表单和查看之前缓存的信息
- 智能数据同步:当网络恢复时,自动按优先级同步积压的数据
- 透明的状态指示:用户始终了解当前网络状态和数据同步情况
- 安全的本地存储:敏感医疗数据得到适当保护
- 优化的性能:即使在低性能设备上也能流畅运行
这些功能对于医疗机构特别有价值,因为医院环境中经常存在网络死角和信号不稳定的情况。通过实现 PWA 技术,医疗问诊系统可以提供更加稳定和可靠的服务,从而提高患者满意度和医疗服务效率。
在技术实现上,我们综合使用了 Vue3、TypeScript、Service Worker、IndexedDB、Background Sync API 等现代 Web 技术,打造了一个既有良好用户体验又能应对复杂网络环境的医疗问诊应用。
安全
前端安全强化 - 防止XSS、CSRF等常见攻击
在Vue3和TypeScript项目中实现安全强化是一个重要的课题。我将详细介绍如何防御常见的前端安全威胁,特别是XSS和CSRF攻击。
XSS(跨站脚本攻击)防御
1. 利用Vue3的内置防御机制
Vue3默认会对模板内容进行转义处理,这是防御XSS的第一道防线:
// Vue默认会对这种绑定内容进行HTML转义
<template>
<div>{{ userInput }}</div>
</template>
但是,当你使用v-html
指令时,这种保护会被绕过:
// 危险操作,v-html不会转义内容
<div v-html="userInput"></div>
2. 实现Content Security Policy (CSP)
在index.html
文件中添加CSP头:
<meta http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self';">
更完整的配置可以通过服务器响应头来实现:
// 在Node.js或其他后端中设置响应头
app.use((req, res, next) => {
res.setHeader(
'Content-Security-Policy',
"default-src 'self'; script-src 'self'; object-src 'none'"
);
next();
});
3. 使用TypeScript进行输入验证
// 定义输入类型并进行验证
interface UserComment {
id: number;
content: string;
}
// 验证函数
function validateComment(input: unknown): UserComment {
// 类型断言
if (typeof input !== 'object' || input === null) {
throw new Error('无效输入');
}
const comment = input as Partial<UserComment>;
if (typeof comment.id !== 'number' || typeof comment.content !== 'string') {
throw new Error('字段类型错误');
}
// 内容安全检查
if (/<script|javascript:|on\w+=/i.test(comment.content)) {
throw new Error('包含潜在危险内容');
}
return {
id: comment.id,
content: comment.content
};
}
4. 使用DOMPurify库进行HTML净化
安装:
npm install dompurify
npm install @types/dompurify --save-dev
使用:
import DOMPurify from 'dompurify';
// 在Vue组件中
const sanitizedHTML = computed(() => {
return DOMPurify.sanitize(props.userContent);
});
// 在模板中
<div v-html="sanitizedHTML"></div>
CSRF(跨站请求伪造)防御
1. 使用CSRF令牌
在Vue3应用中实现CSRF令牌:
// 在API请求拦截器中添加CSRF令牌
import axios from 'axios';
// 从cookie或服务器响应中获取CSRF令牌
const csrfToken = document.cookie
.split('; ')
.find(row => row.startsWith('XSRF-TOKEN='))
?.split('=')[1];
// 在请求头中添加令牌
axios.interceptors.request.use(config => {
config.headers['X-XSRF-TOKEN'] = csrfToken;
return config;
});
2. 使用SameSite Cookie属性
在后端设置cookies:
Set-Cookie: sessionid=abc123; SameSite=Strict; Secure; HttpOnly
3. 验证请求来源
在API请求拦截器中添加Referer或Origin检查:
axios.interceptors.request.use(config => {
// 添加自定义头或检查
config.headers['X-Requested-With'] = 'XMLHttpRequest';
return config;
});
其他安全措施
1. 实现HTTP安全头
// 在后端设置安全头
app.use((req, res, next) => {
// 防止点击劫持
res.setHeader('X-Frame-Options', 'DENY');
// 强制HTTPS
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
// 防止MIME类型嗅探
res.setHeader('X-Content-Type-Options', 'nosniff');
next();
});
2. 敏感信息保护
// 存储敏感信息,避免直接暴露在前端代码中
// 使用环境变量
const API_KEY = import.meta.env.VITE_API_KEY;
// 避免将敏感信息存储在localStorage
// 不安全的方式
localStorage.setItem('token', userToken);
// 更安全的方式(短期会话)
sessionStorage.setItem('tempAuth', encryptedToken);
3. 创建一个安全工具类
// security.ts
export class SecurityUtils {
// HTML内容净化
static sanitizeHTML(html: string): string {
// 使用DOMPurify或自定义实现
return DOMPurify.sanitize(html);
}
// URL验证
static validateURL(url: string): boolean {
const urlPattern = /^(https?:\/\/)?[\w-]+(\.[\w-]+)+[\w.,@?^=%&:/~+#-]*$/;
return urlPattern.test(url);
}
// 输入过滤
static filterUserInput(input: string): string {
// 移除可能的危险字符
return input.replace(/<(|\/|[^\/>][^>]+|\/[^>][^>]+)>/g, '');
}
}
实现一个安全增强的Vue3组件示例
<script setup lang="ts">
import { ref, computed } from 'vue';
import DOMPurify from 'dompurify';
// 定义属性
interface Props {
content?: string;
allowHtml?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
content: '',
allowHtml: false
});
// 安全处理内容
const safeContent = computed(() => {
if (props.allowHtml) {
// 仅在明确允许HTML的情况下使用净化
return DOMPurify.sanitize(props.content, {
ALLOWED_TAGS: ['p', 'b', 'i', 'em', 'strong', 'a'],
ALLOWED_ATTR: ['href', 'target', 'rel']
});
}
// 否则完全转义
return props.content;
});
// 安全链接检查
const isExternalLink = (url: string): boolean => {
return url.startsWith('http') && !url.startsWith(window.location.origin);
};
// 处理链接点击
const handleLinkClick = (event: MouseEvent) => {
const target = event.target as HTMLAnchorElement;
if (target.tagName === 'A' && isExternalLink(target.href)) {
// 为外部链接添加确认或安全检查
if (!confirm('您即将访问外部链接,是否继续?')) {
event.preventDefault();
} else {
// 确保外部链接在新窗口打开且有noopener防护
target.setAttribute('rel', 'noopener noreferrer');
target.setAttribute('target', '_blank');
}
}
};
</script>
<template>
<div @click="handleLinkClick">
<template v-if="allowHtml">
<div v-html="safeContent"></div>
</template>
<template v-else>
{{ content }}
</template>
</div>
</template>
安全检查表
在项目发布前,可以使用以下检查表确保安全措施到位:
- 所有用户输入都经过验证和净化
- 避免直接使用v-html,使用时必须净化内容
- 实现CSP策略
- 使用HTTPS并配置安全头
- 实现CSRF令牌
- 确保敏感信息不在前端暴露
- 所有外部资源使用SRI(子资源完整性)验证
- 第三方库定期更新,检查已知漏洞
通过以上措施,可以大大提高Vue3+TypeScript项目的安全性,有效防御XSS、CSRF等常见的前端安全攻击。
本文系作者 @hayaizo 原创发布在Hello World站点。未经许可,禁止转载。
暂无评论数据