judge-online/main.py
2026-05-28 18:32:56 +08:00

1835 lines
99 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import sqlite3
from contextlib import contextmanager
from datetime import datetime
from io import BytesIO
from typing import cast
import pandas as pd
from fastapi import FastAPI, File, HTTPException, Query, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse, StreamingResponse
from pydantic import BaseModel
app = FastAPI(title='文艺汇演评分系统', description='乌鲁木齐群众文化艺术展评')
app.add_middleware(
CORSMiddleware,
allow_origins=['*'],
allow_credentials=True,
allow_methods=['*'],
allow_headers=['*'],
)
# ==================== 数据库配置 ====================
DB_PATH = 'score_system.db'
@contextmanager
def get_db():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
try:
yield conn
conn.commit()
finally:
conn.close()
def init_db():
with get_db() as conn:
cursor = conn.cursor()
cursor.execute(
"""CREATE TABLE IF NOT EXISTS batches (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, created_at TIMESTAMP)"""
)
cursor.execute(
"""CREATE TABLE IF NOT EXISTS programs (sn TEXT PRIMARY KEY, name TEXT NOT NULL, unit TEXT, performer TEXT, category TEXT, batch_id INTEGER, order_num INTEGER, created_at TIMESTAMP)"""
)
cursor.execute(
"""CREATE TABLE IF NOT EXISTS judges (id TEXT PRIMARY KEY, name TEXT NOT NULL, order_num INTEGER, created_at TIMESTAMP)"""
)
cursor.execute(
"""CREATE TABLE IF NOT EXISTS scores (id INTEGER PRIMARY KEY AUTOINCREMENT, judge_id TEXT NOT NULL, program_sn TEXT NOT NULL, content_score REAL, stage_score REAL, art_score REAL, overall_score REAL, comment TEXT, updated_at TIMESTAMP, UNIQUE(judge_id, program_sn))"""
)
cursor.execute(
"""CREATE TABLE IF NOT EXISTS admins (id TEXT PRIMARY KEY, username TEXT UNIQUE NOT NULL, password TEXT NOT NULL, created_at TIMESTAMP)"""
)
cursor.execute(
"""CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT)"""
)
cursor.execute('SELECT COUNT(*) FROM admins')
if cursor.fetchone()[0] == 0:
cursor.execute(
'INSERT INTO admins (id, username, password, created_at) VALUES (?, ?, ?, ?)',
('admin_001', 'admin', 'admin123', datetime.now()),
)
cursor.execute("SELECT COUNT(*) FROM settings WHERE key = 'award_first'")
if cursor.fetchone()[0] == 0:
cursor.execute(
"INSERT INTO settings (key, value) VALUES ('award_first', '3'), ('award_second', '6'), ('award_third', '10')"
)
cursor.execute("SELECT COUNT(*) FROM settings WHERE key = 'award_first_name'")
if cursor.fetchone()[0] == 0:
cursor.execute(
"INSERT INTO settings (key, value) VALUES ('award_first_name', '一等奖'), ('award_second_name', '二等奖'), ('award_third_name', '三等奖')"
)
cursor.execute("SELECT COUNT(*) FROM settings WHERE key = 'main_title'")
if cursor.fetchone()[0] == 0:
cursor.execute(
"INSERT INTO settings (key, value) VALUES ('main_title', '乌鲁木齐群众文化艺术展评'), ('sub_title', '“建农杯”文艺汇演')"
)
cursor.execute('SELECT COUNT(*) FROM batches')
if cursor.fetchone()[0] == 0:
cursor.execute(
'INSERT INTO batches (name, created_at) VALUES (?, ?)',
('第一批次', datetime.now()),
)
cursor.execute(
'INSERT INTO batches (name, created_at) VALUES (?, ?)',
('第二批次', datetime.now()),
)
cursor.execute('SELECT COUNT(*) FROM judges')
if cursor.fetchone()[0] == 0:
judges_data = [
('P.01', '王飞翔', 1, datetime.now()),
('P.02', '冯建农', 2, datetime.now()),
('P.03', '张宁', 3, datetime.now()),
('P.04', '沈澜涛', 4, datetime.now()),
('P.05', '龚楠', 5, datetime.now()),
]
cursor.executemany(
'INSERT INTO judges (id, name, order_num, created_at) VALUES (?, ?, ?, ?)',
judges_data,
)
init_db()
# ==================== Pydantic模型 ====================
class AdminLogin(BaseModel):
username: str
password: str
class JudgeLogin(BaseModel):
judge_id: str
judge_name: str
class JudgeCreate(BaseModel):
id: str
name: str
class JudgeUpdate(BaseModel):
name: str
class ProgramCreate(BaseModel):
sn: str
name: str
unit: str
performer: str
category: str
batch_id: int
class ProgramUpdate(BaseModel):
name: str
unit: str
performer: str
category: str
batch_id: int
class BatchCreate(BaseModel):
name: str
class ScoreDetailUpdate(BaseModel):
program_sn: str
field: str
value: float | None = None
class ScoreBatchUpdate(BaseModel):
program_sn: str
judge_scores: dict[str, float | None]
class CommentUpdate(BaseModel):
program_sn: str
comment: str
class AwardSettings(BaseModel):
first: int
second: int
third: int
class AwardNamesSettings(BaseModel):
first_name: str
second_name: str
third_name: str
class TitleSettings(BaseModel):
main_title: str
sub_title: str
# ==================== 根路径 ====================
@app.get('/')
async def root():
return HTMLResponse(content=HTML_TEMPLATE)
# ==================== 管理员登录API ====================
@app.post('/api/admin/login')
async def admin_login(login_data: AdminLogin):
with get_db() as conn:
cursor = conn.cursor()
cursor.execute(
'SELECT id, username FROM admins WHERE username = ? AND password = ?',
(login_data.username, login_data.password),
)
admin = cursor.fetchone()
if not admin:
raise HTTPException(status_code=401, detail='管理员用户名或密码错误')
return {'success': True, 'admin_id': admin['id'], 'username': admin['username']}
@app.post('/api/judge/login')
async def judge_login(login_data: JudgeLogin):
with get_db() as conn:
cursor = conn.cursor()
cursor.execute(
'SELECT id, name FROM judges WHERE id = ? AND name = ?',
(login_data.judge_id, login_data.judge_name),
)
judge = cursor.fetchone()
if not judge:
raise HTTPException(status_code=401, detail='评委编号或姓名错误')
return {'success': True, 'judge_id': judge['id'], 'judge_name': judge['name']}
# ==================== 设置API ====================
@app.get('/api/settings/titles')
async def get_title_settings():
with get_db() as conn:
cursor = conn.cursor()
cursor.execute(
"SELECT key, value FROM settings WHERE key IN ('main_title', 'sub_title')"
)
titles = {row['key']: row['value'] for row in cursor.fetchall()}
return {
'main_title': titles.get('main_title', '乌鲁木齐群众文化艺术展评'),
'sub_title': titles.get('sub_title', '“建农杯”文艺汇演'),
}
@app.post('/api/settings/titles')
async def update_title_settings(titles: TitleSettings):
with get_db() as conn:
cursor = conn.cursor()
cursor.execute(
"UPDATE settings SET value = ? WHERE key = 'main_title'",
(titles.main_title,),
)
cursor.execute(
"UPDATE settings SET value = ? WHERE key = 'sub_title'", (titles.sub_title,)
)
return {'success': True}
@app.get('/api/settings/awards')
async def get_award_settings():
with get_db() as conn:
cursor = conn.cursor()
cursor.execute(
"SELECT key, value FROM settings WHERE key IN ('award_first', 'award_second', 'award_third')"
)
counts = {row['key']: int(row['value']) for row in cursor.fetchall()}
cursor.execute(
"SELECT key, value FROM settings WHERE key IN ('award_first_name', 'award_second_name', 'award_third_name')"
)
names = {row['key']: row['value'] for row in cursor.fetchall()}
return {
'first': counts.get('award_first', 3),
'second': counts.get('award_second', 6),
'third': counts.get('award_third', 10),
'first_name': names.get('award_first_name', '一等奖'),
'second_name': names.get('award_second_name', '二等奖'),
'third_name': names.get('award_third_name', '三等奖'),
}
@app.post('/api/settings/awards')
async def update_award_settings(settings: AwardSettings):
with get_db() as conn:
cursor = conn.cursor()
cursor.execute(
"UPDATE settings SET value = ? WHERE key = 'award_first'",
(str(settings.first),),
)
cursor.execute(
"UPDATE settings SET value = ? WHERE key = 'award_second'",
(str(settings.second),),
)
cursor.execute(
"UPDATE settings SET value = ? WHERE key = 'award_third'",
(str(settings.third),),
)
return {'success': True}
@app.post('/api/settings/award_names')
async def update_award_names(settings: AwardNamesSettings):
with get_db() as conn:
cursor = conn.cursor()
cursor.execute(
"UPDATE settings SET value = ? WHERE key = 'award_first_name'",
(settings.first_name,),
)
cursor.execute(
"UPDATE settings SET value = ? WHERE key = 'award_second_name'",
(settings.second_name,),
)
cursor.execute(
"UPDATE settings SET value = ? WHERE key = 'award_third_name'",
(settings.third_name,),
)
return {'success': True}
@app.get('/api/criteria')
async def get_criteria():
return {
'content': {
'label': '内容取材',
'max': 20,
'description': '主题鲜明,健康向上,编排新颖,原创性加分',
},
'stage': {
'label': '舞台风度',
'max': 10,
'description': '服装统一整洁,精神饱满,出场退场从容得体',
},
'art': {
'label': '表演艺术水平',
'max': 60,
'description': '动作流畅协调,节奏感强,技巧娴熟,富有感染力',
},
'overall': {
'label': '综合评价',
'max': 10,
'description': '整体效果印象深刻,现场驾驭能力强',
},
}
# ==================== 批次管理API ====================
@app.get('/api/batches')
async def get_batches():
with get_db() as conn:
cursor = conn.cursor()
cursor.execute('SELECT id, name, created_at FROM batches ORDER BY id')
batches = [{'id': row['id'], 'name': row['name']} for row in cursor.fetchall()]
return batches
@app.post('/api/batches')
async def create_batch(batch: BatchCreate):
with get_db() as conn:
cursor = conn.cursor()
cursor.execute(
'INSERT INTO batches (name, created_at) VALUES (?, ?)',
(batch.name, datetime.now()),
)
new_id = cursor.lastrowid
return {'success': True, 'id': new_id, 'name': batch.name}
@app.delete('/api/batches/{batch_id}')
async def delete_batch(batch_id: int):
with get_db() as conn:
cursor = conn.cursor()
cursor.execute('SELECT id FROM batches WHERE id = ?', (batch_id,))
if not cursor.fetchone():
raise HTTPException(status_code=404, detail='批次不存在')
cursor.execute(
'UPDATE programs SET batch_id = NULL WHERE batch_id = ?', (batch_id,)
)
cursor.execute('DELETE FROM batches WHERE id = ?', (batch_id,))
return {'success': True}
@app.delete('/api/batches/{batch_id}/programs')
async def clear_batch_programs(batch_id: int):
with get_db() as conn:
cursor = conn.cursor()
cursor.execute('SELECT id FROM batches WHERE id = ?', (batch_id,))
if not cursor.fetchone():
raise HTTPException(status_code=404, detail='批次不存在')
cursor.execute('SELECT sn FROM programs WHERE batch_id = ?', (batch_id,))
programs = cursor.fetchall()
for prog in programs:
cursor.execute('DELETE FROM scores WHERE program_sn = ?', (prog['sn'],))
cursor.execute('DELETE FROM programs WHERE batch_id = ?', (batch_id,))
return {'success': True}
# ==================== 评委管理API ====================
@app.get('/api/judges')
async def get_judges():
with get_db() as conn:
cursor = conn.cursor()
cursor.execute('SELECT id, name, order_num FROM judges ORDER BY order_num')
judges = [
{'id': row['id'], 'name': row['name'], 'order_num': row['order_num']}
for row in cursor.fetchall()
]
return judges
@app.post('/api/judges')
async def create_judge(judge: JudgeCreate):
with get_db() as conn:
cursor = conn.cursor()
cursor.execute('SELECT id FROM judges WHERE id = ?', (judge.id,))
if cursor.fetchone():
raise HTTPException(status_code=400, detail='评委编号已存在')
cursor.execute('SELECT MAX(order_num) FROM judges')
max_order = cursor.fetchone()[0] or 0
cursor.execute(
'INSERT INTO judges (id, name, order_num, created_at) VALUES (?, ?, ?, ?)',
(judge.id, judge.name, max_order + 1, datetime.now()),
)
cursor.execute('SELECT sn FROM programs')
for prog in cursor.fetchall():
cursor.execute(
'INSERT OR IGNORE INTO scores (judge_id, program_sn, updated_at) VALUES (?, ?, ?)',
(judge.id, prog['sn'], datetime.now()),
)
return {'success': True}
@app.put('/api/judges/{judge_id}')
async def update_judge(judge_id: str, judge: JudgeUpdate):
with get_db() as conn:
cursor = conn.cursor()
cursor.execute('SELECT id FROM judges WHERE id = ?', (judge_id,))
if not cursor.fetchone():
raise HTTPException(status_code=404, detail='评委不存在')
cursor.execute(
'UPDATE judges SET name = ? WHERE id = ?', (judge.name, judge_id)
)
return {'success': True}
@app.delete('/api/judges/{judge_id}')
async def delete_judge(judge_id: str):
with get_db() as conn:
cursor = conn.cursor()
cursor.execute('SELECT id FROM judges WHERE id = ?', (judge_id,))
if not cursor.fetchone():
raise HTTPException(status_code=404, detail='评委不存在')
cursor.execute('DELETE FROM scores WHERE judge_id = ?', (judge_id,))
cursor.execute('DELETE FROM judges WHERE id = ?', (judge_id,))
return {'success': True}
# ==================== 节目管理API ====================
@app.get('/api/programs')
async def get_programs(batch_id: int | None = Query(None)):
with get_db() as conn:
cursor = conn.cursor()
if batch_id:
cursor.execute(
'SELECT sn, name, unit, performer, category, batch_id, order_num FROM programs WHERE batch_id = ? ORDER BY order_num',
(batch_id,),
)
else:
cursor.execute(
'SELECT sn, name, unit, performer, category, batch_id, order_num FROM programs ORDER BY batch_id, order_num'
)
programs = [
{
'sn': row['sn'],
'name': row['name'],
'unit': row['unit'],
'performer': row['performer'],
'category': row['category'],
'batch_id': row['batch_id'],
'order_num': row['order_num'],
}
for row in cursor.fetchall()
]
return programs
@app.post('/api/programs')
async def create_program(program: ProgramCreate):
with get_db() as conn:
cursor = conn.cursor()
cursor.execute('SELECT sn FROM programs WHERE sn = ?', (program.sn,))
if cursor.fetchone():
raise HTTPException(status_code=400, detail='节目序号已存在')
cursor.execute(
'SELECT MAX(order_num) FROM programs WHERE batch_id = ?',
(program.batch_id,),
)
max_order = cursor.fetchone()[0]
if max_order is None:
max_order = 0
cursor.execute(
'INSERT INTO programs (sn, name, unit, performer, category, batch_id, order_num, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
(
program.sn,
program.name,
program.unit,
program.performer,
program.category,
program.batch_id,
max_order + 1,
datetime.now(),
),
)
cursor.execute('SELECT id FROM judges')
for judge in cursor.fetchall():
cursor.execute(
'INSERT OR IGNORE INTO scores (judge_id, program_sn, updated_at) VALUES (?, ?, ?)',
(judge['id'], program.sn, datetime.now()),
)
return {'success': True}
@app.post('/api/programs/import')
async def import_programs(batch_id: int = Query(...), file: UploadFile = File(...)):
try:
contents = await file.read()
df = pd.read_excel(BytesIO(contents))
required_columns = ['节目序号', '节目名称', '演出单位', '表演者', '节目类别']
for col in required_columns:
if col not in df.columns:
raise HTTPException(status_code=400, detail=f'Excel缺少必要列:{col}')
with get_db() as conn:
cursor = conn.cursor()
cursor.execute(
'SELECT MAX(order_num) FROM programs WHERE batch_id = ?', (batch_id,)
)
max_order_result = cursor.fetchone()[0]
start_order = max_order_result if max_order_result is not None else 0
for idx, row in df.iterrows():
sn = str(row['节目序号']).strip()
name = str(row['节目名称']).strip()
unit = str(row['演出单位']).strip() if pd.notna(row['演出单位']) else ''
performer = (
str(row['表演者']).strip() if pd.notna(row['表演者']) else ''
)
category = (
str(row['节目类别']).strip()
if pd.notna(row['节目类别'])
else '舞蹈'
)
if not sn or not name:
continue
idx = cast(int, idx)
current_order = start_order + idx + 1
cursor.execute('SELECT sn FROM programs WHERE sn = ?', (sn,))
if cursor.fetchone():
cursor.execute(
'UPDATE programs SET name=?, unit=?, performer=?, category=?, batch_id=?, order_num=? WHERE sn=?',
(name, unit, performer, category, batch_id, current_order, sn),
)
else:
cursor.execute(
'INSERT INTO programs (sn, name, unit, performer, category, batch_id, order_num, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
(
sn,
name,
unit,
performer,
category,
batch_id,
current_order,
datetime.now(),
),
)
cursor.execute('SELECT id FROM judges')
for judge in cursor.fetchall():
cursor.execute(
'INSERT OR IGNORE INTO scores (judge_id, program_sn, updated_at) VALUES (?, ?, ?)',
(judge['id'], sn, datetime.now()),
)
return {'success': True, 'message': f'成功导入/更新 {df.shape[0]} 个节目'}
except Exception as e:
raise HTTPException(status_code=400, detail=f'导入失败:{str(e)}') from e
@app.put('/api/programs/{program_sn}')
async def update_program(program_sn: str, program: ProgramUpdate):
with get_db() as conn:
cursor = conn.cursor()
cursor.execute('SELECT sn FROM programs WHERE sn = ?', (program_sn,))
if not cursor.fetchone():
raise HTTPException(status_code=404, detail='节目不存在')
cursor.execute(
'UPDATE programs SET name = ?, unit = ?, performer = ?, category = ?, batch_id = ? WHERE sn = ?',
(
program.name,
program.unit,
program.performer,
program.category,
program.batch_id,
program_sn,
),
)
return {'success': True}
@app.delete('/api/programs/{program_sn}')
async def delete_program(program_sn: str):
with get_db() as conn:
cursor = conn.cursor()
cursor.execute('SELECT sn FROM programs WHERE sn = ?', (program_sn,))
if not cursor.fetchone():
raise HTTPException(status_code=404, detail='节目不存在')
cursor.execute('DELETE FROM scores WHERE program_sn = ?', (program_sn,))
cursor.execute('DELETE FROM programs WHERE sn = ?', (program_sn,))
return {'success': True}
# ==================== 评分API ====================
@app.get('/api/scores/{judge_id}')
async def get_judge_scores(judge_id: str):
with get_db() as conn:
cursor = conn.cursor()
cursor.execute(
'SELECT program_sn, content_score, stage_score, art_score, overall_score, comment FROM scores WHERE judge_id = ?',
(judge_id,),
)
rows = cursor.fetchall()
scores = {}
for row in rows:
scores[row['program_sn']] = {
'content': row['content_score'],
'stage': row['stage_score'],
'art': row['art_score'],
'overall': row['overall_score'],
'comment': row['comment'] or '',
}
cursor.execute('SELECT sn FROM programs')
for row in cursor.fetchall():
if row['sn'] not in scores:
scores[row['sn']] = {
'content': None,
'stage': None,
'art': None,
'overall': None,
'comment': '',
}
return scores
@app.post('/api/scores/{judge_id}')
async def update_score(judge_id: str, score_data: ScoreDetailUpdate):
max_scores = {'content': 20, 'stage': 10, 'art': 60, 'overall': 10}
if score_data.field not in max_scores:
raise HTTPException(status_code=400, detail='无效评分项')
val = score_data.value
if val is not None and (val < 0 or val > max_scores[score_data.field]):
raise HTTPException(
status_code=400, detail=f'分值必须在0-{max_scores[score_data.field]}之间'
)
field_map = {
'content': 'content_score',
'stage': 'stage_score',
'art': 'art_score',
'overall': 'overall_score',
}
with get_db() as conn:
cursor = conn.cursor()
cursor.execute(
f"""
INSERT INTO scores (judge_id, program_sn, {field_map[score_data.field]}, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(judge_id, program_sn) DO UPDATE SET
{field_map[score_data.field]} = excluded.{field_map[score_data.field]},
updated_at = excluded.updated_at
""",
(judge_id, score_data.program_sn, val, datetime.now()),
)
cursor.execute(
'SELECT content_score, stage_score, art_score, overall_score FROM scores WHERE judge_id = ? AND program_sn = ?',
(judge_id, score_data.program_sn),
)
row = cursor.fetchone()
total = 0
if row:
for v in [
row['content_score'],
row['stage_score'],
row['art_score'],
row['overall_score'],
]:
if v is not None:
total += v
return {'success': True, 'total': total}
@app.post('/api/comment/{judge_id}')
async def update_comment(judge_id: str, comment_data: CommentUpdate):
with get_db() as conn:
cursor = conn.cursor()
cursor.execute(
'INSERT INTO scores (judge_id, program_sn, comment, updated_at) VALUES (?, ?, ?, ?) ON CONFLICT(judge_id, program_sn) DO UPDATE SET comment = excluded.comment, updated_at = excluded.updated_at',
(judge_id, comment_data.program_sn, comment_data.comment, datetime.now()),
)
return {'success': True}
@app.post('/api/scores/admin/batch')
async def batch_update_scores(batch_data: ScoreBatchUpdate):
"""管理员批量更新总分"""
with get_db() as conn:
cursor = conn.cursor()
for judge_id, total_score in batch_data.judge_scores.items():
if total_score is not None and (total_score < 0 or total_score > 100):
raise HTTPException(status_code=400, detail='总分必须在0-100之间')
if total_score is None:
cursor.execute(
"""
UPDATE scores SET content_score = NULL, stage_score = NULL, art_score = NULL, overall_score = NULL, updated_at = ?
WHERE judge_id = ? AND program_sn = ?
""",
(datetime.now(), judge_id, batch_data.program_sn),
)
else:
cursor.execute(
"""
INSERT INTO scores (judge_id, program_sn, content_score, stage_score, art_score, overall_score, comment, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(judge_id, program_sn) DO UPDATE SET
content_score = excluded.content_score,
stage_score = excluded.stage_score,
art_score = excluded.art_score,
overall_score = excluded.overall_score,
comment = excluded.comment,
updated_at = excluded.updated_at
""",
(
judge_id,
batch_data.program_sn,
total_score,
None,
None,
None,
'',
datetime.now(),
),
)
return {'success': True}
@app.get('/api/scores/program/{program_sn}')
async def get_program_scores(program_sn: str):
with get_db() as conn:
cursor = conn.cursor()
cursor.execute(
"""
SELECT j.id, j.name,
s.content_score, s.stage_score, s.art_score, s.overall_score,
s.comment
FROM judges j
LEFT JOIN scores s ON j.id = s.judge_id AND s.program_sn = ?
ORDER BY j.order_num
""",
(program_sn,),
)
scores = []
for row in cursor.fetchall():
total = None
if (
row['content_score'] is not None
or row['stage_score'] is not None
or row['art_score'] is not None
or row['overall_score'] is not None
):
total = 0
if row['content_score'] is not None:
total += row['content_score']
if row['stage_score'] is not None:
total += row['stage_score']
if row['art_score'] is not None:
total += row['art_score']
if row['overall_score'] is not None:
total += row['overall_score']
if total == 0 and row['content_score'] is not None:
total = row['content_score']
scores.append(
{'judge_id': row['id'], 'judge_name': row['name'], 'total_score': total}
)
return scores
# ==================== 汇总统计API ====================
@app.get('/api/aggregate')
async def get_aggregate(batch_id: int | None = Query(None)):
with get_db() as conn:
cursor = conn.cursor()
if batch_id:
cursor.execute(
'SELECT sn, name, unit, performer, category FROM programs WHERE batch_id = ? ORDER BY order_num',
(batch_id,),
)
else:
cursor.execute(
'SELECT sn, name, unit, performer, category FROM programs ORDER BY batch_id, order_num'
)
programs = cursor.fetchall()
results = []
for prog in programs:
cursor.execute(
"""
SELECT content_score, stage_score, art_score, overall_score
FROM scores WHERE program_sn = ?
""",
(prog['sn'],),
)
scores_list = []
for row in cursor.fetchall():
total = 0
if row['content_score'] is not None:
total += row['content_score']
if row['stage_score'] is not None:
total += row['stage_score']
if row['art_score'] is not None:
total += row['art_score']
if row['overall_score'] is not None:
total += row['overall_score']
if total != 0 or (total == 0 and row['content_score'] is not None):
scores_list.append(total)
if scores_list:
avg_score = round(sum(scores_list) / len(scores_list), 3)
max_score = max(scores_list)
min_score = min(scores_list)
else:
avg_score, max_score, min_score = 0, 0, 0
results.append(
{
'sn': prog['sn'],
'name': prog['name'],
'unit': prog['unit'],
'performer': prog['performer'],
'category': prog['category'],
'avg_score': avg_score,
'max_score': max_score,
'min_score': min_score,
'scored_count': len(scores_list),
}
)
results.sort(key=lambda x: x['avg_score'], reverse=True)
for idx, r in enumerate(results):
r['rank'] = idx + 1
return results
@app.get('/api/award_winners')
async def get_award_winners(batch_id: int | None = Query(None)):
aggregate = await get_aggregate(batch_id)
settings = await get_award_settings()
first_count, second_count, third_count = (
settings['first'],
settings['second'],
settings['third'],
)
winners = {'first': [], 'second': [], 'third': []}
for idx, prog in enumerate(aggregate):
if idx < first_count:
winners['first'].append(prog)
elif idx < first_count + second_count:
winners['second'].append(prog)
elif idx < first_count + second_count + third_count:
winners['third'].append(prog)
return winners
@app.get('/api/progress')
async def get_progress():
with get_db() as conn:
cursor = conn.cursor()
total_programs = cursor.execute('SELECT COUNT(*) FROM programs').fetchone()[0]
total_judges = cursor.execute('SELECT COUNT(*) FROM judges').fetchone()[0]
total_cells = total_programs * total_judges
scored_cells = cursor.execute(
'SELECT COUNT(*) FROM scores WHERE content_score IS NOT NULL'
).fetchone()[0]
progress = round(scored_cells / total_cells * 100, 1) if total_cells > 0 else 0
return {
'total_programs': total_programs,
'total_judges': total_judges,
'scored_programs': scored_cells,
'total_cells': total_cells,
'progress': progress,
}
@app.get('/api/export_excel')
async def export_excel(batch_id: int | None = Query(None)):
aggregate = await get_aggregate(batch_id)
data = [
{
'名次': item['rank'],
'节目序号': item['sn'],
'节目名称': item['name'],
'演出单位': item['unit'],
'表演者': item['performer'],
'节目类别': item['category'],
'平均分': item['avg_score'],
'最高分': item['max_score'],
'最低分': item['min_score'],
'评分评委数': item['scored_count'],
}
for item in aggregate
]
df = pd.DataFrame(data)
output = BytesIO()
with pd.ExcelWriter(output, engine='openpyxl') as writer:
df.to_excel(writer, sheet_name='评分汇总表', index=False)
output.seek(0)
filename = f'{"batch_" + str(batch_id) if batch_id else "all"}_scores.xlsx'
return StreamingResponse(
output,
media_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
headers={'Content-Disposition': f'attachment; filename={filename}'},
)
# ==================== 打印页面API ====================
@app.get('/print/program/{program_sn}')
async def print_program_score(program_sn: str):
with get_db() as conn:
prog = conn.execute(
'SELECT sn, name, unit, performer, category FROM programs WHERE sn = ?',
(program_sn,),
).fetchone()
if not prog:
raise HTTPException(status_code=404, detail='节目不存在')
judges = conn.execute(
'SELECT id, name FROM judges ORDER BY order_num'
).fetchall()
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 = ?',
(judge['id'], program_sn),
).fetchone()
total = 0
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']
comment = score['comment'] or ''
if total > 0:
all_scores.append(total)
scores_data[judge['name']] = {'total': total, 'comment': comment}
max_score = max(all_scores) if all_scores else 0
min_score = min(all_scores) if all_scores else 0
avg_score = round(sum(all_scores) / len(all_scores), 3) if all_scores else 0
titles = await get_title_settings()
return HTMLResponse(
content=render_print_program_template(
prog, judges, scores_data, max_score, min_score, avg_score, titles
)
)
@app.get('/print/blank/{program_sn}')
async def print_blank_score(program_sn: str):
with get_db() as conn:
prog = conn.execute(
'SELECT sn, name, unit, performer, category FROM programs WHERE sn = ?',
(program_sn,),
).fetchone()
if not prog:
raise HTTPException(status_code=404, detail='节目不存在')
judges = conn.execute(
'SELECT id, name FROM judges ORDER BY order_num'
).fetchall()
criteria = await get_criteria()
titles = await get_title_settings()
return HTMLResponse(
content=render_print_blank_template(prog, judges, criteria, titles)
)
@app.get('/print/award/{award_type}')
async def print_award_list(award_type: str, batch_id: int | None = Query(None)):
winners = await get_award_winners(batch_id)
if award_type not in winners:
raise HTTPException(status_code=404, detail='奖项类型不存在')
award_settings = await get_award_settings()
award_names = {
'first': award_settings['first_name'],
'second': award_settings['second_name'],
'third': award_settings['third_name'],
}
titles = await get_title_settings()
batch_name = ''
if batch_id:
with get_db() as conn:
batch = conn.execute(
'SELECT name FROM batches WHERE id = ?', (batch_id,)
).fetchone()
if batch:
batch_name = f'({batch["name"]})'
return HTMLResponse(
content=render_print_award_template(
award_type, award_names[award_type], winners[award_type], titles, batch_name
)
)
def render_print_program_template(
program, judges, scores_data, max_score, min_score, avg_score, titles
):
html = f"""<!DOCTYPE html><html><head><meta charset="UTF-8"><title>{program['name']} - 评分详情</title>
<style>*{{margin:0;padding:0;box-sizing:border-box;}} body{{font-family: SimSun, 'Microsoft YaHei', serif; padding: 20px;}} .container{{max-width: 1000px; margin: 0 auto;}} h1{{text-align: center; margin-bottom: 5px; font-size: 22px;}} .subtitle{{text-align: center; color: #666; margin-bottom: 20px; font-size: 14px;}} .info{{margin-bottom: 20px; padding: 15px; background: #f5f5f5; border-radius: 8px;}} .info p{{margin: 5px 0;}} .stat-box{{display: flex; gap: 20px; justify-content: center; margin-bottom: 20px;}} .stat-item{{text-align: center; padding: 10px 20px; background: #e8f4e8; border-radius: 8px;}} .stat-item span{{font-size: 24px; font-weight: bold;}} table{{width: 100%; border-collapse: collapse; margin-top: 20px;}} th, td{{border: 1px solid #000; padding: 12px; text-align: center;}} th{{background: #f0f0f0;}} .comment-cell{{text-align: left;}} @media print{{.no-print{{display: none;}}}}</style></head>
<body><div class="container"><h1>{titles['main_title']}</h1><div class="subtitle">{titles['sub_title']} · 评分详情表</div>
<div class="info"><p><strong>节目序号:</strong> {program['sn']}</p><p><strong>节目名称:</strong> {program['name']}</p><p><strong>演出单位:</strong> {program['unit']}</p><p><strong>表演者:</strong> {program['performer']}</p><p><strong>节目类别:</strong> {program['category']}</p></div>
<div class="stat-box"><div class="stat-item">📊 平均分<br><span>{avg_score}</span></div><div class="stat-item">🎯 最高分<br><span>{max_score}</span></div><div class="stat-item">⭐ 最低分<br><span>{min_score}</span></div></div>
<table><thead><tr><th>评委姓名</th><th>总分(100分)</th><th>评委点评</th></tr></thead><tbody>"""
for judge in judges:
s = scores_data.get(judge['name'], {})
html += f"""<tr><td style="font-weight:bold;">{judge['name']}</td><td class="total">{s.get('total') if s.get('total') > 0 else '-'}</td><td class="comment-cell">{s.get('comment', '')}</td></tr>"""
html += f"""</tbody></table><div style="margin-top: 30px;"><p>评委组签名:____________________</p><p style="text-align: right;">日期:{datetime.now().strftime('%Y年%m月%d日')}</p></div>
<div class="no-print" style="margin-top: 30px; text-align: center;"><button onclick="window.print()">🖨️ 打印本页</button> <button onclick="window.close()">关闭</button></div></div></body></html>"""
return html
def render_print_blank_template(program, judges, criteria, titles):
html = f"""<!DOCTYPE html><html><head><meta charset="UTF-8"><title>{program['name']} - 空白评分表</title>
<style>*{{margin:0;padding:0;box-sizing:border-box;}} body{{font-family: SimSun, 'Microsoft YaHei', serif;}} .judge-sheet{{width:100%; max-width:800px; margin:0 auto; padding:30px 20px 40px; page-break-after:always; border-bottom:2px dashed #ccc;}} .judge-sheet:last-child{{page-break-after:auto; border-bottom:none;}} .sheet-top-space{{height:25mm;}} .sheet-bottom-space{{height:20mm;}} h1{{text-align:center; margin-bottom:5px; font-size:22px;}} .subtitle{{text-align:center; color:#666; margin-bottom:15px;}} .program-info{{margin-bottom:20px; padding:12px; background:#f5f5f5; border-radius:8px;}} .judge-title{{font-size:16px; font-weight:bold; margin:15px 0 12px; padding:8px 15px; background:#e8f4e8; border-left:4px solid #48bb78;}} table{{width:100%; border-collapse:collapse; margin:15px 0;}} th,td{{border:1px solid #000; padding:10px; text-align:center;}} th{{background:#f0f0f0;}} .score-desc{{text-align:left; font-size:12px;}} .sign-line{{margin-top:25px; display:flex; justify-content:space-between;}} @media print{{.no-print{{display:none;}} .judge-sheet{{padding:20px 15px 30px; page-break-after:always; border-bottom:none;}}}}</style>
<body>"""
for _judge in judges:
html += f"""<div class="judge-sheet"><div class="sheet-top-space"></div>
<h1>{titles['main_title']}</h1><div class="subtitle">{titles['sub_title']} · 节目评分表</div>
<div class="program-info"><p><strong>节目序号:</strong> {program['sn']} | <strong>节目名称:</strong> {program['name']}</p>
<p><strong>演出单位:</strong> {program['unit']} | <strong>表演者:</strong> {program['performer']} | <strong>节目类别:</strong> {program['category']}</p></div>
<table><thead><tr><th style="width:20%">评分项目</th><th style="width:10%">满分</th><th style="width:15%">得分</th><th style="width:55%">评分要点</th></tr></thead><tbody>"""
for _key, cri in criteria.items():
html += f"""<tr><td style="font-weight:bold;">{cri['label']}</td><td style="text-align:center;">{cri['max']}分</td><td class="score-blank">______</td><td class="score-desc">{cri['description']}</td></tr>"""
html += """<tr style="background:#f9f9f9;"><td style="font-weight:bold;">合计</td><td style="text-align:center;">100分</td><td class="score-blank">______</td><td class="score-desc"></td><tr>
</tbody></table>
<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>'
''
)
return html
def render_print_award_template(award_type, award_name, winners, titles, batch_name=''):
html = f"""<!DOCTYPE html><html><head><meta charset="UTF-8"><title>{award_name}{batch_name}获奖名单</title>
<style>
*{{margin:0;padding:0;box-sizing:border-box;}}
body{{font-family: SimSun, 'Microsoft YaHei', serif; padding: 20px;}}
.container{{max-width: 800px; margin: 0 auto;}}
h1{{text-align: center; font-size: 22px; margin-bottom: 5px;}}
.subtitle{{text-align: center; color: #666; margin-bottom: 20px;}}
.award-title{{text-align: center; font-size: 36px; font-weight: bold; margin: 20px 0; padding: 15px; background: linear-gradient(135deg, #667eea, #764ba2); color: white; border-radius: 12px;}}
table{{width: 100%; border-collapse: collapse; margin-top: 20px;}}
th, td{{border: 1px solid #000; padding: 12px; text-align: center;}}
th{{background: #f0f0f0;}}
.sign-line{{margin-top: 40px; display: flex; justify-content: space-between;}}
@media print{{.no-print{{display: none;}}}}
</style>
<body><div class="container"><h1>{titles['main_title']}</h1><div class="subtitle">{titles['sub_title']}{batch_name}</div>
<div class="award-title">{award_name}</div>
<table>
<thead>
<tr><th>名次</th><th>节目序号</th><th>节目名称</th><th>演出单位</th><th>表演者</th><th>节目类别</th><th>平均分</th></tr>
</thead>
<tbody>"""
for idx, prog in enumerate(winners, 1):
html += f"""<tr><td style="text-align:center;">{idx}</td><td style="text-align:center;">{prog['sn']}</td><td style="text-align:left;">{prog['name']}</td><td style="text-align:left;">{prog['unit']}</td><td style="text-align:left;">{prog['performer']}</td><td style="text-align:center;">{prog['category']}</td><td style="text-align:center;">{prog['avg_score']}</td></tr>"""
html += f"""</tbody>
</table>
<div class="sign-line"><span>评委组签名:____________________</span><span>日期:{datetime.now().strftime('%Y年%m月%d日')}</span></div>
<div class="no-print" style="margin-top: 30px; text-align: center;"><button onclick="window.print()">🖨️ 打印本页</button> <button onclick="window.close()">关闭</button></div></div></body></html>"""
return html
# ==================== HTML模板(前端部分) ====================
HTML_TEMPLATE = """
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>文艺汇演评分系统</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Microsoft YaHei', sans-serif; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); min-height: 100vh; padding: 20px; }
.container { max-width: 1400px; margin: 0 auto; }
.clearfix { clear: both; }
.header, .tabs, .batch-bar, .program-list-sidebar, .score-panel, .summary-panel,
.admin-panel, .settings-panel, .judge-scoring-container, .admin-scoring-container,
.programs-sidebar, .scoring-panel { clear: both; }
.header { background: rgba(255,255,255,0.95); border-radius: 20px; padding: 20px 30px; margin-bottom: 25px; box-shadow: 0 10px 40px rgba(0,0,0,0.1); }
.header h1 { font-size: 28px; color: #2d3748; border-left: 5px solid #667eea; padding-left: 20px; }
.header .subtitle { color: #718096; padding-left: 25px; font-size: 14px; margin-top: 8px; }
.login-bar { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 15px; margin-top: 15px; padding-top: 15px; border-top: 1px solid #e2e8f0; }
.login-area, .user-info-area { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.btn { padding: 6px 16px; border: none; border-radius: 6px; cursor: pointer; font-weight: 500; transition: all 0.2s; font-size: 13px; line-height: 1.5; }
.btn-primary { background: #667eea; color: white; }
.btn-primary:hover { background: #5a67d8; }
.btn-success { background: #48bb78; color: white; }
.btn-success:hover { background: #38a169; }
.btn-warning { background: #ed8936; color: white; }
.btn-outline { background: white; border: 1px solid #cbd5e1; color: #4a5568; }
.btn-danger { background: #e53e3e; color: white; }
.btn-sm { padding: 4px 12px; font-size: 12px; }
.judge-select { padding: 6px 12px; border: 1px solid #cbd5e1; border-radius: 6px; font-size: 14px; background: white; min-width: 150px; cursor: pointer; }
.judge-select:focus { outline: none; border-color: #667eea; box-shadow: 0 0 0 2px rgba(102,126,234,0.2); }
.admin-input { padding: 6px 12px; border: 1px solid #cbd5e1; border-radius: 6px; font-size: 14px; }
.admin-input:focus { outline: none; border-color: #667eea; box-shadow: 0 0 0 2px rgba(102,126,234,0.2); }
.user-info { background: #edf2f7; padding: 6px 16px; border-radius: 30px; font-size: 14px; }
.admin-info { background: #fef5e7; color: #c05621; padding: 6px 16px; border-radius: 30px; font-size: 14px; }
.progress-bar { background: #e2e8f0; border-radius: 20px; height: 8px; margin: 12px 0 5px; overflow: hidden; }
.progress-fill { background: linear-gradient(90deg, #48bb78, #38a169); height: 100%; width: 0%; transition: width 0.3s; }
.tabs { display: flex; gap: 10px; margin-bottom: 20px; flex-wrap: wrap; }
.tab { padding: 8px 20px; cursor: pointer; border-radius: 20px; background: rgba(255,255,255,0.8); }
.tab.active { background: #667eea; color: white; }
.mode-badge { display: inline-block; padding: 4px 12px; border-radius: 20px; font-size: 12px; margin-left: 10px; }
.mode-judge { background: #48bb78; color: white; }
.mode-admin { background: #ed8936; color: white; }
.batch-bar { display: flex; gap: 10px; margin-bottom: 15px; align-items: center; flex-wrap: wrap; }
.batch-btn { padding: 6px 15px; border: none; border-radius: 20px; cursor: pointer; background: #e2e8f0; font-size: 13px; }
.batch-btn.active { background: #667eea; color: white; }
.judge-scoring-container, .admin-scoring-container { background: white; border-radius: 20px; padding: 20px; overflow-x: auto; margin-bottom: 20px; }
.program-list-sidebar { flex: 1; min-width: 250px; background: #f7fafc; border-radius: 16px; padding: 15px; max-height: 600px; overflow-y: auto; }
.program-list-sidebar h4 { margin-bottom: 10px; }
.program-item { padding: 10px; margin: 5px 0; background: white; border-radius: 8px; cursor: pointer; transition: all 0.2s; border-left: 3px solid transparent; }
.program-item:hover { background: #e0e7ff; transform: translateX(3px); }
.program-item.active { background: #e0e7ff; border-left-color: #667eea; font-weight: bold; }
.score-panel { flex: 2; }
.scoring-table { width: 100%; border-collapse: collapse; }
.scoring-table th, .scoring-table td { border: 1px solid #e2e8f0; padding: 10px 6px; text-align: center; font-size: 13px; }
.scoring-table th { background: #f7fafc; font-weight: 600; }
.score-input { width: 70px; padding: 4px 6px; text-align: center; border: 1px solid #cbd5e1; border-radius: 4px; font-size: 13px; }
.detail-score-input { width: 70px; padding: 6px; text-align: center; border: 1px solid #cbd5e1; border-radius: 6px; }
.total-score { font-weight: bold; font-size: 18px; margin: 10px 0; text-align: center; padding: 10px; background: linear-gradient(135deg, #667eea, #764ba2); color: white; border-radius: 12px; }
.summary-panel { margin-top: 30px; background: white; border-radius: 20px; padding: 20px; overflow-x: auto; }
.summary-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.summary-table th, .summary-table td { padding: 10px 8px; text-align: center; border-bottom: 1px solid #e2e8f0; }
.summary-table th { background: #f7fafc; }
.rank-1 { background: #fef5e7; font-weight: bold; }
.award-table-container { max-height: 400px; overflow-y: auto; margin-top: 15px; }
.award-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.award-table th, .award-table td { padding: 10px 8px; text-align: center; border: 1px solid #e2e8f0; }
.award-table th { background: #f7fafc; }
.admin-panel, .settings-panel { background: white; border-radius: 20px; padding: 20px; margin-bottom: 20px; }
.print-buttons { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 15px; }
.modal { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 1000; justify-content: center; align-items: center; }
.modal-content { background: white; padding: 25px; border-radius: 20px; width: 90%; max-width: 500px; }
.modal-content input, .modal-content select { width: 100%; padding: 10px; margin-bottom: 15px; border: 1px solid #cbd5e1; border-radius: 8px; }
.batch-list-item, .judge-list-item, .program-list-item { display: flex; justify-content: space-between; align-items: center; padding: 10px 0; border-bottom: 1px solid #eee; clear: both; }
.batch-list-item span, .judge-list-item span, .program-list-item span { font-size: 14px; }
.batch-buttons, .judge-buttons, .program-buttons { display: flex; gap: 8px; }
.toast { position: fixed; bottom: 20px; right: 20px; background: #333; color: white; padding: 12px 20px; border-radius: 8px; animation: fadeOut 2s ease; z-index: 1000; }
.col-operation {
display: none;
}
body.print-mode .col-operation {
display: table-cell;
}
@keyframes fadeOut { 0% { opacity: 1; } 70% { opacity: 1; } 100% { opacity: 0; visibility: hidden; } }
@media (max-width: 768px) {
.judge-scoring-container > div {
flex-direction: column !important;
}
.judge-scoring-container .program-list-sidebar {
width: 100%;
max-height: 300px;
overflow-y: auto;
margin-bottom: 16px;
}
.judge-scoring-container .score-panel {
width: 100%;
}
#judgeScorePanel [style*="display: grid"] {
grid-template-columns: 1fr !important;
gap: 12px !important;
}
.score-input, .detail-score-input, .admin-input, .judge-select {
width: 100%;
box-sizing: border-box;
}
.scoring-table, .summary-table {
display: block;
overflow-x: auto;
white-space: nowrap;
}
.batch-bar {
flex-wrap: wrap;
}
.batch-btn {
margin-bottom: 8px;
}
.header h1 {
font-size: 20px;
}
.login-area {
flex-wrap: wrap;
}
.login-area .admin-input,
.login-area .judge-select,
.login-area .btn {
width: 100%;
margin-bottom: 8px;
}
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1 id="mainTitle">加载中...</h1>
<div class="subtitle" id="subTitle"></div>
<div class="login-bar">
<div class="login-area" id="loginArea">
<select id="judgeSelect" class="judge-select"><option value="">选择评委</option></select>
<button class="btn btn-primary" onclick="loginAsJudge()">评委登录</button>
<span style="margin:0 5px">或</span>
<input type="text" id="adminUsername" placeholder="管理员账号" class="admin-input" style="width:100px;">
<input type="password" id="adminPassword" placeholder="密码" class="admin-input" style="width:100px;">
<button class="btn btn-warning" onclick="loginAsAdmin()">管理员登录</button>
</div>
<div class="user-info-area" id="userInfoArea" style="display: none;">
<span id="userInfo" class="user-info"></span>
<button class="btn btn-outline btn-sm" onclick="logout()">退出</button>
<button id="editTitleBtn" class="btn btn-primary btn-sm" onclick="showTitleModal()" style="display:none;">✏️ 修改标题</button>
</div>
</div>
<div class="progress-bar"><div class="progress-fill" id="progressFill"></div></div>
<div id="progressText" style="font-size:12px;"></div>
</div>
<div class="tabs">
<div class="tab active" onclick="switchTab('scoring')">🎯 评分打分</div>
<div class="tab" id="adminTab" style="display:none;" onclick="switchTab('admin')">⚙️ 数据管理</div>
<div class="tab" id="awardTab" style="display:none;" onclick="switchTab('award')">🏆 奖项设置</div>
<div class="tab" onclick="switchTab('print')">🖨️ 打印中心</div>
</div>
<!-- 评委评分页面 -->
<div id="judgeScoringTab" style="display:block;">
<div class="batch-bar" id="judgeBatchBar"></div>
<div class="judge-scoring-container" id="judgeScoringContainer">
<div style="text-align:center;padding:40px;">请先登录选择节目评分</div>
</div>
</div>
<!-- 管理员批量评分页面 -->
<div id="adminScoringTab" style="display:none;">
<div class="batch-bar" id="adminBatchBar"></div>
<div class="admin-scoring-container" id="adminScoringContainer"></div>
</div>
<!-- 数据管理页面 -->
<div id="adminTabContent" style="display:none;">
<div class="batch-bar" id="configBatchBar"></div>
<div style="display: flex; gap: 20px; flex-wrap: wrap;">
<div class="admin-panel" style="flex:1;">
<h3>👥 评委管理 <button class="btn btn-primary btn-sm" onclick="showAddJudgeModal()" style="float:right">+ 添加评委</button></h3>
<div id="judgeManageList"></div>
</div>
<div class="admin-panel" style="flex:1;">
<h3>📦 批次管理 <button class="btn btn-primary btn-sm" onclick="showAddBatchModal()" style="float:right">+ 添加批次</button></h3>
<div id="batchList"></div>
</div>
<div class="admin-panel" style="flex:2;">
<h3>📋 节目管理 <button class="btn btn-primary btn-sm" onclick="showAddProgramModal()" style="float:right">+ 添加节目</button>
<button class="btn btn-primary btn-sm" onclick="showImportProgramModal()" style="float:right;margin-right:10px;">📎 Excel导入</button>
</h3>
<div id="programManageContainer"></div>
</div>
</div>
</div>
<!-- 奖项设置页面 -->
<div id="awardTabContent" style="display:none;">
<div class="batch-bar" id="awardBatchBar"></div>
<div class="settings-panel">
<h3>🏆 奖项数量设置</h3>
<div style="display: flex; gap: 20px; flex-wrap: wrap; align-items: center;">
<div><label>一等奖数量:</label><input type="number" id="awardFirst" style="width:80px"></div>
<div><label>二等奖数量:</label><input type="number" id="awardSecond" style="width:80px"></div>
<div><label>三等奖数量:</label><input type="number" id="awardThird" style="width:80px"></div>
<button class="btn btn-primary btn-sm" onclick="saveAwardSettings()">保存数量</button>
</div>
</div>
<div class="settings-panel">
<h3>🏷️ 奖项名称设置</h3>
<div style="display: flex; gap: 20px; flex-wrap: wrap; align-items: center;">
<div><label>一等奖名称:</label><input type="text" id="awardFirstName" style="width:100px"></div>
<div><label>二等奖名称:</label><input type="text" id="awardSecondName" style="width:100px"></div>
<div><label>三等奖名称:</label><input type="text" id="awardThirdName" style="width:100px"></div>
<button class="btn btn-primary btn-sm" onclick="saveAwardNames()">保存名称</button>
</div>
</div>
<div class="settings-panel">
<h3>📊 获奖名单预览</h3>
<div id="awardPreview"></div>
<div class="print-buttons">
<button class="btn btn-success btn-sm" onclick="printAward('first')">打印一等奖名单</button>
<button class="btn btn-success btn-sm" onclick="printAward('second')">打印二等奖名单</button>
<button class="btn btn-success btn-sm" onclick="printAward('third')">打印三等奖名单</button>
</div>
</div>
</div>
<!-- 打印中心页面 -->
<div id="printTabContent" style="display:none;">
<div class="batch-bar" id="printBatchBar"></div>
<div class="admin-panel"><h3>🏆 获奖名单打印</h3>
<div id="awardPreviewPrint"></div>
<div class="print-buttons">
<button class="btn btn-success btn-sm" onclick="printAward('first')">打印一等奖名单</button>
<button class="btn btn-success btn-sm" onclick="printAward('second')">打印二等奖名单</button>
<button class="btn btn-success btn-sm" onclick="printAward('third')">打印三等奖名单</button>
</div>
</div>
</div>
<!-- 汇总表 -->
<div class="summary-panel">
<h3>📊 评分汇总表 <span id="summaryTitle"></span><button class="btn btn-success btn-sm" onclick="exportExcel()" style="float:right">📎 导出Excel</button></h3>
<div style="overflow-x: auto; clear: both;"><table class="summary-table" id="summaryTable"><thead><tr><th>名次</th><th>节目序号</th><th>节目名称</th><th>演出单位</th><th>表演者</th><th>类别</th><th>平均分</th><th>最高分</th><th>最低分</th><th>评委数</th><th class="col-operation">操作</th></tr></thead><tbody id="summaryBody"></tbody></table></div>
</div>
</div>
<!-- 模态框 -->
<div id="programModal" class="modal"><div class="modal-content"><h3 id="programModalTitle">添加节目</h3><input type="text" id="programSn" placeholder="节目序号"><input type="text" id="programName" placeholder="节目名称"><input type="text" id="programUnit" placeholder="演出单位"><input type="text" id="programPerformer" placeholder="表演者"><select id="programCategory"><option value="舞蹈">舞蹈</option><option value="音乐">音乐</option><option value="语言">语言</option><option value="器乐">器乐</option></select><select id="programBatchId"></select><div class="modal-buttons"><button class="btn btn-outline btn-sm" onclick="closeProgramModal()">取消</button><button class="btn btn-primary btn-sm" onclick="saveProgram()">保存</button></div></div></div>
<div id="importModal" class="modal"><div class="modal-content"><h3>从Excel导入节目</h3><p style="margin-bottom:10px; font-size:12px;">Excel需要包含列:节目序号、节目名称、演出单位、表演者、节目类别</p><input type="file" id="importFile" accept=".xlsx,.xls"><select id="importBatchId" style="margin-top:10px;"></select><div class="modal-buttons" style="margin-top:15px;"><button class="btn btn-outline btn-sm" onclick="closeImportModal()">取消</button><button class="btn btn-primary btn-sm" onclick="importPrograms()">导入</button></div></div></div>
<div id="judgeModal" class="modal"><div class="modal-content"><h3 id="judgeModalTitle">添加评委</h3><input type="text" id="judgeId" placeholder="评委编号"><input type="text" id="judgeName" placeholder="评委姓名"><div class="modal-buttons"><button class="btn btn-outline btn-sm" onclick="closeJudgeModal()">取消</button><button class="btn btn-primary btn-sm" onclick="saveJudge()">保存</button></div></div></div>
<div id="batchModal" class="modal"><div class="modal-content"><h3>添加批次</h3><input type="text" id="batchName" placeholder="批次名称"><div class="modal-buttons"><button class="btn btn-outline btn-sm" onclick="closeBatchModal()">取消</button><button class="btn btn-primary btn-sm" onclick="saveBatch()">保存</button></div></div></div>
<div id="titleModal" class="modal"><div class="modal-content"><h3>修改标题</h3><input type="text" id="mainTitleInput" placeholder="主标题"><input type="text" id="subTitleInput" placeholder="副标题"><div class="modal-buttons"><button class="btn btn-outline btn-sm" onclick="closeTitleModal()">取消</button><button class="btn btn-primary btn-sm" onclick="saveTitleSettings()">保存</button></div></div></div>
<script>
let currentUser = null, userType = null, judgesData = [], batchesData = [], programsData = [], criteriaData = {};
let currentBatchId = null, currentJudgeProgram = null;
let editingProgramSn = null, editingJudgeId = null;
async function restoreLogin() {
const stored = localStorage.getItem('score_user');
if (!stored) return false;
try {
const user = JSON.parse(stored);
if (user.type === 'judge') {
// 可选:向后端验证评委是否存在(为了安全,但最小修改可省略直接恢复)
currentUser = { judge_id: user.id, judge_name: user.name };
userType = 'judge';
document.getElementById('loginArea').style.display = 'none';
document.getElementById('userInfoArea').style.display = 'flex';
document.getElementById('userInfo').innerHTML = `👨‍⚖️ 评委:${user.name} <span class="mode-badge mode-judge">评委模式</span>`;
document.getElementById('editTitleBtn').style.display = 'none';
document.getElementById('adminTab').style.display = 'none';
document.getElementById('awardTab').style.display = 'none';
document.getElementById('judgeScoringTab').style.display = 'block';
document.getElementById('adminScoringTab').style.display = 'none';
await loadJudgePrograms();
return true;
} else if (user.type === 'admin') {
currentUser = { id: user.id, username: user.username };
userType = 'admin';
document.getElementById('loginArea').style.display = 'none';
document.getElementById('userInfoArea').style.display = 'flex';
document.getElementById('userInfo').innerHTML = `👑 管理员:${user.username} <span class="mode-badge mode-admin">管理员模式</span>`;
document.getElementById('editTitleBtn').style.display = 'inline-block';
document.getElementById('adminTab').style.display = 'block';
document.getElementById('awardTab').style.display = 'block';
document.getElementById('judgeScoringTab').style.display = 'none';
document.getElementById('adminScoringTab').style.display = 'block';
await loadAdminScoring();
return true;
}
} catch(e) { console.warn(e); }
return false;
}
async function init() {
const loggedIn = await restoreLogin();
await loadTitles();
await loadBatches();
await loadJudges();
await loadPrograms();
await loadCriteria();
await loadAwardNames();
await loadAggregate();
await loadProgress();
setInterval(() => { if(currentUser) loadAggregate(); loadProgress(); }, 5000);
if (!loggedIn) {
document.getElementById('judgeScoringTab').style.display = 'block';
document.getElementById('adminScoringTab').style.display = 'none';
}
const adminPasswordInput = document.getElementById('adminPassword');
const adminUsernameInput = document.getElementById('adminUsername');
const loginHandler = function(e) { if(e.key === 'Enter') loginAsAdmin(); };
if(adminPasswordInput) adminPasswordInput.addEventListener('keypress', loginHandler);
if(adminUsernameInput) adminUsernameInput.addEventListener('keypress', loginHandler);
}
async function loadTitles() { const res = await fetch('/api/settings/titles'); const data = await res.json(); document.getElementById('mainTitle').innerText = data.main_title; document.getElementById('subTitle').innerText = data.sub_title; }
async function loadCriteria() { const res = await fetch('/api/criteria'); criteriaData = await res.json(); }
async function loadBatches() { const res = await fetch('/api/batches'); batchesData = await res.json(); renderBatchBars(); renderBatchSelect(); renderBatchList(); }
async function loadAwardNames() { const res = await fetch('/api/settings/awards'); const data = await res.json(); document.getElementById('awardFirstName').value = data.first_name; document.getElementById('awardSecondName').value = data.second_name; document.getElementById('awardThirdName').value = data.third_name; document.getElementById('awardFirst').value = data.first; document.getElementById('awardSecond').value = data.second; document.getElementById('awardThird').value = data.third; await updateAwardPreview(); }
function renderBatchBars() {
const bars = ['judgeBatchBar', 'adminBatchBar', 'configBatchBar', 'awardBatchBar', 'printBatchBar'];
bars.forEach(barId => {
const bar = document.getElementById(barId);
if(bar){
bar.innerHTML = '<button class="batch-btn" onclick="selectBatch(null)">📊 全部</button>';
batchesData.forEach(b => { bar.innerHTML += `<button class="batch-btn ${currentBatchId===b.id?'active':''}" onclick="selectBatch(${b.id})">📁 ${b.name}</button>`; });
}
});
}
function renderBatchSelect() { const select = document.getElementById('programBatchId'); if(select){ select.innerHTML = ''; batchesData.forEach(b => { select.innerHTML += `<option value="${b.id}">${b.name}</option>`; }); } }
function renderBatchList() {
const container = document.getElementById('batchList');
if(container){
container.innerHTML = '';
batchesData.forEach(b => {
container.innerHTML += `<div class="batch-list-item">
<span>📁 ${b.name}</span>
<div class="batch-buttons">
<button class="btn btn-outline btn-sm" onclick="clearBatchPrograms(${b.id})">🗑️ 清空节目</button>
<button class="btn btn-outline btn-sm" onclick="deleteBatch(${b.id})">删除批次</button>
</div>
</div>`;
});
}
}
async function clearBatchPrograms(batchId) {
if(confirm(`确定要清空该批次的所有节目吗?此操作不可恢复!`)){
const res = await fetch(`/api/batches/${batchId}/programs`, { method:'DELETE' });
if(res.ok){
showToast('该批次所有节目已清空');
await loadPrograms();
if(userType === 'judge') await loadJudgePrograms();
if(userType === 'admin') await loadAdminScoring();
await loadAggregate();
} else {
showToast('操作失败');
}
}
}
async function selectBatch(batchId) {
currentBatchId = batchId;
renderBatchBars();
if(userType === 'judge'){
await loadJudgePrograms();
} else if(userType === 'admin'){
await loadAdminScoring();
}
await loadAggregate();
}
async function loginAsJudge() {
const select = document.getElementById('judgeSelect');
const judgeId = select.value;
if(!judgeId){ showToast('请选择评委'); return; }
const judgeName = select.options[select.selectedIndex].text.split(' (')[0];
const res = await fetch('/api/judge/login', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({judge_id:judgeId, judge_name:judgeName}) });
if(res.ok){
const data = await res.json();
currentUser = data; userType = 'judge';
localStorage.setItem('score_user', JSON.stringify({
type: 'judge',
id: data.judge_id,
name: data.judge_name
}));
document.getElementById('loginArea').style.display = 'none';
document.getElementById('userInfoArea').style.display = 'flex';
document.getElementById('userInfo').innerHTML = `👨‍⚖️ 评委:${data.judge_name} <span class="mode-badge mode-judge">评委模式</span>`;
document.getElementById('editTitleBtn').style.display = 'none';
document.getElementById('adminTab').style.display = 'none';
document.getElementById('awardTab').style.display = 'none';
document.getElementById('judgeScoringTab').style.display = 'block';
document.getElementById('adminScoringTab').style.display = 'none';
await loadJudgePrograms();
} else { showToast('登录失败'); }
}
async function loginAsAdmin() {
const username = document.getElementById('adminUsername').value;
const password = document.getElementById('adminPassword').value;
if(!username||!password){ showToast('请输入账号密码'); return; }
const res = await fetch('/api/admin/login', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({username,password}) });
if(res.ok){
const data = await res.json();
currentUser = data; userType = 'admin';
localStorage.setItem('score_user', JSON.stringify({
type: 'admin',
id: data.admin_id,
username: data.username
}));
document.getElementById('loginArea').style.display = 'none';
document.getElementById('userInfoArea').style.display = 'flex';
document.getElementById('userInfo').innerHTML = `👑 管理员:${data.username} <span class="mode-badge mode-admin">管理员模式</span>`;
document.getElementById('editTitleBtn').style.display = 'inline-block';
document.getElementById('adminTab').style.display = 'block';
document.getElementById('awardTab').style.display = 'block';
document.getElementById('judgeScoringTab').style.display = 'none';
document.getElementById('adminScoringTab').style.display = 'block';
await loadAdminScoring();
} else { showToast('登录失败'); }
}
function logout() {
localStorage.removeItem('score_user');
currentUser = null; userType = null;
document.getElementById('loginArea').style.display = 'flex';
document.getElementById('userInfoArea').style.display = 'none';
document.getElementById('adminTab').style.display = 'none';
document.getElementById('awardTab').style.display = 'none';
document.getElementById('judgeScoringTab').style.display = 'block';
document.getElementById('adminScoringTab').style.display = 'none';
document.getElementById('judgeScoringContainer').innerHTML = '<div style="text-align:center;padding:40px;">请先登录选择节目评分</div>';
switchTab('scoring');
}
async function loadJudgePrograms() {
let url = '/api/programs';
if(currentBatchId) url += `?batch_id=${currentBatchId}`;
const res = await fetch(url);
programsData = await res.json();
renderJudgeProgramList();
}
function renderJudgeProgramList() {
const container = document.getElementById('judgeScoringContainer');
if(!container) return;
let html = '<div style="display:flex; gap:20px; flex-wrap:wrap;">';
html += '<div class="program-list-sidebar"><h4>📋 节目列表</h4><ul style="list-style:none;">';
programsData.forEach(prog => {
const isActive = currentJudgeProgram && currentJudgeProgram.sn === prog.sn;
html += `<li class="program-item ${isActive ? 'active' : ''}" onclick="selectJudgeProgram('${prog.sn}')">
<strong>${escapeHtml(prog.name)}</strong><br><span style="font-size:12px; color:#666;">${prog.sn} | ${escapeHtml(prog.performer)}</span>
</li>`;
});
html += '</ul></div><div class="score-panel" id="judgeScorePanel"><div style="text-align:center;padding:40px;">请选择节目进行评分</div></div></div>';
container.innerHTML = html;
}
async function selectJudgeProgram(programSn) {
currentJudgeProgram = programsData.find(p=>p.sn===programSn);
renderJudgeProgramList();
const res = await fetch(`/api/scores/${currentUser.judge_id}`);
const scores = await res.json();
const progScore = scores[programSn] || {content:null, stage:null, art:null, overall:null, comment:''};
renderJudgeScorePanel(programSn, progScore);
}
function renderJudgeScorePanel(programSn, scores) {
const prog = programsData.find(p=>p.sn===programSn);
if(!prog) return;
const total = (scores.content||0)+(scores.stage||0)+(scores.art||0)+(scores.overall||0);
const html = `<div style="background:white; border-radius:16px; padding:20px;">
<h3>${escapeHtml(prog.name)}</h3><p style="color:#666; margin-bottom:20px;">序号:${prog.sn} | 单位:${escapeHtml(prog.unit)} | 表演者:${escapeHtml(prog.performer)} | 类别:${prog.category}</p>
<div style="display:grid; grid-template-columns:repeat(auto-fit,minmax(200px,1fr)); gap:15px;">
<div style="background:#f7fafc; padding:15px; border-radius:12px; text-align:center;">
<label>📖 内容取材</label><div style="font-size:12px; color:#999;">满分20分</div>
<input type="number" id="score_content" value="${scores.content!==null?scores.content:''}" step="0.5" min="0" max="20" style="width:100%; padding:8px; margin:10px 0; font-size:18px; text-align:center; border:1px solid #ddd; border-radius:8px;">
<div style="font-size:11px; color:#666;">${criteriaData.content?.description || ''}</div>
</div>
<div style="background:#f7fafc; padding:15px; border-radius:12px; text-align:center;">
<label>💃 舞台风度</label><div style="font-size:12px; color:#999;">满分10分</div>
<input type="number" id="score_stage" value="${scores.stage!==null?scores.stage:''}" step="0.5" min="0" max="10" style="width:100%; padding:8px; margin:10px 0; font-size:18px; text-align:center; border:1px solid #ddd; border-radius:8px;">
<div style="font-size:11px; color:#666;">${criteriaData.stage?.description || ''}</div>
</div>
<div style="background:#f7fafc; padding:15px; border-radius:12px; text-align:center;">
<label>🎨 表演艺术水平</label><div style="font-size:12px; color:#999;">满分60分</div>
<input type="number" id="score_art" value="${scores.art!==null?scores.art:''}" step="0.5" min="0" max="60" style="width:100%; padding:8px; margin:10px 0; font-size:18px; text-align:center; border:1px solid #ddd; border-radius:8px;">
<div style="font-size:11px; color:#666;">${criteriaData.art?.description || ''}</div>
</div>
<div style="background:#f7fafc; padding:15px; border-radius:12px; text-align:center;">
<label>⭐ 综合评价</label><div style="font-size:12px; color:#999;">满分10分</div>
<input type="number" id="score_overall" value="${scores.overall!==null?scores.overall:''}" step="0.5" min="0" max="10" style="width:100%; padding:8px; margin:10px 0; font-size:18px; text-align:center; border:1px solid #ddd; border-radius:8px;">
<div style="font-size:11px; color:#666;">${criteriaData.overall?.description || ''}</div>
</div>
</div>
<div class="total-score">🏆 节目总分:<span id="totalScore">${total}</span> / 100</div>
<div style="margin:20px 0;"><textarea id="commentInput" rows="3" placeholder="评委点评(可选)" style="width:100%; padding:10px; border:1px solid #ddd; border-radius:8px;">${escapeHtml(scores.comment||'')}</textarea></div>
<div style="display:flex; gap:10px; justify-content:flex-end;"><button class="btn btn-outline btn-sm" onclick="saveJudgeComment()">💾 保存点评</button></div>
</div>`;
document.getElementById('judgeScorePanel').innerHTML = html;
bindJudgeScoreEvents();
}
function bindJudgeScoreEvents() {
['content','stage','art','overall'].forEach(field => {
const input = document.getElementById(`score_${field}`);
if(input) input.onchange = () => saveJudgeScore(field, parseFloat(input.value)||null);
});
}
async function saveJudgeScore(field, value) {
if(!currentUser || !currentJudgeProgram) return;
const res = await fetch(`/api/scores/${currentUser.judge_id}`, { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({program_sn:currentJudgeProgram.sn, field, value}) });
if(res.ok){ const data = await res.json(); document.getElementById('totalScore').innerText = data.total; await loadAggregate(); await loadProgress(); }
else { const err = await res.json(); showToast(err.detail); }
}
async function saveJudgeComment() {
if(!currentUser || !currentJudgeProgram) return;
const comment = document.getElementById('commentInput').value;
await fetch(`/api/comment/${currentUser.judge_id}`, { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({program_sn:currentJudgeProgram.sn, comment}) });
showToast('点评已保存');
}
async function loadAdminScoring() {
let url = '/api/programs';
if(currentBatchId) url += `?batch_id=${currentBatchId}`;
const res = await fetch(url);
programsData = await res.json();
await renderAdminScoringTable();
}
async function renderAdminScoringTable() {
if(!judgesData.length || !programsData.length){ document.getElementById('adminScoringContainer').innerHTML = '<div style="text-align:center;padding:40px;">加载中...</div>'; return; }
let scoresMap = {};
for(let prog of programsData){ const res = await fetch(`/api/scores/program/${prog.sn}`); scoresMap[prog.sn] = await res.json(); }
let html = '<table class="scoring-table"><thead><tr><th>节目序号</th><th>节目名称</th><th>表演者</th>';
judgesData.forEach(j => { html += `<th>${j.name}<br><span style="font-size:11px;">(${j.id})</span></th>`; });
html += '<th>操作</th></tr></thead><tbody>';
for(let prog of programsData){
html += `<tr><td style="text-align:center;">${prog.sn}</td><td style="text-align:left;">${prog.name}</td><td style="text-align:left;">${escapeHtml(prog.performer)}`;
for(let judge of judgesData){
let score = '';
if(scoresMap[prog.sn]){ const judgeScore = scoresMap[prog.sn].find(s => s.judge_id === judge.id); if(judgeScore && judgeScore.total_score !== null) score = judgeScore.total_score; }
html += `<td><input type="number" class="score-input" id="score_${prog.sn}_${judge.id}" value="${score}" step="0.5" min="0" max="100" onchange="saveAdminScore('${prog.sn}','${judge.id}',this.value)"></td>`;
}
html += `<td><button class="btn btn-outline btn-sm" onclick="printProgram('${prog.sn}')">🖨️ 打印</button></td></tr>`;
}
html += '</tbody></table>';
document.getElementById('adminScoringContainer').innerHTML = html;
}
async function saveAdminScore(programSn, judgeId, value) {
if(!currentUser) return;
let numValue = value === '' ? null : parseFloat(value);
if(numValue !== null && (numValue < 0 || numValue > 100)){
showToast('总分必须在0-100之间');
document.getElementById(`score_${programSn}_${judgeId}`).value = '';
return;
}
let scores = {};
scores[judgeId] = numValue;
try {
const res = await fetch('/api/scores/admin/batch', {
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({program_sn:programSn, judge_scores:scores})
});
if(res.ok){
await loadAggregate();
await loadProgress();
document.getElementById(`score_${programSn}_${judgeId}`).value = numValue !== null ? numValue : '';
showToast('保存成功');
} else {
const err = await res.json();
showToast('保存失败:' + (err.detail || '未知错误'));
}
} catch(e) {
showToast('网络错误:' + e.message);
}
}
async function loadPrograms() { const res = await fetch('/api/programs'); programsData = await res.json(); renderProgramManageList(); }
async function loadJudges() { const res = await fetch('/api/judges'); judgesData = await res.json(); renderJudgeManageList(); const select = document.getElementById('judgeSelect'); select.innerHTML = '<option value="">选择评委</option>'; judgesData.forEach(j => { select.innerHTML += `<option value="${j.id}">${j.name} (${j.id})</option>`; }); if(userType === 'admin') await renderAdminScoringTable(); }
async function loadAggregate() {
let url = '/api/aggregate';
if (currentBatchId) url += `?batch_id=${currentBatchId}`;
const res = await fetch(url);
const data = await res.json();
const tbody = document.getElementById('summaryBody');
tbody.innerHTML = '';
data.forEach(item => {
const row = tbody.insertRow();
if (item.rank === 1) row.className = 'rank-1';
row.insertCell(0).innerText = item.rank;
row.insertCell(1).innerText = item.sn;
row.insertCell(2).innerText = item.name;
row.insertCell(3).innerText = item.unit;
row.insertCell(4).innerText = item.performer;
row.insertCell(5).innerText = item.category;
row.insertCell(6).innerHTML = `<strong>${item.avg_score}</strong>`;
row.insertCell(7).innerText = item.max_score;
row.insertCell(8).innerText = item.min_score;
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>`;
});
const batch = batchesData.find(b => b.id === currentBatchId);
document.getElementById('summaryTitle').innerHTML = batch ? ` - ${batch.name}` : '';
await updateAwardPreview();
await updateAwardPreview('awardPreviewPrint');
}
async function loadProgress() { const res = await fetch('/api/progress'); const data = await res.json(); document.getElementById('progressFill').style.width = `${data.progress}%`; document.getElementById('progressText').innerHTML = `评分进度:${data.scored_programs}/${data.total_cells} (${data.progress}%)`; }
function switchTab(tab) {
document.querySelectorAll('.tab').forEach(t=>t.classList.remove('active'));
document.getElementById('judgeScoringTab').style.display='none'; document.getElementById('adminScoringTab').style.display='none';
document.getElementById('adminTabContent').style.display='none'; document.getElementById('awardTabContent').style.display='none'; document.getElementById('printTabContent').style.display='none';
if(tab==='scoring'){ document.querySelector('.tab:first-child').classList.add('active'); if(userType === 'judge') document.getElementById('judgeScoringTab').style.display='block'; else if(userType === 'admin') document.getElementById('adminScoringTab').style.display='block'; else document.getElementById('judgeScoringTab').style.display='block'; }
else if(tab==='admin'){ document.querySelectorAll('.tab')[1].classList.add('active'); document.getElementById('adminTabContent').style.display='block'; loadBatches(); loadJudges(); loadPrograms(); }
else if(tab==='award'){ document.querySelectorAll('.tab')[2].classList.add('active'); document.getElementById('awardTabContent').style.display='block'; updateAwardPreview(); }
else if(tab==='print'){ document.querySelectorAll('.tab')[3].classList.add('active'); document.getElementById('printTabContent').style.display='block'; updateAwardPreview('awardPreviewPrint'); }
if (tab === 'print') {
document.body.classList.add('print-mode');
} else {
document.body.classList.remove('print-mode');
}
}
async function updateAwardPreview(containerId = 'awardPreview') {
let url = '/api/aggregate';
if(currentBatchId) url += `?batch_id=${currentBatchId}`;
const programs = await fetch(url).then(r=>r.json());
const settings = await fetch('/api/settings/awards').then(r=>r.json());
const firstCount=settings.first, secondCount=settings.second, thirdCount=settings.third;
let html = '<div class="award-table-container"><table class="award-table"><thead><tr><th>名次</th><th>节目序号</th><th>节目名称</th><th>演出单位</th><th>表演者</th><th>类别</th><th>平均分</th><th>奖项</th></tr></thead><tbody>';
programs.forEach((p,idx)=>{
let award='';
if(idx<firstCount) award=settings.first_name;
else if(idx<firstCount+secondCount) award=settings.second_name;
else if(idx<firstCount+secondCount+thirdCount) award=settings.third_name;
else award='-';
html += `<tr><td style="text-align:center;">${idx+1}</td><td style="text-align:center;">${p.sn}</td><td style="text-align:left;">${p.name}</td><td style="text-align:left;">${p.unit}</td><td style="text-align:left;">${p.performer}</td><td style="text-align:center;">${p.category}</td><td style="text-align:center;">${p.avg_score}</td><td style="text-align:center;">${award}</td><tr>`;
});
html += '</tbody></table></div>';
document.getElementById(containerId).innerHTML = html;
}
function renderProgramManageList() {
const container = document.getElementById('programManageContainer');
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); });
let html = '';
for(let batchName in grouped){
html += `<h4 style="margin:15px 0 5px 0;">📁 ${batchName}</h4>`;
grouped[batchName].forEach(p => {
html += `<div class="program-list-item">
<span>${p.sn} | ${escapeHtml(p.name)} | ${escapeHtml(p.unit)} | ${escapeHtml(p.performer)}</span>
<div class="program-buttons">
<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>
</div>`;
});
}
container.innerHTML = html;
}
function renderJudgeManageList() {
const container = document.getElementById('judgeManageList');
if(container){
container.innerHTML = '';
judgesData.forEach(j => {
container.innerHTML += `<div class="judge-list-item">
<span>${j.name} (${j.id})</span>
<div class="judge-buttons">
<button class="btn btn-outline btn-sm" onclick="editJudge('${j.id}')">✏️ 编辑</button>
<button class="btn btn-outline btn-sm" onclick="deleteJudge('${j.id}')">🗑️ 删除</button>
</div>
</div>`;
});
}
}
function printBlank(sn) { window.open(`/print/blank/${sn}`, '_blank'); }
function printProgram(sn) { window.open(`/print/program/${sn}`, '_blank'); }
function printAward(type) {
let url = `/print/award/${type}`;
if(currentBatchId) url += `?batch_id=${currentBatchId}`;
window.open(url, '_blank');
}
async function exportExcel() { let url = '/api/export_excel'; if(currentBatchId) url += `?batch_id=${currentBatchId}`; window.open(url, '_blank'); }
async function saveAwardSettings() { 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', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({first,second,third}) }); showToast('保存成功'); await updateAwardPreview(); }
async function saveAwardNames() { 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', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({first_name,second_name,third_name}) }); showToast('保存成功'); await updateAwardPreview(); }
function showAddBatchModal() { document.getElementById('batchModal').style.display='flex'; }
async function saveBatch() { const name = document.getElementById('batchName').value.trim(); if(!name){ showToast('请输入批次名称'); return; } await fetch('/api/batches', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({name}) }); closeBatchModal(); await loadBatches(); }
async function deleteBatch(id) { if(confirm('确定删除该批次吗?')) { await fetch(`/api/batches/${id}`, { method:'DELETE' }); await loadBatches(); await loadPrograms(); } }
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() { const sn = document.getElementById('programSn').value.trim(); const name = document.getElementById('programName').value.trim(); const unit = document.getElementById('programUnit').value.trim(); const performer = document.getElementById('programPerformer').value.trim(); const category = document.getElementById('programCategory').value; const batch_id = parseInt(document.getElementById('programBatchId').value); if(!sn||!name){ showToast('请填写节目序号和名称'); return; } let res; if(editingProgramSn){ res = await fetch(`/api/programs/${editingProgramSn}`, { method:'PUT', headers:{'Content-Type':'application/json'}, body:JSON.stringify({name,unit,performer,category,batch_id}) }); } else { res = await fetch('/api/programs', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({sn,name,unit,performer,category,batch_id}) }); } if(res.ok){ closeProgramModal(); await loadPrograms(); if(userType==='judge') await loadJudgePrograms(); if(userType==='admin') await loadAdminScoring(); await loadAggregate(); } else { const err=await res.json(); showToast(err.detail); } }
async function deleteProgram(sn) { if(confirm(`确定删除节目 ${sn} 吗?`)){ await fetch(`/api/programs/${sn}`, { method:'DELETE' }); await loadPrograms(); if(userType==='judge') await loadJudgePrograms(); if(userType==='admin') await loadAdminScoring(); await loadAggregate(); } }
function closeProgramModal() { document.getElementById('programModal').style.display='none'; document.getElementById('programSn').disabled=false; }
function showImportProgramModal() { renderBatchSelect(); document.getElementById('importBatchId').innerHTML = document.getElementById('programBatchId').innerHTML; document.getElementById('importModal').style.display='flex'; }
async function importPrograms() { const file = document.getElementById('importFile').files[0]; if(!file){ showToast('请选择Excel文件'); return; } const batch_id = document.getElementById('importBatchId').value; const formData = new FormData(); formData.append('file', file); const res = await fetch(`/api/programs/import?batch_id=${batch_id}`, { method:'POST', body:formData }); if(res.ok){ showToast('导入成功'); closeImportModal(); await loadPrograms(); if(userType==='judge') await loadJudgePrograms(); if(userType==='admin') await loadAdminScoring(); await loadAggregate(); } else { const err=await res.json(); showToast('导入失败:'+err.detail); } }
function closeImportModal() { document.getElementById('importModal').style.display='none'; document.getElementById('importFile').value=''; }
function showAddJudgeModal() { editingJudgeId=null; document.getElementById('judgeModalTitle').innerText='添加评委'; document.getElementById('judgeId').value=''; document.getElementById('judgeName').value=''; document.getElementById('judgeModal').style.display='flex'; }
function editJudge(id) { const judge = judgesData.find(j=>j.id===id); if(!judge) return; editingJudgeId=id; document.getElementById('judgeModalTitle').innerText='编辑评委'; document.getElementById('judgeId').value=judge.id; document.getElementById('judgeId').disabled=true; document.getElementById('judgeName').value=judge.name; document.getElementById('judgeModal').style.display='flex'; }
async function saveJudge() { const id = document.getElementById('judgeId').value.trim(); const name = document.getElementById('judgeName').value.trim(); if(!id||!name){ showToast('请填写评委编号和姓名'); return; } let res; if(editingJudgeId){ res = await fetch(`/api/judges/${editingJudgeId}`, { method:'PUT', headers:{'Content-Type':'application/json'}, body:JSON.stringify({name}) }); } else { res = await fetch('/api/judges', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({id,name}) }); } if(res.ok){ closeJudgeModal(); await loadJudges(); if(userType==='admin') await renderAdminScoringTable(); } else { const err=await res.json(); showToast(err.detail); } }
async function deleteJudge(id) { if(confirm(`确定删除评委 ${id} 吗?`)){ await fetch(`/api/judges/${id}`, { method:'DELETE' }); await loadJudges(); if(userType==='admin') await renderAdminScoringTable(); await loadAggregate(); } }
function closeJudgeModal() { document.getElementById('judgeModal').style.display='none'; document.getElementById('judgeId').disabled=false; }
function closeBatchModal() { document.getElementById('batchModal').style.display='none'; document.getElementById('batchName').value=''; }
function showTitleModal() { fetch('/api/settings/titles').then(r=>r.json()).then(data=>{ document.getElementById('mainTitleInput').value=data.main_title; document.getElementById('subTitleInput').value=data.sub_title; document.getElementById('titleModal').style.display='flex'; }); }
async function saveTitleSettings() { const main_title = document.getElementById('mainTitleInput').value; const sub_title = document.getElementById('subTitleInput').value; await fetch('/api/settings/titles', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({main_title,sub_title}) }); closeTitleModal(); await loadTitles(); }
function closeTitleModal() { document.getElementById('titleModal').style.display='none'; }
function escapeHtml(str) { if(!str) return ''; return str.replace(/[&<>]/g, function(m){ if(m==='&') return '&amp;'; if(m==='<') return '&lt;'; if(m==='>') return '&gt;'; return m; }); }
function showToast(msg) { const toast = document.createElement('div'); toast.className='toast'; toast.innerText=msg; document.body.appendChild(toast); setTimeout(()=>toast.remove(),2000); }
init();
</script>
</body>
</html>
"""