soyailabs / templates /admin_webtoon_duration.html
SOY NV AI
Fix webtoon duration analysis job stall (sync fallback)
ce1f904
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>μ›Ήνˆ° μ œμž‘ κΈ°κ°„ν‘œ(1~20) - SOY NV AI</title>
<link rel="preconnect" href="https://fonts.googleapis.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
<style>
:root{
--bg:#f6f7fb;--card:#fff;--text:#202124;--muted:#5f6368;--border:#e5e7eb;--border-strong:#dadce0;
--primary:#1a73e8;--primary-hover:#1557b0;--shadow:0 10px 24px rgba(17,24,39,.08);--radius:12px;
--danger:#c5221f;
}
*{margin:0;padding:0;box-sizing:border-box}
body{
font-family:'Inter', -apple-system, BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
background: radial-gradient(1200px 600px at 20% -10%, rgba(26,115,232,0.10), rgba(255,255,255,0)) , var(--bg);
color:var(--text);overflow-x:hidden
}
.container{max-width:1180px;margin:22px auto;padding:0 24px}
.page-header{margin:8px 0 14px}
.page-header h1{font-size:18px;font-weight:700;letter-spacing:-.2px}
.page-header p{margin-top:6px;color:var(--muted);font-size:13px;line-height:1.6}
.card{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);overflow:hidden;box-shadow:var(--shadow);margin-bottom:14px}
.card-header{padding:14px 16px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;background:linear-gradient(to bottom,#fff,#fbfbff)}
.card-title{font-size:15px;font-weight:700}
.card-body{padding:16px}
.row{display:flex;gap:10px;flex-wrap:wrap;align-items:center}
.select{
padding:10px 12px;border:1px solid var(--border-strong);border-radius:10px;font-size:13px;outline:none;background:#fff;
min-width: 380px;
}
.btn{padding:10px 12px;border:1px solid var(--border);border-radius:10px;font-size:13px;font-weight:700;cursor:pointer;background:#fff;color:var(--text)}
.btn-primary{background:var(--primary);border-color:var(--primary);color:#fff}
.btn-primary:hover{background:var(--primary-hover);border-color:var(--primary-hover)}
.btn:disabled{opacity:.55;cursor:not-allowed}
.mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;font-size:12px}
.alert{margin-bottom:12px;padding:10px 12px;border-radius:10px;font-size:13px;border:1px solid transparent}
.alert.success{background:#e6f4ea;border-color:#b7dfc0;color:#137333}
.alert.error{background:#fce8e6;border-color:#f6aea9;color:#c5221f}
.hint{color:var(--muted);font-size:12px;line-height:1.5}
table{width:100%;border-collapse:collapse}
thead th{text-align:left;font-size:12px;color:var(--muted);padding:10px 8px;border-bottom:1px solid var(--border);background:#fbfbff}
tbody td{font-size:13px;padding:10px 8px;border-bottom:1px solid var(--border);vertical-align:top}
tbody tr:hover{background:#fafbff}
.pill{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--border);border-radius:999px;padding:6px 10px;font-size:12px;color:var(--muted);background:#fff}
.pill strong{color:var(--text)}
.progress.running::before{content:"●";display:inline-block;margin-right:6px;color:var(--primary);animation:pulse 1.2s infinite ease-in-out}
@keyframes pulse{0%{opacity:.2;transform:scale(.95)}50%{opacity:1;transform:scale(1.05)}100%{opacity:.2;transform:scale(.95)}}
.stage-box{margin-top:8px;border:1px solid var(--border);border-radius:10px;padding:10px;background:#fff}
.stage-box .title{font-weight:900;font-size:12px;margin-bottom:6px}
.stage-item{display:flex;gap:8px;justify-content:space-between;font-size:12px;color:var(--muted);padding:6px 0;border-top:1px dashed var(--border)}
.stage-item:first-of-type{border-top:none}
details.stage-detail{margin-top:6px}
details.stage-detail summary{cursor:pointer; color:var(--primary); font-weight:800; font-size:12px}
.stage-detail-box{margin-top:8px; padding:10px; border:1px solid var(--border); border-radius:10px; background:#fbfbff}
.kv{display:flex;gap:10px;flex-wrap:wrap;font-size:12px;color:var(--muted);margin-bottom:8px}
.kv .tag{border:1px solid var(--border);border-radius:999px;padding:4px 8px;background:#fff}
.tasks{margin-top:8px}
.tasks .t{font-size:12px;color:var(--muted);padding:6px 0;border-top:1px dashed var(--border)}
.tasks .t:first-child{border-top:none}
.tasks .t strong{color:var(--text)}
</style>
</head>
<body>
{% set admin_nav_title = 'μ›Ήνˆ°' %}
{% set admin_nav_icon = 'πŸ“…' %}
{% include '_admin_nav.html' %}
<div class="container">
<div class="page-header">
<h1>μ œμž‘ κΈ°κ°„ν‘œ(1~20ν™”)</h1>
<p>κ°„νŠΈ μ°¨νŠΈμ— ν‘œμ‹œλ˜λŠ” 일정(μž‘μ„±μΌ) 데이터λ₯Ό κΈ°μ€€μœΌλ‘œ, 1ν™”~20ν™” νšŒμ°¨λ³„ κΈ°κ°„/ν”„λ‘œμ νŠΈ 전체 기간을 μ‚°μ •ν•©λ‹ˆλ‹€. <strong>총 μž‘μ—…μΌμ€ 회차 μ‹œμž‘~μ’…λ£Œ(span) κΈ°κ°„</strong>μž…λ‹ˆλ‹€.</p>
</div>
<div id="alertContainer"></div>
<div class="card">
<div class="card-header">
<div class="card-title">ν”„λ‘œμ νŠΈ 선택</div>
<div class="row">
<button class="btn" type="button" onclick="reload()">μƒˆλ‘œκ³ μΉ¨</button>
</div>
</div>
<div class="card-body">
<div class="row">
<select id="uploadSelect" class="select">
<option value="">ν”„λ‘œμ νŠΈλ₯Ό μ„ νƒν•˜μ„Έμš”...</option>
</select>
<button id="analyzeBtn" class="btn btn-primary" type="button" onclick="analyze()">κΈ°κ°„ 뢄석(μ‚°μ •)</button>
<button id="analyzeSyncBtn" class="btn" type="button" onclick="analyze('sync')">ν˜Έν™˜ λͺ¨λ“œ μ‹€ν–‰</button>
<span id="progressText" class="pill mono"></span>
</div>
<div class="hint" style="margin-top:10px;">
- μ‚°μ • 기쀀은 <strong>μž‘μ„±μΌ</strong>의 μ΅œμ†Œ/μ΅œλŒ€μž…λ‹ˆλ‹€. (κ°„νŠΈ μ°¨νŠΈμ™€ 동일)<br>
- "κΈ°κ°„ 뢄석(μ‚°μ •)"은 ν”„λ‘œμ νŠΈλ³„λ‘œ 1~20ν™” κΈ°κ°„ν‘œλ₯Ό DB에 μ €μž₯ν•©λ‹ˆλ‹€.<br>
- <strong>총 μž‘μ—…μΌ</strong>은 회차의 μ‹œμž‘μΌ~μ’…λ£ŒμΌ(span) 기반 κΈ°κ°„(μ’…λ£ŒμΌ-μ‹œμž‘μΌ+1)μž…λ‹ˆλ‹€.
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<div class="card-title">μš”μ•½</div>
<div class="row">
<span id="summaryText" class="mono" style="color:var(--muted)"></span>
</div>
</div>
<div class="card-body">
<table>
<thead>
<tr>
<th style="width:80px;">회차</th>
<th style="width:140px;">μ‹œμž‘</th>
<th style="width:140px;">μ’…λ£Œ</th>
<th style="width:110px;">총 μž‘μ—…μΌ(일)</th>
<th>단계(μ£Όμš” κΈ°κ°„)</th>
</tr>
</thead>
<tbody id="tbody">
<tr><td colspan="5" style="text-align:center; color: var(--muted); padding: 18px;">ν”„λ‘œμ νŠΈλ₯Ό μ„ νƒν•˜μ„Έμš”.</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<script>
const alertContainer = document.getElementById('alertContainer');
const uploadSelect = document.getElementById('uploadSelect');
const analyzeBtn = document.getElementById('analyzeBtn');
const analyzeSyncBtn = document.getElementById('analyzeSyncBtn');
const progressText = document.getElementById('progressText');
const summaryText = document.getElementById('summaryText');
const tbody = document.getElementById('tbody');
function showAlert(message, type) {
alertContainer.innerHTML = `<div class="alert ${type}">${message}</div>`;
setTimeout(() => { alertContainer.innerHTML = ''; }, 6000);
}
async function fetchJson(url, options = {}) {
const res = await fetch(url, { credentials: 'include', ...options });
const text = await res.text();
let data = null;
try { data = text ? JSON.parse(text) : null; } catch { data = { raw: text }; }
return { res, data };
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text == null ? '' : String(text);
return div.innerHTML;
}
function setBusy(busy) {
analyzeBtn.disabled = !!busy;
analyzeSyncBtn.disabled = !!busy;
uploadSelect.disabled = !!busy;
}
function setProgress(text, running) {
progressText.textContent = text || '';
progressText.classList.remove('progress','running');
if (running) progressText.classList.add('progress','running');
}
async function loadUploads() {
const { res, data } = await fetchJson('/api/webtoons/project-uploads');
if (!res.ok) {
showAlert(data?.error || `ν”„λ‘œμ νŠΈ λͺ©λ‘ λ‘œλ“œ μ‹€νŒ¨ (${res.status})`, 'error');
return;
}
const items = data?.uploads || [];
uploadSelect.innerHTML = `<option value="">ν”„λ‘œμ νŠΈλ₯Ό μ„ νƒν•˜μ„Έμš”...</option>` + items.map(u => {
const label = `${u.project_name || '(ν”„λ‘œμ νŠΈλͺ… μ—†μŒ)'} β€” ${u.original_filename || ''}`;
return `<option value="${u.id}">${escapeHtml(label)}</option>`;
}).join('');
}
async function loadDuration() {
const uploadId = uploadSelect.value;
if (!uploadId) return;
tbody.innerHTML = `<tr><td colspan="5" style="text-align:center; color: var(--muted); padding: 18px;">λΆˆλŸ¬μ˜€λŠ” 쀑...</td></tr>`;
const { res, data } = await fetchJson(`/api/webtoons/project-uploads/${uploadId}/duration`);
if (!res.ok) {
showAlert(data?.error || `κΈ°κ°„ μš”μ•½ 쑰회 μ‹€νŒ¨ (${res.status})`, 'error');
tbody.innerHTML = `<tr><td colspan="5" style="text-align:center; color: var(--muted); padding: 18px;">쑰회 μ‹€νŒ¨</td></tr>`;
return;
}
const summary = data?.summary;
const eps = data?.episodes || [];
if (!summary) {
summaryText.textContent = 'κΈ°κ°„ 뢄석 κ²°κ³Όκ°€ μ—†μŠ΅λ‹ˆλ‹€. μƒλ‹¨μ˜ β€œκΈ°κ°„ 뢄석(μ‚°μ •)”을 μ‹€ν–‰ν•˜μ„Έμš”.';
} else {
summaryText.textContent = `ν”„λ‘œμ νŠΈ κΈ°κ°„: ${summary.start_date || '?'} ~ ${summary.end_date || '?'} Β· 총 ${summary.duration_days || 0}일 Β· 착수 간격(μΆ”μ •): ${summary.cadence_days ?? '-'}일`;
}
if (!eps.length) {
tbody.innerHTML = `<tr><td colspan="5" style="text-align:center; color: var(--muted); padding: 18px;">1~20ν™” κΈ°κ°„ 데이터가 μ—†μŠ΅λ‹ˆλ‹€.</td></tr>`;
return;
}
tbody.innerHTML = eps.map(e => {
const stages = e.stage_summary || [];
// 회차 총 μž‘μ—…μΌ = 회차 μ‹œμž‘~μ’…λ£Œ(span) 기반 (λ°±μ—”λ“œμ—μ„œ κ³„μ‚°λœ duration_days)
const totalDays = Number(e.duration_days) || 0;
const stageHtml = stages.length ? `
<div class="stage-box">
<div class="title">μž‘μ—…λ‹¨κ³„ κΈ°κ°„</div>
${stages.slice(0, 8).map(s => `
<div class="stage-item">
<div style="max-width:55%;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">
<strong style="color:var(--text)">${escapeHtml(s.stage_text || '')}</strong>
</div>
<div class="mono">${escapeHtml(s.start_date || '')} ~ ${escapeHtml(s.end_date || '')} (${s.duration_days || 0}일)</div>
</div>
${(s.task_examples && s.task_examples.length) ? `
<details class="stage-detail">
<summary>곡정 상세 보기 (업무/λ‹΄λ‹Ή/ν”Όλ“œλ°±)</summary>
<div class="stage-detail-box">
<div class="kv">
${(s.stage_key ? `<span class="tag">단계킀: <strong>${escapeHtml(s.stage_key)}</strong></span>` : '')}
<span class="tag">업무 ${((s.task_names||[]).length)||0}개</span>
<span class="tag">μž‘μ„±μž ${((s.authors||[]).length)||0}λͺ…</span>
<span class="tag">ν”Όλ“œλ°± ${((s.feedbacks||[]).length)||0}λͺ…</span>
</div>
${(s.task_names && s.task_names.length) ? `<div class="hint">μ£Όμš” 업무: ${escapeHtml((s.task_names||[]).slice(0,10).join(', '))}${(s.task_names||[]).length>10?' ...':''}</div>` : ''}
${(s.authors && s.authors.length) ? `<div class="hint" style="margin-top:4px;">μž‘μ„±μž: ${escapeHtml((s.authors||[]).join(', '))}</div>` : ''}
${(s.feedbacks && s.feedbacks.length) ? `<div class="hint" style="margin-top:4px;">ν”Όλ“œλ°±: ${escapeHtml((s.feedbacks||[]).join(', '))}</div>` : ''}
<div class="tasks">
${(s.task_examples||[]).slice(0,10).map(t => `
<div class="t">
<div class="mono">${escapeHtml(t.written_date||'')}</div>
<div><strong>${escapeHtml(t.task||'(μ—…λ¬΄μ—†μŒ)')}</strong> ${t.author ? `Β· ${escapeHtml(t.author)}`:''} ${t.feedback_assignee ? `Β· ν”Όλ“œλ°±:${escapeHtml(t.feedback_assignee)}`:''}</div>
${t.file_ref ? `<div class="mono">파일: ${escapeHtml(t.file_ref)}</div>`:''}
</div>
`).join('')}
</div>
</div>
</details>
` : ''}
`).join('')}
${stages.length > 8 ? `<div class="hint" style="margin-top:6px;">+ ${stages.length - 8}개 단계 더 있음 (μƒμ„ΈλŠ” μΆ”ν›„ ν™•μž₯)</div>` : ''}
</div>
` : `<span class="hint">(단계 데이터 μ—†μŒ)</span>`;
return `
<tr>
<td><strong>${escapeHtml(e.episode_label || (e.episode_num + 'ν™”'))}</strong></td>
<td class="mono">${escapeHtml(e.start_date || '')}</td>
<td class="mono">${escapeHtml(e.end_date || '')}</td>
<td><strong style="color:var(--primary);font-size:15px;">${totalDays}</strong></td>
<td>${stageHtml}</td>
</tr>
`;
}).join('');
}
async function pollJob(jobId) {
const uploadId = uploadSelect.value;
const startedAt = Date.now();
while (true) {
const { res, data } = await fetchJson(`/api/webtoons/duration/jobs/${jobId}`);
if (!res.ok) {
showAlert(data?.error || `μ§„ν–‰ 쑰회 μ‹€νŒ¨ (${res.status})`, 'error');
setBusy(false);
setProgress('', false);
return;
}
const job = data?.job;
const pct = Math.round((job?.progress || 0) * 100);
const msg = job?.message || '';
if (job?.status === 'running' || job?.status === 'queued') {
setProgress(`${msg} (${pct}%)`, true);
// μš΄μ˜ν™˜κ²½μ—μ„œ λ°±κ·ΈλΌμš΄λ“œ μŠ€λ ˆλ“œκ°€ λ§‰νžŒ 경우 0%μ—μ„œ μž₯μ‹œκ°„ μ •μ§€ν•  수 있음 β†’ ν˜Έν™˜ λͺ¨λ“œ μ•ˆλ‚΄
if (pct === 0 && Date.now() - startedAt > 22 * 1000) {
setProgress(`λŒ€κΈ° 쀑... (0%)`, false);
setBusy(false);
showAlert('진행이 0%μ—μ„œ λ©ˆμΆ°μžˆμŠ΅λ‹ˆλ‹€. μ‹€μ„œλ²„ ν™˜κ²½μ—μ„œ λ°±κ·ΈλΌμš΄λ“œ μž‘μ—…μ΄ μ œν•œλ  수 μžˆμ–΄μš”. β€œν˜Έν™˜ λͺ¨λ“œ 싀행”을 눌러 동기 싀행을 μ‹œλ„ν•΄μ£Όμ„Έμš”.', 'error');
return;
}
} else if (job?.status === 'done') {
setProgress(`μ™„λ£Œ (${pct}%)`, false);
setBusy(false);
await loadDuration();
showAlert('κΈ°κ°„ 뢄석(μ‚°μ •)이 μ™„λ£Œλ˜μ—ˆμŠ΅λ‹ˆλ‹€.', 'success');
return;
} else if (job?.status === 'error') {
setProgress(`였λ₯˜: ${job?.error || 'μ•Œ 수 μ—†λŠ” 였λ₯˜'}`, false);
setBusy(false);
showAlert(job?.error || 'κΈ°κ°„ μ‚°μ • 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€.', 'error');
return;
}
if (Date.now() - startedAt > 5 * 60 * 1000) {
setProgress('μ§„ν–‰ 확인 μ‹œκ°„μ΄ μ΄ˆκ³Όλ˜μ—ˆμŠ΅λ‹ˆλ‹€. μƒˆλ‘œκ³ μΉ¨ ν›„ λ‹€μ‹œ ν™•μΈν•΄μ£Όμ„Έμš”.', false);
setBusy(false);
return;
}
await new Promise(r => setTimeout(r, 1200));
}
}
async function analyze() {
const uploadId = uploadSelect.value;
const mode = (arguments && arguments.length) ? (arguments[0] || '') : '';
const isSync = (String(mode).toLowerCase() === 'sync');
if (!uploadId) {
showAlert('ν”„λ‘œμ νŠΈλ₯Ό μ„ νƒν•΄μ£Όμ„Έμš”.', 'error');
return;
}
if (!confirm(`1~20ν™” 기간을 μ‚°μ •ν• κΉŒμš”? (μž‘μ„±μΌ κΈ°μ€€, κ°„νŠΈμ™€ 동일)${isSync ? '\n\n- ν˜Έν™˜ λͺ¨λ“œ: λ°±κ·ΈλΌμš΄λ“œκ°€ λ§‰νžŒ ν™˜κ²½μ—μ„œ 동기 싀행을 μ‹œλ„ν•©λ‹ˆλ‹€.' : ''}`)) return;
setBusy(true);
setProgress(isSync ? 'κΈ°κ°„ μ‚°μ •(ν˜Έν™˜ λͺ¨λ“œ) μ‹œμž‘...' : 'κΈ°κ°„ μ‚°μ • μ‹œμž‘...', true);
const qs = isSync ? '?mode=sync' : '';
const { res, data } = await fetchJson(`/api/webtoons/project-uploads/${uploadId}/duration/analyze${qs}`, { method: 'POST' });
if (!res.ok) {
setBusy(false);
setProgress('', false);
showAlert(data?.error || `κΈ°κ°„ μ‚°μ • μ‹œμž‘ μ‹€νŒ¨ (${res.status})`, 'error');
return;
}
const jobId = data?.job?.id;
if (!jobId) {
setBusy(false);
setProgress('', false);
await loadDuration();
return;
}
await pollJob(jobId);
}
async function reload() {
await loadUploads();
}
uploadSelect.addEventListener('change', loadDuration);
reload();
</script>
</body>
</html>