feat(web-ui): PipelineTab — 진행 중 파이프라인 카드 보드

This commit is contained in:
2026-05-07 17:28:14 +09:00
parent 5bba880c23
commit 9ffd7889e7
4 changed files with 188 additions and 0 deletions

View File

@@ -0,0 +1,63 @@
import { cancelPipeline, publishPipeline } from '../../../api';
const STEP_LABELS = ['커버','영상','썸네','메타','검토','발행'];
function stepIndex(state) {
if (state.startsWith('cover')) return 0;
if (state.startsWith('video')) return 1;
if (state.startsWith('thumb')) return 2;
if (state.startsWith('meta')) return 3;
if (state.startsWith('ai_review') || state.startsWith('publish_pending')) return 4;
if (state.startsWith('publish')) return 5;
if (state === 'published') return 6;
return -1;
}
export default function PipelineCard({ pipeline, onChanged }) {
const i = stepIndex(pipeline.state);
return (
<div className="pipeline-card">
<div className="pipeline-card__head">
<h4>{pipeline.track_title || `Track #${pipeline.track_id}`}</h4>
{!['published','cancelled','failed'].includes(pipeline.state) && (
<button onClick={async () => { await cancelPipeline(pipeline.id); onChanged(); }}>
취소
</button>
)}
</div>
<div className="pipeline-progress">
{STEP_LABELS.map((lbl, idx) => (
<div key={lbl} className={`pipeline-dot ${idx <= i ? 'is-done' : ''} ${idx === i ? 'is-current' : ''}`}>
<span>{lbl}</span>
</div>
))}
</div>
<div className="pipeline-state">현재: {pipeline.state}</div>
{pipeline.review && (
<div className="pipeline-review">
AI 검토: <strong>{pipeline.review.verdict}</strong>
({pipeline.review.weighted_total}/100)
</div>
)}
{pipeline.state === 'publish_pending' && (
<button className="button primary"
onClick={async () => { await publishPipeline(pipeline.id); onChanged(); }}>
YouTube 업로드
</button>
)}
{pipeline.youtube_video_id && (
<a href={`https://youtu.be/${pipeline.youtube_video_id}`} target="_blank" rel="noreferrer">
유튜브에서 보기
</a>
)}
{pipeline.feedback && pipeline.feedback.length > 0 && (
<details className="pipeline-feedback">
<summary>피드백 히스토리 ({pipeline.feedback.length})</summary>
{pipeline.feedback.map(f => (
<div key={f.id}> [{f.step}] {f.feedback_text}</div>
))}
</details>
)}
</div>
);
}

View File

@@ -0,0 +1,33 @@
import { useState } from 'react';
import { createPipeline, startPipeline } from '../../../api';
export default function PipelineStartModal({ library, onClose, onCreated }) {
const [tid, setTid] = useState(library?.[0]?.id || '');
const [error, setError] = useState('');
const submit = async () => {
try {
const p = await createPipeline(parseInt(tid));
await startPipeline(p.id);
onCreated(p);
} catch (e) { setError(String(e)); }
};
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-body" onClick={e => e.stopPropagation()}>
<h3> 파이프라인 시작</h3>
<select value={tid} onChange={e => setTid(e.target.value)}>
{(library || []).map(t => (
<option key={t.id} value={t.id}>{t.title} ({t.genre})</option>
))}
</select>
{error && <div className="ms-error">{error}</div>}
<div className="modal-actions">
<button onClick={onClose}>취소</button>
<button className="button primary" onClick={submit}>시작</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,54 @@
import { useEffect, useState, useRef } from 'react';
import { listPipelines } from '../../../api';
import PipelineCard from './PipelineCard';
import PipelineStartModal from './PipelineStartModal';
export default function PipelineTab({ library, initialTrackId }) {
const [pipelines, setPipelines] = useState([]);
const [filter, setFilter] = useState('active');
const [modalOpen, setModalOpen] = useState(false);
const timer = useRef(null);
const load = async () => {
try {
const r = await listPipelines(filter);
setPipelines(r.pipelines || []);
} catch { /* swallow */ }
};
useEffect(() => {
load();
timer.current = setInterval(load, 5000);
return () => clearInterval(timer.current);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filter]);
useEffect(() => {
if (initialTrackId) setModalOpen(true);
}, [initialTrackId]);
return (
<div className="pipeline-container">
<div className="pipeline-toolbar">
<button className="button primary" onClick={() => setModalOpen(true)}>+ 파이프라인</button>
<select value={filter} onChange={e => setFilter(e.target.value)}>
<option value="active">진행 </option>
<option value="all">전체</option>
</select>
</div>
<div className="pipeline-grid">
{pipelines.map(p => (
<PipelineCard key={p.id} pipeline={p} onChanged={load} />
))}
{pipelines.length === 0 && <p className="ms-empty">진행 중인 파이프라인이 없습니다</p>}
</div>
{modalOpen && (
<PipelineStartModal
library={library}
onClose={() => setModalOpen(false)}
onCreated={() => { setModalOpen(false); load(); }}
/>
)}
</div>
);
}