Commit ab5d6adb by luoqi

feat: update data export and patient detail templates with new fields and…

feat: update data export and patient detail templates with new fields and drag-and-drop functionality
parent 162d4220
Pipeline #3224 passed with stage
in 3 minutes 4 seconds
......@@ -38,8 +38,6 @@ Thumbs.db
# Database files
*.db
*.sqlite
*.sqlite3
# Logs
*.log
......
......@@ -60,6 +60,14 @@ class DataExporter:
cr.operator,
cr.create_time,
cr.update_time,
cr.next_appointment_time,
cr.failure_reason,
cr.abandon_reason,
cr.ai_feedback_type,
cr.failure_reason_note,
cr.abandon_reason_note,
cr.ai_feedback_note,
cr.callback_status,
p.clinic_name
FROM callback_records cr
LEFT JOIN patients p ON cr.case_number = p.case_number
......@@ -74,7 +82,7 @@ class DataExporter:
for record in records:
case_number = record[0]
clinic_name = record[7] or '未知门诊' # 从patients表获取诊所名称
clinic_name = record[15] or '未知门诊' # 从patients表获取诊所名称(索引更新为15)
if clinic_name not in clinic_data:
clinic_data[clinic_name] = {
......@@ -87,15 +95,34 @@ class DataExporter:
'pending_count': 0
}
# 处理callback_methods字段 - 解析JSON字符串
callback_methods_raw = record[1]
try:
if isinstance(callback_methods_raw, str):
callback_methods = json.loads(callback_methods_raw)
else:
callback_methods = callback_methods_raw if callback_methods_raw else []
except (json.JSONDecodeError, TypeError):
# 如果解析失败,尝试直接使用原始值
callback_methods = [str(callback_methods_raw)] if callback_methods_raw else []
# 添加记录
clinic_data[clinic_name]['records'].append({
'case_number': case_number,
'callback_methods': record[1],
'callback_methods': callback_methods,
'callback_result': record[2],
'callback_record': record[3],
'operator': record[4],
'create_time': record[5],
'update_time': record[6]
'update_time': record[6],
'next_appointment_time': record[7],
'failure_reason': record[8],
'abandon_reason': record[9],
'ai_feedback_type': record[10],
'failure_reason_note': record[11],
'abandon_reason_note': record[12],
'ai_feedback_note': record[13],
'callback_status': record[14]
})
# 统计唯一患者数
......@@ -294,7 +321,7 @@ class DataExporter:
# 设置标题
title = f"{clinic_name} - 回访记录详情"
ws['A1'] = title
ws.merge_cells('A1:H1')
ws.merge_cells('A1:M1')
# 设置标题样式
title_font = Font(size=14, bold=True)
......@@ -306,7 +333,8 @@ class DataExporter:
ws['A1'].alignment = title_alignment
# 设置列标题
headers = ["序号", "病例号", "回访方式", "回访结果", "操作员", "创建时间", "更新时间", "详细记录"]
headers = ["序号", "病例号", "回访方式", "回访结果", "操作员", "创建时间", "更新时间", "详细记录",
"下次预约时间", "不成功备注", "放弃回访备注", "AI反馈类型", "AI反馈备注"]
for col, header in enumerate(headers, 1):
cell = ws.cell(row=3, column=col, value=header)
cell.font = Font(bold=True)
......@@ -324,6 +352,21 @@ class DataExporter:
ws.cell(row=row_idx, column=7, value=record['update_time'].strftime("%Y-%m-%d %H:%M:%S") if record['update_time'] else "")
ws.cell(row=row_idx, column=8, value=record['callback_record'])
# 新增字段
ws.cell(row=row_idx, column=9, value=record.get('next_appointment_time', '') or '')
# 备注字段:根据回访结果显示相应的备注
failure_note = record.get('failure_reason_note', '') or ''
abandon_note = record.get('abandon_reason_note', '') or ''
ws.cell(row=row_idx, column=10, value=failure_note)
ws.cell(row=row_idx, column=11, value=abandon_note)
# AI反馈:分别显示AI反馈类型和备注
ai_feedback_type = record.get('ai_feedback_type', '') or ''
ai_feedback_note = record.get('ai_feedback_note', '') or ''
ws.cell(row=row_idx, column=12, value=ai_feedback_type)
ws.cell(row=row_idx, column=13, value=ai_feedback_note)
# 根据回访结果设置颜色
if record['callback_result'] == '成功':
fill_color = "C6EFCE" # 绿色
......@@ -334,16 +377,16 @@ class DataExporter:
else:
fill_color = "FFFFFF" # 白色
for col in range(1, 9):
for col in range(1, 14): # 更新为13列
ws.cell(row=row_idx, column=col).fill = PatternFill(start_color=fill_color, end_color=fill_color, fill_type="solid")
# 调整列宽
column_widths = [8, 15, 20, 12, 15, 20, 20, 50]
column_widths = [8, 15, 20, 12, 15, 20, 20, 50, 18, 25, 25, 25, 30] # 新增5列的宽度
for col, width in enumerate(column_widths, 1):
ws.column_dimensions[get_column_letter(col)].width = width
# 设置边框
self._add_borders(ws, 3, len(data['records']) + 3, 8)
self._add_borders(ws, 3, len(data['records']) + 3, 13) # 更新为13列
def _add_borders(self, ws, start_row: int, end_row: int, end_col: int):
"""添加边框"""
......
......@@ -142,7 +142,7 @@
border: 1px solid rgba(255, 255, 255, 0.2);
cursor: move;
user-select: none;
transition: box-shadow 0.3s ease;
transition: box-shadow 0.3s ease, transform 0.1s ease;
}
.nav-sidebar:hover {
......@@ -151,7 +151,8 @@
.nav-sidebar.dragging {
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.2);
transform: translateY(-50%) scale(1.02);
scale: 1.02;
transition: none;
}
.nav-drag-handle {
......@@ -171,11 +172,16 @@
background: #9ca3af;
}
.nav-drag-handle:active {
.nav-drag-handle:active,
.nav-sidebar.dragging .nav-drag-handle {
cursor: grabbing;
background: #6b7280;
}
.nav-sidebar.dragging {
cursor: grabbing;
}
.nav-sidebar::before {
content: '';
position: absolute;
......@@ -224,6 +230,8 @@
text-align: center;
}
/* 主内容区域样式 */
.main-content {
margin-left: 0;
......@@ -266,7 +274,7 @@
<!-- 左侧快捷导航 -->
<nav class="nav-sidebar" id="navSidebar">
<div class="nav-drag-handle" id="dragHandle"></div>
<div class="nav-drag-handle" id="dragHandle" title="拖拽移动导航栏"></div>
<a href="#patient-profile" class="nav-item" title="患者画像">
<i class="fas fa-user-circle mr-2"></i>
患者画像
......@@ -736,13 +744,16 @@
class="w-full px-3 py-2 border border-orange-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
>
<option value="">请选择放弃回访的原因</option>
<option value="患者明确拒绝治疗">患者明确拒绝治疗</option>
<option value="患者已在其他医院治疗">患者已在其他医院治疗</option>
<option value="联系方式失效无法联系">联系方式失效无法联系</option>
<option value="患者要求不再联系">患者要求不再联系</option>
<option value="有下次预约">有下次预约</option>
<option value="漏诊项不存在">漏诊项不存在</option>
<option value="漏诊项已治疗">漏诊项已治疗</option>
<option value="CT影像截取识别不完整,患者无漏诊项">CT影像截取识别不完整,患者无漏诊项</option>
<option value="患者持续复诊中">患者持续复诊中</option>
<option value="患者已由专属客服近期回访过">患者已由专属客服近期回访过</option>
<option value="患者已做活动义齿修复">患者已做活动义齿修复</option>
<option value="正畸拔牙患者(缺失牙为治疗设计,不需回访)">正畸拔牙患者(缺失牙为治疗设计,不需回访)</option>
<option value="患者缺失牙但无修复空间(无法种植)">患者缺失牙但无修复空间(无法种植)</option>
<option value="患者正在持续复诊/治疗中">患者正在持续复诊/治疗中</option>
<option value="牙槽骨吸收提示轻微或无明显临床意义(无需回访)">牙槽骨吸收提示轻微或无明显临床意义(无需回访)</option>
<option value="正畸患者出现牙槽骨吸收提示(一般无需回访)">正畸患者出现牙槽骨吸收提示(一般无需回访)</option>
<option value="其他">其他</option>
</select>
......@@ -764,10 +775,13 @@
<label class="block text-sm font-medium text-gray-700 mb-2 text-left">
<i class="fas fa-robot mr-1"></i>AI执行反馈(可选):
</label>
<!-- 一级选项 -->
<select
id="ai-feedback-{{ patient.病历号 }}"
name="ai-feedback"
id="ai-feedback-level1-{{ patient.病历号 }}"
name="ai-feedback-level1"
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
onchange="toggleAIFeedbackLevel2('{{ patient.病历号 }}', this.value)"
>
<option value="">无问题</option>
<option value="AI漏诊检测错误(检测不准确或遗漏)">AI漏诊检测错误(检测不准确或遗漏)</option>
......@@ -779,12 +793,26 @@
<option value="AI生成内容格式错误或信息缺失">AI生成内容格式错误或信息缺失</option>
<option value="AI未能正确解析患者数据或病历">AI未能正确解析患者数据或病历</option>
<option value="AI功能不稳定或响应速度慢">AI功能不稳定或响应速度慢</option>
<option value="漏诊项已治疗">漏诊项已治疗</option>
<option value="CT影像截取识别不完整,患者无漏诊项">CT影像截取识别不完整,患者无漏诊项</option>
<option value="AI牙位标注错误">AI牙位标注错误</option>
<option value="其他AI相关问题">其他AI相关问题</option>
</select>
<!-- 二级选项(仅在选择"AI漏诊检测错误"时显示) -->
<div id="ai-feedback-level2-{{ patient.病历号 }}" class="mt-3 hidden">
<label class="block text-sm font-medium text-gray-700 mb-2 text-left">具体错误类型:</label>
<select
id="ai-feedback-{{ patient.病历号 }}"
name="ai-feedback"
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="">请选择具体错误类型</option>
<option value="CT影像只截取部分,AI识别不完整,误判缺失牙">CT影像只截取部分,AI识别不完整,误判缺失牙</option>
<option value="AI误识别缺失牙,实际存在">AI误识别缺失牙,实际存在</option>
<option value="AI误将正畸拔牙识别为缺失牙">AI误将正畸拔牙识别为缺失牙</option>
<option value="AI缺失牙标注牙位错误">AI缺失牙标注牙位错误</option>
<option value="AI误识别牙槽骨吸收(年轻患者轻微情况)">AI误识别牙槽骨吸收(年轻患者轻微情况)</option>
</select>
</div>
<!-- AI反馈备注 -->
<div class="mt-3">
<label class="block text-sm font-medium text-gray-700 mb-2 text-left">AI反馈备注(可选):</label>
......@@ -827,6 +855,7 @@
initializeScrollTriggers();
initializeNavigation();
loadCallbackRecords();
initializeDragAndDrop();
});
// 初始化滚动触发器
......@@ -855,12 +884,172 @@
});
}
// 初始化拖拽功能
function initializeDragAndDrop() {
const navSidebar = document.getElementById('navSidebar');
const dragHandle = document.getElementById('dragHandle');
if (!navSidebar || !dragHandle) return;
let isDragging = false;
let startX, startY;
let initialX, initialY;
let currentX = 0, currentY = 0;
// 将变量存储到全局对象中,以便重置函数访问
window.navSidebarDragData = {
currentX: 0,
currentY: 0,
updatePosition: updateSidebarPosition
};
// 从localStorage恢复位置
const savedPosition = localStorage.getItem('navSidebarPosition');
if (savedPosition) {
const position = JSON.parse(savedPosition);
currentX = position.x;
currentY = position.y;
window.navSidebarDragData.currentX = currentX;
window.navSidebarDragData.currentY = currentY;
updateSidebarPosition();
}
// 鼠标事件
dragHandle.addEventListener('mousedown', startDrag);
navSidebar.addEventListener('mousedown', startDrag);
document.addEventListener('mousemove', drag);
document.addEventListener('mouseup', endDrag);
// 触摸事件(移动端支持)
dragHandle.addEventListener('touchstart', startDragTouch, { passive: false });
navSidebar.addEventListener('touchstart', startDragTouch, { passive: false });
document.addEventListener('touchmove', dragTouch, { passive: false });
document.addEventListener('touchend', endDrag);
function startDrag(e) {
if (e.target.classList.contains('nav-item') || e.target.closest('.nav-item')) {
return; // 不拖拽导航项
}
isDragging = true;
navSidebar.classList.add('dragging');
startX = e.clientX - currentX;
startY = e.clientY - currentY;
e.preventDefault();
}
function startDragTouch(e) {
if (e.target.classList.contains('nav-item') || e.target.closest('.nav-item')) {
return; // 不拖拽导航项
}
isDragging = true;
navSidebar.classList.add('dragging');
const touch = e.touches[0];
startX = touch.clientX - currentX;
startY = touch.clientY - currentY;
e.preventDefault();
}
function drag(e) {
if (!isDragging) return;
e.preventDefault();
currentX = e.clientX - startX;
currentY = e.clientY - startY;
updateSidebarPosition();
}
function dragTouch(e) {
if (!isDragging) return;
e.preventDefault();
const touch = e.touches[0];
currentX = touch.clientX - startX;
currentY = touch.clientY - startY;
updateSidebarPosition();
}
function endDrag() {
if (!isDragging) return;
isDragging = false;
navSidebar.classList.remove('dragging');
// 边界检查
const rect = navSidebar.getBoundingClientRect();
const windowWidth = window.innerWidth;
const windowHeight = window.innerHeight;
// 确保侧边栏不会完全移出视窗
const minX = -rect.width + 50; // 至少保留50px可见
const maxX = windowWidth - 50;
const minY = -rect.height + 50;
const maxY = windowHeight - 50;
currentX = Math.max(minX, Math.min(maxX, currentX));
currentY = Math.max(minY, Math.min(maxY, currentY));
// 更新全局变量
window.navSidebarDragData.currentX = currentX;
window.navSidebarDragData.currentY = currentY;
updateSidebarPosition();
// 保存位置到localStorage
localStorage.setItem('navSidebarPosition', JSON.stringify({
x: currentX,
y: currentY
}));
}
function updateSidebarPosition() {
navSidebar.style.transform = `translate(${currentX}px, calc(-50% + ${currentY}px))`;
}
// 窗口大小改变时重新检查位置
window.addEventListener('resize', function() {
const rect = navSidebar.getBoundingClientRect();
const windowWidth = window.innerWidth;
const windowHeight = window.innerHeight;
const minX = -rect.width + 50;
const maxX = windowWidth - 50;
const minY = -rect.height + 50;
const maxY = windowHeight - 50;
currentX = Math.max(minX, Math.min(maxX, currentX));
currentY = Math.max(minY, Math.min(maxY, currentY));
// 更新全局变量
window.navSidebarDragData.currentX = currentX;
window.navSidebarDragData.currentY = currentY;
updateSidebarPosition();
localStorage.setItem('navSidebarPosition', JSON.stringify({
x: currentX,
y: currentY
}));
});
}
// 加载回访记录
function loadCallbackRecords() {
const caseNumber = '{{ patient.病历号 }}';
updateCallbackRecords(caseNumber);
}
// 更新回访记录显示
function updateCallbackRecordsDisplay(records) {
const countElement = document.getElementById('callback-records-count');
......@@ -901,6 +1090,23 @@
console.log(`患者 ${patientId} 选择回访结果: ${result}`);
}
// 切换AI反馈二级选项显示
function toggleAIFeedbackLevel2(patientId, level1Value) {
const level2Container = document.getElementById(`ai-feedback-level2-${patientId}`);
const level2Select = document.getElementById(`ai-feedback-${patientId}`);
if (level1Value === 'AI漏诊检测错误(检测不准确或遗漏)') {
// 显示二级选项
if (level2Container) level2Container.classList.remove('hidden');
} else {
// 隐藏二级选项并清空选择
if (level2Container) level2Container.classList.add('hidden');
if (level2Select) level2Select.value = '';
}
console.log(`患者 ${patientId} AI反馈一级选项: ${level1Value}`);
}
// 保存回访记录
function saveCallbackRecord(patientId) {
const checkboxes = document.querySelectorAll(`input[name="callback-method-${patientId}"]:checked`);
......@@ -908,6 +1114,7 @@
const appointmentInput = document.getElementById(`next-appointment-${patientId}`);
const failureSelect = document.getElementById(`failure-reason-${patientId}`);
const abandonSelect = document.getElementById(`abandon-reason-${patientId}`);
const aiFeedbackLevel1Select = document.getElementById(`ai-feedback-level1-${patientId}`);
const aiFeedbackSelect = document.getElementById(`ai-feedback-${patientId}`);
const failureReasonNote = document.getElementById(`failure-reason-note-${patientId}`);
const abandonReasonNote = document.getElementById(`abandon-reason-note-${patientId}`);
......@@ -963,7 +1170,26 @@
}
// AI反馈信息(可选)
const aiFeedback = aiFeedbackSelect ? aiFeedbackSelect.value : null;
let aiFeedback = null;
const aiFeedbackLevel1 = aiFeedbackLevel1Select ? aiFeedbackLevel1Select.value : null;
if (aiFeedbackLevel1 === 'AI漏诊检测错误(检测不准确或遗漏)') {
// 如果选择了漏诊检测错误,使用二级选项的值
aiFeedback = aiFeedbackSelect ? aiFeedbackSelect.value : null;
// 如果二级选项未选择,提示用户
if (!aiFeedback) {
alert('请选择具体的AI漏诊检测错误类型');
if (aiFeedbackSelect) aiFeedbackSelect.focus();
// 恢复按钮状态
const saveBtn = document.querySelector(`button[onclick="saveCallbackRecord('${patientId}')"]`);
saveBtn.innerHTML = '<i class="fas fa-save mr-1"></i>保存回访记录';
saveBtn.disabled = false;
return;
}
} else if (aiFeedbackLevel1) {
// 其他一级选项直接使用一级选项的值
aiFeedback = aiFeedbackLevel1;
}
// 显示保存中状态
const saveBtn = document.querySelector(`button[onclick="saveCallbackRecord('${patientId}')"]`);
......@@ -1023,7 +1249,9 @@
const appointmentInput = document.getElementById(`next-appointment-${patientId}`);
const failureSelect = document.getElementById(`failure-reason-${patientId}`);
const abandonSelect = document.getElementById(`abandon-reason-${patientId}`);
const aiFeedbackLevel1Select = document.getElementById(`ai-feedback-level1-${patientId}`);
const aiFeedbackSelect = document.getElementById(`ai-feedback-${patientId}`);
const aiFeedbackLevel2Container = document.getElementById(`ai-feedback-level2-${patientId}`);
const failureReasonNote = document.getElementById(`failure-reason-note-${patientId}`);
const abandonReasonNote = document.getElementById(`abandon-reason-note-${patientId}`);
const aiFeedbackNote = document.getElementById(`ai-feedback-note-${patientId}`);
......@@ -1045,11 +1273,13 @@
if (successFields) successFields.classList.add('hidden');
if (failureFields) failureFields.classList.add('hidden');
if (abandonFields) abandonFields.classList.add('hidden');
if (aiFeedbackLevel2Container) aiFeedbackLevel2Container.classList.add('hidden');
// 清空表单值
if (appointmentInput) appointmentInput.value = '';
if (failureSelect) failureSelect.value = '';
if (abandonSelect) abandonSelect.value = '';
if (aiFeedbackLevel1Select) aiFeedbackLevel1Select.value = '';
if (aiFeedbackSelect) aiFeedbackSelect.value = '';
if (failureReasonNote) failureReasonNote.value = '';
if (abandonReasonNote) abandonReasonNote.value = '';
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment