first jiwoos commit

This commit is contained in:
minjiu
2026-05-02 11:12:13 +09:00
commit 296adf3073
30 changed files with 9403 additions and 0 deletions

2
webapp/__init__.py Normal file
View File

@@ -0,0 +1,2 @@
"""Web application package for the baseball automation project."""

709
webapp/app.py Normal file
View File

@@ -0,0 +1,709 @@
from __future__ import annotations
import json
import os
import shutil
import re
import subprocess
import sys
import threading
import traceback
import uuid
from datetime import datetime
from pathlib import Path
from typing import Any
from flask import Flask, abort, jsonify, redirect, render_template, request, url_for
APP_DIR = Path(__file__).resolve().parent
ROOT_DIR = APP_DIR.parent
if str(ROOT_DIR) not in sys.path:
sys.path.insert(0, str(ROOT_DIR))
from register_game_playwright import normalize_team_name
JOBS_DIR = ROOT_DIR / "jobs"
LOGS_DIR = ROOT_DIR / "logs"
OUTPUT_DIR = ROOT_DIR / "output"
TEAM_OPTIONS = [
{"value": "HT", "label": "KIA"},
{"value": "KT", "label": "KT"},
{"value": "LG", "label": "LG"},
{"value": "NC", "label": "NC"},
{"value": "OB", "label": "두산"},
{"value": "LT", "label": "롯데"},
{"value": "SK", "label": "SSG"},
{"value": "SS", "label": "삼성"},
{"value": "WO", "label": "키움"},
{"value": "HH", "label": "한화"},
]
GAME_TYPE_OPTIONS = [
{"value": "regular", "label": "정규경기"},
{"value": "wildcard", "label": "와일드카드"},
{"value": "semi_playoff", "label": "준PO"},
{"value": "playoff", "label": "PO"},
{"value": "korean_series", "label": "한국시리즈"},
]
GAME_TYPE_CODE_MAP = {
"regular": None,
"wildcard": "4444",
"semi_playoff": "3333",
"playoff": "5555",
"korean_series": "7777",
}
JOBS_DIR.mkdir(exist_ok=True)
LOGS_DIR.mkdir(exist_ok=True)
def now_iso() -> str:
return datetime.now().isoformat(timespec="seconds")
def safe_name(value: str) -> str:
return "".join(char for char in value if char.isalnum() or char in {"-", "_"})
def job_path(job_id: str) -> Path:
return JOBS_DIR / f"{job_id}.json"
def log_path(job_id: str) -> Path:
return LOGS_DIR / f"{job_id}.log"
def load_job(job_id: str) -> dict[str, Any]:
path = job_path(job_id)
if not path.exists():
raise FileNotFoundError(job_id)
return json.loads(path.read_text(encoding="utf-8"))
def save_job(data: dict[str, Any]) -> None:
job_id = data["job_id"]
job_path(job_id).write_text(
json.dumps(data, ensure_ascii=False, indent=2),
encoding="utf-8",
)
def list_jobs(limit: int = 30) -> list[dict[str, Any]]:
jobs: list[dict[str, Any]] = []
for path in sorted(JOBS_DIR.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True):
try:
jobs.append(json.loads(path.read_text(encoding="utf-8")))
except Exception:
continue
if len(jobs) >= limit:
break
return jobs
def tail_log_preview(job_id: str) -> str:
path = log_path(job_id)
if not path.exists():
return "로그 없음"
try:
lines = [line.strip() for line in path.read_text(encoding="utf-8").splitlines() if line.strip()]
except Exception:
return "로그 읽기 실패"
if not lines:
return "로그 없음"
for line in reversed(lines):
if line.startswith("[") and ("START " in line or "END " in line):
continue
return line[:96]
return lines[-1][:96]
def enrich_job(job: dict[str, Any]) -> dict[str, Any]:
enriched = dict(job)
enriched["log_preview"] = tail_log_preview(job["job_id"])
return enriched
def get_active_job() -> dict[str, Any] | None:
for job in list_jobs(limit=200):
if job.get("status") in {"queued", "running"}:
return enrich_job(job)
return None
def has_active_job() -> bool:
return get_active_job() is not None
def list_recent_reports(limit: int = 12) -> list[dict[str, Any]]:
reports: list[dict[str, Any]] = []
for path in sorted(OUTPUT_DIR.glob("*_report.json"), key=lambda p: p.stat().st_mtime, reverse=True):
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except Exception:
continue
game_info = payload.get("game_info") or {}
reports.append(
{
"game_id": payload.get("game_id") or path.name.replace("_report.json", ""),
"date": game_info.get("date") or "-",
"home_team": game_info.get("home_team") or "-",
"away_team": game_info.get("away_team") or "-",
"start_time": game_info.get("start_time") or "-",
"path": str(path),
}
)
if len(reports) >= limit:
break
return reports
def resolve_report_path(game_id: str, report_path: str | None) -> Path:
if report_path:
return Path(report_path)
return OUTPUT_DIR / f"{game_id}_report.json"
def requires_report(job_type: str) -> bool:
return job_type == "register_basic"
def validate_job_request(job_type: str, game_id: str, manager_mode: str, manager_game_no: str, report_path: str, inning_no: str) -> str | None:
if not game_id:
return "경기 식별 정보가 올바르지 않습니다. game_id 또는 날짜/팀 조합을 확인하세요."
if manager_mode == "manual" and job_type in {"lineup", "record", "finish", "post_update"} and not manager_game_no:
return "관리자 게임번호 직접 입력을 선택했다면 게임번호를 입력하세요."
def get_inn_val(s: str) -> float | None:
if not s: return None
m = re.match(r"^(\d+)([TB]?)$", s.upper().strip())
if not m: return None
num = int(m.group(1))
half = 0.5 if m.group(2) == "B" else 0.0
return num + half
if job_type == "record":
if not inning_no:
return "경기기록 입력은 이닝을 선택해야 합니다."
if inning_no != "all":
if "-" in inning_no:
start_str, end_str = inning_no.split("-", 1)
s_val = get_inn_val(start_str)
e_val = get_inn_val(end_str)
if s_val is None or e_val is None:
return f"이닝 범위 형식이 올바르지 않습니다 (예: 3B-9B). 입력값: {inning_no}"
if not (1.0 <= s_val <= e_val <= 15.5):
return "이닝 범위가 올바르지 않습니다 (1회 초 ~ 15회 말)."
else:
val = get_inn_val(inning_no)
if val is None:
return f"이닝 형식이 올바르지 않습니다 (예: 5T). 입력값: {inning_no}"
if not (1.0 <= val <= 15.5):
return "이닝은 1회부터 15회까지만 선택할 수 있습니다."
if requires_report(job_type):
resolved = resolve_report_path(game_id, report_path)
# 자동화 작업들은 첫 단계에서 리포트를 생성하므로 사전 파일 체크를 하지 않음
# "수동 모드"나 특수한 경우가 아니면 거의 모든 자동화 버튼을 허용
automated_jobs = {"lineup", "record", "register_basic", "post_update", "finish", "compare", "video_review"}
if job_type not in automated_jobs and not resolved.exists():
return f"리포트 파일이 없습니다: {resolved}"
return None
def normalize_team_key(value: str) -> str:
raw = (value or "").strip()
if not raw:
return ""
return raw.casefold()
def team_label_from_code(team_code: str) -> str:
for option in TEAM_OPTIONS:
if option["value"] == team_code:
return option["label"]
return team_code
def team_alias_keys(value: str) -> set[str]:
raw = (value or "").strip()
if not raw:
return set()
normalized = normalize_team_name(raw)
return {raw.casefold(), normalized.casefold()}
def compose_game_id_from_form(form) -> str:
def clean(v: str) -> str:
return "".join((v or "").split())
game_type = clean(form.get("game_type") or "regular")
game_date = clean(form.get("game_date"))
away_code = clean(form.get("away_team_code")).upper()
home_code = clean(form.get("home_team_code")).upper()
dh_raw = clean(form.get("doubleheader_no") or "0")
if not game_date or not away_code or not home_code:
return ""
pure_date = game_date.replace("-", "")
year = pure_date[:4]
month = pure_date[4:6]
day = pure_date[6:8]
type_code = GAME_TYPE_CODE_MAP.get(game_type)
if not type_code or game_type == "regular":
type_code = year
dh = "0"
if dh_raw.isdigit():
dh = str(max(0, min(9, int(dh_raw))))
# 네이버 표준 ID: [연도/타입][월][일][원정][홈][더블헤더][연도]
# 예: 2026 04 14 LT LG 0 2026
return f"{type_code}{month}{day}{away_code}{home_code}{dh}{year}"
def resolve_game_id_from_form(form) -> str:
mode = (form.get("game_id_mode") or "direct").strip()
raw_id = ""
if mode == "direct":
raw_id = (form.get("game_id") or "").strip()
else:
raw_id = compose_game_id_from_form(form)
# 영문/숫자만 남기고 한글 및 특수문자 제거
return "".join(re.findall(r"[A-Za-z0-9]", raw_id))
def build_generated_report_paths(game_id: str, job_type: str, inning_no: str | None = None) -> tuple[Path, Path]:
if job_type == "lineup":
stem = f"{game_id}_lineup_report"
elif job_type == "record" and inning_no and inning_no != "all":
# 범위를 파일명에 표시 (예: 5-9 -> inning_5-9)
safe_inning = safe_name(inning_no)
stem = f"{game_id}_inning_{safe_inning}_report"
elif job_type == "record":
stem = f"{game_id}_full_record_report"
elif job_type == "finish":
stem = f"{game_id}_finish_report"
elif job_type == "post_update":
stem = f"{game_id}_post_report"
elif job_type == "compare":
stem = f"{game_id}_compare_report"
elif job_type == "video_review":
stem = f"{game_id}_review_report"
else:
stem = f"{game_id}_report"
return OUTPUT_DIR / f"{stem}.json", OUTPUT_DIR / f"{stem}.txt"
def build_command(job_type: str, game_id: str, manager_game_no: str | None, report_path: str | None, inning_no: str | None = None) -> list[str]:
python_exe = sys.executable
cmd = [python_exe]
if job_type == "register_basic":
cmd.extend([str(ROOT_DIR / "register_game_basic_playwright.py"), "--game-id", game_id, "--close"])
elif job_type == "lineup":
cmd.extend([str(ROOT_DIR / "lineup_only_playwright.py"), "--game-id", game_id, "--close"])
if manager_game_no:
cmd.extend(["--manager-game-no", manager_game_no])
elif job_type == "record":
cmd.extend([str(ROOT_DIR / "record_game_playwright.py"), "--game-id", game_id, "--close"])
if manager_game_no:
cmd.extend(["--manager-game-no", manager_game_no])
elif job_type == "finish":
cmd.extend([str(ROOT_DIR / "finish_game_playwright.py"), "--game-id", game_id, "--close"])
if manager_game_no:
cmd.extend(["--manager-game-no", manager_game_no])
elif job_type == "post_update":
cmd.extend([str(ROOT_DIR / "update_game_post_playwright.py"), "--game-id", game_id, "--close"])
if manager_game_no:
cmd.extend(["--manager-game-no", manager_game_no])
elif job_type == "compare":
cmd.extend([str(ROOT_DIR / "compare_history_with_report.py"), "--game-id", game_id])
elif job_type == "video_review":
cmd.extend([str(ROOT_DIR / "video_review_playwright.py"), "--game-id", game_id, "--review-only", "--close"])
else:
raise ValueError(f"지원하지 않는 작업 타입입니다: {job_type}")
if report_path:
cmd.extend(["--report-path", report_path])
return cmd
def build_job_steps(job: dict[str, Any]) -> list[list[str]]:
job_type = job["type"]
game_id = job["game_id"]
manager_game_no = job.get("manager_game_no") or None
report_path = job.get("report_path") or None
inning_no = job.get("inning_no") or None
python_exe = sys.executable
if job_type == "video_review":
return [
[
python_exe,
str(ROOT_DIR / "game_report.py"),
"--game-id",
game_id,
"--output-json",
report_path,
],
build_command(job_type, game_id, manager_game_no, report_path, inning_no),
]
if job_type == "register_basic":
return [
[
python_exe,
str(ROOT_DIR / "game_report.py"),
"--game-id",
game_id,
"--output-json",
report_path,
],
build_command(job_type, game_id, manager_game_no, report_path, inning_no),
]
if job_type == "lineup":
return [
[
python_exe,
str(ROOT_DIR / "game_report.py"),
"--game-id",
game_id,
"--lineup-only",
"--output-json",
report_path,
],
build_command(job_type, game_id, manager_game_no, report_path, inning_no),
]
if job_type == "record":
report_command = [
python_exe,
str(ROOT_DIR / "game_report.py"),
"--game-id",
game_id,
"--output-json",
report_path,
]
if inning_no and inning_no != "all":
if "-" in inning_no:
try:
# 1T-9B 같은 형식 처리
start_val, end_val = inning_no.split("-", 1)
report_command.extend(["--start-inning", start_val, "--end-inning", end_val])
except ValueError:
pass
else:
report_command.extend(["--inning", str(inning_no)])
return [
report_command,
build_command(job_type, game_id, manager_game_no, report_path, inning_no),
]
if job_type in {"finish", "post_update"}:
return [
[
python_exe,
str(ROOT_DIR / "game_report.py"),
"--game-id",
game_id,
"--output-json",
report_path,
],
build_command(job_type, game_id, manager_game_no, report_path, inning_no),
]
if job_type == "compare":
return [
[
python_exe,
str(ROOT_DIR / "game_report.py"),
"--game-id",
game_id,
"--output-json",
report_path,
"--output-txt",
str(Path(report_path).with_suffix(".txt")),
],
build_command(job_type, game_id, manager_game_no, report_path, inning_no),
]
return [build_command(job_type, game_id, manager_game_no, report_path, inning_no)]
def run_job(job_id: str) -> None:
job = load_job(job_id)
job["status"] = "running"
job["started_at"] = now_iso()
save_job(job)
env = os.environ.copy()
env["PYTHONIOENCODING"] = "utf-8"
env["JOB_ID"] = job_id
log_file = log_path(job_id)
with log_file.open("a", encoding="utf-8") as log:
steps = build_job_steps(job)
log.write(f"[{now_iso()}] START steps={len(steps)}\n")
log.flush()
try:
return_code = 0
for step_index, command in enumerate(steps, start=1):
log.write(f"[{now_iso()}] STEP {step_index}/{len(steps)} {' '.join(command)}\n")
log.flush()
process = subprocess.Popen(
command,
cwd=str(ROOT_DIR),
stdout=log,
stderr=log,
text=True,
env=env,
)
return_code = process.wait()
if return_code != 0:
break
job = load_job(job_id)
job["finished_at"] = now_iso()
job["return_code"] = return_code
if return_code == 0:
job["status"] = "success"
job["error"] = None
else:
job["status"] = "failed"
job["error"] = f"프로세스 종료 코드: {return_code}"
save_job(job)
log.write(f"[{now_iso()}] END return_code={return_code}\n")
except Exception as exc:
job = load_job(job_id)
job["status"] = "failed"
job["finished_at"] = now_iso()
job["error"] = str(exc)
save_job(job)
log.write(f"[{now_iso()}] EXCEPTION {exc}\n")
log.write(traceback.format_exc())
def create_job(job_type: str, game_id: str, manager_game_no: str | None, report_path: str | None, inning_no: str | None = None) -> dict[str, Any]:
normalized_game_id = safe_name(game_id) or "unknown"
job_id = f"{normalized_game_id}-{job_type}-{uuid.uuid4().hex[:8]}"
if report_path:
report_path_value = report_path
elif job_type in {"lineup", "record", "compare", "video_review"}:
report_path_value = str(build_generated_report_paths(game_id, job_type, inning_no)[0])
else:
report_path_value = str(OUTPUT_DIR / f"{game_id}_report.json")
try:
from db_logging import start_job as db_start_job
db_start_job(job_id=job_id, game_id=game_id, start_inning=inning_no or "")
except Exception as e:
print(f"DB logging init error: {e}")
job = {
"job_id": job_id,
"type": job_type,
"game_id": game_id,
"manager_game_no": manager_game_no or "",
"report_path": report_path_value,
"inning_no": inning_no or "",
"status": "queued",
"created_at": now_iso(),
"started_at": None,
"finished_at": None,
"log_file": str(log_path(job_id)),
"error": None,
"return_code": None,
}
save_job(job)
thread = threading.Thread(target=run_job, args=(job_id,), daemon=True)
thread.start()
return job
app = Flask(__name__, template_folder="templates", static_folder="static")
@app.get("/")
def index():
jobs = [enrich_job(job) for job in list_jobs()]
active_job = get_active_job()
recent_reports = list_recent_reports()
error = request.args.get("error", "").strip()
message = request.args.get("message", "").strip()
def split_inning(val: str):
if not val: return "1", "T"
m = re.match(r"^(\d+)([TB])$", val.upper())
if m: return m.group(1), m.group(2)
return val, "T"
start_num, start_half = split_inning(request.args.get("start_inning") or "1T")
end_num, end_half = split_inning(request.args.get("end_inning") or "9B")
defaults = {
"game_id_mode": (request.args.get("game_id_mode") or "parse").strip(),
"game_id": (request.args.get("game_id") or "").strip(),
"game_type": (request.args.get("game_type") or "regular").strip(),
"game_date": (request.args.get("game_date") or "").strip(),
"home_team_code": (request.args.get("home_team_code") or "").strip(),
"away_team_code": (request.args.get("away_team_code") or "").strip(),
"doubleheader_no": (request.args.get("doubleheader_no") or "0").strip(),
"manager_mode": (request.args.get("manager_mode") or "auto").strip(),
"manager_game_no": (request.args.get("manager_game_no") or "").strip(),
"report_path": (request.args.get("report_path") or "").strip(),
"inning_no": (request.args.get("inning_no") or "1T-9B").strip(),
"start_inning_num": start_num,
"start_inning_half": start_half,
"end_inning_num": end_num,
"end_inning_half": end_half,
}
return render_template(
"index.html",
jobs=jobs,
defaults=defaults,
error=error,
message=message,
active_job=active_job,
recent_reports=recent_reports,
team_options=TEAM_OPTIONS,
game_type_options=GAME_TYPE_OPTIONS,
)
@app.post("/jobs/<job_type>")
def start_job(job_type: str):
if job_type not in {"register_basic", "lineup", "record", "finish", "post_update", "compare", "video_review"}:
abort(404)
game_id = resolve_game_id_from_form(request.form)
manager_mode = (request.form.get("manager_mode") or "auto").strip()
manager_game_no = (request.form.get("manager_game_no") or "").strip() if manager_mode == "manual" else ""
report_path = (request.form.get("report_path") or "").strip()
inning_no = (request.form.get("inning_no") or "").strip()
redirect_params = {
"game_id_mode": (request.form.get("game_id_mode") or "direct").strip(),
"game_id": (request.form.get("game_id") or "").strip(),
"game_type": (request.form.get("game_type") or "regular").strip(),
"game_date": (request.form.get("game_date") or "").strip(),
"home_team_code": (request.form.get("home_team_code") or "").strip(),
"away_team_code": (request.form.get("away_team_code") or "").strip(),
"doubleheader_no": (request.form.get("doubleheader_no") or "0").strip(),
"manager_mode": manager_mode,
"manager_game_no": manager_game_no,
"report_path": report_path,
"inning_no": inning_no,
"start_inning": (request.form.get("start_inning") or "1").strip(),
"end_inning": (request.form.get("end_inning") or "9").strip(),
}
validation_error = validate_job_request(job_type, game_id, manager_mode, manager_game_no, report_path, inning_no)
if validation_error:
return redirect(url_for("index", error=validation_error, **redirect_params))
if has_active_job():
return redirect(url_for("index", error="실행 중인 작업이 있습니다. 완료 후 다시 시도하세요.", **redirect_params))
create_job(
job_type=job_type,
game_id=game_id,
manager_game_no=None if job_type == "register_basic" else (manager_game_no or None),
report_path=report_path or None,
inning_no=inning_no or None,
)
return redirect(url_for("index", **redirect_params))
def clear_files(paths: list[Path]) -> int:
deleted = 0
for path in paths:
if path.exists() and path.is_file():
path.unlink()
deleted += 1
return deleted
@app.post("/maintenance/clear-logs")
def clear_logs():
deleted = clear_files(list(LOGS_DIR.glob("*.log")))
return redirect(url_for("index", message=f"로그 {deleted}개를 삭제했습니다."))
@app.post("/maintenance/clear-jobs")
def clear_jobs():
deleted = clear_files(list(JOBS_DIR.glob("*.json")))
return redirect(url_for("index", message=f"작업 상태 {deleted}개를 삭제했습니다."))
@app.post("/maintenance/clear-reports")
def clear_reports():
targets = list(OUTPUT_DIR.glob("*_report.json")) + list(OUTPUT_DIR.glob("*_report.txt"))
deleted = clear_files(targets)
return redirect(url_for("index", message=f"리포트 {deleted}개를 삭제했습니다."))
@app.get("/db-logs/<job_id>")
def view_db_logs(job_id: str):
try:
from db_logging import get_combined_logs
logs = get_combined_logs(job_id)
except Exception as e:
return f"DB 로드 실패: {e}", 500
return render_template("logs.html", job_id=job_id, logs=logs)
@app.post("/maintenance/clear-runtime-profiles")
def clear_runtime_profiles():
runtime_dirs = [path for path in ROOT_DIR.glob("playwright-user-data-runtime-*") if path.is_dir()]
deleted = 0
for path in runtime_dirs:
try:
shutil.rmtree(path, ignore_errors=False)
deleted += 1
except Exception:
continue
return redirect(url_for("index", message=f"런타임 프로필 {deleted}개를 삭제했습니다."))
@app.get("/api/dashboard")
def dashboard_api():
jobs = [enrich_job(job) for job in list_jobs()]
active_job = get_active_job()
recent_reports = list_recent_reports()
return jsonify({
"jobs": jobs,
"active_job": active_job,
"recent_reports": recent_reports
})
@app.get("/jobs")
def jobs_api():
return jsonify(list_jobs())
@app.get("/jobs/<job_id>")
def job_detail(job_id: str):
try:
job = load_job(job_id)
except FileNotFoundError:
abort(404)
return render_template("job.html", job=job)
@app.get("/jobs/<job_id>/status")
def job_status(job_id: str):
try:
return jsonify(load_job(job_id))
except FileNotFoundError:
abort(404)
@app.get("/jobs/<job_id>/log")
def job_log(job_id: str):
path = log_path(job_id)
if not path.exists():
abort(404)
return path.read_text(encoding="utf-8"), 200, {"Content-Type": "text/plain; charset=utf-8"}
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000, debug=False, use_reloader=False)

