319 lines
12 KiB
HTML
319 lines
12 KiB
HTML
{% extends "base.html" %}
|
|
|
|
{% block content %}
|
|
<div class="container-fluid py-4">
|
|
<h2 class="mb-4">🎯 目标管理</h2>
|
|
|
|
<!-- 目标列表 -->
|
|
<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">
|
|
<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="mb-3">
|
|
<label class="form-label">目标名称</label>
|
|
<input type="text" class="form-control" id="goal-name" required>
|
|
</div>
|
|
<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 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 class="mb-3">
|
|
<label class="form-label">目标内容 (Markdown)</label>
|
|
<textarea class="form-control" id="goal-content" rows="8"></textarea>
|
|
</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" id="save-goal">保存</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 关系管理 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';
|
|
|
|
// 加载目标列表
|
|
async function loadGoals() {
|
|
const res = await fetch(API_BASE);
|
|
const goals = await res.json();
|
|
const grid = document.getElementById('goals-grid');
|
|
|
|
// 获取每个目标的子目标信息
|
|
const goalsWithChildren = 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};
|
|
}));
|
|
|
|
grid.innerHTML = goalsWithChildren.map(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="col-md-4 col-lg-3">
|
|
<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>
|
|
</div>
|
|
`}).join('');
|
|
}
|
|
|
|
// 保存目标
|
|
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 = 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();
|
|
console.log('Save result:', result);
|
|
|
|
if (res.ok) {
|
|
bootstrap.Modal.getInstance(document.getElementById('goalModal')).hide();
|
|
loadGoals();
|
|
} else {
|
|
alert(result.error || '保存失败');
|
|
}
|
|
} catch (e) {
|
|
alert('网络错误: ' + e.message);
|
|
}
|
|
}
|
|
|
|
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('goal-content').value = g.content || '';
|
|
document.getElementById('goalModalTitle').textContent = '编辑目标';
|
|
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);
|
|
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('goal-content').value = '';
|
|
document.getElementById('goalModalTitle').textContent = '新建目标';
|
|
});
|
|
|
|
loadGoals();
|
|
</script>
|
|
{% endblock %} |