Files
piano-plan/app/templates/problems.html
T
hmo e50a9207b4 feat: v1.4.0 - 典型方案采纳、推荐方案列表、审计字段、导航优化
- 添加典型方案采纳功能 (POST /api/plans/<id>/adopt)
- 添加推荐方案列表 (GET /api/students/<id>/recommended-plans)
- PracticePlan 新增 created_by/updated_by/updated_at 审计字段
- 方案编辑/详情页导航优化 (bfcache 处理、pageshow 事件)
- 方案列表支持删除功能
- 学员列表'暂无方案/问题'样式统一
- 更新文档:问题文件已废弃(迁移到数据库)
- 更新部署脚本和验证清单
2026-04-27 02:01:22 +08:00

481 lines
18 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "base.html" %}
{% block title %}问题配置 - 钢琴练习方案系统{% endblock %}
{% block page_css %}
<style>
.problem-card { transition: all 0.2s; }
.problem-card:hover { transform: translateY(-2px); box-shadow: 0 4px 15px rgba(0,0,0,0.1); }
.category-badge { font-size: 11px; padding: 3px 8px; }
.category-综合 { background: #f5f5f5; color: #616161; }
.category-乐理相关 { background: #e3f2fd; color: #1565c0; }
.category-演奏能力 { background: #e8f5e9; color: #2e7d32; }
.category-其他 { background: #fff3e0; color: #e65100; }
.editor-textarea { font-family: monospace; font-size: 13px; }
.modal-footer-with-top {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
padding: 1rem;
border-top: 1px solid #dee2e6;
background: #f8f9fa;
position: sticky;
bottom: 0;
}
</style>
{% endblock %}
{% block content %}
<!-- Tab导航 -->
<ul class="nav nav-tabs mb-4" id="settingsTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="problems-tab" data-bs-toggle="tab" data-bs-target="#problemsPane" type="button">
<i class="bi bi-list-check"></i> 问题配置
</button>
</li>
</ul>
<div class="tab-content" id="settingsTabsContent">
<!-- 问题配置面板 -->
<div class="tab-pane fade show active" id="problemsPane" role="tabpanel">
<div class="d-flex justify-content-between align-items-center mb-4">
<h4><i class="bi bi-list-check"></i> 问题配置</h4>
<div>
<button type="button" class="btn btn-outline-secondary btn-sm me-2" onclick="loadProblems()">
<i class="bi bi-arrow-clockwise"></i> 刷新
</button>
<button type="button" class="btn btn-primary btn-sm" onclick="showAddModal()">
<i class="bi bi-plus-lg"></i> 新增问题
</button>
</div>
</div>
<div class="mb-3">
<input type="text" class="form-control" placeholder="搜索问题..." id="searchInput" onkeyup="applyProblemFilters()">
</div>
<!-- 筛选和分组控制 -->
<div class="row g-3 align-items-center mb-3">
<div class="col-auto">
<label class="form-label mb-0">筛选:</label>
</div>
<div class="col-auto">
<select class="form-select" id="filterCategory" onchange="applyProblemFilters()">
<option value="">全部分类</option>
<option value="综合">综合</option>
<option value="乐理相关">乐理相关</option>
<option value="演奏能力">演奏能力</option>
<option value="其他">其他</option>
</select>
</div>
<div class="col-auto">
<label class="form-label mb-0">分组:</label>
</div>
<div class="col-auto">
<select class="form-select" id="groupByCategory" onchange="applyProblemFilters()">
<option value="">不分组</option>
<option value="category">按分类分组</option>
</select>
</div>
<div class="col-auto ms-auto">
<span id="problems-count" class="text-muted small"></span>
</div>
</div>
<div class="row" id="problemList"></div>
</div>
</div>
<!-- 新增问题模态框 -->
<div class="modal fade" id="addModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">新增问题</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">问题编号</label>
<input type="text" class="form-control" id="newProblemId" placeholder="如: 16">
<small class="text-muted">数字编号,将自动补齐为2位</small>
</div>
<div class="mb-3">
<label class="form-label">问题名称 *</label>
<input type="text" class="form-control" id="newProblemName" placeholder="如: 手小">
</div>
<div class="mb-3">
<label class="form-label">分类</label>
<select class="form-select" id="newProblemCategory">
<option value="综合">综合</option>
<option value="乐理相关">乐理相关</option>
<option value="演奏能力">演奏能力</option>
<option value="其他">其他</option>
</select>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
<button type="button" class="btn btn-primary" onclick="createProblem()">创建</button>
</div>
</div>
</div>
</div>
<!-- 编辑问题模态框 -->
<div class="modal fade" id="editModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">编辑问题</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="row g-3">
<div class="col-md-8">
<div class="mb-3">
<label class="form-label">问题名称 *</label>
<input type="text" class="form-control" id="editProblemName">
</div>
</div>
<div class="col-md-4">
<div class="mb-3">
<label class="form-label">分类</label>
<select class="form-select" id="editProblemCategory">
<option value="综合">综合</option>
<option value="乐理相关">乐理相关</option>
<option value="演奏能力">演奏能力</option>
<option value="其他">其他</option>
</select>
</div>
</div>
</div>
<div class="mb-3">
<label class="form-label">问题描述 (Markdown)</label>
<textarea class="form-control editor-textarea" id="editProblemContent" rows="20"></textarea>
</div>
</div>
<div class="modal-footer-with-top">
<button type="button" class="btn btn-secondary" id="cancelEditProblemBtn">取消</button>
<button type="button" class="btn btn-primary" onclick="saveProblem()">保存</button>
</div>
</div>
</div>
</div>
<!-- 删除确认模态框 -->
<div class="modal fade" id="deleteModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">确认删除</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p>确定要删除问题 <strong id="deleteProblemName"></strong> 吗?</p>
<p class="text-muted small">删除后可在 bk 文件夹中找到备份</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
<button type="button" class="btn btn-danger" onclick="confirmDelete()">删除</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
let allProblems = [];
let currentEditId = null;
let currentDeleteId = null;
const PROBLEM_FILTER_KEY = 'problem_filters';
function saveProblemFilterState() {
const state = {
search: document.getElementById('searchInput').value,
filterCategory: document.getElementById('filterCategory').value,
groupBy: document.getElementById('groupByCategory').value
};
sessionStorage.setItem(PROBLEM_FILTER_KEY, JSON.stringify(state));
}
function restoreProblemFilterState() {
const saved = sessionStorage.getItem(PROBLEM_FILTER_KEY);
if (!saved) return;
try {
const state = JSON.parse(saved);
if (state.search) document.getElementById('searchInput').value = state.search;
if (state.filterCategory) document.getElementById('filterCategory').value = state.filterCategory;
if (state.groupBy) document.getElementById('groupByCategory').value = state.groupBy;
saveProblemFilterState();
} catch (e) {
console.error('恢复问题筛选状态失败', e);
}
}
window.pageInit = function() {
loadProblems();
};
// ========== 问题配置相关 ==========
async function loadProblems() {
const response = await fetch('/api/problems');
allProblems = await response.json();
restoreProblemFilterState();
applyProblemFilters();
}
function applyProblemFilters() {
saveProblemFilterState();
const search = document.getElementById('searchInput').value.toLowerCase();
const filterCategory = document.getElementById('filterCategory').value;
const groupBy = document.getElementById('groupByCategory').value;
// 筛选
let filtered = allProblems.filter(p => {
if (search && !p.name.toLowerCase().includes(search)) return false;
if (filterCategory && p.category !== filterCategory) return false;
return true;
});
// 更新计数
document.getElementById('problems-count').textContent = `${filtered.length} 个问题`;
renderProblems(filtered, groupBy);
}
function renderProblems(problems, groupBy) {
const container = document.getElementById('problemList');
if (problems.length === 0) {
container.innerHTML = '<div class="col-12 text-center text-muted py-5"><p>未找到问题配置</p></div>';
return;
}
if (groupBy) {
// 分组显示
const groups = {};
problems.forEach(p => {
const key = p.category || '其他';
if (!groups[key]) groups[key] = [];
groups[key].push(p);
});
container.innerHTML = Object.entries(groups).map(([key, items]) => `
<div class="col-12 mb-3">
<div class="card">
<div class="card-header bg-light">
<h6 class="mb-0">分类:${key}${items.length}个)</h6>
</div>
<div class="card-body p-2">
<div class="row g-2">
${items.map(p => `<div class="col-md-4 col-sm-6">${renderProblemCard(p)}</div>`).join('')}
</div>
</div>
</div>
</div>
`).join('');
} else {
// 不分组
container.innerHTML = problems.map(p => `
<div class="col-md-4 col-sm-6 mb-3">
${renderProblemCard(p)}
</div>
`).join('');
}
}
function renderProblemCard(p) {
return `
<div class="card problem-card h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-2">
<h6 class="card-title mb-0">${p.name}</h6>
<span class="badge category-badge category-${p.category}">${p.category}</span>
</div>
<p class="text-muted small mb-2">编号: ${p.id}</p>
<div class="btn-group btn-group-sm">
<button type="button" class="btn btn-outline-primary" onclick="editProblem('${p.id}')">
<i class="bi bi-pencil"></i> 编辑
</button>
<button type="button" class="btn btn-outline-danger" onclick="showDeleteConfirm('${p.id}', '${p.name}')">
<i class="bi bi-trash"></i> 删除
</button>
</div>
</div>
</div>
`;
}
// 新增
function showAddModal() {
document.getElementById('newProblemId').value = '';
document.getElementById('newProblemName').value = '';
document.getElementById('newProblemCategory').value = '综合';
new bootstrap.Modal(document.getElementById('addModal')).show();
}
async function createProblem() {
const id = document.getElementById('newProblemId').value.trim();
const name = document.getElementById('newProblemName').value.trim();
const category = document.getElementById('newProblemCategory').value;
if (!id || !name) {
alert('请填写编号和名称');
return;
}
const response = await fetch('/api/problems', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({id, name, category})
});
if (response.ok) {
bootstrap.Modal.getInstance(document.getElementById('addModal')).hide();
loadProblems();
} else {
const err = await response.json();
alert(err.error || '创建失败');
}
}
// 编辑
let editProblemOriginalState = { name: '', content: '' }; // 记录原始状态
async function editProblem(problemId) {
currentEditId = problemId;
const response = await fetch(`/api/problems/${problemId}`);
const data = await response.json();
if (data.error) {
alert(data.error);
return;
}
const name = data.name;
document.getElementById('editProblemName').value = name;
document.getElementById('editProblemCategory').value = data.category || '综合';
document.getElementById('editProblemContent').value = data.content || '';
// 记录原始状态
editProblemOriginalState.name = name;
editProblemOriginalState.content = data.content;
if (window.problemEditor) {
window.problemEditor.toTextArea();
window.problemEditor = null;
}
if (window.EasyMDE) {
window.problemEditor = new EasyMDE({
element: document.getElementById('editProblemContent'),
spellChecker: false,
status: false,
toolbar: ['bold', 'italic', 'heading', '|', 'code', 'quote', 'unordered-list', '|', 'preview', 'side-by-side', 'fullscreen'],
initialValue: data.content
});
}
const editModal = new bootstrap.Modal(document.getElementById('editModal'), {
keyboard: false // 禁用 ESC 关闭,由我们手动处理
});
const modalEl = document.getElementById('editModal');
// 取消按钮点击处理
document.getElementById('cancelEditProblemBtn').onclick = () => {
if (isEditProblemDirty()) {
if (confirm('内容已修改,确定要关闭吗?')) {
editModal.hide();
}
} else {
editModal.hide();
}
};
// ESC 键处理
const handleEscape = (e) => {
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
if (isEditProblemDirty()) {
if (confirm('内容已修改,确定要关闭吗?')) {
modalEl.removeEventListener('keydown', handleEscape);
editModal.hide();
}
} else {
modalEl.removeEventListener('keydown', handleEscape);
editModal.hide();
}
}
};
modalEl.addEventListener('keydown', handleEscape);
editModal.show();
editModal._element.addEventListener('shown.bs.modal', function() {
if (window.problemEditor && window.problemEditor.codemirror) {
window.problemEditor.codemirror.refresh();
}
});
}
// 检测编辑问题是否有修改
function isEditProblemDirty() {
const currentName = document.getElementById('editProblemName').value;
const currentContent = window.problemEditor ? window.problemEditor.value() : document.getElementById('editProblemContent').value;
return currentName !== editProblemOriginalState.name || currentContent !== editProblemOriginalState.content;
}
async function saveProblem() {
const name = document.getElementById('editProblemName').value.trim();
const category = document.getElementById('editProblemCategory').value;
const content = window.problemEditor ? window.problemEditor.value() : document.getElementById('editProblemContent').value;
if (!name) {
alert('请填写名称');
return;
}
const response = await fetch(`/api/problems/${currentEditId}`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name, category, content})
});
if (response.ok) {
bootstrap.Modal.getInstance(document.getElementById('editModal')).hide();
if (window.problemEditor) {
window.problemEditor.toTextArea();
window.problemEditor = null;
}
loadProblems();
} else {
const err = await response.json();
alert(err.error || '保存失败');
}
}
// 删除
function showDeleteConfirm(problemId, problemName) {
currentDeleteId = problemId;
document.getElementById('deleteProblemName').textContent = problemName;
new bootstrap.Modal(document.getElementById('deleteModal')).show();
}
async function confirmDelete() {
const response = await fetch(`/api/problems/${currentDeleteId}`, {
method: 'DELETE'
});
if (response.ok) {
bootstrap.Modal.getInstance(document.getElementById('deleteModal')).hide();
loadProblems();
} else {
const err = await response.json();
alert(err.error || '删除失败');
}
}
</script>
{% endblock %}