// Admin Page — API Keys, LLM, RAG Config, Transcription Schedule const AdminPage = ({ lang, activePage, currentUser }) => { const t = lang === 'zh'; const pages = { 'admin-api': , 'admin-llm': , 'admin-rag': , 'admin-schedule': , 'admin-queue': , 'admin-users': , 'admin-quota-requests': , 'admin-external-api': , }; return (

{t ? '後台管理' : 'Administration'}

{{ 'admin-api': t ? 'API 金鑰管理' : 'API Key Management', 'admin-llm': t ? 'LLM 模型設定' : 'LLM Model Settings', 'admin-rag': t ? 'RAG 參數設定' : 'RAG Configuration', 'admin-schedule': t ? '轉錄排程管理' : 'Transcription Schedule', 'admin-queue': t ? '轉錄序列' : 'Transcription Queue', 'admin-users': t ? '使用者管理' : 'User Management', 'admin-quota-requests': t ? 'Quota 申請' : 'Quota Requests', 'admin-external-api': t ? '外部 API 狀態' : 'External API Status' }[activePage]}

{pages[activePage]}
); }; // ── API Keys Tab ── // Provider colour palette. Free-form provider strings fall back to TOKEN.accent. const PROVIDER_COLORS = { openai: '#22c55e', anthropic: '#f59e0b', google: '#6366f1', 'zeabur-aihub': '#22d3ee', }; const PROVIDER_PRESETS = ['openai', 'anthropic', 'google', 'zeabur-aihub']; const ApiKeysTab = ({ lang }) => { const t = lang === 'zh'; const [keys, setKeys] = React.useState(null); const [error, setError] = React.useState(null); const [adding, setAdding] = React.useState(false); const [editing, setEditing] = React.useState(null); // { id, label, api_key } const [toast, setToast] = React.useState(null); const [newKey, setNewKey] = React.useState({ provider: '', label: '', api_key: '' }); const reload = React.useCallback(async () => { setError(null); try { const res = await apiFetch('/admin/api-keys'); if (!res.ok) throw new Error(`HTTP ${res.status}`); setKeys(await res.json()); } catch (err) { setError(err.message); } }, []); React.useEffect(() => { reload(); }, [reload]); const flashToast = (text, kind = 'success') => { setToast({ text, kind }); setTimeout(() => setToast(null), 3500); }; const handleCreate = async () => { if (!newKey.provider.trim() || !newKey.label.trim() || !newKey.api_key.trim()) { flashToast(t ? '請填寫完整欄位' : 'All fields required', 'error'); return; } try { const res = await apiFetch('/admin/api-keys', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(newKey), }); if (res.status === 409) { flashToast(t ? '同一供應商已有此 label' : 'Duplicate label for provider', 'error'); return; } if (!res.ok) throw new Error(`HTTP ${res.status}`); setAdding(false); setNewKey({ provider: '', label: '', api_key: '' }); flashToast(t ? '已新增' : 'Added'); await reload(); } catch (err) { flashToast((t ? '失敗:' : 'Failed: ') + err.message, 'error'); } }; const handleUpdate = async () => { const payload = {}; if (editing.label && editing.label.trim()) payload.label = editing.label.trim(); if (editing.api_key && editing.api_key.trim()) payload.api_key = editing.api_key.trim(); try { const res = await apiFetch(`/admin/api-keys/${editing.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); if (res.status === 409) { flashToast(t ? '同一供應商已有此 label' : 'Duplicate label for provider', 'error'); return; } if (!res.ok) throw new Error(`HTTP ${res.status}`); setEditing(null); flashToast(t ? '已更新' : 'Updated'); await reload(); } catch (err) { flashToast((t ? '失敗:' : 'Failed: ') + err.message, 'error'); } }; const handleDelete = async (k) => { if (!window.confirm(t ? `確定要刪除 ${k.provider}/${k.label}?` : `Delete ${k.provider}/${k.label}?`)) return; try { const res = await apiFetch(`/admin/api-keys/${k.id}`, { method: 'DELETE' }); if (res.status === 409) { const body = await res.json().catch(() => ({})); const refs = body?.detail?.referenced_by || []; flashToast( (t ? '無法刪除:仍被以下 step 使用 — ' : 'Cannot delete: still referenced by — ') + refs.join(', '), 'error', ); return; } if (!res.ok) throw new Error(`HTTP ${res.status}`); flashToast(t ? '已刪除' : 'Deleted'); await reload(); } catch (err) { flashToast((t ? '失敗:' : 'Failed: ') + err.message, 'error'); } }; return (

{t ? '管理各 LLM 供應商的 API 金鑰。AI 處理步驟設定頁會引用這裡的金鑰。' : 'Manage API keys per provider. Referenced by AI step config.'}

