From 9ffd7889e75639523237645a1e0fa5feb622fe66 Mon Sep 17 00:00:00 2001 From: gahusb Date: Thu, 7 May 2026 17:28:14 +0900 Subject: [PATCH] =?UTF-8?q?feat(web-ui):=20PipelineTab=20=E2=80=94=20?= =?UTF-8?q?=EC=A7=84=ED=96=89=20=EC=A4=91=20=ED=8C=8C=EC=9D=B4=ED=94=84?= =?UTF-8?q?=EB=9D=BC=EC=9D=B8=20=EC=B9=B4=EB=93=9C=20=EB=B3=B4=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/music/MusicStudio.css | 38 +++++++++++ src/pages/music/components/PipelineCard.jsx | 63 +++++++++++++++++++ .../music/components/PipelineStartModal.jsx | 33 ++++++++++ src/pages/music/components/PipelineTab.jsx | 54 ++++++++++++++++ 4 files changed, 188 insertions(+) create mode 100644 src/pages/music/components/PipelineCard.jsx create mode 100644 src/pages/music/components/PipelineStartModal.jsx create mode 100644 src/pages/music/components/PipelineTab.jsx diff --git a/src/pages/music/MusicStudio.css b/src/pages/music/MusicStudio.css index f799685..b709d2c 100644 --- a/src/pages/music/MusicStudio.css +++ b/src/pages/music/MusicStudio.css @@ -3279,3 +3279,41 @@ .ms-error { color: #f87171; } + +/* === PipelineTab === */ +.pipeline-container { padding:16px; } +.pipeline-toolbar { display:flex; gap:12px; margin-bottom:16px; align-items:center; } +.pipeline-toolbar select { padding:6px 10px; background:rgba(255,255,255,.04); + border:1px solid var(--ms-line, #2a2a3a); border-radius:8px; color:var(--ms-text, #f0f0f5); font-size:13px; } +.pipeline-grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(320px, 1fr)); gap:16px; } +.pipeline-card { background:rgba(0,0,0,.3); border:1px solid var(--ms-line, #2a2a3a); + border-radius:14px; padding:16px; display:flex; flex-direction:column; gap:8px; } +.pipeline-card__head { display:flex; justify-content:space-between; align-items:center; } +.pipeline-card__head h4 { margin:0; font-size:14px; color:var(--ms-text, #f0f0f5); } +.pipeline-card__head button { padding:4px 10px; background:rgba(248,113,113,.15); color:#fca5a5; + border:1px solid rgba(248,113,113,.3); border-radius:6px; cursor:pointer; font-size:11px; } +.pipeline-progress { display:flex; gap:6px; margin:8px 0; } +.pipeline-dot { flex:1; text-align:center; padding:6px 0; border-radius:8px; + background:rgba(255,255,255,.05); font-size:11px; color:var(--ms-muted, #a0a0b0); } +.pipeline-dot.is-done { background:rgba(56,189,248,.2); color:#bae6fd; } +.pipeline-dot.is-current { box-shadow:0 0 8px rgba(56,189,248,.6); } +.pipeline-state { font-size:13px; color:var(--ms-text, #f0f0f5); } +.pipeline-review { font-size:12px; color:var(--ms-muted, #a0a0b0); } +.pipeline-review strong { color:#bae6fd; } +.pipeline-feedback { margin-top:8px; font-size:12px; color:var(--ms-muted, #a0a0b0); } +.pipeline-feedback summary { cursor:pointer; } +.pipeline-card a { color:#bae6fd; font-size:12px; } +.ms-empty { padding:32px; text-align:center; color:var(--ms-muted, #a0a0b0); grid-column:1/-1; } + +/* Modal — shared */ +.modal-overlay { position:fixed; inset:0; background:rgba(0,0,0,.6); + display:flex; align-items:center; justify-content:center; z-index:1000; } +.modal-body { background:#1a1a2e; padding:24px; border-radius:14px; min-width:320px; + border:1px solid var(--ms-line, #2a2a3a); } +.modal-body h3 { margin:0 0 12px; font-size:15px; color:var(--ms-text, #f0f0f5); } +.modal-body select { width:100%; padding:8px; background:rgba(255,255,255,.04); + border:1px solid var(--ms-line, #2a2a3a); border-radius:8px; color:var(--ms-text, #f0f0f5); font-size:13px; } +.modal-actions { display:flex; justify-content:flex-end; gap:8px; margin-top:16px; } +.modal-actions button { padding:6px 14px; background:rgba(255,255,255,.05); color:var(--ms-text, #f0f0f5); + border:1px solid var(--ms-line, #2a2a3a); border-radius:8px; cursor:pointer; font-size:13px; } +.modal-actions .button.primary { background:rgba(56,189,248,.2); color:#bae6fd; border-color:rgba(56,189,248,.4); } diff --git a/src/pages/music/components/PipelineCard.jsx b/src/pages/music/components/PipelineCard.jsx new file mode 100644 index 0000000..34461cb --- /dev/null +++ b/src/pages/music/components/PipelineCard.jsx @@ -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 ( +
+
+

{pipeline.track_title || `Track #${pipeline.track_id}`}

+ {!['published','cancelled','failed'].includes(pipeline.state) && ( + + )} +
+
+ {STEP_LABELS.map((lbl, idx) => ( +
+ {lbl} +
+ ))} +
+
현재: {pipeline.state}
+ {pipeline.review && ( +
+ AI 검토: {pipeline.review.verdict} + ({pipeline.review.weighted_total}/100) +
+ )} + {pipeline.state === 'publish_pending' && ( + + )} + {pipeline.youtube_video_id && ( + + 유튜브에서 보기 + + )} + {pipeline.feedback && pipeline.feedback.length > 0 && ( +
+ 피드백 히스토리 ({pipeline.feedback.length}) + {pipeline.feedback.map(f => ( +
• [{f.step}] {f.feedback_text}
+ ))} +
+ )} +
+ ); +} diff --git a/src/pages/music/components/PipelineStartModal.jsx b/src/pages/music/components/PipelineStartModal.jsx new file mode 100644 index 0000000..2932edb --- /dev/null +++ b/src/pages/music/components/PipelineStartModal.jsx @@ -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 ( +
+
e.stopPropagation()}> +

새 파이프라인 시작

+ + {error &&
{error}
} +
+ + +
+
+
+ ); +} diff --git a/src/pages/music/components/PipelineTab.jsx b/src/pages/music/components/PipelineTab.jsx new file mode 100644 index 0000000..e52bd30 --- /dev/null +++ b/src/pages/music/components/PipelineTab.jsx @@ -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 ( +
+
+ + +
+
+ {pipelines.map(p => ( + + ))} + {pipelines.length === 0 &&

진행 중인 파이프라인이 없습니다

} +
+ {modalOpen && ( + setModalOpen(false)} + onCreated={() => { setModalOpen(false); load(); }} + /> + )} +
+ ); +}