feat: 댓글 답장 초안 일괄 생성

초안 없는 댓글을 한 번에 처리한다. 서버에서 통짜로 돌리지 않고 화면에서 한 건씩
순차 호출한다 — 실측 건당 26~32초라 26건이면 13분짜리 요청이 되어 프록시에서
끊기기 때문이다. 순차 방식이면 진행률이 보이고, 중단해도 앞서 만든 초안은 남는다.

- 진행 바 + 남은 건수, 중단 버튼(진행 중인 1건은 마저 끝냄)
- 완성되는 대로 카드에 반영되어 기다리는 동안에도 읽을 수 있다
- 실패한 건은 건너뛰고 개수만 보고한다(다시 눌러 재시도)
- 확인창에 예상 소요 시간을 알려준다 — 13분짜리 작업을 모르고 시작하지 않게
- 버튼에 남은 건수를 표시하고, 할 게 없으면 비활성화

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hehihoho3@gmail.com 2026-08-03 12:32:11 +09:00
parent d451aff8cc
commit 42ec17b054

View File

@ -14,6 +14,9 @@
<p class="sub">내 영상에 달린 댓글 중 아직 답 안 한 것만 모아, 영상 내용까지 보고 초안을 씁니다.</p> <p class="sub">내 영상에 달린 댓글 중 아직 답 안 한 것만 모아, 영상 내용까지 보고 초안을 씁니다.</p>
</div> </div>
<div class="actions"> <div class="actions">
<button class="btn btn-secondary" id="batchBtn" onclick="draftAll()">
<i data-lucide="wand-sparkles" style="width:15px;"></i> 전체 초안 생성
</button>
<button class="btn btn-primary" id="collectBtn" onclick="collect()"> <button class="btn btn-primary" id="collectBtn" onclick="collect()">
<i data-lucide="download" style="width:15px;"></i> 댓글 수집 <i data-lucide="download" style="width:15px;"></i> 댓글 수집
</button> </button>
@ -36,6 +39,22 @@
</div> </div>
</div> </div>
<!-- 일괄 생성 진행 -->
<div id="batchBar" class="card mb-4 hidden" role="status" aria-live="polite">
<div class="flex items-center gap-3" style="flex-wrap:wrap;">
<i data-lucide="loader-2" class="animate-spin" style="width:16px; color:var(--accent);"></i>
<span class="text-sm font-semibold" id="batchLabel">초안 생성 중...</span>
<div class="bar-track" style="flex:1; min-width:140px;">
<div class="bar-fill" id="batchFill" style="width:0%; background:var(--accent);"></div>
</div>
<span class="badge badge-muted" id="batchCount">0 / 0</span>
<button class="btn btn-secondary" style="padding:0.4rem 0.7rem;" onclick="cancelBatch()">중단</button>
</div>
<p class="text-xs text-muted mt-2" id="batchNote">
영상을 처음 읽는 건은 시간이 더 걸립니다. 만든 초안은 그때그때 저장되니 중단해도 남습니다.
</p>
</div>
<div id="list" class="rp-list"></div> <div id="list" class="rp-list"></div>
<div id="state" class="rp-state hidden"></div> <div id="state" class="rp-state hidden"></div>
@ -299,9 +318,21 @@
} }
hideState(); hideState();
document.getElementById('list').innerHTML = items.map(cardHtml).join(''); document.getElementById('list').innerHTML = items.map(cardHtml).join('');
updateBatchBtn();
if(window.lucide) lucide.createIcons(); if(window.lucide) lucide.createIcons();
} }
/** 일괄 생성 버튼에 남은 건수를 붙이고, 할 게 없으면 끈다. */
function updateBatchBtn(){
const btn = document.getElementById('batchBtn');
if(!btn || btn.disabled) return; // 실행 중이면 건드리지 않는다
const n = items.filter(c => c.status !== 'DONE' && !c.draftA && !c.draftB).length;
btn.innerHTML = `<i data-lucide="wand-sparkles" style="width:15px;"></i> 전체 초안 생성${n ? ` (${n})` : ''}`;
btn.classList.toggle('btn-secondary', true);
btn.style.opacity = n ? '' : '.5';
btn.style.pointerEvents = n ? '' : 'none';
}
// ---------- 액션 ---------- // ---------- 액션 ----------
async function collect(){ async function collect(){
const btn = document.getElementById('collectBtn'); const btn = document.getElementById('collectBtn');
@ -343,6 +374,68 @@
} }
} }
// ---------- 일괄 생성 ----------
// 서버에서 통짜로 돌리면 몇 분짜리 요청이 되어 프록시에서 끊긴다.
// 한 건씩 순차 호출해 진행률을 보여주고, 중단해도 앞서 만든 초안은 남게 한다.
let batchAbort = false;
function setBatchUI(on, done, total, label){
const bar = document.getElementById('batchBar');
bar.classList.toggle('hidden', !on);
document.getElementById('batchBtn').disabled = on;
document.getElementById('collectBtn').disabled = on;
if(on){
document.getElementById('batchCount').textContent = `${done} / ${total}`;
document.getElementById('batchFill').style.width = total ? (done/total*100)+'%' : '0%';
if(label) document.getElementById('batchLabel').textContent = label;
if(window.lucide) lucide.createIcons();
}
}
function cancelBatch(){
batchAbort = true;
document.getElementById('batchLabel').textContent = '중단하는 중... (진행 중인 1건은 끝냅니다)';
}
async function draftAll(){
const targets = items.filter(c => c.status !== 'DONE' && !c.draftA && !c.draftB);
if(targets.length === 0){
toast('초안이 없는 댓글이 없습니다');
return;
}
// 실측 건당 26~32초(영상을 처음 읽는 비용). 예상 시간을 알려주고 시작한다.
const mins = Math.max(1, Math.round(targets.length * 30 / 60));
if(!confirm(`초안 없는 댓글 ${targets.length}건에 초안을 만듭니다.\n`
+ `건당 30초 안팎이라 약 ${mins}분 걸립니다.\n\n`
+ `창을 켜둔 채로 두시면 되고, 만든 초안은 그때그때 저장됩니다.\n진행할까요?`)) return;
batchAbort = false;
let done = 0, failed = 0;
setBatchUI(true, 0, targets.length, '초안 생성 중...');
for(const c of targets){
if(batchAbort) break;
try {
const updated = await api(`${API}/${c.id}/draft?refreshContext=false`, { method:'POST' });
const i = items.findIndex(v => v.id === c.id);
if(i >= 0) items[i] = updated;
} catch(e){
failed++;
console.warn('초안 실패', c.id, e.message);
}
done++;
setBatchUI(true, done, targets.length);
render(); // 완성되는 대로 카드에 반영
}
setBatchUI(false);
loadStats();
const msg = batchAbort
? `중단했습니다 — ${done - failed}건 생성`
: `${done - failed}건 생성 완료`;
toast(failed ? `${msg} · ${failed}건 실패` : msg, failed > 0);
}
async function copyDraft(btn, id, key){ async function copyDraft(btn, id, key){
const c = items.find(v => v.id === id); const c = items.find(v => v.id === id);
if(!c) return; if(!c) return;