setAdding(true)} size="sm">{t ? '新增金鑰' : 'Add Key'}
{error && (
{(t ? '載入失敗:' : 'Load failed: ') + error}
)} {adding && (

{t ? '新增 API 金鑰' : 'Add API Key'}

setNewKey(k => ({ ...k, provider: e.target.value }))} placeholder="openai" /> {PROVIDER_PRESETS.map(p =>
setNewKey(k => ({ ...k, label: e.target.value }))} placeholder="main" />
setNewKey(k => ({ ...k, api_key: e.target.value }))} placeholder="sk-..." type="password" />
{t ? '儲存' : 'Save'} { setAdding(false); setNewKey({ provider: '', label: '', api_key: '' }); }}>{t ? '取消' : 'Cancel'}
)} {editing && (

{t ? '編輯金鑰' : 'Edit API Key'} · {editing.provider}

setEditing(s => ({ ...s, label: e.target.value }))} />
setEditing(s => ({ ...s, api_key: e.target.value }))} type="password" placeholder="sk-..." />
{t ? '儲存' : 'Save'} setEditing(null)}>{t ? '取消' : 'Cancel'}
)}
{keys === null ? (
{t ? '載入中…' : 'Loading…'}
) : keys.length === 0 ? (
{t ? '尚無金鑰' : 'No keys yet'}
) : keys.map(k => (
{k.provider}
{k.label}
{k.api_key_masked}
setEditing({ id: k.id, provider: k.provider, label: k.label, api_key: '' })} /> handleDelete(k)} />
))}
{toast && (
{toast.text}
)}
); }; // ── AI Steps Tab (replaces LLMTab) ── const STEP_KEYS_ORDER = ['answer', 'rewrite', 'summary', 'embedding', 'transcription']; const STEP_LABELS = { answer: { zh: 'Answer 模型(RAG 答案)', en: 'Answer (RAG)' }, rewrite: { zh: 'Rewrite 模型(查詢改寫)', en: 'Rewrite (query rewrite)' }, summary: { zh: 'Summary 模型(每集摘要)', en: 'Summary (per-episode)' }, embedding: { zh: 'Embedding 模型(向量化)', en: 'Embedding (vectorization)' }, transcription: { zh: 'Transcription(語音轉錄)', en: 'Transcription (speech)' }, }; // Common base_url / model presets per provider (UI hints; admin can free-type). const BASE_URL_PRESETS_BY_PROVIDER = { openai: ['https://api.openai.com/v1'], anthropic: ['https://api.anthropic.com/v1'], google: ['https://generativelanguage.googleapis.com/v1'], 'zeabur-aihub': ['https://hnd1.aihub.zeabur.ai/v1'], }; const CHAT_MODEL_PRESETS_BY_PROVIDER = { openai: ['gpt-4o', 'gpt-4o-mini', 'gpt-5-mini'], anthropic: ['claude-opus-4-7', 'claude-sonnet-4-6', 'claude-haiku-4-5'], google: ['gemini-2.5-pro'], 'zeabur-aihub': ['gpt-4o', 'gpt-4o-mini', 'gpt-5-mini'], }; const EMBEDDING_MODEL_PRESETS = ['text-embedding-3-small', 'text-embedding-3-large']; const WHISPER_API_MODEL_PRESETS = ['whisper-1']; const WHISPER_LOCAL_MODEL_PRESETS = ['base', 'small', 'medium', 'large-v3']; const AiStepsTab = ({ lang }) => { const t = lang === 'zh'; const [steps, setSteps] = React.useState(null); const [keys, setKeys] = React.useState(null); const [drafts, setDrafts] = React.useState({}); const [savingKey, setSavingKey] = React.useState(null); const [error, setError] = React.useState(null); const [toast, setToast] = React.useState(null); const flashToast = (text, kind = 'success') => { setToast({ text, kind }); setTimeout(() => setToast(null), 3500); }; const reload = React.useCallback(async () => { setError(null); try { const [stepsRes, keysRes] = await Promise.all([ apiFetch('/admin/ai-steps'), apiFetch('/admin/api-keys'), ]); if (!stepsRes.ok) throw new Error(`steps HTTP ${stepsRes.status}`); if (!keysRes.ok) throw new Error(`keys HTTP ${keysRes.status}`); const stepsData = await stepsRes.json(); const keysData = await keysRes.json(); setSteps(stepsData); setKeys(keysData); // seed drafts from server state so admin sees current values in inputs const seeded = {}; for (const s of stepsData) { seeded[s.step_key] = { base_url: s.base_url || '', model: s.model || '', api_key_id: s.api_key_id || '', extra_config: s.extra_config || {}, }; } setDrafts(seeded); } catch (err) { setError(err.message); } }, []); React.useEffect(() => { reload(); }, [reload]); const setDraft = (step_key, patch) => setDrafts(d => ({ ...d, [step_key]: { ...d[step_key], ...patch } })); const setExtra = (step_key, patch) => setDrafts(d => ({ ...d, [step_key]: { ...d[step_key], extra_config: { ...d[step_key].extra_config, ...patch } } })); const keysByProvider = React.useMemo(() => { if (!keys) return {}; const acc = {}; for (const k of keys) (acc[k.provider] = acc[k.provider] || []).push(k); return acc; }, [keys]); const providerOfKey = React.useCallback((api_key_id) => { if (!keys || !api_key_id) return null; return keys.find(k => k.id === api_key_id)?.provider || null; }, [keys]); const save = async (step) => { setSavingKey(step.step_key); const draft = drafts[step.step_key]; let payload; if (step.step_type === 'whisper') { const provider = draft.extra_config?.provider; if (provider === 'faster-whisper') { payload = { step_type: 'whisper', base_url: null, model: draft.model, api_key_id: null, extra_config: { provider: 'faster-whisper', model_dir: draft.extra_config?.model_dir || '' }, }; } else { payload = { step_type: 'whisper', base_url: draft.base_url, model: draft.model, api_key_id: draft.api_key_id || null, extra_config: { provider: 'openai' }, }; } } else { payload = { step_type: step.step_type, base_url: draft.base_url, model: draft.model, api_key_id: draft.api_key_id || null, extra_config: draft.extra_config || {}, }; } try { const res = await apiFetch(`/admin/ai-steps/${step.step_key}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); if (!res.ok) { const body = await res.json().catch(() => ({})); const detail = typeof body.detail === 'string' ? body.detail : JSON.stringify(body.detail); flashToast((t ? '儲存失敗:' : 'Save failed: ') + detail, 'error'); return; } flashToast(t ? `已儲存 ${step.step_key}` : `Saved ${step.step_key}`); await reload(); } catch (err) { flashToast((t ? '失敗:' : 'Failed: ') + err.message, 'error'); } finally { setSavingKey(null); } }; if (steps === null) return
{t ? '載入中…' : 'Loading…'}
; if (error) return
{(t ? '載入失敗:' : 'Load failed: ') + error}
; const stepsByKey = Object.fromEntries(steps.map(s => [s.step_key, s])); return (

{t ? '為每個 AI 處理步驟挑選 base_url、model 與 API 金鑰。金鑰請先到「API 金鑰管理」新增。' : 'Configure base_url, model, and api_key for each AI processing step. Add keys via "API Keys" tab first.'}

{STEP_KEYS_ORDER.map(sk => { const step = stepsByKey[sk]; if (!step) return null; return ( setDraft(sk, patch)} setExtra={(patch) => setExtra(sk, patch)} onSave={() => save(step)} saving={savingKey === sk} lang={lang} /> ); })}
{toast && (
{toast.text}
)}
); }; const AiStepSection = ({ step, draft, keys, keysByProvider, providerOfKey, setDraft, setExtra, onSave, saving, lang }) => { const t = lang === 'zh'; const isWhisper = step.step_type === 'whisper'; const whisperProvider = isWhisper ? (draft.extra_config?.provider || 'openai') : null; const isLocalWhisper = isWhisper && whisperProvider === 'faster-whisper'; // Embedding step: filter api_keys to provider==openai only. const visibleKeys = step.step_key === 'embedding' ? (keysByProvider.openai || []) : keys; // Provider that drives presets is whichever provider the chosen api_key has. const inferredProvider = providerOfKey(draft.api_key_id) || (step.step_key === 'embedding' ? 'openai' : null); const baseUrlPresets = inferredProvider ? (BASE_URL_PRESETS_BY_PROVIDER[inferredProvider] || []) : []; let modelPresets = []; if (step.step_type === 'chat') { modelPresets = inferredProvider ? (CHAT_MODEL_PRESETS_BY_PROVIDER[inferredProvider] || []) : []; } else if (step.step_type === 'embedding') { modelPresets = EMBEDDING_MODEL_PRESETS; } else if (isWhisper && !isLocalWhisper) { modelPresets = WHISPER_API_MODEL_PRESETS; } else if (isLocalWhisper) { modelPresets = WHISPER_LOCAL_MODEL_PRESETS; } const presetIdBase = `presets-${step.step_key}`; // Embedding model change warning: compare draft.model vs server-side step.model const embeddingModelChanged = step.step_key === 'embedding' && draft.model && step.model && draft.model !== step.model; return (

{STEP_LABELS[step.step_key][t ? 'zh' : 'en']}

{step.step_key} · {step.step_type}
{isWhisper && (
)} {!isLocalWhisper && ( <>
setDraft({ base_url: e.target.value })} placeholder="https://api.openai.com/v1" /> {baseUrlPresets.map(u =>
setDraft({ model: e.target.value })} placeholder="gpt-4o" /> {modelPresets.map(m =>
)} {isLocalWhisper && (
setDraft({ model: e.target.value })} placeholder="base" /> {WHISPER_LOCAL_MODEL_PRESETS.map(m =>
setExtra({ model_dir: e.target.value })} placeholder="/models/faster-whisper" />
)} {embeddingModelChanged && (
{t ? '⚠️ 改 model 會讓既有 vector 失效,需要 reindex。' : '⚠️ Changing the model invalidates existing vectors; reindex required.'}
)}
{saving ? (t ? '儲存中…' : 'Saving…') : (t ? '儲存' : 'Save')}
); }; const SliderParam = ({ label, value, min, max, step, onChange, hint }) => (
{value}
onChange(Number(e.target.value))} style={{ width: '100%', accentColor: TOKEN.accent }} />

{hint}

); // ── RAG Tab ── const RAGTab = ({ lang }) => { const t = lang === 'zh'; const [cfg, setCfg] = React.useState({ chunkSize: 512, overlap: 64, topK: 5, similarity: 0.72, embedModel: 'text-embedding-3-large', rerank: true, hybridSearch: true }); const set = (k, v) => setCfg(c => ({ ...c, [k]: v })); return (
set('chunkSize', v)} hint={t ? '建議 256–1024' : 'Recommended 256–1024'} /> set('overlap', v)} hint={t ? '避免截斷語意' : 'Prevents semantic cutoff'} />
set('topK', v)} hint={t ? '返回最相關的 K 個段落' : 'Return K most relevant segments'} /> set('similarity', v)} hint={`≥ ${cfg.similarity} ${t ? '才納入結果' : 'to include in results'}`} />
set('rerank', v)} hint={t ? '使用 Cross-Encoder 精排' : 'Use Cross-Encoder for precision'} /> set('hybridSearch', v)} hint={t ? 'BM25 + 向量搜尋混合' : 'BM25 + Vector search blend'} />
{['text-embedding-3-large', 'text-embedding-3-small', 'text-embedding-ada-002'].map(m => ( ))}
{t ? '儲存設定' : 'Save Configuration'}
); }; const Section = ({ title, children }) => (

{title}

{children}
); const ToggleParam = ({ label, value, onChange, hint }) => (
onChange(!value)} style={{ width: 36, height: 20, borderRadius: 99, background: value ? TOKEN.accent : TOKEN.surfaceBorder, cursor: 'pointer', position: 'relative', transition: 'background 0.15s', flexShrink: 0 }}>

