feat: 初始提交 v1.2.0 - 钢琴练习方案生成系统
This commit is contained in:
@@ -0,0 +1,286 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}API设置 - 钢琴练习方案系统{% endblock %}
|
||||
|
||||
{% block sidebar_nav %}
|
||||
<a class="nav-link" href="/">
|
||||
<i class="bi bi-people"></i> 学员管理
|
||||
</a>
|
||||
<a class="nav-link" href="/settings">
|
||||
<i class="bi bi-gear"></i> 问题配置
|
||||
</a>
|
||||
<a class="nav-link active" href="/api-settings">
|
||||
<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">
|
||||
<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>
|
||||
<hr>
|
||||
<a class="nav-link" href="#" onclick="showChangePasswordModal()">
|
||||
<i class="bi bi-key"></i> 修改密码
|
||||
</a>
|
||||
<a class="nav-link" href="#" onclick="logout()">
|
||||
<i class="bi bi-box-arrow-right"></i> 退出登录
|
||||
</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="bi bi-key"></i> AI API 配置</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="alert alert-info">
|
||||
<i class="bi bi-info-circle"></i> 配置AI生成练习报告所需的API参数。
|
||||
</div>
|
||||
|
||||
<form id="apiConfigForm">
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">提供商</label>
|
||||
<select class="form-select" id="apiProvider" onchange="onProviderChange()">
|
||||
<option value="minimax">MiniMax (Token Plan)</option>
|
||||
<option value="volcengine">火山引擎 (Volcengine)</option>
|
||||
<option value="deepseek">DeepSeek</option>
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="openrouter">OpenRouter</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">模型</label>
|
||||
<select class="form-select" id="apiModel">
|
||||
<option value="MiniMax-M2.7-highspeed">MiniMax-M2.7-highspeed</option>
|
||||
<option value="doubao-seed-2.0-pro">doubao-seed-2.0-pro</option>
|
||||
<option value="doubao-seed-code">doubao-seed-code</option>
|
||||
<option value="doubao-seed-2.0-lite">doubao-seed-2.0-lite</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">API Key</label>
|
||||
<div class="input-group">
|
||||
<input type="password" class="form-control" id="apiKey" placeholder="请输入API Key">
|
||||
<button class="btn btn-outline-secondary" type="button" onclick="toggleApiKey()">
|
||||
<i class="bi bi-eye" id="apiKeyToggleIcon"></i>
|
||||
</button>
|
||||
</div>
|
||||
<small class="text-muted" id="apiKeyPreview"></small>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">API Endpoint</label>
|
||||
<input type="text" class="form-control" id="apiEndpoint" placeholder="https://ark.cn-beijing.volces.com/api/coding/v3">
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Temperature</label>
|
||||
<input type="number" class="form-control" id="apiTemperature" min="0" max="2" step="0.1" value="0.7">
|
||||
<small class="text-muted">控制输出的随机性,值越大越有创造性</small>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button type="button" class="btn btn-primary" onclick="saveApiConfig()">
|
||||
<i class="bi bi-save"></i> 保存配置
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary" onclick="testApiConnection()">
|
||||
<i class="bi bi-plug"></i> 测试连接
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-3" id="apiTestResult"></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-3">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0"><i class="bi bi-lightbulb"></i> 推荐配置</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>提供商</th>
|
||||
<th>推荐模型</th>
|
||||
<th>Endpoint</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>MiniMax</td>
|
||||
<td>MiniMax-M2.7-highspeed</td>
|
||||
<td><code>https://api.minimaxi.com/anthropic/v1</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>火山方舟</td>
|
||||
<td>doubao-seed-2.0-pro</td>
|
||||
<td><code>https://ark.cn-beijing.volces.com/api/coding/v3</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>DeepSeek</td>
|
||||
<td>deepseek-chat</td>
|
||||
<td><code>https://api.deepseek.com</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>OpenRouter</td>
|
||||
<td>deepseek/deepseek-r1</td>
|
||||
<td><code>https://openrouter.ai/api/v1</code></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
const ROLE_LABELS = { 'admin': '管理员', 'user': '普通用户' };
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
fetch('/api/check-login').then(r => r.json()).then(data => {
|
||||
if (!data.logged_in) {
|
||||
window.location.href = '/login';
|
||||
return;
|
||||
}
|
||||
if (data.role !== 'admin') {
|
||||
window.location.href = '/';
|
||||
return;
|
||||
}
|
||||
|
||||
const userDisplay = data.username + ' (' + ROLE_LABELS[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('templatesNav').style.display = '';
|
||||
}
|
||||
|
||||
loadApiConfig();
|
||||
}).catch(() => {
|
||||
window.location.href = '/login';
|
||||
});
|
||||
});
|
||||
|
||||
const providerDefaults = {
|
||||
'minimax': {
|
||||
endpoint: 'https://api.minimaxi.com/anthropic/v1',
|
||||
model: 'MiniMax-M2.7-highspeed',
|
||||
temperature: 0.7,
|
||||
api_key: ''
|
||||
},
|
||||
'volcengine': {
|
||||
endpoint: 'https://ark.cn-beijing.volces.com/api/coding/v3',
|
||||
model: 'doubao-seed-2.0-pro',
|
||||
temperature: 0.7,
|
||||
api_key: ''
|
||||
},
|
||||
'deepseek': {
|
||||
endpoint: 'https://api.deepseek.com',
|
||||
model: 'deepseek-chat',
|
||||
temperature: 0.7,
|
||||
api_key: ''
|
||||
},
|
||||
'openai': {
|
||||
endpoint: 'https://api.openai.com/v1',
|
||||
model: 'gpt-4o-mini',
|
||||
temperature: 0.7,
|
||||
api_key: ''
|
||||
},
|
||||
'openrouter': {
|
||||
endpoint: 'https://openrouter.ai/api/v1',
|
||||
model: 'anthropic/claude-3-haiku',
|
||||
temperature: 0.7,
|
||||
api_key: ''
|
||||
}
|
||||
};
|
||||
|
||||
async function onProviderChange() {
|
||||
const provider = document.getElementById('apiProvider').value;
|
||||
const defaults = providerDefaults[provider] || providerDefaults['volcengine'];
|
||||
|
||||
document.getElementById('apiModel').value = defaults.model;
|
||||
document.getElementById('apiEndpoint').value = defaults.endpoint;
|
||||
document.getElementById('apiTemperature').value = defaults.temperature;
|
||||
document.getElementById('apiKey').value = defaults.api_key || '';
|
||||
}
|
||||
|
||||
async function saveApiConfig(silent = false, overrideProvider = null) {
|
||||
const provider = overrideProvider || document.getElementById('apiProvider').value;
|
||||
const config = {
|
||||
provider: provider,
|
||||
model: document.getElementById('apiModel').value,
|
||||
api_key: document.getElementById('apiKey').value,
|
||||
base_url: document.getElementById('apiEndpoint').value,
|
||||
temperature: parseFloat(document.getElementById('apiTemperature').value)
|
||||
};
|
||||
|
||||
const r = await fetch('/api/config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(config)
|
||||
});
|
||||
const data = await r.json();
|
||||
if (!silent) {
|
||||
if (data.error) {
|
||||
alert(data.error);
|
||||
} else {
|
||||
alert('保存成功');
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
async function loadApiConfig() {
|
||||
try {
|
||||
const r = await fetch('/api/config');
|
||||
const config = await r.json();
|
||||
document.getElementById('apiProvider').value = config.provider || 'volcengine';
|
||||
document.getElementById('apiModel').value = config.model || 'doubao-seed-2.0-pro';
|
||||
document.getElementById('apiKey').value = config.api_key || '';
|
||||
document.getElementById('apiEndpoint').value = config.base_url || '';
|
||||
document.getElementById('apiTemperature').value = config.temperature || 0.7;
|
||||
|
||||
if (config.api_key_preview) {
|
||||
document.getElementById('apiKeyPreview').textContent = '当前: ' + config.api_key_preview;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function testApiConnection() {
|
||||
const resultDiv = document.getElementById('apiTestResult');
|
||||
resultDiv.innerHTML = '<div class="alert alert-info">测试中...</div>';
|
||||
|
||||
const r = await fetch('/api/config/test', { method: 'POST' });
|
||||
const data = await r.json();
|
||||
|
||||
if (data.success) {
|
||||
resultDiv.innerHTML = '<div class="alert alert-success"><i class="bi bi-check-circle"></i> 连接成功</div>';
|
||||
} else {
|
||||
resultDiv.innerHTML = '<div class="alert alert-danger"><i class="bi bi-x-circle"></i> 连接失败: ' + (data.error || '未知错误') + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function toggleApiKey() {
|
||||
const input = document.getElementById('apiKey');
|
||||
const icon = document.getElementById('apiKeyToggleIcon');
|
||||
if (input.type === 'password') {
|
||||
input.type = 'text';
|
||||
icon.className = 'bi bi-eye-slash';
|
||||
} else {
|
||||
input.type = 'password';
|
||||
icon.className = 'bi bi-eye';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,200 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<title>{% block title %}钢琴练习方案管理系统{% endblock %}</title>
|
||||
|
||||
<!-- 公共CSS -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet">
|
||||
{% block extra_css %}{% endblock %}
|
||||
|
||||
<style>
|
||||
body { background-color: #f5f7fa; }
|
||||
.sidebar { min-height: 100vh; background: #2c3e50; color: white; }
|
||||
.sidebar .nav-link { color: rgba(255,255,255,0.8); padding: 12px 20px; }
|
||||
.sidebar .nav-link:hover, .sidebar .nav-link.active { background: rgba(255,255,255,0.1); color: white; }
|
||||
.main-content { padding: 20px; }
|
||||
.card { border: none; border-radius: 10px; box-shadow: 0 2px 10px rgba(0,0,0,0.05); }
|
||||
|
||||
/* 移动端响应式 */
|
||||
@media (max-width: 767.98px) {
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: 60px;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
min-height: auto;
|
||||
max-height: calc(100vh - 60px);
|
||||
z-index: 1040;
|
||||
overflow-y: auto;
|
||||
transform: translateY(-100%);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
.sidebar.collapsed {
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
.sidebar:not(.collapsed) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
.main-content {
|
||||
margin-top: 60px;
|
||||
padding: 10px;
|
||||
}
|
||||
.mobile-nav-toggle {
|
||||
display: flex !important;
|
||||
}
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.mobile-nav-toggle {
|
||||
display: none !important;
|
||||
}
|
||||
.sidebar {
|
||||
position: static;
|
||||
transform: none !important;
|
||||
}
|
||||
.sidebar.collapsed {
|
||||
transform: none !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% block page_css %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<!-- 移动端顶部导航 -->
|
||||
<nav class="mobile-nav-toggle navbar navbar-dark bg-dark d-flex d-md-none" style="position:fixed;top:0;left:0;right:0;z-index:1050;padding:10px;">
|
||||
<div class="container-fluid d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<span class="navbar-brand mb-0"><i class="bi bi-music-note-beamed"></i> 钢琴方案</span>
|
||||
<small id="mobileUserDisplay" class="d-block text-white-50" style="font-size:10px;"></small>
|
||||
</div>
|
||||
<button class="btn btn-outline-light btn-sm" onclick="toggleMobileNav()">
|
||||
<i class="bi bi-list"></i>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<!-- 侧边栏 -->
|
||||
<div class="col-md-2 sidebar p-0 collapsed" id="sidebar">
|
||||
<div class="p-3 text-center border-bottom border-secondary d-none d-md-block">
|
||||
<h5><i class="bi bi-music-note-beamed"></i> 钢琴方案</h5>
|
||||
<small id="currentUserDisplay" class="text-light"></small>
|
||||
</div>
|
||||
<nav class="nav flex-column">
|
||||
{% block sidebar_nav %}{% endblock %}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<div class="col-md-10 main-content">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 公共JS -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script>
|
||||
// 移动端导航切换
|
||||
function toggleMobileNav() {
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
if (sidebar) {
|
||||
sidebar.classList.toggle('collapsed');
|
||||
}
|
||||
}
|
||||
|
||||
// 点击导航链接后关闭移动端导航
|
||||
document.querySelectorAll('.sidebar .nav-link').forEach(link => {
|
||||
link.addEventListener('click', () => {
|
||||
if (window.innerWidth < 768) {
|
||||
toggleMobileNav();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 退出登录
|
||||
function logout() {
|
||||
if (!confirm('确定要退出登录吗?')) return;
|
||||
fetch('/api/logout', { method: 'POST' }).then(() => window.location.href = '/login');
|
||||
}
|
||||
|
||||
// 修改密码
|
||||
function showChangePasswordModal() {
|
||||
document.getElementById('oldPassword').value = '';
|
||||
document.getElementById('newPassword').value = '';
|
||||
document.getElementById('confirmPassword').value = '';
|
||||
new bootstrap.Modal(document.getElementById('changePwdModal')).show();
|
||||
}
|
||||
</script>
|
||||
{% block extra_js %}{% endblock %}
|
||||
|
||||
<!-- 修改密码弹窗 -->
|
||||
<div class="modal fade" id="changePwdModal" 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">
|
||||
<form id="changePwdForm">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">原密码</label>
|
||||
<input type="password" class="form-control" id="oldPassword" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">新密码</label>
|
||||
<input type="password" class="form-control" id="newPassword" required>
|
||||
<div class="form-text">8位以上,包含大小写字母、数字和特殊字符</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">确认新密码</label>
|
||||
<input type="password" class="form-control" id="confirmPassword" required>
|
||||
</div>
|
||||
</form>
|
||||
</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="changePwdBtn">确认修改</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.getElementById('changePwdBtn').addEventListener('click', function() {
|
||||
const oldPassword = document.getElementById('oldPassword').value;
|
||||
const newPassword = document.getElementById('newPassword').value;
|
||||
const confirmPassword = document.getElementById('confirmPassword').value;
|
||||
|
||||
if (!oldPassword || !newPassword || !confirmPassword) {
|
||||
alert('请填写完整');
|
||||
return;
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
alert('两次输入的新密码不一致');
|
||||
return;
|
||||
}
|
||||
|
||||
fetch('/api/users/change-password', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ old_password: oldPassword, new_password: newPassword })
|
||||
}).then(r => r.json()).then(data => {
|
||||
if (data.error) {
|
||||
alert(data.error);
|
||||
} else {
|
||||
alert('密码修改成功,请重新登录');
|
||||
logout();
|
||||
}
|
||||
}).catch(err => {
|
||||
console.error(err);
|
||||
alert('请求失败');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,332 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}班级管理 - 钢琴练习方案系统{% endblock %}
|
||||
|
||||
{% block sidebar_nav %}
|
||||
<a class="nav-link" href="/">
|
||||
<i class="bi bi-people"></i> 学员管理
|
||||
</a>
|
||||
<a class="nav-link" href="/settings" id="settingsNav" style="display:none;">
|
||||
<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 active" 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>
|
||||
<hr>
|
||||
<a class="nav-link" href="#" onclick="showChangePasswordModal()">
|
||||
<i class="bi bi-key"></i> 修改密码
|
||||
</a>
|
||||
<a class="nav-link" href="#" onclick="logout()">
|
||||
<i class="bi bi-box-arrow-right"></i> 退出登录
|
||||
</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<h4><i class="bi bi-collection"></i> 班级管理</h4>
|
||||
<select class="form-select form-select-sm" style="width:auto;" id="activeFilter" onchange="loadClasses()">
|
||||
<option value="">全部班级</option>
|
||||
<option value="true" selected>进行中</option>
|
||||
<option value="false">已结束</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn btn-primary" id="addClassBtn" style="display:none;">
|
||||
<i class="bi bi-plus-circle"></i> 新增班级
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<table class="table table-hover" id="classesTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>班级名称</th>
|
||||
<th>描述</th>
|
||||
<th>进行中</th>
|
||||
<th>学员数</th>
|
||||
<th>创建时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 新增/编辑班级弹窗 -->
|
||||
<div class="modal fade" id="classModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="classModalTitle">新增班级</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="classForm">
|
||||
<input type="hidden" id="classId">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">班级名称</label>
|
||||
<input type="text" class="form-control" id="className" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">描述</label>
|
||||
<textarea class="form-control" id="classDesc" rows="2"></textarea>
|
||||
</div>
|
||||
<div class="mb-3 form-check">
|
||||
<input type="checkbox" class="form-check-input" id="classActive" checked>
|
||||
<label class="form-check-label" for="classActive">进行中</label>
|
||||
</div>
|
||||
</form>
|
||||
</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="saveClassBtn">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 班级学员弹窗 -->
|
||||
<div class="modal fade" id="classStudentsModal" 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="mb-3">
|
||||
<button type="button" class="btn btn-sm btn-success" id="assignStudentsBtn">分配学员</button>
|
||||
</div>
|
||||
<table class="table" id="classStudentsTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>姓名</th>
|
||||
<th>手机</th>
|
||||
<th>练习时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分配学员弹窗 -->
|
||||
<div class="modal fade" id="assignModal" 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 id="studentCheckboxes"></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="confirmAssignBtn">确认分配</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
let currentUserRole = 'user';
|
||||
let currentClassId = null;
|
||||
let allStudents = [];
|
||||
|
||||
// Toast 通知函数
|
||||
function showToast(message, isError = true) {
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast-container position-fixed top-50 start-50 translate-middle`;
|
||||
toast.style.zIndex = '9999';
|
||||
toast.innerHTML = `
|
||||
<div class="toast show" role="alert">
|
||||
<div class="toast-header ${isError ? 'bg-danger' : 'bg-success'} text-white">
|
||||
<strong class="me-auto">${isError ? '错误' : '成功'}</strong>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="toast"></button>
|
||||
</div>
|
||||
<div class="toast-body">${message}</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(toast);
|
||||
setTimeout(() => toast.remove(), 3000);
|
||||
}
|
||||
|
||||
// 检查登录状态
|
||||
fetch('/api/check-login').then(r => r.json()).then(data => {
|
||||
if (!data.logged_in) {
|
||||
window.location.href = '/login';
|
||||
return;
|
||||
}
|
||||
currentUserRole = data.role;
|
||||
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 = '';
|
||||
document.getElementById('addClassBtn').style.display = 'inline-block';
|
||||
} else {
|
||||
document.getElementById('settingsNav').style.display = '';
|
||||
document.getElementById('apiSettingsNav').style.display = 'none';
|
||||
document.getElementById('templatesNav').style.display = 'none';
|
||||
}
|
||||
loadClasses();
|
||||
});
|
||||
|
||||
// 加载班级列表
|
||||
function loadClasses() {
|
||||
const activeFilter = document.getElementById('activeFilter').value;
|
||||
const url = activeFilter ? '/api/classes?active=' + activeFilter : '/api/classes';
|
||||
fetch(url).then(r => r.json()).then(classes => {
|
||||
const tbody = document.querySelector('#classesTable tbody');
|
||||
const isAdmin = currentUserRole === 'admin';
|
||||
tbody.innerHTML = classes.map(c => `
|
||||
<tr>
|
||||
<td>${c.id}</td>
|
||||
<td>${c.name}</td>
|
||||
<td>${c.description || '-'}</td>
|
||||
<td>${c.active ? '<span class="badge bg-success">进行中</span>' : '<span class="badge bg-secondary">已结束</span>'}</td>
|
||||
<td><a href="#" onclick="viewClassStudents(${c.id})">${c.student_count}</a></td>
|
||||
<td>${c.created_at}</td>
|
||||
<td>
|
||||
${isAdmin ? `<button type="button" class="btn btn-sm btn-primary me-1" onclick="editClass(${c.id}, '${c.name}', '${c.description || ''}', ${c.active})">编辑</button>
|
||||
<button type="button" class="btn btn-sm btn-danger" onclick="deleteClass(${c.id})">删除</button>` : ''}
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
});
|
||||
}
|
||||
|
||||
// 保存班级
|
||||
document.getElementById('saveClassBtn').onclick = () => {
|
||||
const id = document.getElementById('classId').value;
|
||||
const name = document.getElementById('className').value.trim();
|
||||
const description = document.getElementById('classDesc').value;
|
||||
const active = document.getElementById('classActive').checked;
|
||||
|
||||
if (!name) {
|
||||
alert('请输入班级名称');
|
||||
return;
|
||||
}
|
||||
|
||||
const method = id ? 'PUT' : 'POST';
|
||||
fetch('/api/classes' + (id ? '/' + id : ''), {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, description, active })
|
||||
}).then(r => r.json()).then(data => {
|
||||
if (data.error) {
|
||||
showToast(data.error);
|
||||
} else {
|
||||
const modal = bootstrap.Modal.getInstance(document.getElementById('classModal'));
|
||||
if (modal) modal.hide();
|
||||
document.querySelectorAll('.modal-backdrop').forEach(el => el.remove());
|
||||
document.body.classList.remove('modal-open');
|
||||
loadClasses();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 编辑班级
|
||||
function editClass(id, name, desc, active) {
|
||||
document.getElementById('classId').value = id;
|
||||
document.getElementById('className').value = name;
|
||||
document.getElementById('classDesc').value = desc;
|
||||
document.getElementById('classActive').checked = active !== false;
|
||||
document.getElementById('classModalTitle').textContent = '编辑班级';
|
||||
new bootstrap.Modal(document.getElementById('classModal')).show();
|
||||
}
|
||||
|
||||
// 删除班级
|
||||
function deleteClass(id) {
|
||||
if (!confirm('确定要删除该班级吗?学员不会被删除。')) return;
|
||||
fetch('/api/classes/' + id, { method: 'DELETE' }).then(r => r.json()).then(data => {
|
||||
if (data.error) {
|
||||
showToast(data.error);
|
||||
} else {
|
||||
loadClasses();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 查看班级学员
|
||||
function viewClassStudents(classId) {
|
||||
currentClassId = classId;
|
||||
fetch('/api/classes/' + classId + '/students').then(r => r.json()).then(students => {
|
||||
const tbody = document.querySelector('#classStudentsTable tbody');
|
||||
tbody.innerHTML = students.length ? students.map(s => `
|
||||
<tr><td>${s.name}</td><td>${s.phone || '-'}</td><td>${s.practice_time}</td></tr>
|
||||
`).join('') : '<tr><td colspan="3" class="text-center">暂无学员</td></tr>';
|
||||
new bootstrap.Modal(document.getElementById('classStudentsModal')).show();
|
||||
});
|
||||
}
|
||||
|
||||
// 分配学员
|
||||
document.getElementById('assignStudentsBtn').onclick = () => {
|
||||
fetch('/api/students').then(r => r.json()).then(students => {
|
||||
allStudents = students;
|
||||
const container = document.getElementById('studentCheckboxes');
|
||||
container.innerHTML = students.map(s => `
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" value="${s.id}" ${s.class_id == currentClassId ? 'checked' : ''}>
|
||||
<label class="form-check-label">${s.name} (${s.practice_time})</label>
|
||||
</div>
|
||||
`).join('');
|
||||
new bootstrap.Modal(document.getElementById('assignModal')).show();
|
||||
});
|
||||
};
|
||||
|
||||
document.getElementById('confirmAssignBtn').onclick = () => {
|
||||
const checked = Array.from(document.querySelectorAll('#studentCheckboxes input:checked')).map(c => parseInt(c.value));
|
||||
fetch('/api/classes/' + currentClassId + '/assign', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ student_ids: checked })
|
||||
}).then(r => r.json()).then(data => {
|
||||
if (data.error) {
|
||||
showToast(data.error);
|
||||
} else {
|
||||
const modal = bootstrap.Modal.getInstance(document.getElementById('assignModal'));
|
||||
if (modal) modal.hide();
|
||||
document.querySelectorAll('.modal-backdrop').forEach(el => el.remove());
|
||||
document.body.classList.remove('modal-open');
|
||||
loadClasses();
|
||||
viewClassStudents(currentClassId);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 新增班级按钮
|
||||
document.getElementById('addClassBtn').onclick = () => {
|
||||
document.getElementById('classId').value = '';
|
||||
document.getElementById('className').value = '';
|
||||
document.getElementById('classDesc').value = '';
|
||||
document.getElementById('classActive').checked = true;
|
||||
document.getElementById('classModalTitle').textContent = '新增班级';
|
||||
new bootstrap.Modal(document.getElementById('classModal')).show();
|
||||
};
|
||||
</script>
|
||||
{% endblock %}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,94 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>登录 - 钢琴练习方案系统</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet">
|
||||
<style>
|
||||
body { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); min-height: 100vh; display: flex; align-items: center; justify-content: center; }
|
||||
.login-card { background: white; border-radius: 15px; box-shadow: 0 10px 40px rgba(0,0,0,0.2); padding: 40px; max-width: 400px; width: 100%; }
|
||||
.form-control:focus { border-color: #667eea; box-shadow: 0 0 0 0.2rem rgba(102, 126, 234, 0.25); }
|
||||
.btn-primary { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border: none; }
|
||||
.btn-primary:hover { background: linear-gradient(135deg, #5a6fd6 0%, #6a4190 100%); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-card">
|
||||
<div class="text-center mb-4">
|
||||
<i class="bi bi-music-note-beamed" style="font-size: 48px; color: #667eea;"></i>
|
||||
<h4 class="mt-3">钢琴练习方案系统</h4>
|
||||
<p class="text-muted">请登录继续</p>
|
||||
</div>
|
||||
|
||||
<form id="loginForm">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">用户名</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="bi bi-person"></i></span>
|
||||
<input type="text" class="form-control" id="username" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">密码</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="bi bi-lock"></i></span>
|
||||
<input type="password" class="form-control" id="password" required>
|
||||
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword()">
|
||||
<i class="bi bi-eye" id="toggleIcon"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="errorMsg" class="alert alert-danger d-none"></div>
|
||||
<button type="submit" class="btn btn-primary w-100 py-2">
|
||||
<i class="bi bi-box-arrow-in-right"></i> 登录
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="text-center mt-3">
|
||||
<small class="text-muted">密码要求:8位以上,包含大小写字母、数字和特殊字符</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script>
|
||||
function togglePassword() {
|
||||
const input = document.getElementById('password');
|
||||
const icon = document.getElementById('toggleIcon');
|
||||
if (input.type === 'password') {
|
||||
input.type = 'text';
|
||||
icon.classList.remove('bi-eye');
|
||||
icon.classList.add('bi-eye-slash');
|
||||
} else {
|
||||
input.type = 'password';
|
||||
icon.classList.remove('bi-eye-slash');
|
||||
icon.classList.add('bi-eye');
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('loginForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const errorMsg = document.getElementById('errorMsg');
|
||||
errorMsg.classList.add('d-none');
|
||||
|
||||
const response = await fetch('/api/login', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
username: document.getElementById('username').value,
|
||||
password: document.getElementById('password').value
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (response.ok) {
|
||||
window.location.href = '/';
|
||||
} else {
|
||||
errorMsg.textContent = result.error || '登录失败';
|
||||
errorMsg.classList.remove('d-none');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,370 @@
|
||||
{% 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: #e3f2fd; color: #1565c0; }
|
||||
.category-认知类 { background: #f3e5f5; color: #7b1fa2; }
|
||||
.category-节奏类 { background: #fff3e0; color: #e65100; }
|
||||
.category-表现类 { background: #fce4ec; color: #c2185b; }
|
||||
.category-习惯类 { background: #e8f5e9; color: #2e7d32; }
|
||||
.category-综合类 { background: #f5f5f5; color: #616161; }
|
||||
.editor-textarea { font-family: monospace; font-size: 13px; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block sidebar_nav %}
|
||||
<a class="nav-link" href="/">
|
||||
<i class="bi bi-people"></i> 学员管理
|
||||
</a>
|
||||
<a class="nav-link active" 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">
|
||||
<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>
|
||||
<hr>
|
||||
<a class="nav-link" href="#" onclick="showChangePasswordModal()">
|
||||
<i class="bi bi-key"></i> 修改密码
|
||||
</a>
|
||||
<a class="nav-link" href="#" onclick="logout()">
|
||||
<i class="bi bi-box-arrow-right"></i> 退出登录
|
||||
</a>
|
||||
{% 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="filterProblems()">
|
||||
</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>
|
||||
<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="mb-3">
|
||||
<label class="form-label">问题名称 *</label>
|
||||
<input type="text" class="form-control" id="editProblemName">
|
||||
</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">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</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 src="https://cdn.jsdelivr.net/npm/easymde/dist/easymde.min.js"></script>
|
||||
<script>
|
||||
let allProblems = [];
|
||||
let currentEditId = null;
|
||||
let currentDeleteId = null;
|
||||
|
||||
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('templatesNav').style.display = '';
|
||||
document.getElementById('apiSettingsNav').style.display = '';
|
||||
} else {
|
||||
document.getElementById('templatesNav').style.display = 'none';
|
||||
document.getElementById('apiSettingsNav').style.display = 'none';
|
||||
}
|
||||
}).catch(() => {
|
||||
window.location.href = '/login';
|
||||
});
|
||||
|
||||
loadProblems();
|
||||
});
|
||||
|
||||
// ========== 问题配置相关 ==========
|
||||
|
||||
async function loadProblems() {
|
||||
const response = await fetch('/api/problems');
|
||||
allProblems = await response.json();
|
||||
renderProblems(allProblems);
|
||||
}
|
||||
|
||||
function renderProblems(problems) {
|
||||
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;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
problems.forEach(p => {
|
||||
html += `
|
||||
<div class="col-md-4 col-sm-6 mb-3 problem-item" data-name="${p.name.toLowerCase()}">
|
||||
<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>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
function filterProblems() {
|
||||
const search = document.getElementById('searchInput').value.toLowerCase();
|
||||
document.querySelectorAll('.problem-item').forEach(item => {
|
||||
item.style.display = item.dataset.name.includes(search) ? 'block' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
// 新增
|
||||
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 || '创建失败');
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑
|
||||
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.filename.replace(`${problemId}_`, '').replace('.md', '');
|
||||
document.getElementById('editProblemName').value = name;
|
||||
document.getElementById('editProblemContent').value = 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
|
||||
});
|
||||
}
|
||||
|
||||
var editModal = new bootstrap.Modal(document.getElementById('editModal'));
|
||||
editModal.show();
|
||||
editModal._element.addEventListener('shown.bs.modal', function() {
|
||||
if (window.problemEditor && window.problemEditor.codemirror) {
|
||||
window.problemEditor.codemirror.refresh();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function saveProblem() {
|
||||
const name = document.getElementById('editProblemName').value.trim();
|
||||
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, 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 %}
|
||||
@@ -0,0 +1,148 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>初始设置 - 钢琴练习方案系统</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet">
|
||||
<style>
|
||||
body { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); min-height: 100vh; display: flex; align-items: center; justify-content: center; }
|
||||
.setup-card { background: white; border-radius: 15px; box-shadow: 0 10px 40px rgba(0,0,0,0.2); padding: 40px; max-width: 450px; width: 100%; }
|
||||
.form-control:focus { border-color: #667eea; box-shadow: 0 0 0 0.2rem rgba(102, 126, 234, 0.25); }
|
||||
.btn-primary { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border: none; }
|
||||
.btn-primary:hover { background: linear-gradient(135deg, #5a6fd6 0%, #6a4190 100%); }
|
||||
.password-rules { font-size: 12px; color: #6c757d; }
|
||||
.password-rules li { margin-bottom: 2px; }
|
||||
.password-rules .valid { color: #28a745; }
|
||||
.password-rules .invalid { color: #dc3545; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="setup-card">
|
||||
<div class="text-center mb-4">
|
||||
<i class="bi bi-music-note-beamed" style="font-size: 48px; color: #667eea;"></i>
|
||||
<h4 class="mt-3">欢迎使用系统</h4>
|
||||
<p class="text-muted">请创建管理员账户</p>
|
||||
</div>
|
||||
|
||||
<form id="setupForm">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">用户名</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="bi bi-person"></i></span>
|
||||
<input type="text" class="form-control" id="username" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">密码</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="bi bi-lock"></i></span>
|
||||
<input type="password" class="form-control" id="password" required oninput="checkPassword()">
|
||||
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('password')">
|
||||
<i class="bi bi-eye" id="toggleIcon1"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">确认密码</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="bi bi-lock-fill"></i></span>
|
||||
<input type="password" class="form-control" id="confirmPassword" required>
|
||||
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('confirmPassword')">
|
||||
<i class="bi bi-eye" id="toggleIcon2"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="password-rules mb-3">
|
||||
<li id="ruleLength"><i class="bi bi-circle"></i> 至少8位</li>
|
||||
<li id="ruleUpper"><i class="bi bi-circle"></i> 包含大写字母</li>
|
||||
<li id="ruleLower"><i class="bi bi-circle"></i> 包含小写字母</li>
|
||||
<li id="ruleNumber"><i class="bi bi-circle"></i> 包含数字</li>
|
||||
<li id="ruleSpecial"><i class="bi bi-circle"></i> 包含特殊字符(!@#$%^&*等)</li>
|
||||
</ul>
|
||||
|
||||
<div id="errorMsg" class="alert alert-danger d-none"></div>
|
||||
<button type="submit" class="btn btn-primary w-100 py-2">
|
||||
<i class="bi bi-check-circle"></i> 创建账户
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script>
|
||||
function togglePassword(id) {
|
||||
const input = document.getElementById(id);
|
||||
const icon = input.nextElementSibling.querySelector('i');
|
||||
if (input.type === 'password') {
|
||||
input.type = 'text';
|
||||
icon.classList.remove('bi-eye');
|
||||
icon.classList.add('bi-eye-slash');
|
||||
} else {
|
||||
input.type = 'password';
|
||||
icon.classList.remove('bi-eye-slash');
|
||||
icon.classList.add('bi-eye');
|
||||
}
|
||||
}
|
||||
|
||||
function checkPassword() {
|
||||
const pwd = document.getElementById('password').value;
|
||||
|
||||
const rules = {
|
||||
ruleLength: pwd.length >= 8,
|
||||
ruleUpper: /[A-Z]/.test(pwd),
|
||||
ruleLower: /[a-z]/.test(pwd),
|
||||
ruleNumber: /[0-9]/.test(pwd),
|
||||
ruleSpecial: /[!@#$%^&*(),.?":{}|<>]/.test(pwd)
|
||||
};
|
||||
|
||||
for (const [id, valid] of Object.entries(rules)) {
|
||||
const el = document.getElementById(id);
|
||||
if (valid) {
|
||||
el.classList.add('valid');
|
||||
el.classList.remove('invalid');
|
||||
el.querySelector('i').className = 'bi bi-check-circle-fill';
|
||||
} else {
|
||||
el.classList.remove('valid');
|
||||
el.classList.add('invalid');
|
||||
el.querySelector('i').className = 'bi bi-circle';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('setupForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const errorMsg = document.getElementById('errorMsg');
|
||||
errorMsg.classList.add('d-none');
|
||||
|
||||
const password = document.getElementById('password').value;
|
||||
const confirmPassword = document.getElementById('confirmPassword').value;
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
errorMsg.textContent = '两次密码输入不一致';
|
||||
errorMsg.classList.remove('d-none');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch('/api/setup', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
username: document.getElementById('username').value,
|
||||
password: password,
|
||||
confirm_password: confirmPassword
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (response.ok) {
|
||||
window.location.href = '/';
|
||||
} else {
|
||||
errorMsg.textContent = result.error || '设置失败';
|
||||
errorMsg.classList.remove('d-none');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,309 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}模板管理 - 钢琴练习方案系统{% endblock %}
|
||||
|
||||
{% block page_css %}
|
||||
<style>
|
||||
.template-card { cursor: pointer; transition: all 0.2s; }
|
||||
.template-card:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
|
||||
.template-card.active { border-color: #667eea; background: #f8f9ff; }
|
||||
.preview-pane { background: white; border-radius: 8px; padding: 20px; min-height: 300px; }
|
||||
.preview-content { white-space: pre-wrap; font-family: monospace; font-size: 13px; line-height: 1.6; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block sidebar_nav %}
|
||||
<a class="nav-link" href="/">
|
||||
<i class="bi bi-people"></i> 学员管理
|
||||
</a>
|
||||
<a class="nav-link" href="/settings">
|
||||
<i class="bi bi-gear"></i> 问题配置
|
||||
</a>
|
||||
<a class="nav-link" href="/api-settings">
|
||||
<i class="bi bi-key"></i> API设置
|
||||
</a>
|
||||
<a class="nav-link active" href="/templates">
|
||||
<i class="bi bi-file-earmark-text"></i> 模板管理
|
||||
</a>
|
||||
<a class="nav-link" 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>
|
||||
<hr>
|
||||
<a class="nav-link" href="#" onclick="logout()">
|
||||
<i class="bi bi-box-arrow-right"></i> 退出登录
|
||||
</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h4><i class="bi bi-file-earmark-text"></i> 模板管理</h4>
|
||||
<button class="btn btn-primary" onclick="showCreateModal()">
|
||||
<i class="bi bi-plus"></i> 新建模板
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info">
|
||||
<i class="bi bi-info-circle"></i>
|
||||
模板管理用于维护AI提示词和报告导出格式。只有管理员可以修改。
|
||||
可用变量在模板中用 <code>{变量名}</code> 格式使用。
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0"><i class="bi bi-list"></i> 模板列表</h6>
|
||||
</div>
|
||||
<div class="card-body p-0" id="templateList"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<div class="card" id="templateEditor" style="display:none;">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h6 class="mb-0" id="editorTitle">编辑模板</h6>
|
||||
<div>
|
||||
<button class="btn btn-sm btn-success" onclick="saveTemplate()">
|
||||
<i class="bi bi-save"></i> 保存
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-danger" onclick="deleteCurrentTemplate()">
|
||||
<i class="bi bi-trash"></i> 删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">模板名称</label>
|
||||
<input type="text" class="form-control" id="templateName">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">模板类型</label>
|
||||
<select class="form-select" id="templateType" disabled>
|
||||
<option value="ai_prompt">AI提示词模板</option>
|
||||
<option value="report">报告导出模板</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">模板描述</label>
|
||||
<input type="text" class="form-control" id="templateDesc" placeholder="模板用途说明">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">排序</label>
|
||||
<input type="number" class="form-control" id="templateSortOrder" value="0" min="0">
|
||||
<small class="text-muted">数字越小排越前</small>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">模板内容</label>
|
||||
<textarea id="templateContent"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-muted text-center mt-5" id="emptyHint">
|
||||
<i class="bi bi-arrow-left"></i> 请从左侧选择一个模板进行编辑
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 创建模板弹窗 -->
|
||||
<div class="modal fade" id="createModal" 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="newTemplateName" placeholder="输入模板名称">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">模板类型</label>
|
||||
<select class="form-select" id="newTemplateType">
|
||||
<option value="ai_prompt">AI提示词模板</option>
|
||||
<option value="report">报告导出模板</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">模板描述</label>
|
||||
<input type="text" class="form-control" id="newTemplateDesc" placeholder="模板用途说明">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">排序</label>
|
||||
<input type="number" class="form-control" id="newTemplateSortOrder" value="0" min="0">
|
||||
<small class="text-muted">数字越小排越前</small>
|
||||
</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="createTemplate()">创建</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/easymde/dist/easymde.min.js"></script>
|
||||
<script>
|
||||
let templates = [];
|
||||
let currentTemplate = null;
|
||||
let editor = null;
|
||||
let createModal;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
createModal = new bootstrap.Modal(document.getElementById('createModal'));
|
||||
checkLogin();
|
||||
});
|
||||
|
||||
function checkLogin() {
|
||||
fetch('/api/check-login').then(r => r.json()).then(data => {
|
||||
if (!data.logged_in) {
|
||||
window.location.href = '/login';
|
||||
return;
|
||||
}
|
||||
const userDisplay = data.username + ' (' + (data.role === 'admin' ? '管理员' : '用户') + ')';
|
||||
document.getElementById('currentUserDisplay').textContent = userDisplay;
|
||||
const mobileDisplay = document.getElementById('mobileUserDisplay');
|
||||
if (mobileDisplay) mobileDisplay.textContent = userDisplay;
|
||||
|
||||
if (data.role === 'admin') {
|
||||
document.getElementById('usersNav').style.display = '';
|
||||
}
|
||||
loadTemplates();
|
||||
}).catch(() => {
|
||||
window.location.href = '/login';
|
||||
});
|
||||
}
|
||||
|
||||
async function loadTemplates() {
|
||||
const resp = await fetch('/templates/templates');
|
||||
templates = await resp.json();
|
||||
renderTemplateList();
|
||||
}
|
||||
|
||||
function renderTemplateList() {
|
||||
const list = document.getElementById('templateList');
|
||||
if (templates.length === 0) {
|
||||
list.innerHTML = '<div class="p-3 text-muted">暂无模板</div>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = templates.map(t => `
|
||||
<div class="template-card card m-2 p-2 ${currentTemplate && currentTemplate.id === t.id ? 'active' : ''}"
|
||||
onclick="selectTemplate(${t.id})">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<strong>${t.name}</strong>
|
||||
<br><small class="text-muted">${t.type === 'ai_prompt' ? 'AI提示词' : '报告模板'}</small>
|
||||
</div>
|
||||
<i class="bi bi-chevron-right"></i>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
async function selectTemplate(id) {
|
||||
currentTemplate = templates.find(t => t.id === id);
|
||||
if (!currentTemplate) return;
|
||||
|
||||
document.getElementById('templateEditor').style.display = 'block';
|
||||
document.getElementById('emptyHint').style.display = 'none';
|
||||
|
||||
document.getElementById('templateName').value = currentTemplate.name;
|
||||
document.getElementById('templateType').value = currentTemplate.type;
|
||||
document.getElementById('templateDesc').value = currentTemplate.description || '';
|
||||
document.getElementById('templateSortOrder').value = currentTemplate.sort_order || 0;
|
||||
|
||||
if (editor) {
|
||||
editor.toTextArea();
|
||||
editor = null;
|
||||
}
|
||||
editor = new EasyMDE({
|
||||
element: document.getElementById('templateContent'),
|
||||
spellChecker: false,
|
||||
status: false,
|
||||
toolbar: ['bold', 'italic', 'heading', '|', 'code', 'quote', 'unordered-list', '|', 'side-by-side', 'fullscreen'],
|
||||
initialValue: currentTemplate.content
|
||||
});
|
||||
|
||||
renderTemplateList();
|
||||
}
|
||||
|
||||
function showCreateModal() {
|
||||
document.getElementById('newTemplateName').value = '';
|
||||
document.getElementById('newTemplateType').value = 'ai_prompt';
|
||||
document.getElementById('newTemplateDesc').value = '';
|
||||
document.getElementById('newTemplateSortOrder').value = '0';
|
||||
createModal.show();
|
||||
}
|
||||
|
||||
async function createTemplate() {
|
||||
const name = document.getElementById('newTemplateName').value.trim();
|
||||
const type = document.getElementById('newTemplateType').value;
|
||||
const desc = document.getElementById('newTemplateDesc').value.trim();
|
||||
const sort_order = parseInt(document.getElementById('newTemplateSortOrder').value) || 0;
|
||||
|
||||
if (!name) { alert('请输入模板名称'); return; }
|
||||
|
||||
const resp = await fetch('/templates/templates', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({name, type, content: '', description: desc, sort_order})
|
||||
});
|
||||
|
||||
if (resp.ok) {
|
||||
createModal.hide();
|
||||
await loadTemplates();
|
||||
} else {
|
||||
alert('创建失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function saveTemplate() {
|
||||
if (!currentTemplate) return;
|
||||
|
||||
const name = document.getElementById('templateName').value.trim();
|
||||
const content = editor ? editor.value() : document.getElementById('templateContent').value;
|
||||
const description = document.getElementById('templateDesc').value;
|
||||
const sort_order = parseInt(document.getElementById('templateSortOrder').value) || 0;
|
||||
|
||||
if (!name) { alert('请输入模板名称'); return; }
|
||||
|
||||
const resp = await fetch(`/templates/templates/${currentTemplate.id}`, {
|
||||
method: 'PUT',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({name, content, description, sort_order})
|
||||
});
|
||||
|
||||
if (resp.ok) {
|
||||
currentTemplate.name = name;
|
||||
currentTemplate.content = content;
|
||||
currentTemplate.description = description;
|
||||
currentTemplate.sort_order = sort_order;
|
||||
alert('保存成功');
|
||||
renderTemplateList();
|
||||
} else {
|
||||
alert('保存失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteCurrentTemplate() {
|
||||
if (!currentTemplate) return;
|
||||
if (!confirm('确定要删除这个模板吗?')) return;
|
||||
|
||||
const resp = await fetch(`/templates/templates/${currentTemplate.id}`, {method: 'DELETE'});
|
||||
if (resp.ok) {
|
||||
currentTemplate = null;
|
||||
document.getElementById('templateEditor').style.display = 'none';
|
||||
document.getElementById('emptyHint').style.display = 'block';
|
||||
if (editor) { editor.toTextArea(); editor = null; }
|
||||
await loadTemplates();
|
||||
} else {
|
||||
alert('删除失败');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,264 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}用户管理 - 钢琴练习方案系统{% endblock %}
|
||||
|
||||
{% block sidebar_nav %}
|
||||
<a class="nav-link" href="/">
|
||||
<i class="bi bi-people"></i> 学员管理
|
||||
</a>
|
||||
<a class="nav-link" href="/settings">
|
||||
<i class="bi bi-gear"></i> 问题配置
|
||||
</a>
|
||||
<a class="nav-link" href="/api-settings">
|
||||
<i class="bi bi-key"></i> API设置
|
||||
</a>
|
||||
<a class="nav-link" href="/classes">
|
||||
<i class="bi bi-collection"></i> 班级管理
|
||||
</a>
|
||||
<a class="nav-link active" href="/users">
|
||||
<i class="bi bi-person-badge"></i> 用户管理
|
||||
</a>
|
||||
<a class="nav-link" href="/templates" id="templatesNav" style="display:none;">
|
||||
<i class="bi bi-file-earmark-text"></i> 模板管理
|
||||
</a>
|
||||
<hr>
|
||||
<a class="nav-link" href="#" onclick="showChangePasswordModal()">
|
||||
<i class="bi bi-key"></i> 修改密码
|
||||
</a>
|
||||
<a class="nav-link" href="#" onclick="logout()">
|
||||
<i class="bi bi-box-arrow-right"></i> 退出登录
|
||||
</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h4><i class="bi bi-person-badge"></i> 用户管理</h4>
|
||||
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#userModal">
|
||||
<i class="bi bi-plus-circle"></i> 新增用户
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<table class="table table-hover" id="usersTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>用户名</th>
|
||||
<th>角色</th>
|
||||
<th>创建时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 新增/编辑用户弹窗 -->
|
||||
<div class="modal fade" id="userModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="userModalTitle">新增用户</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="userForm">
|
||||
<input type="hidden" id="userId">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">用户名</label>
|
||||
<input type="text" class="form-control" id="username" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" id="passwordLabel">初始密码</label>
|
||||
<input type="password" class="form-control" id="password">
|
||||
<div class="form-text">8位以上,包含大小写字母、数字和特殊字符</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">角色</label>
|
||||
<select class="form-select" id="role">
|
||||
<option value="user">普通用户</option>
|
||||
<option value="admin">管理员</option>
|
||||
</select>
|
||||
</div>
|
||||
</form>
|
||||
</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="saveUserBtn">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 重置密码弹窗 -->
|
||||
<div class="modal fade" id="resetPwdModal" 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">
|
||||
<form id="resetPwdForm">
|
||||
<input type="hidden" id="resetUserId">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">新密码</label>
|
||||
<input type="password" class="form-control" id="newPassword" required>
|
||||
<div class="form-text">8位以上,包含大小写字母、数字和特殊字符</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
|
||||
<button type="button" class="btn btn-warning" id="confirmResetBtn">重置密码</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
const ROLE_LABELS = { 'admin': '管理员', 'user': '普通用户' };
|
||||
|
||||
// 检查登录状态
|
||||
fetch('/api/check-login').then(r => r.json()).then(data => {
|
||||
if (!data.logged_in || data.role !== 'admin') {
|
||||
window.location.href = '/login';
|
||||
return;
|
||||
}
|
||||
const userDisplay = data.username + ' (' + ROLE_LABELS[data.role] + ')';
|
||||
document.getElementById('currentUserDisplay').textContent = userDisplay;
|
||||
const mobileDisplay = document.getElementById('mobileUserDisplay');
|
||||
if (mobileDisplay) mobileDisplay.textContent = userDisplay;
|
||||
|
||||
document.getElementById('templatesNav').style.display = '';
|
||||
loadUsers();
|
||||
});
|
||||
|
||||
// Toast 通知函数
|
||||
function showToast(message, isError = true) {
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'toast-container position-fixed top-50 start-50 translate-middle';
|
||||
toast.style.zIndex = '9999';
|
||||
toast.innerHTML = `
|
||||
<div class="toast show" role="alert">
|
||||
<div class="toast-header ${isError ? 'bg-danger' : 'bg-success'} text-white">
|
||||
<strong class="me-auto">${isError ? '错误' : '成功'}</strong>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="toast"></button>
|
||||
</div>
|
||||
<div class="toast-body">${message}</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(toast);
|
||||
setTimeout(() => toast.remove(), 3000);
|
||||
}
|
||||
|
||||
// 加载用户列表
|
||||
function loadUsers() {
|
||||
fetch('/api/users')
|
||||
.then(r => {
|
||||
if (!r.ok) throw new Error('请求失败: ' + r.status);
|
||||
return r.json();
|
||||
})
|
||||
.then(users => {
|
||||
const tbody = document.querySelector('#usersTable tbody');
|
||||
if (users.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="5" class="text-center text-muted">暂无用户</td></tr>';
|
||||
} else {
|
||||
tbody.innerHTML = users.map(u => `
|
||||
<tr>
|
||||
<td>${u.id}</td>
|
||||
<td>${u.username}</td>
|
||||
<td><span class="badge ${u.role === 'admin' ? 'bg-danger' : 'bg-secondary'}">${ROLE_LABELS[u.role]}</span></td>
|
||||
<td>${u.created_at}</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-warning me-1" onclick="openResetPwd(${u.id})">重置密码</button>
|
||||
<button class="btn btn-sm btn-danger" onclick="deleteUser(${u.id})">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('加载用户失败:', err);
|
||||
alert('加载用户失败,请刷新页面重试');
|
||||
});
|
||||
}
|
||||
|
||||
// 保存用户
|
||||
document.getElementById('saveUserBtn').onclick = () => {
|
||||
const id = document.getElementById('userId').value;
|
||||
const username = document.getElementById('username').value.trim();
|
||||
const password = document.getElementById('password').value;
|
||||
const role = document.getElementById('role').value;
|
||||
|
||||
if (!username || !password) {
|
||||
alert('请填写完整');
|
||||
return;
|
||||
}
|
||||
|
||||
const method = id ? 'PUT' : 'POST';
|
||||
fetch('/api/users' + (id ? '/' + id : ''), {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password, role })
|
||||
}).then(r => r.json()).then(data => {
|
||||
if (data.error) {
|
||||
showToast(data.error);
|
||||
} else {
|
||||
const modal = bootstrap.Modal.getInstance(document.getElementById('userModal'));
|
||||
if (modal) modal.hide();
|
||||
document.querySelectorAll('.modal-backdrop').forEach(el => el.remove());
|
||||
document.body.classList.remove('modal-open');
|
||||
loadUsers();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 重置密码
|
||||
function openResetPwd(id) {
|
||||
document.getElementById('resetUserId').value = id;
|
||||
document.getElementById('newPassword').value = '';
|
||||
new bootstrap.Modal(document.getElementById('resetPwdModal')).show();
|
||||
}
|
||||
|
||||
document.getElementById('confirmResetBtn').onclick = () => {
|
||||
const id = document.getElementById('resetUserId').value;
|
||||
const newPassword = document.getElementById('newPassword').value;
|
||||
if (!newPassword) {
|
||||
alert('请输入新密码');
|
||||
return;
|
||||
}
|
||||
fetch('/api/users/' + id + '/reset-password', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ new_password: newPassword })
|
||||
}).then(r => r.json()).then(data => {
|
||||
if (data.error) {
|
||||
showToast(data.error);
|
||||
} else {
|
||||
const modal = bootstrap.Modal.getInstance(document.getElementById('resetPwdModal'));
|
||||
if (modal) modal.hide();
|
||||
document.querySelectorAll('.modal-backdrop').forEach(el => el.remove());
|
||||
document.body.classList.remove('modal-open');
|
||||
showToast('密码重置成功', false);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 删除用户
|
||||
function deleteUser(id) {
|
||||
if (!confirm('确定要删除该用户吗?')) return;
|
||||
fetch('/api/users/' + id, { method: 'DELETE' }).then(r => r.json()).then(data => {
|
||||
if (data.error) {
|
||||
showToast(data.error);
|
||||
} else {
|
||||
loadUsers();
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,179 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ student_name }} - 练习方案</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Microsoft YaHei", sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
.container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
|
||||
}
|
||||
.header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 25px;
|
||||
text-align: center;
|
||||
}
|
||||
.header h1 { font-size: 22px; margin-bottom: 5px; }
|
||||
.header p { opacity: 0.9; font-size: 14px; }
|
||||
.body { padding: 20px; }
|
||||
.student-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: #f8f9fa;
|
||||
padding: 15px;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.student-info .name { font-weight: bold; font-size: 18px; }
|
||||
.student-info .time { color: #666; font-size: 14px; }
|
||||
.section { margin-bottom: 20px; }
|
||||
.section-title {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
margin-bottom: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
border-bottom: 1px solid #eee;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
.section-title:not(:first-child) { margin-top: 20px; }
|
||||
.tags { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.tag {
|
||||
padding: 6px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.tag.severity-严重 { background: #ffebee; color: #c62828; }
|
||||
.tag.severity-中等 { background: #fff3e0; color: #ef6c00; }
|
||||
.tag.severity-轻微 { background: #e8f5e9; color: #2e7d32; }
|
||||
.problem-item { background: #f8f9fa; padding: 12px; border-radius: 8px; margin-bottom: 10px; }
|
||||
.problem-item h4 { font-size: 15px; margin-bottom: 8px; color: #333; }
|
||||
.problem-item p { font-size: 13px; color: #666; line-height: 1.6; }
|
||||
.schedule-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
.schedule-item:last-child { border-bottom: none; }
|
||||
.schedule-item .phase {
|
||||
width: 60px;
|
||||
font-weight: bold;
|
||||
color: #667eea;
|
||||
font-size: 13px;
|
||||
}
|
||||
.schedule-item .details { flex: 1; }
|
||||
.schedule-item .content { font-size: 14px; color: #333; }
|
||||
.schedule-item .purpose { font-size: 12px; color: #999; margin-top: 3px; }
|
||||
.schedule-item .duration {
|
||||
width: 50px;
|
||||
text-align: right;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
background: #f5f5f5;
|
||||
padding: 3px 8px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.ai-report {
|
||||
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
|
||||
padding: 15px;
|
||||
border-radius: 10px;
|
||||
font-size: 14px;
|
||||
line-height: 1.8;
|
||||
color: #333;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.ai-report h2 { font-size: 16px; color: #667eea; margin-bottom: 10px; }
|
||||
.ai-report p { margin-bottom: 8px; }
|
||||
.ai-report ul, .ai-report ol { margin-left: 20px; margin-bottom: 8px; }
|
||||
.ai-report li { margin-bottom: 4px; }
|
||||
.footer {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="card">
|
||||
<div class="header">
|
||||
<h1>🎹 个性化练习方案</h1>
|
||||
<p>针对性问题专项训练</p>
|
||||
</div>
|
||||
<div class="body">
|
||||
<div class="student-info">
|
||||
<span class="name">{{ student_name }}</span>
|
||||
<span class="time">{{ content.practice_time }} (共{{ content.total_daily_minutes }}分钟)</span>
|
||||
</div>
|
||||
|
||||
{% if content.ai_report %}
|
||||
<div class="section">
|
||||
<div class="section-title">📝 AI个性化报告</div>
|
||||
<div class="ai-report">{{ content.ai_report }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">🔍 问题诊断</div>
|
||||
<div class="tags">
|
||||
{% for problem in content.problems %}
|
||||
<span class="tag severity-{{ problem.severity }}">
|
||||
{{ problem.name }} · {{ problem.severity }}
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if content.problem_details %}
|
||||
<div class="section">
|
||||
<div class="section-title">📚 问题详解</div>
|
||||
{% for detail in content.problem_details %}
|
||||
<div class="problem-item">
|
||||
<h4>{{ detail.name }} ({{ detail.severity }})</h4>
|
||||
<p>{{ detail.content }}</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">📅 每日练习计划</div>
|
||||
{% for item in content.daily_schedule %}
|
||||
<div class="schedule-item">
|
||||
<div class="phase">{{ item.phase }}</div>
|
||||
<div class="details">
|
||||
<div class="content">{{ item.content }}</div>
|
||||
<div class="purpose">{{ item.purpose }}</div>
|
||||
</div>
|
||||
<div class="duration">{{ item.duration }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
坚持练习 · 必有进步
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user