judge-online/main.py
2026-05-31 01:13:02 +08:00

2453 lines
118 KiB
Python
Raw Permalink 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 UTC, datetime, timedelta
from html import escape
from io import BytesIO
from typing import Annotated, cast
import jwt
import pandas as pd
from fastapi import Depends, FastAPI, File, HTTPException, Query, Request, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse, StreamingResponse
from pydantic import BaseModel, BeforeValidator, InstanceOf
SECRET_KEY = 'QET5hv3A@148fefo#5#W5x#aF%9Y#8!2'
ALGORITHM = 'HS256'
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=['*'],
allow_credentials=True,
allow_methods=['*'],
allow_headers=['*'],
)
# ==================== 数据库配置 ====================
DB_PATH = 'score_system.db'
@contextmanager
def get_db(write=False):
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
conn.execute('PRAGMA journal_mode=WAL')
conn.execute('PRAGMA busy_timeout=5000')
conn.execute('PRAGMA foreign_keys=ON')
if write:
conn.execute('BEGIN IMMEDIATE')
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
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 NOT NULL, order_num INTEGER, created_at TIMESTAMP, UNIQUE(batch_id, order_num), FOREIGN KEY (batch_id) REFERENCES batches(id) ON DELETE CASCADE ON UPDATE CASCADE)"""
)
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, admin_total_score REAL, comment TEXT, updated_at TIMESTAMP, UNIQUE(judge_id, program_sn), FOREIGN KEY (program_sn) REFERENCES programs(sn) ON DELETE CASCADE ON UPDATE CASCADE, FOREIGN KEY (judge_id) REFERENCES judges(id) ON DELETE CASCADE ON UPDATE CASCADE)"""
)
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 = '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:
batches_data = []
cursor.executemany(
'INSERT INTO batches (name, created_at) VALUES (?, ?)',
batches_data,
)
cursor.execute('SELECT COUNT(*) FROM judges')
if cursor.fetchone()[0] == 0:
judges_data = []
cursor.executemany(
'INSERT INTO judges (id, name, order_num, created_at) VALUES (?, ?, ?, ?)',
judges_data,
)
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', '三等奖')"
)
init_db()
def create_access_token(data: dict):
to_encode = data.copy()
expire = datetime.now(UTC) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({'exp': expire})
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
def verify_token(token: str):
try:
return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
except jwt.PyJWTError as exc:
raise HTTPException(status_code=401, detail='Token 无效') from exc
async def require_admin(request: Request):
token = request.headers.get('Authorization', '').replace('Bearer ', '')
payload = verify_token(token)
if payload.get('role') != 'admin':
raise HTTPException(status_code=403, detail='需要管理员权限')
return payload
# ==================== Pydantic模型 ====================
def escape_html(value: str) -> str:
if value is None:
return ''
return escape(str(value).strip())
EscapedStr = Annotated[str, BeforeValidator(escape_html)]
class AdminLogin(BaseModel):
username: EscapedStr
password: EscapedStr
class JudgeLogin(BaseModel):
judge_id: EscapedStr
judge_name: EscapedStr
class JudgeCreate(BaseModel):
id: EscapedStr
name: EscapedStr
class JudgeUpdate(BaseModel):
name: EscapedStr
class ProgramCreate(BaseModel):
sn: EscapedStr
name: EscapedStr
unit: EscapedStr
performer: EscapedStr
category: EscapedStr
batch_id: int
class ProgramUpdate(BaseModel):
name: EscapedStr
unit: EscapedStr
performer: EscapedStr
category: EscapedStr
batch_id: int
class BatchCreate(BaseModel):
name: EscapedStr
class ScoreDetailUpdate(BaseModel):
program_sn: EscapedStr
field: EscapedStr
value: float | None = None
class ScoreBatchUpdate(BaseModel):
program_sn: EscapedStr
judge_scores: dict[EscapedStr, float | None]
class CommentUpdate(BaseModel):
program_sn: EscapedStr
comment: EscapedStr
class AwardSettings(BaseModel):
first: int
second: int
third: int
class AwardNamesSettings(BaseModel):
first_name: EscapedStr
second_name: EscapedStr
third_name: EscapedStr
class TitleSettings(BaseModel):
main_title: EscapedStr
sub_title: EscapedStr
# ==================== 根路径 ====================
@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='管理员用户名或密码错误')
token = create_access_token(
{'id': admin['id'], 'username': admin['username'], 'role': 'admin'}
)
return {
'success': True,
'admin_id': admin['id'],
'username': admin['username'],
'token': token,
}
@app.get('/api/admin/check')
async def check_admin(admin_id: str):
with get_db() as conn:
cursor = conn.cursor()
cursor.execute('SELECT id FROM admins WHERE id = ?', (admin_id,))
exists = cursor.fetchone() is not None
return {'exists': exists}
@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']}
@app.get('/api/judge/check')
async def check_judge(judge_id: str):
with get_db() as conn:
cursor = conn.cursor()
cursor.execute('SELECT id FROM judges WHERE id = ?', (judge_id,))
exists = cursor.fetchone() is not None
return {'exists': exists}
# ==================== 设置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', dependencies=[Depends(require_admin)])
async def update_title_settings(titles: TitleSettings):
with get_db(write=True) 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', dependencies=[Depends(require_admin)])
async def update_award_settings(settings: AwardSettings):
with get_db(write=True) 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', dependencies=[Depends(require_admin)])
async def update_award_names(settings: AwardNamesSettings):
with get_db(write=True) 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', dependencies=[Depends(require_admin)])
async def create_batch(batch: BatchCreate):
with get_db(write=True) 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}', dependencies=[Depends(require_admin)])
async def delete_batch(batch_id: int):
with get_db(write=True) as conn:
cursor = conn.cursor()
cursor.execute('DELETE FROM batches WHERE id = ?', (batch_id,))
if cursor.rowcount == 0:
raise HTTPException(status_code=404, detail='批次不存在')
return {'success': True}
@app.delete('/api/batches/{batch_id}/programs', dependencies=[Depends(require_admin)])
async def clear_batch_programs(batch_id: int):
with get_db(write=True) as conn:
cursor = conn.cursor()
cursor.execute('DELETE FROM programs WHERE batch_id = ?', (batch_id,))
if cursor.rowcount == 0:
raise HTTPException(status_code=404, detail='批次不存在')
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', dependencies=[Depends(require_admin)])
async def create_judge(judge: JudgeCreate):
with get_db(write=True) 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}', dependencies=[Depends(require_admin)])
async def update_judge(judge_id: str, judge: JudgeUpdate):
with get_db(write=True) 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}', dependencies=[Depends(require_admin)])
async def delete_judge(judge_id: str):
with get_db(write=True) as conn:
cursor = conn.cursor()
cursor.execute('DELETE FROM judges WHERE id = ?', (judge_id,))
if cursor.rowcount == 0:
raise HTTPException(status_code=404, detail='评委不存在')
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', dependencies=[Depends(require_admin)])
async def create_program(program: ProgramCreate):
with get_db(write=True) as conn:
cursor = conn.cursor()
cursor.execute(
"""
INSERT INTO programs (sn, name, unit, performer, category, batch_id, order_num, created_at)
VALUES (?, ?, ?, ?, ?, ?, (SELECT COALESCE(MAX(order_num), 0) + 1 FROM programs WHERE batch_id = ?), ?)
""",
(
program.sn,
program.name,
program.unit,
program.performer,
program.category,
program.batch_id,
datetime.now(),
program.batch_id,
),
)
if cursor.rowcount == 0:
raise HTTPException(status_code=400, detail='节目序号已存在')
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/reorder', dependencies=[Depends(require_admin)])
async def reorder_programs(data: dict):
"""批量更新节目顺序,data: {'batch_id': 1, 'sn_list': ['P001', 'P002', ...]}"""
batch_id = data.get('batch_id')
sn_list = data.get('sn_list', [])
if not batch_id or not sn_list:
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 = ?',
(idx, sn, batch_id),
)
return {'success': True}
@app.post('/api/programs/import', dependencies=[Depends(require_admin)])
async def import_programs(
request: Request, batch_id: int = Query(...), file: UploadFile = File(...)
):
MAX_MB = 10
content_length = request.headers.get('content-length')
if content_length and int(content_length) > MAX_MB * 1024 * 1024:
raise HTTPException(status_code=413, detail=f'文件大小超过 {MAX_MB}MB 限制')
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(write=True) as conn:
cursor = conn.cursor()
cursor.execute(
'SELECT COALESCE(MAX(order_num), 0) FROM programs WHERE batch_id = ?',
(batch_id,),
)
max_order = cursor.fetchone()[0]
next_order = max_order + 1
inserted_count = 0
updated_count = 0
for _, row in df.iterrows():
sn = escape_html(row['节目编号'])
name = escape_html(row['节目名称'])
unit = escape_html(row['演出单位']) if pd.notna(row['演出单位']) else ''
performer = (
escape_html(row['表演者']) if pd.notna(row['表演者']) else ''
)
category = (
escape_html(row['节目类别']) if pd.notna(row['节目类别']) else ''
)
if not sn or not name:
continue
# 检查节目是否已存在
cursor.execute('SELECT sn, batch_id FROM programs WHERE sn = ?', (sn,))
exists = cursor.fetchone()
if exists:
# 如果节目已存在且不属于当前导入的批次,则报错,禁止跨批次移动
if exists['batch_id'] != batch_id:
raise HTTPException(
status_code=400,
detail=f'节目编号 {sn} 已存在于批次ID {exists["batch_id"]} 中,不能通过导入移动到当前批次。'
f'如需移动,请先删除原节目或使用编辑功能修改批次。',
)
# 同批次更新(保留原有 order_num)
cursor.execute(
"""UPDATE programs
SET name = ?, unit = ?, performer = ?, category = ?, batch_id = ?
WHERE sn = ?""",
(name, unit, performer, category, batch_id, sn),
)
updated_count += 1
else:
# 新增节目,分配新的 order_num
cursor.execute(
"""INSERT INTO programs
(sn, name, unit, performer, category, batch_id, order_num, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(
sn,
name,
unit,
performer,
category,
batch_id,
next_order,
datetime.now(),
),
)
next_order += 1
inserted_count += 1
# 为新节目初始化所有评委的评分记录(与原逻辑一致)
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'导入完成:新增 {inserted_count} 个节目,更新 {updated_count} 个节目',
}
except Exception as exc:
raise HTTPException(
status_code=400,
detail=exc.detail if isinstance(exc, HTTPException) else str(exc),
) from exc
@app.put('/api/programs/{program_sn}', dependencies=[Depends(require_admin)])
async def update_program(program_sn: str, program: ProgramUpdate):
with get_db(write=True) as conn:
cursor = conn.cursor()
# 获取当前节目的信息(特别是原 batch_id 和 order_num)
cursor.execute(
'SELECT batch_id, order_num FROM programs WHERE sn = ?', (program_sn,)
)
old = cursor.fetchone()
if not old:
raise HTTPException(status_code=404, detail='节目不存在')
new_batch_id = program.batch_id
# 如果批次发生了变更
if old['batch_id'] != new_batch_id:
# 获取目标批次的最大 order_num
cursor.execute(
'SELECT COALESCE(MAX(order_num), 0) FROM programs WHERE batch_id = ?',
(new_batch_id,),
)
max_order = cursor.fetchone()[0]
new_order_num = max_order + 1 # 新节目放到末尾
else:
# 批次未变,保留原 order_num
new_order_num = old['order_num']
# 更新节目信息,包括 batch_id 和 order_num
cursor.execute(
"""
UPDATE programs
SET name = ?, unit = ?, performer = ?, category = ?, batch_id = ?, order_num = ?
WHERE sn = ?
""",
(
program.name,
program.unit,
program.performer,
program.category,
new_batch_id,
new_order_num,
program_sn,
),
)
return {'success': True}
@app.delete('/api/programs/{program_sn}', dependencies=[Depends(require_admin)])
async def delete_program(program_sn: str):
with get_db(write=True) as conn:
cursor = conn.cursor()
cursor.execute('DELETE FROM programs WHERE sn = ?', (program_sn,))
if cursor.rowcount == 0:
raise HTTPException(status_code=404, detail='节目不存在')
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(write=True) 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(write=True) 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', dependencies=[Depends(require_admin)])
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之间')
cursor.execute('SELECT id FROM judges WHERE id = ?', (judge_id,))
if not cursor.fetchone():
raise HTTPException(status_code=404, detail=f'评委 {judge_id} 不存在')
with get_db(write=True) as conn:
cursor = conn.cursor()
for judge_id, total_score in batch_data.judge_scores.items():
if total_score is None:
cursor.execute(
"""
UPDATE scores SET admin_total_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, admin_total_score, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(judge_id, program_sn) DO UPDATE SET
admin_total_score = excluded.admin_total_score,
updated_at = excluded.updated_at
""",
(judge_id, batch_data.program_sn, total_score, datetime.now()),
)
return {'success': True}
@app.post('/api/scores/program/batch')
async def get_programs_scores(data: dict):
sn_list = data.get('sn_list', [])
if isinstance(sn_list, str):
sn_list = [s.strip() for s in sn_list.split(',') if s.strip()]
if not sn_list:
return {}
with get_db() as conn:
cursor = conn.cursor()
placeholders = ','.join(['?' for _ in sn_list])
cursor.execute(
f"""
SELECT
program_sn,
judge_id,
CASE
WHEN (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)
THEN COALESCE(admin_total_score,
COALESCE(content_score,0) + COALESCE(stage_score,0) +
COALESCE(art_score,0) + COALESCE(overall_score,0))
ELSE NULL
END AS final_total
FROM scores
WHERE program_sn IN ({placeholders})
""",
sn_list,
)
rows = cursor.fetchall()
result = {}
for row in rows:
prog_sn = row['program_sn']
if prog_sn not in result:
result[prog_sn] = {}
result[prog_sn][row['judge_id']] = row['final_total']
return result
# ==================== 汇总统计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()
if not programs:
return []
sn_list = [p['sn'] for p in programs]
placeholders = ','.join(['?' for _ in sn_list])
cursor.execute(
f"""
SELECT
t.program_sn,
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(DISTINCT CASE WHEN t.has_score THEN t.judge_id ELSE NULL END) AS scored_count
FROM (
SELECT
program_sn,
judge_id,
CASE
WHEN admin_total_score IS NOT NULL THEN admin_total_score
WHEN (content_score IS NOT NULL OR stage_score IS NOT NULL OR art_score IS NOT NULL OR overall_score IS NOT NULL)
THEN COALESCE(content_score,0) + COALESCE(stage_score,0) + COALESCE(art_score,0) + COALESCE(overall_score,0)
ELSE NULL
END 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
GROUP BY t.program_sn
""",
sn_list,
)
stats = {}
for row in cursor.fetchall():
stats[row['program_sn']] = {
'avg_score': row['avg_score'] if row['avg_score'] is not None else None,
'max_score': row['max_score'],
'min_score': row['min_score'],
'scored_count': row['scored_count'] or 0,
}
results = []
for prog in programs:
stat = stats.get(
prog['sn'],
{'avg_score': None, 'max_score': 0, 'min_score': 0, 'scored_count': 0},
)
results.append(
{
'sn': prog['sn'],
'name': prog['name'],
'unit': prog['unit'],
'performer': prog['performer'],
'category': prog['category'],
'avg_score': round(stat['avg_score'], 3)
if stat['avg_score'] is not None
else None,
'max_score': stat['max_score'],
'min_score': stat['min_score'],
'scored_count': stat['scored_count'],
}
)
results.sort(
key=lambda x: x['avg_score'] if x['avg_score'] else 0, reverse=True
)
for idx, r in enumerate(results):
r['rank'] = idx + 1
return results
@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 OR stage_score IS NOT NULL OR art_score IS NOT NULL OR overall_score IS NOT NULL OR admin_total_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,
admin_total_score, comment
FROM scores WHERE judge_id = ? AND program_sn = ?""",
(judge['id'], program_sn),
).fetchone()
total = None
has_score = False
comment = ''
if score:
# 优先使用管理员总分
if score['admin_total_score'] is not None:
total = score['admin_total_score']
has_score = True
else:
# 计算明细分之和
sum_score = 0
if score['content_score'] is not None:
sum_score += score['content_score']
has_score = True
if score['stage_score'] is not None:
sum_score += score['stage_score']
has_score = True
if score['art_score'] is not None:
sum_score += score['art_score']
has_score = True
if score['overall_score'] is not None:
sum_score += score['overall_score']
has_score = True
if has_score:
total = sum_score
comment = score['comment'] or ''
if has_score:
all_scores.append(total)
scores_data[judge['name']] = {
'total': total,
'comment': comment,
'has_score': has_score,
}
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)
)
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('/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'], {})
total_display = (
'-'
if not s.get('has_score')
else (s.get('total') if s.get('total') is not None else '-')
)
html += f"""<tr><td style="font-weight:bold;">{judge['name']}</td><td class="total">{total_display}</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>{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; }
.header, .tabs, .batch-bar, .program-list-sidebar, .score-panel, .summary-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: 15px; 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; }
.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; }
.functionBar { padding: 10px 20px; margin-bottom: 10px; border-radius: 20px; background: #f7fafc; }
.tabs { display: flex; gap: 10px; margin-bottom: 20px; flex-wrap: wrap; }
.tab { padding: 8px 20px; cursor: pointer; border-radius: 14px; background: white; border-left: 3px solid transparent; }
.tab.active { background: #e0e7ff; font-weight: bold; border-left-color: #667eea; }
.batch-bar { display: flex; gap: 10px; margin-bottom: 5px; align-items: center; flex-wrap: wrap; }
.batch-btn { padding: 6px 15px; border: none; border-radius: 12px; cursor: pointer; background: white; font-size: 13px; border-left: 3px solid transparent; }
.batch-btn.active { background: #e0e7ff; font-weight: bold; border-left-color: #667eea }
.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: 20px; 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; }
.admin-content { display: flex; column-gap: 10px; flex-wrap: nowrap; }
.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; }
.settings-panel { background: white; border-radius: 20px; padding: 20px; margin-bottom: 10px; }
.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; }
.program-list-item { transition: background-color 0.3s ease; }
.toast { position: fixed; top: 20px; left: 50%; transform: translateX(-50%); 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) {
.login-area .admin-input,
.login-area .judge-select,
.login-area .btn {
width: 100%;
margin-bottom: 8px;
}
.judge-scoring-container .program-list-sidebar {
width: 100%;
max-height: 200px;
overflow-y: auto;
margin-bottom: 16px;
}
.admin-scoring-container {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.admin-scoring-container .scoring-table {
min-width: max(100%, 600px);
}
.admin-content {
flex-wrap: wrap;
}
.scoring-table, .summary-table {
display: block;
overflow-x: auto;
white-space: nowrap;
}
}
</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="functionBar">
<div class="tabs">
<div id="tab-scoring" class="tab active" onclick="switchTab('scoring')">🎯 评分打分</div>
<div id="tab-admin" class="tab" style="display:none;" onclick="switchTab('admin')">⚙️ 数据管理</div>
<div id="tab-award" class="tab" style="display:none;" onclick="switchTab('award')">🏆 奖项设置</div>
<div id="tab-print" class="tab" onclick="switchTab('print')">🖨️ 打印中心</div>
</div>
<div class="batch-bar" id="setBatchBar"></div>
</div>
<!-- 评委评分页面 -->
<div id="judgeScoringTab" style="display:block;">
<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="admin-scoring-container" id="adminScoringContainer"></div>
</div>
<!-- 数据管理页面 -->
<div id="adminTabContent" style="display:none;">
<div class="admin-content">
<div class="settings-panel" style="flex: 1 0 auto;">
<h3>👥 评委管理 <button class="btn btn-primary btn-sm" onclick="showAddJudgeModal()" style="float:right">+ 添加评委</button></h3>
<div id="judgeManageList"></div>
</div>
<div class="settings-panel" style="flex: 1 0 auto;">
<h3>📦 批次管理 <button class="btn btn-primary btn-sm" onclick="showAddBatchModal()" style="float:right">+ 添加批次</button></h3>
<div id="batchList"></div>
</div>
</div>
<div class="settings-panel">
<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 id="awardTabContent" style="display:none;">
<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>
<!-- 打印中心页面 -->
<div id="printTabContent" style="display:none;">
<div class="settings-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;
let cachedAggregate = null, cachedAwardSettings = null;
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;
}
}
// 为所有模态框添加统一的键盘快捷键支持(Enter 保存,ESC 关闭)
function setupModalKeyboard() {
document.addEventListener('keydown', function(event) {
// 获取当前可见的模态框(display === 'flex')
const visibleModal = document.querySelector('.modal[style*="display: flex"]');
if (!visibleModal) return;
// 阻止事件冒泡,避免意外提交表单(如果有外层表单)
if (event.key === 'Enter' || event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
}
if (event.key === 'Enter') {
// 触发当前模态框内的保存按钮点击
const saveBtn = visibleModal.querySelector('.btn-primary'); // 保存按钮通常使用 .btn-primary
if (saveBtn && typeof saveBtn.onclick === 'function') {
saveBtn.click();
} else {
// 兼容:通过 id 或特定类查找保存按钮(各模态框实际保存按钮是 .btn-primary)
// 已有结构符合,无需额外处理
}
}
else if (event.key === 'Escape') {
// 触发当前模态框内的取消/关闭按钮点击
const cancelBtn = visibleModal.querySelector('.modal-buttons .btn-outline');
if (cancelBtn && typeof cancelBtn.onclick === 'function') {
cancelBtn.click();
}
}
});
}
async function restoreLogin() {
const stored = localStorage.getItem('score_user');
if (!stored) return false;
try {
const user = JSON.parse(stored);
if (user.type === 'judge') {
const checkRes = await fetch(`/api/judge/check?judge_id=${user.id}`);
if (!checkRes.ok) throw new Error('用户校验请求失败');
const checkData = await checkRes.json();
if (!checkData.exists) throw new Error('用户不存在');
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('tab-admin').style.display = 'none';
document.getElementById('tab-award').style.display = 'none';
document.getElementById('judgeScoringTab').style.display = 'block';
document.getElementById('adminScoringTab').style.display = 'none';
await loadJudgePrograms();
return true;
} else if (user.type === 'admin') {
const checkRes = await fetch(`/api/admin/check?admin_id=${user.id}`);
if (!checkRes.ok) throw new Error('用户校验请求失败');
const checkData = await checkRes.json();
if (!checkData.exists) throw new Error('用户不存在');
currentUser = { id: user.id, username: user.username };
userType = 'admin';
userToken = user.token;
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('tab-admin').style.display = 'block';
document.getElementById('tab-award').style.display = 'block';
document.getElementById('judgeScoringTab').style.display = 'none';
document.getElementById('adminScoringTab').style.display = 'block';
await loadAdminScoring();
return true;
}
} catch(e) {
console.warn(e);
localStorage.removeItem('score_user');
showToast('登录状态失效,请重新登录')
return false;
}
return false;
}
async function init() {
const loggedIn = await restoreLogin();
await loadTitles();
await loadBatches();
await loadJudges();
await loadPrograms();
await loadCriteria();
await loadAggregate();
await loadAwardNames();
await loadProgress();
refreshTimer = setInterval(() => { loadAggregate(); loadProgress(); }, 5000);
window.addEventListener('beforeunload', function() {
if (refreshTimer) clearInterval(refreshTimer);
});
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);
setupModalKeyboard();
}
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();
cachedAwardSettings = data;
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(cachedAggregate, cachedAwardSettings);
}
function renderBatchBars() {
const bars = ['setBatchBar'];
bars.forEach(barId => {
const bar = document.getElementById(barId);
if(bar){
bar.innerHTML = `<button class="batch-btn ${currentBatchId===null?'active':''}" 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 authFetch(`/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('tab-admin').style.display = 'none';
document.getElementById('tab-award').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,
token: data.token,
}));
userToken = data.token;
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('tab-admin').style.display = 'block';
document.getElementById('tab-award').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('tab-admin').style.display = 'none';
document.getElementById('tab-award').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();
if (programsData.length > 0) {
await selectJudgeProgram(programsData[0].sn);
}
}
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;">';
if (programsData.length === 0) {
html += '<li style="padding:10px; color:#999;">暂无节目,请联系管理员添加</li>';
} else {
programsData.forEach(prog => {
const isActive = currentJudgeProgram && currentJudgeProgram.sn === prog.sn;
html += `<li class="program-item ${isActive ? 'active' : ''}" onclick="selectJudgeProgram('${prog.sn}')">
<strong>${prog.name}</strong><br><span style="font-size:12px; color:#666;">${prog.sn} | ${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>${prog.name}</h3><p style="color:#666; margin-bottom:20px;">编号:${prog.sn} | 单位:${prog.unit} | 表演者:${prog.performer} | 类别:${prog.category}</p>
<div class="score-cards" 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;">${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) {
return;
}
let oldValue;
input.onfocus = () => {
oldValue = input.value;
};
input.onchange = () => {
let val = parseFloat(input.value);
if (isNaN(val)) val = null;
saveJudgeScore(field, val).then((success)=>{
if (success) {
oldValue = input.value;
} else {
input.value = oldValue;
}
})
};
});
}
async function saveJudgeScore(field, value) {
if(!currentUser || !currentJudgeProgram) return;
try{
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();
return true;
} else {
const err = await res.json();
showToast('保存失败:' + (err.detail || '未知错误'));
}
} catch(e) {
showToast('网络错误:' + e.message);
}
return false;
}
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;
}
const snList = programsData.map(p => p.sn);
const scoresMapRes = await fetch('/api/scores/program/batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sn_list: snList })
});
const scoresMap = await scoresMapRes.json(); // { program_sn: { judge_id: total } }
let html = '<table class="scoring-table"><thead><tr><th>节目编号</th><th>节目名称</th><th>表演者</th>';
judgesData.forEach(j => { html += `<th>${j.name}</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;">${prog.performer}`;
for(let judge of judgesData){
let score = '';
const progScores = scoresMap[prog.sn];
if(progScores && progScores[judge.id] !== undefined && progScores[judge.id] !== null)
score = progScores[judge.id];
let scoreValue = (score !== null && score !== undefined) ? score : '';
html += `<td><input type="number" class="score-input" id="score_${prog.sn}_${judge.id}" value="${scoreValue}" 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 += '</tbody></tr>';
document.getElementById('adminScoringContainer').innerHTML = html;
bindAdminScoreEvents(programsData, judgesData);
}
function bindAdminScoreEvents(programsData, judgesData) {
for(let prog of programsData){
for(let judge of judgesData){
const input = document.getElementById(`score_${prog.sn}_${judge.id}`);
if (!input){
continue;
}
let oldValue;
input.onfocus = () => {
oldValue = input.value;
}
input.onchange = () => {
if(!currentUser) return;
let numValue = input.value === '' ? null : parseFloat(input.value);
if(numValue !== null && (numValue < 0 || numValue > 100)){
showToast('总分必须在0-100之间');
input.value = oldValue;
return;
}
let scores = {};
scores[judge.id] = numValue;
saveAdminScore(prog.sn, scores).then((success)=>{
if(success){
oldValue = input.value;
} else {
input.value = oldValue;
}
})
}
}
}
}
async function saveAdminScore(programSn, scores) {
try {
const res = await authFetch('/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();
showToast('保存成功');
return true;
} else {
const err = await res.json();
showToast('保存失败:' + (err.detail || '未知错误'));
}
} catch(e) {
showToast('网络错误:' + e.message);
}
return false;
}
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();
cachedAggregate = data;
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;
let avgDisplay = item.avg_score === null ? '-' : `<strong>${item.avg_score}</strong>`;
row.insertCell(6).innerHTML = avgDisplay;
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(cachedAggregate, cachedAwardSettings);
}
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.getElementById('tab-scoring').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.getElementById('tab-admin').classList.add('active');
document.getElementById('adminTabContent').style.display='block';
loadBatches();
loadJudges();
loadPrograms();
}
else if(tab==='award'){
document.getElementById('tab-award').classList.add('active');
document.getElementById('awardTabContent').style.display='block';
updateAwardPreview(cachedAggregate, cachedAwardSettings);
}
else if(tab==='print'){
document.getElementById('tab-print').classList.add('active');
document.getElementById('printTabContent').style.display='block';
updateAwardPreview(cachedAggregate, cachedAwardSettings);
}
if (tab === 'print') {
document.body.classList.add('print-mode');
} else {
document.body.classList.remove('print-mode');
}
}
async function updateAwardPreview(aggregateData = null, awardSettingsData = null) {
let programs = aggregateData;
if (!programs) {
if (cachedAggregate) {
programs = cachedAggregate;
} else {
let url = '/api/aggregate';
if (currentBatchId) url += `?batch_id=${currentBatchId}`;
programs = await fetch(url).then(r => r.json());
}
}
let settings = awardSettingsData;
if (!settings) {
if (cachedAwardSettings) {
settings = cachedAwardSettings;
} else {
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 = '-';
let avgDisplay = p.avg_score === null ? '-' : p.avg_score;
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;">${avgDisplay}</td><td style="text-align:center;">${award}</td></tr>`;
});
html += '</tbody></table></div>';
const containers = ['awardPreviewPrint'];
containers.forEach(id => {
const el = document.getElementById(id);
if (el) el.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) {
// 组内按 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, 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>
</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 authFetch('/api/settings/awards', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ first, second, third })
});
cachedAwardSettings = null;
showToast('保存成功');
await loadAwardNames();
}
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 authFetch('/api/settings/award_names', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ first_name, second_name, third_name })
});
cachedAwardSettings = null;
showToast('保存成功');
await loadAwardNames();
}
function showAddBatchModal() { document.getElementById('batchModal').style.display='flex'; }
async function saveBatch() {
const name = document.getElementById('batchName').value.trim();
if(!name){ showToast('请输入批次名称'); return; }
await authFetch('/api/batches', {
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({name})
});
closeBatchModal();
await loadBatches();
}
async function deleteBatch(id) {
if(confirm('确定删除该批次吗?')) {
const res = await authFetch(`/api/batches/${id}`, {
method:'DELETE',
});
if (res.ok) {
currentBatchId = null;
renderBatchBars();
await loadBatches();
await loadPrograms();
await loadAggregate();
if (userType === 'judge') await loadJudgePrograms();
if (userType === 'admin') await loadAdminScoring();
showToast('批次已删除');
} else {
showToast('删除失败');
}
}
}
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));
}
function highlightProgramRow(sn) {
// 在节目管理列表中查找包含该节目编号的容器
const programItems = document.querySelectorAll('#programManageContainer .program-list-item');
for (let item of programItems) {
if (item.innerText.includes(sn)) {
// 添加高亮样式
item.style.transition = 'background-color 0.3s';
item.style.backgroundColor = '#fef5e7';
setTimeout(() => {
item.style.backgroundColor = '';
}, 2000);
// 可选:滚动到视图
item.scrollIntoView({ behavior: 'smooth', block: 'center' });
break;
}
}
}
async function swapAndSave(batchId, snA, snB, direction, movedSn) {
// direction: 'up' 或 'down', movedSn 是被移动的节目编号
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) {
// 重新加载所有依赖数据
await loadPrograms(); // 重新加载节目数据
if (userType === 'judge') await loadJudgePrograms();
if (userType === 'admin') await loadAdminScoring();
await loadAggregate();
renderProgramManageList(); // 刷新节目管理列表
// 计算被移动节目的新位置
const newList = getProgramsOfBatch(batchId);
const newIndex = newList.findIndex(p => p.sn === movedSn);
if (newIndex !== -1) {
const position = newIndex + 1; // 第几位(从1开始)
const directionText = direction === 'up' ? '上移' : '下移';
showToast(`${directionText}成功,当前排名:第 ${position} 位`);
// 高亮显示该节目行
highlightProgramRow(movedSn);
} else {
showToast('排序已保存');
}
} 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;
// 注意:snA 是当前节目,snB 是上一个节目,被移动的是 sn
swapAndSave(batchId, sn, list[idx-1].sn, 'up', 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, 'down', 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() {
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; }
if(!batch_id){ showToast('请先添加批次'); return; }
let res;
if(editingProgramSn){
res = await authFetch(`/api/programs/${editingProgramSn}`, {
method:'PUT',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({name,unit,performer,category,batch_id})
});
} else {
res = await authFetch('/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 authFetch(`/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;
if(!batch_id){ showToast('请先添加批次'); return; }
const formData = new FormData();
formData.append('file', file);
const res = await authFetch(`/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 authFetch(`/api/judges/${editingJudgeId}`, {
method:'PUT',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({name})
});
} else {
res = await authFetch('/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 authFetch(`/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 authFetch('/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 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>
"""