{hint}

); // ── Schedule Tab ── // useTranscriptionStatus: poll GET /shows/{id}/transcription-status every 5s while enabled. // Returns { data, error }. enabled=false returns idle state (no fetch, no interval). const useTranscriptionStatus = (showId, enabled) => { const [data, setData] = React.useState(null); const [error, setError] = React.useState(null); React.useEffect(() => { if (!enabled || !showId) return undefined; let cancelled = false; const controller = new AbortController(); const fetchOnce = async () => { try { const res = await apiFetch(`/shows/${showId}/transcription-status`, { signal: controller.signal }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const json = await res.json(); if (!cancelled) { setData(json); setError(null); } } catch (err) { if (cancelled || err.name === 'AbortError') return; setError(err.message || String(err)); } }; fetchOnce(); const id = setInterval(fetchOnce, 5000); return () => { cancelled = true; controller.abort(); clearInterval(id); }; }, [showId, enabled]); return { data, error }; }; const TranscriptionProgressPanel = ({ showId, expanded, lang }) => { const t = lang === 'zh'; const { data, error } = useTranscriptionStatus(showId, expanded); if (!expanded) return null; if (error && !data) { return (
{(t ? '載入進度失敗:' : 'Failed to load progress: ') + error}
); } if (!data) { return (
{t ? '載入中…' : 'Loading…'}
); } return (
{t ? '處理中' : 'Currently Processing'}
{data.currently_processing.length === 0 ? (
{t ? '目前沒有轉錄中' : 'None currently processing'}
) : (
    {data.currently_processing.map(ep => (
  • {ep.episode_title}
  • ))}
)}
{t ? '近期失敗' : 'Recent Failures'}
{data.recent_failures.length === 0 ? (
{t ? '近期沒有失敗' : 'No recent failures'}
) : (
    {data.recent_failures.map(ep => { const badge = categoryToBadge(ep.error_category, lang); return (
  • {ep.episode_title} {ep.error_category && {badge.label}}
    {ep.error_message && (
    {ep.error_message}
    )}
  • ); })}
)}
); }; const ScheduleTab = ({ lang }) => { const t = lang === 'zh'; const { isMobile } = useViewport(); const [shows, setShows] = React.useState(null); const [loading, setLoading] = React.useState(true); const [fetchError, setFetchError] = React.useState(null); const [showForm, setShowForm] = React.useState(false); const [form, setForm] = React.useState({ rss: '', name: '', freq: 'daily', time: '06:00', dayOfWeek: 0, whisperModel: 'large-v3', maxEp: 0 }); const [rssLoading, setRssLoading] = React.useState(false); const [rssError, setRssError] = React.useState(null); const [rssPreview, setRssPreview] = React.useState(null); const [syncingId, setSyncingId] = React.useState(null); const [confirmState, setConfirmState] = React.useState(null); const [queueStatus, setQueueStatus] = React.useState(null); const [editState, setEditState] = React.useState(null); const [runningId, setRunningId] = React.useState(null); // Selection set is transient client-side state; not persisted. const [selectedIds, setSelectedIds] = React.useState(() => new Set()); const [expandedIds, setExpandedIds] = React.useState(() => new Set()); const toggleExpand = (showId) => { setExpandedIds(prev => { const next = new Set(prev); if (next.has(showId)) next.delete(showId); else next.add(showId); return next; }); }; const [batchRefreshing, setBatchRefreshing] = React.useState(false); const [batchTranscribing, setBatchTranscribing] = React.useState(false); const [batchTranscribeConfirmOpen, setBatchTranscribeConfirmOpen] = React.useState(false); const setF = (k, v) => setForm(f => ({ ...f, [k]: v })); const toggleSelect = (showId) => { setSelectedIds(prev => { const next = new Set(prev); if (next.has(showId)) next.delete(showId); else next.add(showId); return next; }); }; const clearSelection = () => setSelectedIds(new Set()); const selectAll = (allShowIds) => setSelectedIds(new Set(allShowIds)); const fetchQueueStatus = React.useCallback(async () => { try { const res = await apiFetch(`/admin/queue-status`); if (!res.ok) return; setQueueStatus(await res.json()); } catch (_) { // 靜默失敗,不影響主 UI } }, []); React.useEffect(() => { fetchQueueStatus(); const id = setInterval(fetchQueueStatus, 30000); return () => clearInterval(id); }, [fetchQueueStatus]); const loadSchedules = React.useCallback(async () => { setLoading(true); setFetchError(null); try { const res = await apiFetch(`/admin/schedules`); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); setShows(data); } catch (err) { setFetchError(err.message); } finally { setLoading(false); } }, []); React.useEffect(() => { loadSchedules(); }, [loadSchedules]); const VALID_FREQUENCIES = ['daily', 'weekly', 'manual']; const DAY_LABELS_ZH = ['一', '二', '三', '四', '五', '六', '日']; const DAY_LABELS_EN = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; const formatScheduleHint = (form, lang) => { const isZh = lang === 'zh'; if (form.frequency === 'manual') { return isZh ? '不會自動執行' : 'Will not run automatically'; } if (form.frequency === 'daily') { return isZh ? `每日 ${form.run_time} (UTC) 觸發` : `Runs daily at ${form.run_time} (UTC)`; } if (form.frequency === 'weekly') { const idx = Math.max(0, Math.min(6, form.day_of_week ?? 0)); const dayZh = DAY_LABELS_ZH[idx]; const dayEn = DAY_LABELS_EN[idx]; return isZh ? `每週${dayZh} ${form.run_time} (UTC) 觸發` : `Runs every ${dayEn} at ${form.run_time} (UTC)`; } return ''; }; const handleOpenEdit = (item) => { if (!item.schedule) return; const persistedFreq = item.schedule.frequency; const isLegacy = !VALID_FREQUENCIES.includes(persistedFreq); setEditState({ item, hourlyFallback: isLegacy, form: { enabled: item.schedule.enabled === true, frequency: isLegacy ? 'daily' : persistedFreq, run_time: item.schedule.run_time, day_of_week: item.schedule.day_of_week ?? 0, whisper_model: item.schedule.whisper_model, max_episodes_per_run: item.schedule.max_episodes_per_run, }, }); }; const handleOpenAddSchedule = (item) => { setEditState({ item, hourlyFallback: false, form: { enabled: false, frequency: 'manual', run_time: '06:00', day_of_week: 0, whisper_model: 'large-v3', max_episodes_per_run: 5, }, }); }; const handleSaveEdit = async () => { if (!editState) return; try { const res = await apiFetch(`/shows/${editState.item.show_id}/schedule`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(editState.form), }); if (!res.ok) { const detail = await res.text(); throw new Error(detail || `HTTP ${res.status}`); } setEditState(null); await loadSchedules(); } catch (err) { alert((t ? '更新失敗:' : 'Update failed: ') + err.message); } }; const handleBatchRefreshEpisodes = async () => { const ids = Array.from(selectedIds); if (ids.length === 0) return; const idToTitle = new Map((shows || []).map(s => [s.show_id, s.show_title])); setBatchRefreshing(true); try { const results = await Promise.allSettled( ids.map(async (id) => { const res = await apiFetch(`/shows/${id}/sync`, { method: 'POST' }); if (!res.ok) { const detail = await res.text(); throw new Error(detail || `HTTP ${res.status}`); } return res.json(); }) ); let added = 0, updated = 0; const failures = []; results.forEach((r, idx) => { if (r.status === 'fulfilled') { added += r.value.added || 0; updated += r.value.updated || 0; } else { failures.push(`${idToTitle.get(ids[idx]) || ids[idx]}: ${r.reason && r.reason.message ? r.reason.message : 'error'}`); } }); const summary = t ? `已更新 ${ids.length - failures.length}/${ids.length} 個節目(新增 ${added} 集、更新 ${updated} 集)` : `Refreshed ${ids.length - failures.length}/${ids.length} shows (added ${added}, updated ${updated})`; const failText = failures.length ? '\n' + (t ? '失敗:\n' : 'Failed:\n') + failures.join('\n') : ''; alert(summary + failText); await loadSchedules(); // Selection persists (per spec). } finally { setBatchRefreshing(false); } }; const handleBatchTranscribePending = async () => { const ids = Array.from(selectedIds); if (ids.length === 0) return; const idToTitle = new Map((shows || []).map(s => [s.show_id, s.show_title])); setBatchTranscribing(true); try { const results = await Promise.allSettled( ids.map(async (id) => { // Omit max_episodes query so backend uses each show's own schedule.max_episodes. const res = await apiFetch(`/shows/${id}/transcribe-latest`, { method: 'POST' }); if (!res.ok) { const detail = await res.text(); throw new Error(detail || `HTTP ${res.status}`); } return res.json(); }) ); let queued = 0; const failures = []; results.forEach((r, idx) => { if (r.status === 'fulfilled') { queued += r.value.queued || 0; } else { failures.push(`${idToTitle.get(ids[idx]) || ids[idx]}: ${r.reason && r.reason.message ? r.reason.message : 'error'}`); } }); const summary = t ? `已對 ${ids.length - failures.length}/${ids.length} 個節目排入 ${queued} 集轉錄` : `Queued ${queued} episodes across ${ids.length - failures.length}/${ids.length} shows`; const failText = failures.length ? '\n' + (t ? '失敗:\n' : 'Failed:\n') + failures.join('\n') : ''; alert(summary + failText); await loadSchedules(); } finally { setBatchTranscribing(false); } }; const handleRunNow = async (item) => { setRunningId(item.show_id); try { const res = await apiFetch(`/shows/${item.show_id}/transcribe-latest`, { method: 'POST' }); if (!res.ok) { const detail = await res.text(); throw new Error(detail || `HTTP ${res.status}`); } const data = await res.json(); alert(t ? `已排入 ${data.queued} 集(新增 ${data.synced.added}/更新 ${data.synced.updated})` : `Queued ${data.queued} episodes (added ${data.synced.added}/updated ${data.synced.updated})`); } catch (err) { alert((t ? '執行失敗:' : 'Run failed: ') + err.message); } finally { setRunningId(null); await loadSchedules(); } }; const handleFetchRSS = async () => { if (!form.rss) return; setRssLoading(true); setRssError(null); setRssPreview(null); try { const res = await apiFetch(`/rss-preview?url=${encodeURIComponent(form.rss)}`); if (!res.ok) { const detail = await res.text(); throw new Error(detail || `HTTP ${res.status}`); } const data = await res.json(); setRssPreview(data); setF('name', data.title); } catch (err) { setRssError(err.message); } finally { setRssLoading(false); } }; const handleAddSchedule = async () => { if (!form.rss || !form.name) return; try { const createRes = await apiFetch(`/shows`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ rss_url: form.rss }), }); if (!createRes.ok && createRes.status !== 409) { const detail = await createRes.text(); throw new Error(detail || `HTTP ${createRes.status}`); } let show; if (createRes.ok) { show = await createRes.json(); } else { const listRes = await apiFetch(`/shows`); const list = await listRes.json(); show = list.find(s => s.rss_url === form.rss); if (!show) throw new Error(t ? '找不到對應節目' : 'Show not found'); } await apiFetch(`/shows/${show.id}/schedule`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled: true, frequency: form.freq, run_time: form.time, day_of_week: form.dayOfWeek, whisper_model: form.whisperModel, max_episodes_per_run: form.maxEp || 5, }), }); setShowForm(false); setForm({ rss: '', name: '', freq: 'daily', time: '06:00', whisperModel: 'large-v3', maxEp: 5 }); setRssPreview(null); await loadSchedules(); } catch (err) { alert((t ? '建立失敗:' : 'Create failed: ') + err.message); } }; const handleSyncShow = async (item) => { setSyncingId(item.show_id); try { const res = await apiFetch(`/shows/${item.show_id}/sync`, { method: 'POST' }); if (!res.ok) { const detail = await res.text(); throw new Error(detail || `HTTP ${res.status}`); } const data = await res.json(); alert(t ? `已更新節目集數:新增 ${data.added} 集、更新 ${data.updated} 集(總計 ${data.total} 集)` : `Episodes refreshed: added ${data.added}, updated ${data.updated} (total ${data.total})`); await loadSchedules(); } catch (err) { alert((t ? '更新失敗:' : 'Refresh failed: ') + err.message); } finally { setSyncingId(null); } }; const handleRemoveSchedule = async (item) => { try { const res = await apiFetch(`/shows/${item.show_id}/schedule`, { method: 'DELETE' }); if (!res.ok && res.status !== 204) { const detail = await res.text(); throw new Error(detail || `HTTP ${res.status}`); } await loadSchedules(); } catch (err) { alert((t ? '移除排程失敗:' : 'Remove schedule failed: ') + err.message); } }; const handleDeleteShow = async (item) => { try { const res = await apiFetch(`/shows/${item.show_id}`, { method: 'DELETE' }); if (!res.ok && res.status !== 204) { const detail = await res.text(); throw new Error(detail || `HTTP ${res.status}`); } await loadSchedules(); } catch (err) { alert((t ? '刪除節目失敗:' : 'Delete show failed: ') + err.message); } }; const openDeleteShowConfirm = async (item) => { let pending_count = 0, running_count = 0; try { const res = await apiFetch(`/admin/queue`); if (res.ok) { const q = await res.json(); pending_count = (q.pending || []).filter(r => r.show_id === item.show_id).length; running_count = (q.running || []).filter(r => r.show_id === item.show_id).length; } } catch {} setConfirmState({ kind: 'delete-show', item, cascade: { pending_count, running_count } }); }; const confirmLabels = { 'delete-show': { title: t ? '刪除節目' : 'Delete Show', message: (item, extra) => { const base = t ? `即將刪除節目「${item.show_title}」及其所有集數、逐字稿、排程設定。此操作不可復原。` : `About to delete show "${item.show_title}" and all its episodes, transcripts, and schedule. This cannot be undone.`; const cascade = extra && extra.cascade; if (cascade && (cascade.pending_count > 0 || cascade.running_count > 0)) { const cascadeLine = t ? `將同時取消 ${cascade.pending_count} 筆排隊中、${cascade.running_count} 筆執行中的轉錄任務。` : `Will cancel ${cascade.pending_count} pending and ${cascade.running_count} running transcription jobs.`; return base + '\n\n' + cascadeLine; } return base; }, confirmLabel: t ? '確認刪除' : 'Confirm Delete', handler: handleDeleteShow, }, 'remove-schedule': { title: t ? '移除排程' : 'Remove Schedule', message: (item) => t ? `即將移除節目「${item.show_title}」的轉錄排程設定。節目與已轉錄集數不受影響。` : `About to remove the transcription schedule for "${item.show_title}". The show and transcribed episodes are not affected.`, confirmLabel: t ? '確認移除' : 'Confirm Remove', handler: handleRemoveSchedule, }, }; const renderConfirmMessage = () => { if (!confirmState) return ''; const cfg = confirmLabels[confirmState.kind]; return cfg.message(confirmState.item, confirmState); }; return (

{t ? '設定各節目的自動轉錄排程與進度監控。' : 'Configure auto-transcription schedules and monitor progress.'}

setShowForm(v => !v)}>{t ? '新增節目' : 'Add Show'}
{queueStatus && (
🟢 {t ? '執行中' : 'Active'} {queueStatus.active}/{queueStatus.max_concurrent} ⏳ {t ? '佇列中' : 'Queued'} {queueStatus.pending_in_db}
)} {/* Add Schedule Form */} {showForm && (

{t ? '新增節目轉錄排程' : 'New Show with Transcription Schedule'}

{/* RSS Input */}
setF('rss', e.target.value)} placeholder="https://feeds.example.com/my-podcast" icon="rss" />
{rssLoading ? (t ? '讀取中...' : 'Loading...') : (t ? '讀取 RSS' : 'Fetch RSS')}
{rssPreview && (
{rssPreview.title}
{rssPreview.episode_count} {t ? '集' : 'eps'}{rssPreview.latest_published_at ? ` · ${t ? '最新' : 'Latest'}: ${rssPreview.latest_published_at.slice(0, 10)}` : ''}
)} {rssError && (
{rssError}
)}
{/* Name */}
setF('name', e.target.value)} placeholder={t ? '輸入或自動填入' : 'Enter or auto-filled from RSS'} />
{/* Frequency */}
{/* Time */}
setF('time', e.target.value)} style={{ width: '100%', background: TOKEN.surfaceRaised, border: `1px solid ${TOKEN.surfaceBorder}`, borderRadius: 8, padding: '9px 12px', color: TOKEN.text, fontSize: 14, outline: 'none', fontFamily: 'inherit', colorScheme: 'dark' }} />
{/* Max episodes */}
setF('maxEp', Number(e.target.value))} style={{ width: '100%', background: TOKEN.surfaceRaised, border: `1px solid ${TOKEN.surfaceBorder}`, borderRadius: 8, padding: '9px 12px', color: TOKEN.text, fontSize: 14, outline: 'none', fontFamily: 'inherit' }} />
{/* Whisper Model */}
{[ { id: 'large-v3', label: 'large-v3', hint: t ? '最高精度' : 'Best accuracy' }, { id: 'medium', label: 'medium', hint: t ? '平衡' : 'Balanced' }, { id: 'small', label: 'small', hint: t ? '快速' : 'Fast' }, { id: 'base', label: 'base', hint: t ? '最快' : 'Fastest' }, ].map(m => (
setF('whisperModel', m.id)} style={{ padding: '7px 14px', borderRadius: 8, border: `1px solid ${form.whisperModel === m.id ? TOKEN.accent : TOKEN.surfaceBorder}`, background: form.whisperModel === m.id ? TOKEN.accentDim : TOKEN.surfaceRaised, cursor: 'pointer', transition: 'all 0.12s' }}>
{m.label}
{m.hint}
))}
{t ? '建立排程' : 'Create Schedule'} { setShowForm(false); setRssPreview(null); }}>{t ? '取消' : 'Cancel'}
)} {loading && (
{t ? '載入中...' : 'Loading...'}
)} {fetchError && (
{(t ? '載入失敗:' : 'Load failed: ') + fetchError}
)} {!loading && !fetchError && shows && (
{shows.length === 0 && (
{t ? '目前沒有節目,請先新增。' : 'No shows yet.'}
)} {shows.length > 0 && (() => { const allIds = shows.map(s => s.show_id); const allSelected = allIds.length > 0 && allIds.every(id => selectedIds.has(id)); const someSelected = selectedIds.size > 0; return ( {/* Master row: select-all checkbox + (when selected) count + clear */}
{someSelected && ( {t ? `已選 ${selectedIds.size} 個` : `${selectedIds.size} selected`} )}
{/* Batch action bar — only when something is selected */} {someSelected && (
{t ? `已選 ${selectedIds.size} 個節目` : `${selectedIds.size} shows selected`} {batchRefreshing ? (t ? '更新中...' : 'Refreshing...') : (t ? '更新節目集數' : 'Refresh Episodes')} setBatchTranscribeConfirmOpen(true)} disabled={batchRefreshing || batchTranscribing}> {batchTranscribing ? (t ? '排入中...' : 'Queueing...') : (t ? '轉錄未完成集數' : 'Transcribe Pending')} {t ? '取消選取' : 'Clear'}
)}
); })()} {shows.map(item => { const sched = item.schedule; const checked = selectedIds.has(item.show_id); const lastTx = item.last_transcribed_at ? item.last_transcribed_at.slice(0, 16).replace('T', ' ') : '—'; const refreshDisabled = syncingId === item.show_id; const refreshLabel = refreshDisabled ? (t ? '更新中...' : 'Refreshing...') : (t ? '更新節目集數' : 'Refresh Episodes'); const menuItems = [ ...(sched ? [] : [{ label: t ? '新增排程' : 'Add Schedule', icon: 'plus', onClick: () => handleOpenAddSchedule(item) }]), { label: refreshLabel, icon: 'refresh', onClick: () => handleSyncShow(item), disabled: refreshDisabled }, ...(sched ? [{ label: t ? '編輯排程' : 'Edit Schedule', icon: 'settings', onClick: () => handleOpenEdit(item) }] : []), ...(sched ? [{ label: t ? '移除排程' : 'Remove Schedule', icon: 'trash', onClick: () => setConfirmState({ kind: 'remove-schedule', item }) }] : []), { label: t ? '刪除節目' : 'Delete Show', icon: 'trash', onClick: () => openDeleteShowConfirm(item), danger: true }, ]; return (
toggleSelect(item.show_id)} aria-label={t ? `選取 ${item.show_title}` : `Select ${item.show_title}`} style={{ accentColor: TOKEN.accent, width: 16, height: 16, cursor: 'pointer', marginTop: 5, flexShrink: 0 }} />
{item.show_title} {item.pending_count > 0 && {item.pending_count} {t ? '集待轉錄' : 'pending'}} {!sched && {t ? '未設定' : 'No schedule'}}
{item.rss_url} {sched && {t ? '頻率' : 'Freq'}: {sched.frequency}{sched.frequency === 'weekly' ? ` · ${(t ? DAY_LABELS_ZH : DAY_LABELS_EN)[sched.day_of_week ?? 0]}` : ''} · {sched.run_time}} {t ? '最後轉錄' : 'Last'}: {lastTx} {sched && {sched.whisper_model}}
toggleExpand(item.show_id)}> {expandedIds.has(item.show_id) ? (t ? '收合進度' : 'Hide Progress') : (t ? '查看進度' : 'View Progress')} {sched && ( handleRunNow(item)} disabled={runningId === item.show_id}> {runningId === item.show_id ? (t ? '執行中...' : 'Running...') : (t ? '立刻執行轉錄' : 'Run Transcribe Now')} )}
{sched && (() => { const status = sched.last_refresh_status || 'pending'; const ts = sched.last_refresh_at; const msg = sched.last_refresh_message; const colorMap = { success: '#22c55e', failed: '#f87171', pending: TOKEN.textMuted }; const iconMap = { success: '✓', failed: '✗', pending: '·' }; const color = colorMap[status] || TOKEN.textMuted; const icon = iconMap[status] || '·'; const label = ts ? (t ? `${formatRelativeTime(new Date(ts).getTime(), lang)}刷新` : `Refreshed ${formatRelativeTime(new Date(ts).getTime(), lang)}`) : (t ? '尚未刷新' : 'Not yet refreshed'); return (
{icon} {label} {status === 'failed' && msg && ( {msg} )}
); })()}
); })}
)} { const { kind, item } = confirmState; setConfirmState(null); confirmLabels[kind].handler(item); }} onCancel={() => setConfirmState(null)} /> { setBatchTranscribeConfirmOpen(false); handleBatchTranscribePending(); }} onCancel={() => setBatchTranscribeConfirmOpen(false)} /> setEditState(null)} > {editState && (
setEditState(s => ({ ...s, form: { ...s.form, enabled: !s.form.enabled } }))} role="switch" aria-checked={editState.form.enabled} style={{ width: 36, height: 20, borderRadius: 99, background: editState.form.enabled ? TOKEN.accent : TOKEN.surfaceBorder, cursor: 'pointer', position: 'relative', transition: 'background 0.15s', flexShrink: 0 }}>
{editState.form.enabled ? (t ? '已啟用' : 'Enabled') : (t ? '已停用' : 'Disabled')}

{t ? '待 cron 功能上線後生效。' : 'Takes effect once cron support ships.'}

{editState.hourlyFallback && (
{t ? '原設定『每小時』已停用,已改為每天,請確認後儲存。' : "The previous 'hourly' setting is no longer supported; switched to daily. Please confirm and save."}
)} {editState.form.frequency === 'manual' && ( <>
{t ? '不會自動執行,需從清單點「立即執行」' : 'Will not run automatically. Trigger manually from the list.'}
{formatScheduleHint(editState.form, lang)}
)}
{editState.form.frequency === 'weekly' && (
{(t ? DAY_LABELS_ZH : DAY_LABELS_EN).map((label, i) => { const selected = editState.form.day_of_week === i; return ( ); })}
)} {editState.form.frequency !== 'manual' && (
setEditState(s => ({ ...s, form: { ...s.form, run_time: e.target.value } }))} style={{ width: '100%', boxSizing: 'border-box', background: TOKEN.surfaceRaised, border: `1px solid ${TOKEN.surfaceBorder}`, borderRadius: 8, padding: '9px 12px', color: TOKEN.text, fontSize: 14, outline: 'none', fontFamily: 'inherit', colorScheme: 'dark' }} />
{formatScheduleHint(editState.form, lang)}
)}
setEditState(s => ({ ...s, form: { ...s.form, max_episodes_per_run: Number(e.target.value) } }))} style={{ width: '100%', boxSizing: 'border-box', background: TOKEN.surfaceRaised, border: `1px solid ${TOKEN.surfaceBorder}`, borderRadius: 8, padding: '9px 12px', color: TOKEN.text, fontSize: 14, outline: 'none', fontFamily: 'inherit' }} />
{editState.item && editState.item.schedule && (
{t ? '最後刷新狀態' : 'Last Refresh'}
{t ? '時間:' : 'At: '}{editState.item.schedule.last_refresh_at ? new Date(editState.item.schedule.last_refresh_at).toLocaleString() : (t ? '尚未刷新' : 'Not yet refreshed')}
{t ? '狀態:' : 'Status: '}{editState.item.schedule.last_refresh_status || '—'}
{t ? '訊息:' : 'Message: '}{editState.item.schedule.last_refresh_message || '—'}
)}
)}
); }; Object.assign(window, { AdminPage });