2453 lines
118 KiB
Python
2453 lines
118 KiB
Python
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>
|
||
"""
|