feat: 问题数据迁移到数据库;学员详情页URL导航改造;侧边栏统一
- 问题从文件系统迁移到数据库 problems 表 - 移除 PROBLEMS_DIR 配置和文件读取逻辑 - student.html 完整重写:编辑/添加/删除问题,生成方案进度显示 - 学员详情页支持独立URL访问 (/student/<id>) - 统一侧边栏到 base.html - 更新文档:DEPLOYMENT_SOP, MODELS, STRUCTURE, FRONTEND_ARCH - 部署到生产环境 v1.2.0
This commit is contained in:
+286
-139
@@ -24,33 +24,36 @@
|
||||
.markdown-body th { background: #f8f9fa; }
|
||||
.markdown-body code { background: #f4f4f4; padding: 2px 6px; border-radius: 3px; font-size: 0.9em; }
|
||||
.markdown-body blockquote { border-left: 4px solid #dee2e6; margin: 1rem 0; padding: 0.5rem 1rem; background: #f8f9fa; }
|
||||
/* 方案列表样式 */
|
||||
.plan-problem-text { font-weight: 600; color: #2c3e50; font-size: 0.95rem; }
|
||||
.plan-meta-text { color: #95a5a6; font-size: 0.8rem; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block sidebar_nav %}
|
||||
<a class="nav-link active" href="#" onclick="showStudentList()">
|
||||
<a class="nav-link {% if active_nav == 'students' %}active{% endif %}" href="/students">
|
||||
<i class="bi bi-people"></i> 学员管理
|
||||
</a>
|
||||
<a class="nav-link" href="#" onclick="showSettings()" id="settingsNav">
|
||||
<a class="nav-link {% if active_nav == 'plans' %}active{% endif %}" href="/plans">
|
||||
<i class="bi bi-clipboard-check"></i> 方案管理
|
||||
</a>
|
||||
<a class="nav-link {% if active_nav == 'settings' %}active{% endif %}" href="/settings">
|
||||
<i class="bi bi-gear"></i> 问题配置
|
||||
</a>
|
||||
<a class="nav-link" href="/api-settings" id="apiSettingsNav" style="display:none;">
|
||||
<i class="bi bi-key"></i> API设置
|
||||
</a>
|
||||
<a class="nav-link" href="/templates" id="templatesNav" style="display:none;">
|
||||
<i class="bi bi-file-earmark-text"></i> 模板管理
|
||||
</a>
|
||||
<a class="nav-link" href="/classes" id="classesNav" style="display:none;">
|
||||
<a class="nav-link {% if active_nav == 'classes' %}active{% endif %}" href="/classes">
|
||||
<i class="bi bi-collection"></i> 班级管理
|
||||
</a>
|
||||
<a class="nav-link" href="/users" id="usersNav" style="display:none;">
|
||||
<i class="bi bi-person-badge"></i> 用户管理
|
||||
<a class="nav-link {% if active_nav == 'api-settings' %}active{% endif %}" href="/api-settings" id="apiSettingsNav" style="display:none;">
|
||||
<i class="bi bi-key"></i> API设置
|
||||
</a>
|
||||
<a class="nav-link {% if active_nav == 'templates' %}active{% endif %}" href="/templates" id="templatesNav" style="display:none;">
|
||||
<i class="bi bi-file-earmark-text"></i> 模板管理
|
||||
</a>
|
||||
<hr>
|
||||
<a class="nav-link" href="#" onclick="showChangePasswordModal()">
|
||||
<a class="nav-link" href="#" onclick="showChangePasswordModal(); return false;">
|
||||
<i class="bi bi-key"></i> 修改密码
|
||||
</a>
|
||||
<a class="nav-link" href="#" onclick="logout()">
|
||||
<a class="nav-link" href="#" onclick="logout(); return false;">
|
||||
<i class="bi bi-box-arrow-right"></i> 退出登录
|
||||
</a>
|
||||
{% endblock %}
|
||||
@@ -90,8 +93,8 @@
|
||||
|
||||
<!-- 学员详情页面 -->
|
||||
<div id="studentDetailPage" style="display: none;">
|
||||
<button class="btn btn-link mb-3" onclick="showStudentList()">
|
||||
<i class="bi bi-arrow-left"></i> 返回列表
|
||||
<button class="btn btn-link mb-3" onclick="goBack()">
|
||||
<i class="bi bi-arrow-left"></i> <span id="backBtnText">返回列表</span>
|
||||
</button>
|
||||
|
||||
<div class="row">
|
||||
@@ -223,7 +226,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
|
||||
<button type="button" class="btn btn-secondary" id="cancelProblemsBtn">取消</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveProblems()">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -232,7 +235,7 @@
|
||||
|
||||
<!-- 方案查看模态框 -->
|
||||
<div class="modal fade" id="planDetailModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-dialog modal-fullscreen modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">练习方案</h5>
|
||||
@@ -322,7 +325,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
|
||||
<button type="button" class="btn btn-secondary" id="cancelEditPlanBtn">取消</button>
|
||||
<button type="button" class="btn btn-primary" onclick="savePlanContent()">
|
||||
<i class="bi bi-save"></i> 保存
|
||||
</button>
|
||||
@@ -373,7 +376,6 @@
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/easymde/dist/easymde.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/tabulator-tables@5/dist/js/tabulator.min.js"></script>
|
||||
<script>
|
||||
let currentStudentId = null;
|
||||
@@ -384,41 +386,21 @@ const problemList = {{ problem_list | tojson }};
|
||||
const severityLevels = {{ severity_levels | tojson }};
|
||||
const practiceTimeOptions = {{ practice_time_options | tojson }};
|
||||
|
||||
// 初始化
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
fetch('/api/check-login').then(r => r.json()).then(data => {
|
||||
if (!data.logged_in) {
|
||||
window.location.href = '/login';
|
||||
return;
|
||||
}
|
||||
const ROLE_LABELS = { 'admin': '管理员', 'user': '普通用户' };
|
||||
|
||||
const userDisplay = data.username + ' (' + (ROLE_LABELS[data.role] || data.role) + ')';
|
||||
document.getElementById('currentUserDisplay').textContent = userDisplay;
|
||||
const mobileDisplay = document.getElementById('mobileUserDisplay');
|
||||
if (mobileDisplay) mobileDisplay.textContent = userDisplay;
|
||||
|
||||
if (data.role === 'admin') {
|
||||
document.getElementById('usersNav').style.display = '';
|
||||
document.getElementById('settingsNav').style.display = '';
|
||||
document.getElementById('apiSettingsNav').style.display = '';
|
||||
document.getElementById('templatesNav').style.display = '';
|
||||
} else {
|
||||
document.getElementById('settingsNav').style.display = '';
|
||||
document.getElementById('apiSettingsNav').style.display = 'none';
|
||||
document.getElementById('templatesNav').style.display = 'none';
|
||||
}
|
||||
document.getElementById('classesNav').style.display = '';
|
||||
loadAiTemplates();
|
||||
loadReportTemplates();
|
||||
}).catch(() => {
|
||||
window.location.href = '/login';
|
||||
});
|
||||
|
||||
// 页面初始化(base.html 统一登录检查后调用)
|
||||
window.pageInit = function(data) {
|
||||
loadAiTemplates();
|
||||
loadReportTemplates();
|
||||
loadClassFilter();
|
||||
loadStudents();
|
||||
initProblemCheckboxes();
|
||||
});
|
||||
|
||||
// 检查 URL 参数,自动打开学员详情
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const studentId = urlParams.get('student_id');
|
||||
if (studentId) {
|
||||
showStudentDetail(parseInt(studentId));
|
||||
}
|
||||
};
|
||||
|
||||
// 加载AI提示词模板列表
|
||||
async function loadAiTemplates() {
|
||||
@@ -532,23 +514,39 @@ async function loadClassFilter() {
|
||||
// 渲染学员列表
|
||||
function renderStudentList(students) {
|
||||
const container = document.getElementById('studentList');
|
||||
if (students.length === 0) {
|
||||
if (!container) { console.error('studentList element not found'); return; }
|
||||
console.log('renderStudentList called with', students?.length, 'students');
|
||||
if (!students || students.length === 0) {
|
||||
container.innerHTML = '<div class="col-12 text-center text-muted py-5"><p>暂无学员,请添加</p></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
students.forEach(s => {
|
||||
// 构建问题显示文本
|
||||
let problemText = '';
|
||||
if (s.problem_names && s.problem_names.length > 0) {
|
||||
if (s.problem_names.length >= 3) {
|
||||
problemText = `${s.problem_names[0]}、${s.problem_names[1]} 等${s.problem_names.length}个`;
|
||||
} else {
|
||||
problemText = s.problem_names.join('、');
|
||||
}
|
||||
} else {
|
||||
problemText = `${s.problem_count} 个问题`;
|
||||
}
|
||||
|
||||
html += `
|
||||
<div class="col-md-4 col-sm-6 mb-3">
|
||||
<div class="card student-card" onclick="showStudentDetail(${s.id})">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">${s.name}</h5>
|
||||
<p class="card-text text-muted small">${s.wechat_nickname || ''} ${s.phone ? '| ' + s.phone : ''}</p>
|
||||
<span class="badge bg-info">${s.practice_time}</span>
|
||||
<span class="badge bg-secondary">${s.problem_count} 个问题</span>
|
||||
<span class="badge bg-primary">${s.plan_count} 个方案</span>
|
||||
</div>
|
||||
<div class="card">
|
||||
<a href="/student/${s.id}" class="text-decoration-none">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">${s.name}</h5>
|
||||
<p class="card-text text-muted small">${s.wechat_nickname || ''} ${s.phone ? '| ' + s.phone : ''}</p>
|
||||
<span class="badge bg-info">${s.practice_time}</span>
|
||||
<span class="badge bg-secondary">${problemText}</span>
|
||||
<span class="badge bg-primary">${s.plan_count} 个方案</span>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -684,30 +682,57 @@ async function saveStudent() {
|
||||
|
||||
// 显示学员详情
|
||||
async function showStudentDetail(studentId) {
|
||||
currentStudentId = studentId;
|
||||
try {
|
||||
currentStudentId = studentId;
|
||||
|
||||
const response = await fetch(`/api/students/${studentId}`);
|
||||
const data = await response.json();
|
||||
// 检查来源页面
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
window.returnUrl = urlParams.get('from');
|
||||
document.getElementById('backBtnText').textContent = window.returnUrl ? '返回' : '返回列表';
|
||||
|
||||
document.getElementById('detailName').textContent = data.student.name;
|
||||
document.getElementById('detailWechatNickname').textContent = data.student.wechat_nickname || '未填写';
|
||||
document.getElementById('detailPhone').textContent = data.student.phone || '未填写';
|
||||
document.getElementById('detailPracticeTime').textContent = data.student.practice_time || '30分钟';
|
||||
const response = await fetch(`/api/students/${studentId}`);
|
||||
const data = await response.json();
|
||||
|
||||
const classEl = document.getElementById('detailClass');
|
||||
if (data.student.class_name) {
|
||||
classEl.innerHTML = '<span class="badge bg-secondary">' + data.student.class_name + '</span>';
|
||||
} else {
|
||||
classEl.innerHTML = '<span class="text-muted">未分配班级</span>';
|
||||
document.getElementById('detailName').textContent = data.student.name;
|
||||
document.getElementById('detailWechatNickname').textContent = data.student.wechat_nickname || '未填写';
|
||||
document.getElementById('detailPhone').textContent = data.student.phone || '未填写';
|
||||
document.getElementById('detailPracticeTime').textContent = data.student.practice_time || '30分钟';
|
||||
|
||||
const classEl = document.getElementById('detailClass');
|
||||
if (data.student.class_name) {
|
||||
classEl.innerHTML = '<span class="badge bg-secondary">' + data.student.class_name + '</span>';
|
||||
} else {
|
||||
classEl.innerHTML = '<span class="text-muted">未分配班级</span>';
|
||||
}
|
||||
|
||||
document.getElementById('detailNotes').textContent = data.student.notes || '无备注';
|
||||
|
||||
renderProblemList(data.problems);
|
||||
renderPlanList(data.plans);
|
||||
|
||||
document.getElementById('studentListPage').style.display = 'none';
|
||||
document.getElementById('studentDetailPage').style.display = 'block';
|
||||
|
||||
// 检查是否需要自动打开方案编辑模态框
|
||||
const action = urlParams.get('action');
|
||||
const planIdFromUrl = urlParams.get('plan_id');
|
||||
if (action === 'edit' && planIdFromUrl) {
|
||||
currentPlanId = parseInt(planIdFromUrl);
|
||||
// 延迟一下确保 DOM 已渲染
|
||||
setTimeout(() => editPlanContent(), 100);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('showStudentDetail error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('detailNotes').textContent = data.student.notes || '无备注';
|
||||
|
||||
renderProblemList(data.problems);
|
||||
renderPlanList(data.plans);
|
||||
|
||||
document.getElementById('studentListPage').style.display = 'none';
|
||||
document.getElementById('studentDetailPage').style.display = 'block';
|
||||
// 返回
|
||||
function goBack() {
|
||||
if (window.returnUrl) {
|
||||
window.location.href = window.returnUrl;
|
||||
} else {
|
||||
showStudentList();
|
||||
}
|
||||
}
|
||||
|
||||
// 返回学员列表
|
||||
@@ -808,10 +833,29 @@ function renderPlanList(plans) {
|
||||
|
||||
let html = '';
|
||||
plans.forEach(p => {
|
||||
// 构建显示文本:问题【模板 | 时间】
|
||||
let problemText = '';
|
||||
if (p.problem_names && p.problem_names.length > 0) {
|
||||
const problems = p.problem_names.slice(0, 3).join('、');
|
||||
const more = p.problem_names.length > 3 ? `等${p.problem_names.length}个` : '';
|
||||
problemText = `${problems}${more}`;
|
||||
}
|
||||
|
||||
const template = p.template_name || '无模板';
|
||||
const time = p.created_at || '';
|
||||
const metaText = `【${template}${time ? ' | ' + time : ''}】`;
|
||||
|
||||
html += `
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<span>生成于 ${p.created_at}</span>
|
||||
<div>
|
||||
<div class="d-flex align-items-center mb-2 gap-2">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="typical_${p.id}"
|
||||
${p.is_typical ? 'checked' : ''}
|
||||
onchange="toggleTypical(${p.id}, this.checked)">
|
||||
<label class="form-check-label small text-muted" for="typical_${p.id}">典型</label>
|
||||
</div>
|
||||
<span class="plan-problem-text">${problemText}</span>
|
||||
<span class="plan-meta-text">${metaText}</span>
|
||||
<div class="ms-auto">
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="viewPlan(${p.id})">查看</button>
|
||||
<button class="btn btn-sm btn-outline-danger" onclick="deletePlan(${p.id})">删除</button>
|
||||
</div>
|
||||
@@ -821,7 +865,25 @@ function renderPlanList(plans) {
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
// 切换典型方案状态
|
||||
async function toggleTypical(planId, isTypical) {
|
||||
try {
|
||||
const resp = await fetch(`/api/plans/${planId}/typical`, { method: 'POST' });
|
||||
if (!resp.ok) {
|
||||
// 恢复原状态
|
||||
document.getElementById('typical_' + planId).checked = !isTypical;
|
||||
alert('设置失败');
|
||||
}
|
||||
} catch (e) {
|
||||
// 恢复原状态
|
||||
document.getElementById('typical_' + planId).checked = !isTypical;
|
||||
alert('设置失败: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 显示问题记录模态框
|
||||
let problemsModalOriginalState = []; // 记录原始状态用于检测修改
|
||||
|
||||
function showProblemsModal() {
|
||||
fetch(`/api/students/${currentStudentId}/problems`)
|
||||
.then(r => r.json())
|
||||
@@ -829,14 +891,14 @@ function showProblemsModal() {
|
||||
const currentProblemIds = currentProblems.map(p => p.problem_id);
|
||||
|
||||
document.querySelectorAll('.problem-checkbox-input').forEach(cb => {
|
||||
const isChecked = currentProblemIds.includes(cb.value);
|
||||
const isChecked = currentProblemIds.includes(parseInt(cb.value));
|
||||
cb.checked = isChecked;
|
||||
|
||||
const severityDiv = document.getElementById(`severity_${cb.value}`);
|
||||
severityDiv.style.display = isChecked ? 'block' : 'none';
|
||||
|
||||
if (isChecked) {
|
||||
const problem = currentProblems.find(p => p.problem_id === cb.value);
|
||||
const problem = currentProblems.find(p => p.problem_id === parseInt(cb.value));
|
||||
if (problem) {
|
||||
const levelRadios = document.querySelectorAll(`input[name="level_${cb.value}"]`);
|
||||
levelRadios.forEach(r => {
|
||||
@@ -849,9 +911,82 @@ function showProblemsModal() {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 记录加载后的原始状态
|
||||
problemsModalOriginalState = getProblemsModalState();
|
||||
});
|
||||
|
||||
new bootstrap.Modal(document.getElementById('problemsModal')).show();
|
||||
const modal = new bootstrap.Modal(document.getElementById('problemsModal'), {
|
||||
keyboard: false // 禁用 ESC 关闭,由我们手动处理
|
||||
});
|
||||
|
||||
const modalEl = document.getElementById('problemsModal');
|
||||
|
||||
// 监听取消按钮 - 如果有修改则确认
|
||||
document.getElementById('cancelProblemsBtn').onclick = () => {
|
||||
if (isProblemsModalDirty()) {
|
||||
if (confirm('内容已修改,确定要关闭吗?')) {
|
||||
modal.hide();
|
||||
}
|
||||
} else {
|
||||
modal.hide();
|
||||
}
|
||||
};
|
||||
|
||||
// 监听键盘事件处理 ESC
|
||||
const handleEscape = (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (isProblemsModalDirty()) {
|
||||
if (confirm('内容已修改,确定要关闭吗?')) {
|
||||
modalEl.removeEventListener('keydown', handleEscape);
|
||||
modal.hide();
|
||||
}
|
||||
} else {
|
||||
modalEl.removeEventListener('keydown', handleEscape);
|
||||
modal.hide();
|
||||
}
|
||||
}
|
||||
};
|
||||
modalEl.addEventListener('keydown', handleEscape);
|
||||
|
||||
modal.show();
|
||||
}
|
||||
|
||||
// 获取当前问题弹窗的状态
|
||||
function getProblemsModalState() {
|
||||
const state = [];
|
||||
document.querySelectorAll('.problem-checkbox-input').forEach(cb => {
|
||||
if (cb.checked) {
|
||||
const problemId = cb.value;
|
||||
const levelEl = document.querySelector(`input[name="level_${problemId}"]:checked`);
|
||||
const severityEl = document.querySelector(`input[name="severity_${problemId}"]:checked`);
|
||||
state.push({
|
||||
problem_id: problemId,
|
||||
level: levelEl ? levelEl.value : '入门',
|
||||
severity: severityEl ? severityEl.value : '中等'
|
||||
});
|
||||
}
|
||||
});
|
||||
return state;
|
||||
}
|
||||
|
||||
// 检测问题弹窗是否有修改
|
||||
function isProblemsModalDirty() {
|
||||
const currentState = getProblemsModalState();
|
||||
|
||||
// 比较数量
|
||||
if (currentState.length !== problemsModalOriginalState.length) return true;
|
||||
|
||||
// 比较每个问题的状态
|
||||
for (const current of currentState) {
|
||||
const original = problemsModalOriginalState.find(o => o.problem_id === current.problem_id);
|
||||
if (!original) return true;
|
||||
if (original.level !== current.level || original.severity !== current.severity) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// 保存问题
|
||||
@@ -859,10 +994,10 @@ async function saveProblems() {
|
||||
const problems = [];
|
||||
|
||||
document.querySelectorAll('.problem-checkbox-input:checked').forEach(cb => {
|
||||
const problemId = cb.value;
|
||||
const problemId = parseInt(cb.value);
|
||||
const problemName = cb.dataset.name;
|
||||
const severityEl = document.querySelector(`input[name="severity_${problemId}"]:checked`);
|
||||
const levelEl = document.querySelector(`input[name="level_${problemId}"]:checked`);
|
||||
const severityEl = document.querySelector(`input[name="severity_${cb.value}"]:checked`);
|
||||
const levelEl = document.querySelector(`input[name="level_${cb.value}"]:checked`);
|
||||
|
||||
problems.push({
|
||||
problem_id: problemId,
|
||||
@@ -970,56 +1105,9 @@ async function generatePlan() {
|
||||
}
|
||||
}
|
||||
|
||||
// 查看方案
|
||||
async function viewPlan(planId) {
|
||||
currentPlanId = planId;
|
||||
const response = await fetch(`/api/plans/${planId}`);
|
||||
const data = await response.json();
|
||||
|
||||
let html = `
|
||||
<div class="mb-3">
|
||||
<strong>学员:</strong>${data.student_name}
|
||||
<strong>练习时间:</strong>${data.content.practice_time}
|
||||
<strong>生成时间:</strong>${data.created_at}
|
||||
</div>
|
||||
<h6>问题诊断</h6>
|
||||
<div class="mb-3">
|
||||
`;
|
||||
|
||||
data.content.problems.forEach(p => {
|
||||
html += `<span class="problem-tag severity-${p.severity}">${p.name}(${p.severity})</span> `;
|
||||
});
|
||||
|
||||
html += `</div>`;
|
||||
|
||||
if (data.content.ai_report) {
|
||||
const aiReportHtml = marked.parse(data.content.ai_report);
|
||||
html += `
|
||||
<h6>AI个性化练习报告</h6>
|
||||
<div class="mb-3 p-3 bg-light rounded" style="max-height: 500px; overflow-y: auto;">${aiReportHtml}</div>
|
||||
`;
|
||||
} else if (data.content.ai_report_error) {
|
||||
html += `
|
||||
<h6>AI报告</h6>
|
||||
<div class="mb-3 p-3 bg-warning rounded">AI生成失败: ${data.content.ai_report_error}</div>
|
||||
`;
|
||||
}
|
||||
|
||||
html += `
|
||||
<h6>每日练习计划(共${data.content.total_daily_minutes}分钟)</h6>
|
||||
<table class="table table-sm">
|
||||
<thead><tr><th>环节</th><th>时长</th><th>内容</th><th>目的</th></tr></thead>
|
||||
<tbody>
|
||||
`;
|
||||
|
||||
data.content.daily_schedule.forEach(item => {
|
||||
html += `<tr><td>${item.phase}</td><td>${item.duration}</td><td>${item.content}</td><td>${item.purpose}</td></tr>`;
|
||||
});
|
||||
|
||||
html += '</tbody></table>';
|
||||
|
||||
document.getElementById('planDetailContent').innerHTML = html;
|
||||
new bootstrap.Modal(document.getElementById('planDetailModal')).show();
|
||||
// 查看方案 - 跳转到方案详情页
|
||||
function viewPlan(planId) {
|
||||
window.location.href = `/plan/${planId}`;
|
||||
}
|
||||
|
||||
// 下载PDF
|
||||
@@ -1061,6 +1149,8 @@ async function previewReportTemplate() {
|
||||
// 编辑方案内容
|
||||
let planContentEditor = null;
|
||||
let scheduleTable = null;
|
||||
let editPlanOriginalState = { ai_report: '', scheduleData: [] }; // 记录原始状态
|
||||
|
||||
async function editPlanContent() {
|
||||
const resp = await fetch(`/api/plans/${currentPlanId}`);
|
||||
const data = await resp.json();
|
||||
@@ -1070,6 +1160,9 @@ async function editPlanContent() {
|
||||
document.getElementById('editGeneratedAt').value = content.generated_at || '';
|
||||
document.getElementById('editProblemsCount').value = (content.problems || []).length + ' 个问题';
|
||||
|
||||
// 记录原始状态
|
||||
editPlanOriginalState.ai_report = content.ai_report || '';
|
||||
|
||||
if (planContentEditor) {
|
||||
planContentEditor.toTextArea();
|
||||
planContentEditor = null;
|
||||
@@ -1095,6 +1188,9 @@ async function editPlanContent() {
|
||||
purpose: item.purpose || ''
|
||||
}));
|
||||
|
||||
// 记录原始表格数据
|
||||
editPlanOriginalState.scheduleData = JSON.parse(JSON.stringify(scheduleData));
|
||||
|
||||
scheduleTable = new Tabulator("#editDailyScheduleTable", {
|
||||
data: scheduleData,
|
||||
layout: "fitDataFill",
|
||||
@@ -1119,7 +1215,58 @@ async function editPlanContent() {
|
||||
]
|
||||
});
|
||||
|
||||
new bootstrap.Modal(document.getElementById('editPlanContentModal')).show();
|
||||
const modal = new bootstrap.Modal(document.getElementById('editPlanContentModal'), {
|
||||
keyboard: false // 禁用 ESC 关闭,由我们手动处理
|
||||
});
|
||||
|
||||
const modalEl = document.getElementById('editPlanContentModal');
|
||||
|
||||
// 取消按钮点击处理
|
||||
const cancelBtn = document.getElementById('cancelEditPlanBtn');
|
||||
cancelBtn.onclick = () => {
|
||||
if (isEditPlanDirty()) {
|
||||
if (confirm('内容已修改,确定要关闭吗?')) {
|
||||
modal.hide();
|
||||
}
|
||||
} else {
|
||||
modal.hide();
|
||||
}
|
||||
};
|
||||
|
||||
// ESC 键处理
|
||||
const handleEscape = (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (isEditPlanDirty()) {
|
||||
if (confirm('内容已修改,确定要关闭吗?')) {
|
||||
modalEl.removeEventListener('keydown', handleEscape);
|
||||
modal.hide();
|
||||
}
|
||||
} else {
|
||||
modalEl.removeEventListener('keydown', handleEscape);
|
||||
modal.hide();
|
||||
}
|
||||
}
|
||||
};
|
||||
modalEl.addEventListener('keydown', handleEscape);
|
||||
|
||||
modal.show();
|
||||
}
|
||||
|
||||
// 检测编辑方案内容是否有修改
|
||||
function isEditPlanDirty() {
|
||||
// 检查 AI 报告
|
||||
const currentAiReport = planContentEditor ? planContentEditor.value() : document.getElementById('editAiReport').value;
|
||||
if (currentAiReport !== editPlanOriginalState.ai_report) return true;
|
||||
|
||||
// 检查表格数据
|
||||
if (scheduleTable) {
|
||||
const currentData = scheduleTable.getData();
|
||||
if (JSON.stringify(currentData) !== JSON.stringify(editPlanOriginalState.scheduleData)) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// 添加一行
|
||||
|
||||
Reference in New Issue
Block a user