feat(web-ui): PipelineTab — 진행 중 파이프라인 카드 보드
This commit is contained in:
@@ -3279,3 +3279,41 @@
|
|||||||
.ms-error {
|
.ms-error {
|
||||||
color: #f87171;
|
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); }
|
||||||
|
|||||||
63
src/pages/music/components/PipelineCard.jsx
Normal file
63
src/pages/music/components/PipelineCard.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
src/pages/music/components/PipelineStartModal.jsx
Normal file
33
src/pages/music/components/PipelineStartModal.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
54
src/pages/music/components/PipelineTab.jsx
Normal file
54
src/pages/music/components/PipelineTab.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user