Files
piano-plan/app/templates/goals.html
T

483 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 page_css %}
<style>
.goal-editor-textarea { font-family: monospace; font-size: 13px; }
</style>
{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<h2 class="mb-4">🎯 目标管理</h2>
<!-- 筛选和分组控制 -->
<div class="card mb-3">
<div class="card-body">
<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="filter-level" onchange="applyFilters()">
<option value="">全部级别</option>
<option value="启蒙">启蒙</option>
<option value="入门">入门</option>
<option value="进阶">进阶</option>
<option value="熟练">熟练</option>
<option value="精通">精通</option>
</select>
</div>
<div class="col-auto">
<select class="form-select" id="filter-category" onchange="applyFilters()">
<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="group-by" onchange="applyFilters()">
<option value="">不分组</option>
<option value="level">按级别分组</option>
<option value="category">按分类分组</option>
</select>
</div>
<div class="col-auto ms-auto">
<span id="goals-count" class="text-muted small"></span>
</div>
</div>
</div>
</div>
<!-- 目标列表 -->
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">目标库</h5>
<button class="btn btn-primary btn-sm" data-bs-toggle="modal" data-bs-target="#goalModal">
+ 新建目标
</button>
</div>
<div class="card-body">
<div id="goals-grid" class="row g-3"></div>
</div>
</div>
</div>
</div>
</div>
<!-- 目标编辑 Modal -->
<div class="modal fade" id="goalModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="goalModalTitle">新建目标</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="goal-id">
<div class="row g-3">
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">目标名称</label>
<input type="text" class="form-control" id="goal-name" required>
</div>
</div>
<div class="col-md-3">
<div class="mb-3">
<label class="form-label">级别</label>
<select class="form-select" id="goal-level">
<option value="启蒙">启蒙</option>
<option value="入门" selected>入门</option>
<option value="进阶">进阶</option>
<option value="熟练">熟练</option>
<option value="精通">精通</option>
</select>
</div>
</div>
<div class="col-md-3">
<div class="mb-3">
<label class="form-label">分类</label>
<select class="form-select" id="goal-category">
<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 goal-editor-textarea" id="goal-content" rows="15"></textarea>
</div>
</div>
<div class="modal-footer-with-top">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
<button type="button" class="btn btn-primary" id="save-goal">保存</button>
</div>
</div>
</div>
</div>
<style>
.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>
<!-- 关系管理 Modal -->
<div class="modal fade" id="relationModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="relationModalTitle">管理子目标</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="relation-goal-id">
<p class="text-muted">为「<span id="current-goal-name" class="fw-bold"></span>」添加子目标</p>
<!-- 添加子目标区域 -->
<div class="mb-3">
<label class="form-label">添加子目标(可多选)</label>
<select class="form-select" id="available-goals-select" multiple size="5">
</select>
<div class="form-text">按住 Ctrl/Cmd 可多选</div>
</div>
<button class="btn btn-primary btn-sm mb-3" onclick="addSelectedChildren()">
<i class="bi bi-plus"></i> 添加选中目标
</button>
<!-- 已关联的子目标 -->
<h6 class="mt-3">已关联的子目标</h6>
<div id="child-goals-list" class="list-group"></div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
{{ super() }}
<script>
const API_BASE = '/api/goals';
let allGoals = []; // 缓存所有目标数据
// 加载目标列表
async function loadGoals() {
const res = await fetch(API_BASE);
const goals = await res.json();
// 获取每个目标的子目标信息
allGoals = await Promise.all(goals.map(async g => {
const childrenRes = await fetch(`${API_BASE}/${g.id}/children`);
const children = await childrenRes.json();
return {...g, children: children};
}));
applyFilters();
}
// 应用筛选和分组
function applyFilters() {
const filterLevel = document.getElementById('filter-level').value;
const filterCategory = document.getElementById('filter-category').value;
const groupBy = document.getElementById('group-by').value;
// 筛选
let filtered = allGoals.filter(g => {
if (filterLevel && g.level !== filterLevel) return false;
if (filterCategory && g.category !== filterCategory) return false;
return true;
});
// 更新计数
document.getElementById('goals-count').textContent = `${filtered.length} 个目标`;
// 渲染
const grid = document.getElementById('goals-grid');
if (groupBy) {
// 分组显示
const groups = {};
filtered.forEach(g => {
const key = groupBy === 'level' ? (g.level || '入门') : (g.category || '综合');
if (!groups[key]) groups[key] = [];
groups[key].push(g);
});
grid.innerHTML = Object.entries(groups).map(([key, goals]) => `
<div class="col-12 mb-3">
<div class="card">
<div class="card-header bg-light">
<h6 class="mb-0">${groupBy === 'level' ? '级别' : '分类'}${key}${goals.length}个)</h6>
</div>
<div class="card-body p-2">
<div class="row g-2">
${goals.map(g => `<div class="col-md-4 col-lg-3">${renderGoalCard(g)}</div>`).join('')}
</div>
</div>
</div>
</div>
`).join('');
} else {
// 不分组
grid.innerHTML = filtered.map(g => `
<div class="col-md-4 col-lg-3">
${renderGoalCard(g)}
</div>
`).join('');
}
}
// 渲染单个目标卡片
function renderGoalCard(g) {
const level = g.level || '入门';
const category = g.category || '综合';
const childNames = g.children && g.children.length > 0
? g.children.map(c => escapeHtml(c.name)).join(', ')
: '';
return `
<div class="card h-100">
<div class="card-body">
<h6 class="card-title">${escapeHtml(g.name)}</h6>
<div class="mb-1">
<span class="badge bg-primary">${category}</span>
<span class="badge bg-secondary">${level}</span>
</div>
${childNames ? `<div class="small text-muted mb-2">子目标: ${childNames}</div>` : ''}
<p class="card-text small text-muted">${escapeHtml(g.content || '').substring(0, 80)}${g.content && g.content.length > 80 ? '...' : ''}</p>
</div>
<div class="card-footer bg-transparent">
<button class="btn btn-sm btn-outline-primary" onclick="editGoal(${g.id})">编辑</button>
<button class="btn btn-sm btn-outline-secondary" onclick="manageRelations(${g.id})">关联</button>
<button class="btn btn-sm btn-outline-danger" onclick="deleteGoal(${g.id})">删除</button>
</div>
</div>
`;
}
// 保存目标
async function saveGoal() {
const id = document.getElementById('goal-id').value;
const name = document.getElementById('goal-name').value;
const level = document.getElementById('goal-level').value;
const category = document.getElementById('goal-category').value;
const content = goalEditor ? goalEditor.value() : document.getElementById('goal-content').value;
if (!name) { alert('请输入目标名称'); return; }
const method = id ? 'PUT' : 'POST';
const url = id ? `${API_BASE}/${id}` : API_BASE;
const payload = {name, level, category, content};
try {
const res = await fetch(url, {
method,
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload)
});
const result = await res.json();
if (res.ok) {
bootstrap.Modal.getInstance(document.getElementById('goalModal')).hide();
loadGoals();
} else {
alert(result.error || '保存失败');
}
} catch (e) {
alert('网络错误: ' + e.message);
}
}
let goalEditor = null;
function editGoal(id) {
fetch(`${API_BASE}/${id}`)
.then(r => r.json())
.then(g => {
document.getElementById('goal-id').value = g.id;
document.getElementById('goal-name').value = g.name;
document.getElementById('goal-level').value = g.level || '入门';
document.getElementById('goal-category').value = g.category || '综合';
document.getElementById('goalModalTitle').textContent = '编辑目标';
// 初始化或更新 Markdown 编辑器
const contentEl = document.getElementById('goal-content');
if (window.EasyMDE) {
if (goalEditor) {
goalEditor.toTextArea();
goalEditor = null;
}
goalEditor = new EasyMDE({
element: contentEl,
spellChecker: false,
status: false,
toolbar: ['bold', 'italic', 'heading', '|', 'code', 'quote', 'unordered-list', '|', 'preview', 'side-by-side', 'fullscreen'],
initialValue: g.content || ''
});
} else {
contentEl.value = g.content || '';
}
new bootstrap.Modal(document.getElementById('goalModal')).show();
});
}
async function deleteGoal(id) {
if (!confirm('确定删除此目标?')) return;
await fetch(`${API_BASE}/${id}`, {method: 'DELETE'});
loadGoals();
}
async function manageRelations(id) {
document.getElementById('relation-goal-id').value = id;
// 获取当前目标信息和所有目标
const [currentGoal, allGoals, children] = await Promise.all([
fetch(`${API_BASE}/${id}`).then(r => r.json()),
fetch(API_BASE).then(r => r.json()),
fetch(`${API_BASE}/${id}/children`).then(r => r.json())
]);
// 显示当前目标名称
document.getElementById('current-goal-name').textContent = currentGoal.name;
// 已关联的子目标
const childIds = children.map(c => c.id);
renderChildrenList(children, id);
// 可选的子目标(下拉列表中排除自己 和 已关联的)
const availableSelect = document.getElementById('available-goals-select');
availableSelect.innerHTML = allGoals
.filter(g => g.id !== id && !childIds.includes(g.id))
.map(g => `<option value="${g.id}">${escapeHtml(g.name)}</option>`)
.join('');
new bootstrap.Modal(document.getElementById('relationModal')).show();
}
function renderChildrenList(children, parentId) {
const list = document.getElementById('child-goals-list');
if (children.length === 0) {
list.innerHTML = '<p class="text-muted">暂无关联子目标</p>';
return;
}
list.innerHTML = children.map(g =>
`<div class="list-group-item d-flex justify-content-between align-items-center">
${escapeHtml(g.name)}
<button class="btn btn-sm btn-outline-danger" onclick="removeChildRelation(${parentId}, ${g.id})">
<i class="bi bi-trash"></i> 移除
</button>
</div>`
).join('');
}
async function addSelectedChildren() {
const parentId = document.getElementById('relation-goal-id').value;
const select = document.getElementById('available-goals-select');
const selectedOptions = Array.from(select.selectedOptions);
if (selectedOptions.length === 0) {
alert('请先选择要添加的子目标');
return;
}
for (const option of selectedOptions) {
const childId = parseInt(option.value);
try {
const res = await fetch(`${API_BASE}/${parentId}/children`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({child_goal_id: childId})
});
if (!res.ok) {
const err = await res.json();
alert(err.error || '添加失败');
}
} catch (e) {
console.error(e);
}
}
// 刷新列表
const children = await fetch(`${API_BASE}/${parentId}/children`).then(r => r.json());
renderChildrenList(children, parentId);
// 从下拉框移除已添加的
selectedOptions.forEach(opt => opt.remove());
}
async function removeChildRelation(parentId, childId) {
if (!confirm('确定移除此关联?')) return;
await fetch(`${API_BASE}/${parentId}/children/${childId}`, {method: 'DELETE'});
// 刷新列表
const children = await fetch(`${API_BASE}/${parentId}/children`).then(r => r.json());
renderChildrenList(children, parentId);
// 刷新下拉框
const allGoals = await fetch(API_BASE).then(r => r.json());
const childIds = children.map(c => c.id);
const select = document.getElementById('available-goals-select');
const currentGoalId = parseInt(document.getElementById('relation-goal-id').value);
select.innerHTML = allGoals
.filter(g => g.id !== currentGoalId && !childIds.includes(g.id))
.map(g => `<option value="${g.id}">${escapeHtml(g.name)}</option>`)
.join('');
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
document.getElementById('save-goal').addEventListener('click', saveGoal);
// 新建目标时初始化 Markdown 编辑器
document.getElementById('goalModal').addEventListener('shown.bs.modal', () => {
// 只有在没有初始化过编辑器时才初始化
if (window.EasyMDE && !goalEditor) {
const contentEl = document.getElementById('goal-content');
goalEditor = new EasyMDE({
element: contentEl,
spellChecker: false,
status: false,
toolbar: ['bold', 'italic', 'heading', '|', 'code', 'quote', 'unordered-list', '|', 'preview', 'side-by-side', 'fullscreen'],
initialValue: ''
});
}
});
document.getElementById('goalModal').addEventListener('hidden.bs.modal', () => {
document.getElementById('goal-id').value = '';
document.getElementById('goal-name').value = '';
document.getElementById('goal-level').value = '入门';
document.getElementById('goal-category').value = '综合';
document.getElementById('goalModalTitle').textContent = '新建目标';
// 清理 Markdown 编辑器
if (goalEditor) {
goalEditor.toTextArea();
goalEditor = null;
}
document.getElementById('goal-content').value = '';
});
loadGoals();
</script>
{% endblock %}