|
|
<!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 || []; |
|
|
|
|
|
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); |
|
|
|
|
|
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> |
|
|
|
|
|
|
|
|
|