419
webapp/static/style.css Normal file
View File

@@ -0,0 +1,419 @@
:root {
--bg: #f4f7f3;
--panel: #ffffff;
--ink: #1d2421;
--muted: #66736d;
--line: #dbe4dc;
--primary: #1f6b53;
--primary-soft: rgba(31, 107, 83, 0.12);
--danger: #b34131;
--warn: #9d6a12;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: "Pretendard", "Apple SD Gothic Neo", "Malgun Gothic", sans-serif;
color: var(--ink);
background:
radial-gradient(circle at top right, rgba(31, 107, 83, 0.06), transparent 20%),
linear-gradient(180deg, #f9fbf8 0%, var(--bg) 100%);
}
.page {
width: min(980px, calc(100% - 24px));
margin: 24px auto 40px;
display: grid;
gap: 16px;
}
.panel {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 20px;
padding: 20px;
box-shadow: 0 10px 28px rgba(27, 33, 29, 0.06);
}
.hero {
display: grid;
gap: 16px;
background: linear-gradient(135deg, rgba(31, 107, 83, 0.08), rgba(235, 242, 237, 0.9));
}
.hero h1,
.section-head h2,
.section-head h1 {
margin: 0 0 8px;
}
.hero p,
.helper,
.empty {
margin: 0;
color: var(--muted);
}
.preview-box {
display: grid;
gap: 4px;
padding: 14px 16px;
border-radius: 16px;
background: #fff;
border: 1px solid var(--line);
}
.preview-label {
font-size: 13px;
color: var(--muted);
}
#game-id-preview {
font-family: Consolas, "SFMono-Regular", monospace;
font-size: 20px;
color: var(--primary);
}
.notice {
padding: 14px 16px;
border-radius: 16px;
font-weight: 600;
}
.notice.error {
background: #fff3f1;
color: var(--danger);
border: 1px solid #efc6c0;
}
.notice.success {
background: #f0faf4;
color: var(--primary);
border: 1px solid #c6e1d1;
}
.notice.warn {
background: #fff8e7;
color: var(--warn);
border: 1px solid #ecd6a7;
}
.job-form {
display: grid;
gap: 16px;
}
.grid-two,
.grid-parse {
display: grid;
gap: 16px;
}
.grid-two {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.grid-parse {
grid-template-columns: minmax(0, 1fr) 180px;
}
label {
display: grid;
gap: 6px;
}
label.wide {
grid-column: 1 / -1;
}
label span {
font-size: 14px;
color: var(--muted);
}
input,
select {
width: 100%;
border: 1px solid #c9d4cb;
border-radius: 12px;
padding: 12px 14px;
font-size: 15px;
background: #fff;
color: var(--ink);
}
input:focus,
select:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 4px var(--primary-soft);
}
.flatpickr-input[readonly] {
background: #fff;
cursor: pointer;
}
.flatpickr-calendar {
border: 1px solid var(--line);
border-radius: 18px;
box-shadow: 0 18px 40px rgba(32, 37, 35, 0.14);
font-family: "Pretendard", "Apple SD Gothic Neo", "Malgun Gothic", sans-serif;
}
.flatpickr-months {
background: linear-gradient(135deg, rgba(31, 107, 83, 0.10), rgba(255,255,255,0.95));
border-radius: 18px 18px 0 0;
}
.flatpickr-weekdays {
background: #f7faf5;
}
.flatpickr-day.selected,
.flatpickr-day.startRange,
.flatpickr-day.endRange {
background: var(--primary);
border-color: var(--primary);
}
.flatpickr-day.today {
border-color: #cb7f1a;
}
.section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}
.section-head.compact {
margin-bottom: 0;
}
.action-strip {
display: grid;
gap: 14px;
}
.inline-field {
display: grid;
gap: 6px;
max-width: 220px;
}
.inline-field label {
font-size: 14px;
color: var(--muted);
}
.button-row {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.btn,
button.btn {
border: 1px solid #d7e0d8;
border-radius: 999px;
padding: 12px 16px;
font-size: 14px;
font-weight: 700;
cursor: pointer;
background: #eff4ef;
color: var(--ink);
text-decoration: none;
}
.btn.primary {
background: var(--primary);
border-color: var(--primary);
color: #fff;
}
.btn.danger {
background: #f8e8e5;
border-color: #efc6c0;
color: var(--danger);
}
.btn.warning {
background: #fff3e0;
border-color: #ffcc80;
color: #e65100;
}
.job-list {
display: grid;
gap: 12px;
}
.job-card {
border: 1px solid var(--line);
border-radius: 14px;
padding: 14px;
background: #fff;
min-width: 0;
}
.job-top {
display: flex;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
}
.status {
text-transform: uppercase;
font-size: 12px;
letter-spacing: 0.04em;
color: var(--muted);
}
.status-success {
border-left: 6px solid var(--primary);
}
.status-running {
border-left: 6px solid #c9831e;
}
.status-failed {
border-left: 6px solid var(--danger);
}
.status-queued {
border-left: 6px solid #7e8b83;
}
.job-body {
display: grid;
gap: 4px;
color: var(--muted);
font-size: 14px;
}
.job-actions {
display: flex;
gap: 12px;
margin-top: 10px;
flex-wrap: wrap;
min-width: 0;
}
.text-link {
color: var(--primary);
text-decoration: none;
font-size: 14px;
}
.log-preview-link {
display: block;
width: 100%;
color: var(--ink);
font-weight: 600;
text-decoration: none;
overflow-wrap: anywhere;
word-break: break-word;
line-height: 1.4;
}
.log-preview-link:hover,
.text-link:hover {
color: var(--primary);
text-decoration: underline;
}
@media (max-width: 720px) {
.page {
width: min(100% - 16px, 100%);
margin: 16px auto 28px;
}
.panel {
padding: 16px;
border-radius: 16px;
}
.grid-two,
.grid-parse {
grid-template-columns: 1fr;
}
.button-row {
display: grid;
grid-template-columns: 1fr;
}
.section-head {
flex-direction: column;
align-items: flex-start;
}
}
/* Inning Range Selection Styles */
.inning-range-group {
display: flex !important;
flex-direction: row;
align-items: flex-end;
gap: 16px;
max-width: none !important;
width: 100%;
margin-bottom: 8px;
}
.inning-select-item {
display: grid;
gap: 6px;
flex: 0 0 160px;
}
.inning-split {
display: flex;
gap: 4px;
}
.inning-split select {
padding: 10px 8px;
}
.inning-split select:first-child {
flex: 1;
}
.inning-split select:last-child {
flex: 0 0 62px;
}
.inning-select-item.checkbox-item {
/* ... */
display: flex;
align-items: center;
height: 46px;
flex: 0 0 auto;
}
.inning-select-item.checkbox-item label {
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
cursor: pointer;
font-size: 15px;
color: var(--ink);
margin: 0;
padding-bottom: 4px;
}
.inning-select-item.checkbox-item input[type="checkbox"] {
width: 18px;
height: 18px;
margin: 0;
cursor: pointer;
}

422
webapp/templates/index.html Normal file
View File

@@ -0,0 +1,422 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>야구 자동화</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css">
</head>
<body>
<main class="page">
<section class="panel hero">
<div class="hero-copy">
<h1>야구 자동화</h1>
<p>경기 ID를 직접 넣거나 날짜와 팀을 고른 뒤, 라인업 또는 특정 이닝만 바로 입력합니다.</p>
</div>
<div class="preview-box">
<span class="preview-label">현재 저장 대상 경기 ID</span>
<strong id="game-id-preview">{{ defaults.game_id or '-' }}</strong>
</div>
</section>
{% if error %}
<section class="panel notice error">{{ error }}</section>
{% endif %}
<section class="panel">
<form class="job-form" method="post" id="main-job-form" action="{{ url_for('start_job', job_type='lineup') }}">
<div class="section-head compact">
<h2>경기 선택</h2>
<span class="helper">라인업은 자동으로 라인업 전용 리포트를 만들고, 경기기록은 선택한 이닝만 담아 실행합니다.</span>
</div>
<div class="grid-two">
<label>
<span>경기 ID 입력 방식</span>
<select name="game_id_mode" id="game_id_mode">
<option value="direct" {% if defaults.game_id_mode == 'direct' %}selected{% endif %}>직접 입력</option>
<option value="composed" {% if defaults.game_id_mode == 'composed' %}selected{% endif %}>날짜/팀 조합</option>
<option value="parse" {% if defaults.game_id_mode == 'parse' %}selected{% endif %}>텍스트 붙여넣기</option>
</select>
</label>
<label>
<span>관리자 게임번호</span>
<select name="manager_mode" id="manager_mode">
<option value="auto" {% if defaults.manager_mode == 'auto' %}selected{% endif %}>자동 찾기</option>
<option value="manual" {% if defaults.manager_mode == 'manual' %}selected{% endif %}>직접 입력</option>
</select>
</label>
</div>
<label data-mode="direct">
<span>경기 ID</span>
<input name="game_id" placeholder="예: 20260404LGWO02026" value="{{ defaults.game_id }}">
</label>
<div class="grid-two" data-mode="composed">
<label>
<span>경기 구분</span>
<select name="game_type" id="game_type">
{% for option in game_type_options %}
<option value="{{ option.value }}" {% if defaults.game_type == option.value %}selected{% endif %}>{{ option.label }}</option>
{% endfor %}
</select>
</label>
<label>
<span>경기 날짜</span>
<input class="date-input" type="text" name="game_date" id="game_date" placeholder="날짜 선택" value="{{ defaults.game_date }}">
</label>
</div>
<div class="grid-two" data-mode="composed">
<label>
<span>어웨이팀</span>
<select name="away_team_code" id="away_team_code">
<option value="">선택</option>
{% for team in team_options %}
<option value="{{ team.value }}" {% if defaults.away_team_code == team.value %}selected{% endif %}>{{ team.label }}</option>
{% endfor %}
</select>
</label>
<label>
<span>홈팀</span>
<select name="home_team_code" id="home_team_code">
<option value="">선택</option>
{% for team in team_options %}
<option value="{{ team.value }}" {% if defaults.home_team_code == team.value %}selected{% endif %}>{{ team.label }}</option>
{% endfor %}
</select>
</label>
</div>
<label data-mode="composed">
<span>더블헤더 순번</span>
<select name="doubleheader_no" id="doubleheader_no">
<option value="0" {% if defaults.doubleheader_no == '0' %}selected{% endif %}>0: 더블헤더 아님</option>
<option value="1" {% if defaults.doubleheader_no == '1' %}selected{% endif %}>1: 더블헤더 1차전</option>
<option value="2" {% if defaults.doubleheader_no == '2' %}selected{% endif %}>2: 더블헤더 2차전</option>
</select>
</label>
<div class="grid-parse" data-mode="parse">
<label class="wide">
<span>기록 사이트 텍스트</span>
<input type="text" id="parse_text" placeholder="예: 11115 2026-04-11 정규경기 대전 한화 KIA" autocomplete="off">
</label>
<label>
<span>더블헤더</span>
<select name="doubleheader_no_parse" id="doubleheader_no_parse">
<option value="0">일반</option>
<option value="1">DH 1차전</option>
<option value="2">DH 2차전</option>
</select>
</label>
</div>
<label data-manager-mode="manual">
<span>관리자 게임번호</span>
<input name="manager_game_no" placeholder="예: 11080" value="{{ defaults.manager_game_no }}">
</label>
<div class="action-strip">
<div class="inline-field inning-range-group">
<div class="inning-select-item">
<label>시작</label>
<div class="inning-split">
<select id="start_inning_num">
{% for i in range(1, 13) %}
<option value="{{ i }}" {% if defaults.start_inning_num == i|string %}selected{% endif %}>{{ i }}회</option>
{% endfor %}
</select>
<select id="start_inning_half">
<option value="T" {% if defaults.start_inning_half == 'T' %}selected{% endif %}></option>
<option value="B" {% if defaults.start_inning_half == 'B' %}selected{% endif %}></option>
</select>
</div>
</div>
<div class="inning-select-item">
<label>종료</label>
<div class="inning-split">
<select id="end_inning_num">
{% for i in range(1, 13) %}
<option value="{{ i }}" {% if defaults.end_inning_num == i|string %}selected{% endif %}>{{ i }}회</option>
{% endfor %}
</select>
<select id="end_inning_half">
<option value="T" {% if defaults.end_inning_half == 'T' %}selected{% endif %}></option>
<option value="B" {% if defaults.end_inning_half == 'B' %}selected{% endif %}></option>
</select>
</div>
</div>
<div class="inning-select-item checkbox-item">
<label>
<input type="checkbox" id="all_innings_check"> 전체
</label>
</div>
</div>
<input type="hidden" name="inning_no" id="inning_no" value="{{ defaults.inning_no }}">
<div class="button-row">
<button class="btn primary" type="submit" formaction="{{ url_for('start_job', job_type='lineup') }}" title="선택한 경기의 라인업 전용 리포트를 자동 생성한 뒤 라인업만 입력합니다.">라인업 입력</button>
<button class="btn danger" type="submit" formaction="{{ url_for('start_job', job_type='record') }}" title="선택한 이닝만 담긴 리포트를 자동 생성한 뒤 그 이닝의 경기기록만 입력합니다.">경기기록 입력</button>
<button class="btn warning" type="submit" formaction="{{ url_for('start_job', job_type='video_review') }}" title="경기의 모든 합의판정(비디오 판독) 내역을 모아서 한꺼번에 등록합니다.">합의판정 등록</button>
<button class="btn" type="submit" formaction="{{ url_for('start_job', job_type='register_basic') }}" title="신규 경기 등록 화면에서 기본 경기 정보만 입력합니다.">경기 등록</button>
<button class="btn" type="submit" formaction="{{ url_for('start_job', job_type='post_update') }}" title="전체 리포트를 자동 생성한 뒤 관중 수, 종료시간, 심판 정보를 입력합니다.">경기 후 정보</button>
<button class="btn" type="submit" formaction="{{ url_for('start_job', job_type='finish') }}" title="전체 리포트를 자동 생성한 뒤 게임종료 팝업에서 승패/홀드/세이브/블론세이브를 입력합니다.">경기 마무리</button>
<button class="btn" type="submit" formaction="{{ url_for('start_job', job_type='compare') }}" title="전체 리포트를 자동 생성한 뒤 history.txt와 비교해서 불일치 리포트를 만듭니다.">기록 비교</button>
</div>
</div>
<p class="helper">
경기 ID 형식은 `경기구분4 + 월일4 + 어웨이2 + 홈2 + 더블헤더1 + 연도4`입니다.
정규경기는 앞 4자리에 연도를 쓰고, 와일드카드 `4444`, 준PO `3333`, PO `5555`, 한국시리즈 `7777`을 씁니다.
</p>
</form>
</section>
<section class="panel">
<div class="section-head">
<h2>최근 작업</h2>
<a class="text-link" href="{{ url_for('jobs_api') }}" target="_blank" rel="noopener">JSON 보기</a>
</div>
<div id="job-list-container" class="job-list">
{% if jobs %}
{% for job in jobs %}
<article class="job-card status-{{ job.status }}">
<div class="job-top">
<strong>{{ job.type }}</strong>
<span class="status">{{ job.status }}</span>
</div>
<div class="job-body">
<div>경기 ID: {{ job.game_id }}</div>
<div>이닝: {{ job.inning_no or '-' }}</div>
<div>게임번호: {{ job.manager_game_no or '-' }}</div>
<div>생성: {{ job.created_at }}</div>
</div>
<div class="job-actions">
<a class="text-link log-preview-link" href="{{ url_for('job_detail', job_id=job.job_id) }}">{{ job.log_preview }}</a>
<a class="text-link" href="{{ url_for('job_log', job_id=job.job_id) }}" target="_blank" rel="noopener">로그</a>
<a class="text-link" href="{{ url_for('view_db_logs', job_id=job.job_id) }}" target="_blank" rel="noopener">DB 상세로그</a>
</div>
</article>
{% endfor %}
{% else %}
<p class="empty">아직 작업이 없습니다.</p>
{% endif %}
</div>
</section>
<section class="panel">
<div class="section-head">
<h2>편의 기능</h2>
</div>
<div class="button-row">
<form method="post" action="{{ url_for('clear_logs') }}">
<button class="btn" type="submit">로그 초기화</button>
</form>
<form method="post" action="{{ url_for('clear_jobs') }}">
<button class="btn" type="submit">작업 상태 초기화</button>
</form>
<form method="post" action="{{ url_for('clear_reports') }}">
<button class="btn" type="submit">리포트 초기화</button>
</form>
<form method="post" action="{{ url_for('clear_runtime_profiles') }}">
<button class="btn" type="submit">런타임 프로필 초기화</button>
</form>
</div>
</section>
</main>
<script src="https://cdn.jsdelivr.net/npm/flatpickr"></script>
<script src="https://cdn.jsdelivr.net/npm/flatpickr/dist/l10n/ko.js"></script>
<script>
const modeSelect = document.getElementById('game_id_mode');
const managerModeSelect = document.getElementById('manager_mode');
const gameTypeSelect = document.getElementById('game_type');
const dateInput = document.getElementById('game_date');
const awayTeamCode = document.getElementById('away_team_code');
const homeTeamCode = document.getElementById('home_team_code');
const dhInput = document.getElementById('doubleheader_no');
const dhInputParse = document.getElementById('doubleheader_no_parse');
const directGameIdInput = document.querySelector('input[name="game_id"]');
const parseInput = document.getElementById('parse_text');
const managerGameNoInput = document.querySelector('input[name="manager_game_no"]');
const preview = document.getElementById('game-id-preview');
flatpickr(dateInput, {
locale: 'ko',
dateFormat: 'Y-m-d',
allowInput: true,
disableMobile: true,
defaultDate: dateInput?.value || null
});
function syncModeVisibility() {
const mode = modeSelect.value;
document.querySelectorAll('[data-mode]').forEach((node) => {
node.style.display = node.getAttribute('data-mode') === mode ? '' : 'none';
});
}
function syncManagerModeVisibility() {
const mode = managerModeSelect.value;
document.querySelectorAll('[data-manager-mode]').forEach((node) => {
node.style.display = node.getAttribute('data-manager-mode') === mode ? '' : 'none';
});
}
function buildComposedId() {
const rawDate = dateInput.value || '';
const away = awayTeamCode.value || '';
const home = homeTeamCode.value || '';
const gameType = gameTypeSelect.value || 'regular';
const mode = modeSelect.value;
const dh = (mode === 'parse' ? dhInputParse.value : dhInput.value) || '0';
if (!rawDate || !away || !home) return '-';
const [year, month, day] = rawDate.split('-');
const typeMap = {
regular: year,
wildcard: '4444',
semi_playoff: '3333',
playoff: '5555',
korean_series: '7777',
};
return `${typeMap[gameType] || year}${month}${day}${away}${home}${dh}${year}`;
}
function syncPreview() {
const mode = modeSelect.value;
preview.textContent = mode === 'direct'
? (directGameIdInput.value.trim() || '-')
: buildComposedId();
}
function handleParse() {
const text = parseInput.value.trim();
if (!text) return;
// 탭이나 공백으로 분리
const parts = text.split(/\t|\s{2,}/).map((s) => s.trim()).filter(Boolean);
if (parts.length < 5) return;
const teamMap = { 'KIA': 'HT', '한화': 'HH', '두산': 'OB', '롯데': 'LT', 'SSG': 'SK', '삼성': 'SS', '키움': 'WO', 'Hero': 'WO', 'LG': 'LG', 'NC': 'NC', 'KT': 'KT', '상무': 'SM' };
const typeMap = { '정규경기': 'regular', '와일드카드': 'wildcard', '준플레이오프': 'semi_playoff', '플레이오프': 'playoff', '한국시리즈': 'korean_series', '시범경기': 'exhibition' };
// 1. 관리자 게임번호 (보통 첫 번째 숫자 뭉치)
if (/^\d+$/.test(parts[0])) {
managerGameNoInput.value = parts[0];
managerModeSelect.value = 'manual';
syncManagerModeVisibility();
}
// 2. 날짜 찾기 (YYYY-MM-DD 형식)
const datePart = parts.find(p => /^\d{4}-\d{2}-\d{2}$/.test(p));
if (datePart) {
dateInput.value = datePart;
if (dateInput._flatpickr) dateInput._flatpickr.setDate(datePart);
}
// 3. 경기 타입 찾기
for (const [key, val] of Object.entries(typeMap)) {
if (text.includes(key)) {
game_type.value = val;
break;
}
}
// 4. 팀 찾기 (원정 vs 홈 구분)
// 네이버 일정 텍스트는 보통 [시간] [종류] [장소] [홈팀] [원정팀] 순서인 경우가 많음
// 여기서는 텍스트에 포함된 팀 이름들을 순서대로 추출
const foundTeams = [];
const words = text.split(/[\s\t]+/);
words.forEach(word => {
for (const [name, code] of Object.entries(teamMap)) {
if (word.includes(name) && !foundTeams.some(t => t.code === code)) {
foundTeams.push({ name, code });
}
}
});
if (foundTeams.length >= 2) {
// 보통 일정 텍스트 상 먼저 나오는 팀이 홈팀인 경우가 많으므로 (네이버 기준)
homeTeamCode.value = foundTeams[0].code;
awayTeamCode.value = foundTeams[1].code;
}
syncPreview();
}
async function refreshDashboard() {
try {
const response = await fetch('/api/dashboard');
const data = await response.json();
const container = document.getElementById('job-list-container');
if (!container) return;
container.innerHTML = data.jobs.length ? data.jobs.map((job) => `
<article class="job-card status-${job.status}">
<div class="job-top"><strong>${job.type}</strong><span class="status">${job.status}</span></div>
<div class="job-body">
<div>경기 ID: ${job.game_id}</div>
<div>이닝: ${job.inning_no || '-'}</div>
<div>게임번호: ${job.manager_game_no || '-'}</div>
<div>생성: ${job.created_at}</div>
</div>
<div class="job-actions">
<a class="text-link log-preview-link" href="/jobs/${job.job_id}">${job.log_preview}</a>
<a class="text-link" href="/jobs/${job.job_id}/log" target="_blank" rel="noopener">로그</a>
<a class="text-link" href="/db-logs/${job.job_id}" target="_blank" rel="noopener">DB 상세로그</a>
</div>
</article>
`).join('') : '<p class="empty">아직 작업이 없습니다.</p>';
} catch (error) {
console.error(error);
}
}
const startInningNum = document.getElementById('start_inning_num');
const startInningHalf = document.getElementById('start_inning_half');
const endInningNum = document.getElementById('end_inning_num');
const endInningHalf = document.getElementById('end_inning_half');
const inningNoHidden = document.getElementById('inning_no');
const allInningsCheck = document.getElementById('all_innings_check');
function syncInningNo() {
if (allInningsCheck.checked) {
inningNoHidden.value = 'all';
[startInningNum, startInningHalf, endInningNum, endInningHalf].forEach(el => el.disabled = true);
} else {
[startInningNum, startInningHalf, endInningNum, endInningHalf].forEach(el => el.disabled = false);
const start = startInningNum.value + startInningHalf.value;
const end = endInningNum.value + endInningHalf.value;
if (start === end) {
inningNoHidden.value = start;
} else {
inningNoHidden.value = `${start}-${end}`;
}
}
}
[startInningNum, startInningHalf, endInningNum, endInningHalf].forEach(node => {
node.addEventListener('change', syncInningNo);
});
allInningsCheck.addEventListener('change', syncInningNo);
// 초기 상태 반영
if (inningNoHidden.value === 'all') {
allInningsCheck.checked = true;
}
syncInningNo();
modeSelect.addEventListener('change', syncModeVisibility);
managerModeSelect.addEventListener('change', syncManagerModeVisibility);
[gameTypeSelect, dateInput, awayTeamCode, homeTeamCode, dhInput, dhInputParse, directGameIdInput, modeSelect].forEach((node) => {
node?.addEventListener('input', syncPreview);
node?.addEventListener('change', syncPreview);
});
parseInput?.addEventListener('input', handleParse);
syncModeVisibility();
syncManagerModeVisibility();
syncPreview();
setInterval(refreshDashboard, 5000);
</script>
</body>
</html>

40
webapp/templates/job.html Normal file
View File

@@ -0,0 +1,40 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>작업 상세</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
<main class="page">
<section class="panel">
<div class="section-head">
<h1>작업 상세</h1>
<a class="text-link" href="{{ url_for('index') }}">처음으로</a>
</div>
<div class="detail-grid">
<div><strong>작업 ID</strong><span>{{ job.job_id }}</span></div>
<div><strong>타입</strong><span>{{ job.type }}</span></div>
<div><strong>상태</strong><span>{{ job.status }}</span></div>
<div><strong>경기 ID</strong><span>{{ job.game_id }}</span></div>
<div><strong>게임번호</strong><span>{{ job.manager_game_no or '-' }}</span></div>
<div><strong>리포트 경로</strong><span>{{ job.report_path }}</span></div>
<div><strong>생성</strong><span>{{ job.created_at }}</span></div>
<div><strong>시작</strong><span>{{ job.started_at or '-' }}</span></div>
<div><strong>종료</strong><span>{{ job.finished_at or '-' }}</span></div>
<div><strong>에러</strong><span>{{ job.error or '-' }}</span></div>
</div>
<div class="button-row">
<a class="btn" href="{{ url_for('job_status', job_id=job.job_id) }}" target="_blank" rel="noopener">상태 JSON</a>
<a class="btn primary" href="{{ url_for('job_log', job_id=job.job_id) }}" target="_blank" rel="noopener">로그 보기</a>
</div>
</section>
</main>
{% if job.status in ['queued', 'running'] %}
<script>
setTimeout(() => window.location.reload(), 7000);
</script>
{% endif %}
</body>
</html>

134
webapp/templates/logs.html Normal file
View File

@@ -0,0 +1,134 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>통합 실행 로그: {{ job_id }}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='filename=' + 'style.css') if false else url_for('static', filename='style.css') }}">
<style>
body { padding: 20px; font-family: 'Inter', sans-serif; background-color: #f8faf9; }
.container { max-width: 1200px; margin: 0 auto; }
h2 { margin-top: 20px; margin-bottom: 20px; font-weight: 700; color: #1a1c1a; }
.toolbar { background: white; padding: 15px 20px; border-radius: 12px; margin-bottom: 20px; display: flex; align-items: center; justify-content: space-between; box-shadow: 0 2px 8px rgba(0,0,0,0.05); }
.filter-group { display: flex; align-items: center; gap: 10px; }
table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 12px rgba(0,0,0,0.05); }
th, td { padding: 14px 18px; border-bottom: 1px solid #edf2ef; font-size: 14px; text-align: left; }
th { background: #f2f5f3; color: #5f6661; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; font-size: 12px; }
tr.fail-row { background-color: #fff5f5; }
tr.fail-row:hover { background-color: #ffebeb; }
tr:hover { background-color: #fcfdfc; }
.badge { display: inline-block; padding: 4px 8px; border-radius: 6px; font-size: 11px; font-weight: 700; text-transform: uppercase; }
.badge-pitch { background: #e3f2fd; color: #1976d2; }
.badge-event { background: #f3e5f5; color: #7b1fa2; }
.status-tag { display: flex; align-items: center; gap: 6px; font-weight: 600; }
.status-success { color: #2e7d32; }
.status-fail { color: #d32f2f; }
.error-msg { color: #d32f2f; font-size: 13px; font-weight: 500; font-family: monospace; background: rgba(211, 47, 47, 0.05); padding: 4px 8px; border-radius: 4px; }
.nav { margin-bottom: 20px; }
.back-btn { display: inline-block; padding: 10px 16px; background: #fff; border: 1px solid #d0d7d3; border-radius: 8px; text-decoration: none; color: #1a1c1a; font-weight: 600; transition: all 0.2s; }
.back-btn:hover { background: #f9f9f9; transform: translateX(-4px); }
/* Toggle Switch */
.switch { position: relative; display: inline-block; width: 44px; height: 24px; }
.switch input { opacity: 0; width: 0; height: 0; }
.slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #cbd5e0; transition: .4s; border-radius: 24px; }
.slider:before { position: absolute; content: ""; height: 18px; width: 18px; left: 3px; bottom: 3px; background-color: white; transition: .4s; border-radius: 50%; }
input:checked + .slider { background-color: #f56565; }
input:checked + .slider:before { transform: translateX(20px); }
</style>
</head>
<body>
<div class="container">
<div class="nav">
<a href="{{ url_for('index') }}" class="back-btn">&larr; 대시보드로 돌아가기</a>
</div>
<div class="hero" style="margin-bottom: 30px;">
<h1 style="font-size: 28px; color: #1a1c1a; margin-bottom: 8px;">경기 통합 액션 로그</h1>
<p style="color: #5f6661;">Job ID: <code style="background: #e9ecef; padding: 2px 6px; border-radius: 4px;">{{ job_id }}</code></p>
</div>
<div class="toolbar">
<div class="filter-group">
<label class="switch">
<input type="checkbox" id="failFilter">
<span class="slider"></span>
</label>
<span style="font-weight: 600; color: #4a5568;">실패한 로그만 보기</span>
</div>
<div style="color: #718096; font-size: 14px;">
<strong>{{ logs|length }}</strong>개의 액션이 기록됨
</div>
</div>
<table id="logTable">
<thead>
<tr>
<th width="80">유형</th>
<th width="60">이닝</th>
<th width="150">대상(타자/교체)</th>
<th>동작 상세</th>
<th>실제 입력/결과</th>
<th width="100">상태</th>
<th>에러/비고</th>
<th width="100">시간</th>
</tr>
</thead>
<tbody>
{% if logs %}
{% for log in logs %}
<tr class="log-row {% if not log.is_success %}fail-row{% endif %}" data-success="{{ log.is_success }}">
<td>
<span class="badge badge-{{ log.type }}">{{ '투구' if log.type == 'pitch' else '교체' }}</span>
</td>
<td>{{ log.inning }}</td>
<td style="font-weight: 600;">{{ log.target_name }}</td>
<td>{{ log.action_desc }}</td>
<td>{{ log.actual_desc or '-' }}</td>
<td>
<div class="status-tag status-{{ 'success' if log.is_success else 'fail' }}">
{{ '✔ 성공' if log.is_success else '✖ 실패' }}
</div>
</td>
<td>
{% if log.error_msg %}
<div class="error-msg">{{ log.error_msg }}</div>
{% else %}
-
{% endif %}
</td>
<td style="color: #718096; font-size: 12px;">{{ log.log_time.split(' ')[1] }}</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="8" style="text-align: center; padding: 40px; color: #a0aec0;">기록된 로그가 없습니다.</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
<script>
const failFilter = document.getElementById('failFilter');
const logRows = document.querySelectorAll('.log-row');
failFilter.addEventListener('change', function() {
const showOnlyFail = this.checked;
logRows.forEach(row => {
const isSuccess = row.getAttribute('data-success') === 'True' || row.getAttribute('data-success') === '1';
if (showOnlyFail) {
row.style.display = isSuccess ? 'none' : '';
} else {
row.style.display = '';
}
});
});
</script>
</body>
</html>