first jiwoos commit
This commit is contained in:
422
webapp/templates/index.html
Normal file
422
webapp/templates/index.html
Normal 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
40
webapp/templates/job.html
Normal 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
134
webapp/templates/logs.html
Normal 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">← 대시보드로 돌아가기</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>
|
||||
Reference in New Issue
Block a user