481 lines
18 KiB
HTML
481 lines
18 KiB
HTML
{% 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="card mb-3">
|
||
<div class="card-body">
|
||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||
<h5 class="mb-0"><i class="bi bi-list-check"></i> 问题配置</h5>
|
||
<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>
|
||
<input type="text" class="form-control mb-3" placeholder="搜索问题..." id="searchInput" onkeyup="applyProblemFilters()">
|
||
<div class="row g-3 align-items-center">
|
||
<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>
|
||
</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 %} |