This commit is contained in:
darkclip 2026-05-30 23:27:52 +08:00
parent 01b3849309
commit f9fa280dee
Signed by: hl
GPG Key ID: A948B2AA5A5D3BDB
2 changed files with 162 additions and 60 deletions

222
main.py
View File

@ -567,6 +567,11 @@ async def reorder_programs(data: dict):
raise HTTPException(status_code=400, detail='缺少参数')
with get_db(write=True) as conn:
cursor = conn.cursor()
# 1. 先将该批次所有节目的 order_num 清空,避免唯一约束冲突
cursor.execute(
'UPDATE programs SET order_num = NULL WHERE batch_id = ?', (batch_id,)
)
# 2. 按新顺序重新赋值
for idx, sn in enumerate(sn_list, start=1):
cursor.execute(
'UPDATE programs SET order_num = ? WHERE sn = ? AND batch_id = ?',
@ -901,10 +906,10 @@ async def get_aggregate(batch_id: int | None = Query(None)):
f"""
SELECT
t.program_sn,
AVG(CASE WHEN t.final_total > 0 THEN t.final_total ELSE NULL END) AS avg_score,
MAX(CASE WHEN t.final_total > 0 THEN t.final_total ELSE NULL END) AS max_score,
MIN(CASE WHEN t.final_total > 0 THEN t.final_total ELSE NULL END) AS min_score,
COUNT(CASE WHEN t.final_total > 0 THEN 1 ELSE NULL END) AS scored_count
AVG(CASE WHEN t.has_score THEN t.final_total ELSE NULL END) AS avg_score,
MAX(CASE WHEN t.has_score THEN t.final_total ELSE NULL END) AS max_score,
MIN(CASE WHEN t.has_score THEN t.final_total ELSE NULL END) AS min_score,
COUNT(CASE WHEN t.has_score THEN 1 ELSE NULL END) AS scored_count
FROM (
SELECT
program_sn,
@ -912,7 +917,10 @@ async def get_aggregate(batch_id: int | None = Query(None)):
COALESCE(admin_total_score,
COALESCE(content_score,0) + COALESCE(stage_score,0) +
COALESCE(art_score,0) + COALESCE(overall_score,0)
) AS final_total
) AS final_total,
(admin_total_score IS NOT NULL OR
content_score IS NOT NULL OR stage_score IS NOT NULL OR
art_score IS NOT NULL OR overall_score IS NOT NULL) AS has_score
FROM scores
WHERE program_sn IN ({placeholders})
) t
@ -921,13 +929,10 @@ async def get_aggregate(batch_id: int | None = Query(None)):
sn_list,
)
# 将结果转换为字典,key为program_sn,值为字典
stats = {}
for row in cursor.fetchall():
stats[row['program_sn']] = {
'avg_score': row['avg_score']
if row['avg_score'] is not None and row['scored_count'] > 0
else None,
'avg_score': row['avg_score'] if row['avg_score'] is not None else None,
'max_score': row['max_score'] or 0,
'min_score': row['min_score'] or 0,
'scored_count': row['scored_count'] or 0,
@ -937,7 +942,7 @@ async def get_aggregate(batch_id: int | None = Query(None)):
for prog in programs:
stat = stats.get(
prog['sn'],
{'avg_score': 0, 'max_score': 0, 'min_score': 0, 'scored_count': 0},
{'avg_score': None, 'max_score': 0, 'min_score': 0, 'scored_count': 0},
)
results.append(
{
@ -1029,25 +1034,36 @@ async def print_program_score(program_sn: str):
scores_data, all_scores = {}, []
for judge in judges:
score = conn.execute(
'SELECT content_score, stage_score, art_score, overall_score, comment FROM scores WHERE judge_id = ? AND program_sn = ?',
"""SELECT content_score, stage_score, art_score, overall_score,
admin_total_score, comment
FROM scores WHERE judge_id = ? AND program_sn = ?""",
(judge['id'], program_sn),
).fetchone()
total = 0
has_score = False
comment = ''
if score:
total = 0
if score['content_score'] is not None:
total += score['content_score']
if score['stage_score'] is not None:
total += score['stage_score']
if score['art_score'] is not None:
total += score['art_score']
if score['overall_score'] is not None:
total += score['overall_score']
if total == 0 and score['content_score'] is not None:
total = score['content_score']
# 优先使用管理员总分
if score['admin_total_score'] is not None:
total = score['admin_total_score']
has_score = True
else:
# 计算明细分之和
if score['content_score'] is not None:
total += score['content_score']
has_score = True
if score['stage_score'] is not None:
total += score['stage_score']
has_score = True
if score['art_score'] is not None:
total += score['art_score']
has_score = True
if score['overall_score'] is not None:
total += score['overall_score']
has_score = True
comment = score['comment'] or ''
if total > 0:
# 只有已评分的评委才计入统计(包括0分)
if has_score:
all_scores.append(total)
scores_data[judge['name']] = {'total': total, 'comment': comment}
max_score = max(all_scores) if all_scores else 0
@ -1160,7 +1176,7 @@ def render_print_blank_template(program, judges, criteria, titles):
<div class="sign-line"><span>评委签名:____________________</span><span>日期:____年____月____日</span></div>
<div class="sheet-bottom-space"></div></div>"""
html += (
'<div class="no-print" style="position:fixed; bottom:20px; right:20px;"><button onclick="window.print()">🖨️ 打印</button></div></body></html>'
'<div class="no-print" style="position:fixed; bottom:20px; right:20px;"><button onclick="window.print()">🖨️ 打印本页</button></div></body></html>'
''
)
return html
@ -1440,6 +1456,46 @@ HTML_TEMPLATE = """
let refreshTimer = null;
let userToken = null;
// ========== 统一认证请求封装 ==========
async function authFetch(url, options = {}) {
// 如果有 token,添加到请求头
if (userToken) {
options.headers = options.headers || {};
options.headers['Authorization'] = 'Bearer ' + userToken;
}
try {
const response = await fetch(url, options);
if (response.status === 401 || response.status === 403) {
// token 无效或过期,清除本地登录状态
localStorage.removeItem('score_user');
userToken = null;
currentUser = null;
userType = null;
// 显示提示
showToast('登录已过期,请重新登录');
// 重置界面到未登录状态
document.getElementById('loginArea').style.display = 'flex';
document.getElementById('userInfoArea').style.display = 'none';
document.getElementById('tab-admin').style.display = 'none';
document.getElementById('tab-award').style.display = 'none';
document.getElementById('judgeScoringTab').style.display = 'block';
document.getElementById('adminScoringTab').style.display = 'none';
switchTab('scoring');
throw new Error('登录已过期');
}
return response;
} catch (err) {
if (err.message === '登录已过期') {
// 重新抛出,让调用方不再继续处理
throw err;
}
// 其他网络错误
console.error('请求失败:', err);
showToast('网络错误,请稍后重试');
throw err;
}
}
async function restoreLogin() {
const stored = localStorage.getItem('score_user');
if (!stored) return false;
@ -1562,9 +1618,8 @@ HTML_TEMPLATE = """
async function clearBatchPrograms(batchId) {
if(confirm(`确定要清空该批次的所有节目吗?此操作不可恢复!`)){
const res = await fetch(`/api/batches/${batchId}/programs`, {
const res = await authFetch(`/api/batches/${batchId}/programs`, {
method:'DELETE',
headers:{'Authorization': 'Bearer ' + userToken},
});
if(res.ok){
showToast('该批次所有节目已清空');
@ -1816,7 +1871,7 @@ HTML_TEMPLATE = """
score = progScores[judge.id];
html += `<td><input type="number" class="score-input" id="score_${prog.sn}_${judge.id}" value="${score}" step="0.5" min="0" max="100"></td>`;
}
html += `<td><button class="btn btn-outline btn-sm" onclick="printProgram('${prog.sn}')">🖨️ 打印</button></td></tr>`;
html += `<td><button class="btn btn-outline btn-sm" onclick="printProgram('${prog.sn}')">🖨️ 打印评分</button></td></tr>`;
}
html += '</tbody></tr>';
document.getElementById('adminScoringContainer').innerHTML = html;
@ -1858,9 +1913,9 @@ HTML_TEMPLATE = """
async function saveAdminScore(programSn, scores) {
try {
const res = await fetch('/api/scores/admin/batch', {
const res = await authFetch('/api/scores/admin/batch', {
method:'POST',
headers:{'Content-Type':'application/json', 'Authorization': 'Bearer ' + userToken},
headers:{'Content-Type':'application/json'},
body:JSON.stringify({program_sn:programSn, judge_scores:scores})
});
if(res.ok){
@ -1905,7 +1960,7 @@ HTML_TEMPLATE = """
row.insertCell(9).innerText = item.scored_count;
const btnCell = row.insertCell(10);
btnCell.className = 'col-operation';
btnCell.innerHTML = `<button class="btn btn-outline btn-sm" onclick="printBlank('${item.sn}')">📄 空白评分表</button><button class="btn btn-outline btn-sm" onclick="printProgram('${item.sn}')">🖨️ 打印</button>`;
btnCell.innerHTML = `<button class="btn btn-outline btn-sm" onclick="printBlank('${item.sn}')">📄 空白评分表</button><button class="btn btn-outline btn-sm" onclick="printProgram('${item.sn}')">🖨️ 打印评分</button>`;
});
const batch = batchesData.find(b => b.id === currentBatchId);
document.getElementById('summaryTitle').innerHTML = batch ? ` - ${batch.name}` : '';
@ -1988,16 +2043,26 @@ HTML_TEMPLATE = """
function renderProgramManageList() {
const container = document.getElementById('programManageContainer');
if(!container) return;
if (!container) return;
let grouped = {};
programsData.forEach(p => { let batch = batchesData.find(b=>b.id===p.batch_id); let batchName = batch ? batch.name : '未分组'; if(!grouped[batchName]) grouped[batchName] = []; grouped[batchName].push(p); });
// 按批次分组
programsData.forEach(p => {
let batch = batchesData.find(b => b.id === p.batch_id);
let batchName = batch ? batch.name : '未分组';
if (!grouped[batchName]) grouped[batchName] = [];
grouped[batchName].push(p);
});
let html = '';
for(let batchName in grouped){
for (let batchName in grouped) {
// 组内按 order_num 排序(确保后端顺序)
grouped[batchName].sort((a, b) => (a.order_num || 0) - (b.order_num || 0));
html += `<h4 style="margin:15px 0 5px 0;">📁 ${batchName}</h4>`;
grouped[batchName].forEach(p => {
html += `<div class="program-list-item">
<span>${p.sn} | ${p.name} | ${p.unit} | ${p.performer}</span>
grouped[batchName].forEach((p, idx, arr) => {
html += `<div class="program-list-item" style="display:flex; justify-content:space-between; align-items:center; padding:10px 0; border-bottom:1px solid #eee;">
<span style="flex:1;">${idx+1}. ${p.sn} | ${p.name} | ${p.unit} | ${p.performer}</span>
<div class="program-buttons">
${idx > 0 ? `<button class="btn btn-outline btn-sm" onclick="moveProgramUp('${p.sn}', '${p.batch_id}')">⬆️ 上移</button>` : ''}
${idx < arr.length-1 ? `<button class="btn btn-outline btn-sm" onclick="moveProgramDown('${p.sn}', '${p.batch_id}')">⬇️ 下移</button>` : ''}
<button class="btn btn-outline btn-sm" onclick="editProgram('${p.sn}')">✏️ 编辑</button>
<button class="btn btn-outline btn-sm" onclick="deleteProgram('${p.sn}')">🗑️ 删除</button>
</div>
@ -2036,9 +2101,9 @@ HTML_TEMPLATE = """
const first = parseInt(document.getElementById('awardFirst').value) || 0;
const second = parseInt(document.getElementById('awardSecond').value) || 0;
const third = parseInt(document.getElementById('awardThird').value) || 0;
await fetch('/api/settings/awards', {
await authFetch('/api/settings/awards', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + userToken },
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ first, second, third })
});
cachedAwardSettings = null;
@ -2050,9 +2115,9 @@ HTML_TEMPLATE = """
const first_name = document.getElementById('awardFirstName').value;
const second_name = document.getElementById('awardSecondName').value;
const third_name = document.getElementById('awardThirdName').value;
await fetch('/api/settings/award_names', {
await authFetch('/api/settings/award_names', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + userToken },
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ first_name, second_name, third_name })
});
cachedAwardSettings = null;
@ -2064,9 +2129,9 @@ HTML_TEMPLATE = """
async function saveBatch() {
const name = document.getElementById('batchName').value.trim();
if(!name){ showToast('请输入批次名称'); return; }
await fetch('/api/batches', {
await authFetch('/api/batches', {
method:'POST',
headers:{'Content-Type':'application/json', 'Authorization': 'Bearer ' + userToken},
headers:{'Content-Type':'application/json'},
body:JSON.stringify({name})
});
closeBatchModal();
@ -2074,9 +2139,8 @@ HTML_TEMPLATE = """
}
async function deleteBatch(id) {
if(confirm('确定删除该批次吗?')) {
const res = await fetch(`/api/batches/${id}`, {
const res = await authFetch(`/api/batches/${id}`, {
method:'DELETE',
headers:{'Authorization': 'Bearer ' + userToken},
});
if (res.ok) {
currentBatchId = null;
@ -2093,6 +2157,47 @@ HTML_TEMPLATE = """
}
}
function getProgramsOfBatch(batchId) {
const targetId = parseInt(batchId);
return programsData.filter(p => parseInt(p.batch_id) === targetId)
.sort((a, b) => (a.order_num || 0) - (b.order_num || 0));
}
async function swapAndSave(batchId, snA, snB) {
let list = getProgramsOfBatch(batchId);
const indexA = list.findIndex(p => p.sn === snA);
const indexB = list.findIndex(p => p.sn === snB);
if (indexA === -1 || indexB === -1) return;
[list[indexA], list[indexB]] = [list[indexB], list[indexA]];
const snList = list.map(p => p.sn);
const res = await authFetch('/api/programs/reorder', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ batch_id: batchId, sn_list: snList })
});
if (res.ok) {
showToast('排序已保存');
await loadPrograms(); // 重新加载节目数据
if (userType === 'judge') await loadJudgePrograms();
if (userType === 'admin') await loadAdminScoring();
await loadAggregate();
renderProgramManageList(); // 刷新节目管理列表
} else {
const err = await res.json();
showToast('保存失败:' + (err.detail || '未知错误'));
}
}
function moveProgramUp(sn, batchId) {
let list = getProgramsOfBatch(batchId);
let idx = list.findIndex(p => p.sn === sn);
if (idx <= 0) return;
swapAndSave(batchId, sn, list[idx-1].sn);
}
function moveProgramDown(sn, batchId) {
let list = getProgramsOfBatch(batchId);
let idx = list.findIndex(p => p.sn === sn);
if (idx === -1 || idx >= list.length-1) return;
swapAndSave(batchId, sn, list[idx+1].sn);
}
function showAddProgramModal() { editingProgramSn=null; document.getElementById('programModalTitle').innerText='添加节目'; document.getElementById('programSn').disabled=false; document.getElementById('programSn').value=''; document.getElementById('programName').value=''; document.getElementById('programUnit').value=''; document.getElementById('programPerformer').value=''; document.getElementById('programCategory').value='舞蹈'; renderBatchSelect(); document.getElementById('programModal').style.display='flex'; }
function editProgram(sn) { const prog = programsData.find(p=>p.sn===sn); if(!prog) return; editingProgramSn=sn; document.getElementById('programModalTitle').innerText='编辑节目'; document.getElementById('programSn').value=prog.sn; document.getElementById('programSn').disabled=true; document.getElementById('programName').value=prog.name; document.getElementById('programUnit').value=prog.unit; document.getElementById('programPerformer').value=prog.performer; document.getElementById('programCategory').value=prog.category; renderBatchSelect(); document.getElementById('programBatchId').value=prog.batch_id; document.getElementById('programModal').style.display='flex'; }
async function saveProgram() {
@ -2106,15 +2211,15 @@ HTML_TEMPLATE = """
if(!batch_id){ showToast('请先添加批次'); return; }
let res;
if(editingProgramSn){
res = await fetch(`/api/programs/${editingProgramSn}`, {
res = await authFetch(`/api/programs/${editingProgramSn}`, {
method:'PUT',
headers:{'Content-Type':'application/json', 'Authorization': 'Bearer ' + userToken},
headers:{'Content-Type':'application/json'},
body:JSON.stringify({name,unit,performer,category,batch_id})
});
} else {
res = await fetch('/api/programs', {
res = await authFetch('/api/programs', {
method:'POST',
headers:{'Content-Type':'application/json', 'Authorization': 'Bearer ' + userToken},
headers:{'Content-Type':'application/json'},
body:JSON.stringify({sn,name,unit,performer,category,batch_id})
});
}
@ -2131,9 +2236,8 @@ HTML_TEMPLATE = """
}
async function deleteProgram(sn) {
if(confirm(`确定删除节目 ${sn} 吗?`)){
await fetch(`/api/programs/${sn}`, {
await authFetch(`/api/programs/${sn}`, {
method:'DELETE',
headers:{'Authorization': 'Bearer ' + userToken},
});
await loadPrograms();
if(userType==='judge') await loadJudgePrograms();
@ -2151,9 +2255,8 @@ HTML_TEMPLATE = """
if(!batch_id){ showToast('请先添加批次'); return; }
const formData = new FormData();
formData.append('file', file);
const res = await fetch(`/api/programs/import?batch_id=${batch_id}`, {
const res = await authFetch(`/api/programs/import?batch_id=${batch_id}`, {
method:'POST',
headers:{'Authorization': 'Bearer ' + userToken},
body:formData
});
if(res.ok){
@ -2178,15 +2281,15 @@ HTML_TEMPLATE = """
if(!id||!name){ showToast('请填写评委编号和姓名'); return; }
let res;
if(editingJudgeId){
res = await fetch(`/api/judges/${editingJudgeId}`, {
res = await authFetch(`/api/judges/${editingJudgeId}`, {
method:'PUT',
headers:{'Content-Type':'application/json', 'Authorization': 'Bearer ' + userToken},
headers:{'Content-Type':'application/json'},
body:JSON.stringify({name})
});
} else {
res = await fetch('/api/judges', {
res = await authFetch('/api/judges', {
method:'POST',
headers:{'Content-Type':'application/json', 'Authorization': 'Bearer ' + userToken},
headers:{'Content-Type':'application/json'},
body:JSON.stringify({id,name})
});
}
@ -2201,9 +2304,8 @@ HTML_TEMPLATE = """
}
async function deleteJudge(id) {
if(confirm(`确定删除评委 ${id} 吗?`)){
await fetch(`/api/judges/${id}`, {
await authFetch(`/api/judges/${id}`, {
method:'DELETE',
headers:{'Authorization': 'Bearer ' + userToken},
});
await loadJudges();
if(userType==='admin') await renderAdminScoringTable();
@ -2217,9 +2319,9 @@ HTML_TEMPLATE = """
async function saveTitleSettings() {
const main_title = document.getElementById('mainTitleInput').value;
const sub_title = document.getElementById('subTitleInput').value;
await fetch('/api/settings/titles', {
await authFetch('/api/settings/titles', {
method:'POST',
headers:{'Content-Type':'application/json', 'Authorization': 'Bearer ' + userToken},
headers:{'Content-Type':'application/json'},
body:JSON.stringify({main_title,sub_title})
});
closeTitleModal();

Binary file not shown.