Files
video-subtitle-remover/web/index.html
T
liuchenandClaude Haiku 4.5 b2dfbce962
Docker Build and Push / check-secrets (push) Failing after 4s
Docker Build and Push / build-and-push (cpu, latest) (push) Has been skipped
Docker Build and Push / build-and-push (directml, latest) (push) Has been skipped
Docker Build and Push / build-and-push (cuda, 11.8) (push) Has been skipped
Docker Build and Push / build-and-push (cuda, 12.6) (push) Has been skipped
Docker Build and Push / build-and-push (cuda, 12.8) (push) Has been skipped
Build Windows CPU / build (push) Has been cancelled
Build Windows CUDA 11.8 / build (push) Has been cancelled
Build Windows CUDA 12.6 / build (push) Has been cancelled
Build Windows CUDA 12.8 / build (push) Has been cancelled
Build Windows DirectML / build (push) Has been cancelled
Refactor VSR web: align UI with df-autotest, add auth and platform worker
- Redesign web/index.html to match df-autotest light theme (sidebar layout,
  same token system, badges, panels, modals, toast notifications)
- Add simple cookie-based auth (ADMIN_PASSWORD env via .env)
- Add platform Worker (poll external platform for VSR tasks, sign requests
  with HMAC same as df-autotest)
- Add Settings page for platform connection config (DB-stored, no env vars)
- Add worker log API with incremental polling
- Remove bottom console strip; add per-task log modal with SQLite persistence
- Task table: add created_at / started_at columns, move progress to detail modal
- Fix modal close buttons (direct onclick, remove unreliable data-close delegation)
- Load .env via python-dotenv on startup

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-06-27 14:03:50 +08:00

1327 lines
60 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>视频字幕去除器 · VSR</title>
<style>
:root {
color-scheme: light;
--bg: #eef1f6;
--bg-grad-1: #f3f6fb;
--bg-grad-2: #e7ecf4;
--surface: #ffffff;
--surface-2: #f7f9fc;
--ink: #131a24;
--ink-2: #3d4856;
--muted: #707f90;
--line: #e2e8f0;
--line-strong: #cdd7e3;
--brand: #1f6feb;
--brand-2: #0a8a8a;
--brand-grad: linear-gradient(135deg, #2563eb 0%, #0ea5a5 100%);
--good: #16895a;
--good-bg: #e6f6ee;
--warn: #b5710a;
--warn-bg: #fdf0d8;
--bad: #cf3636;
--bad-bg: #fde8e8;
--info: #2563eb;
--info-bg: #e8f0ff;
--neutral: #5b6675;
--neutral-bg: #eef2f7;
--radius: 14px;
--radius-sm: 10px;
--shadow-sm: 0 1px 2px rgba(19,26,36,.06), 0 1px 1px rgba(19,26,36,.04);
--shadow: 0 10px 30px rgba(19,26,36,.08), 0 2px 6px rgba(19,26,36,.05);
--shadow-lg: 0 24px 60px rgba(19,26,36,.16);
}
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; }
body {
background:
radial-gradient(1200px 600px at 100% -10%, rgba(37,99,235,.08), transparent 60%),
radial-gradient(1000px 500px at -10% 110%, rgba(14,165,165,.08), transparent 55%),
linear-gradient(160deg, var(--bg-grad-1), var(--bg-grad-2));
color: var(--ink);
font-family: "Inter", "PingFang SC", "Microsoft YaHei", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
display: flex;
flex-direction: column;
min-height: 100vh;
}
button, input, select, textarea { font: inherit; color: inherit; }
button { border: 0; cursor: pointer; background: none; }
h1, h2, h3, h4, p { margin: 0; }
/* ── Shell ─────────────────────────────────────────────── */
.shell {
flex: 1;
display: grid;
grid-template-columns: 264px minmax(0, 1fr);
min-height: 0;
}
/* ── Sidebar ────────────────────────────────────────────── */
.sidebar {
background: linear-gradient(180deg, #101826 0%, #0b1320 100%);
color: #e8eef6;
padding: 22px 16px;
display: flex;
flex-direction: column;
gap: 22px;
position: sticky;
top: 0;
height: 100vh;
overflow-y: auto;
}
.brand { display: flex; align-items: center; gap: 12px; }
.brand-mark {
width: 44px; height: 44px; border-radius: 12px;
display: grid; place-items: center;
background: var(--brand-grad);
color: #fff; font-weight: 800; font-size: 15px;
box-shadow: 0 8px 20px rgba(37,99,235,.35);
}
.brand strong { display: block; font-size: 15px; letter-spacing: .2px; }
.brand span { display: block; color: #93a4b8; font-size: 11px; margin-top: 2px; }
.nav-block { display: grid; gap: 4px; }
.nav-label { font-size: 11px; text-transform: uppercase; letter-spacing: 1px; color: #6c7e93; padding: 6px 12px 2px; }
.nav-item {
min-height: 42px; border-radius: 10px;
display: flex; align-items: center; gap: 11px; padding: 0 12px;
color: #c4d2e2; font-size: 14px; font-weight: 600;
transition: background 140ms ease, color 140ms ease;
width: 100%; text-align: left;
}
.nav-item:hover { background: rgba(255,255,255,.06); color: #fff; }
.nav-item.active {
background: linear-gradient(135deg, rgba(37,99,235,.28), rgba(14,165,165,.22));
color: #fff;
box-shadow: inset 0 0 0 1px rgba(255,255,255,.08);
}
.nav-icon {
width: 26px; height: 26px; border-radius: 8px;
display: inline-grid; place-items: center;
background: rgba(255,255,255,.08);
font-size: 13px;
}
.sidebar-footer {
margin-top: auto;
border-top: 1px solid rgba(255,255,255,.08);
padding-top: 16px; display: grid; gap: 10px;
}
.ws-pill {
display: inline-flex; width: fit-content; align-items: center; gap: 8px;
border-radius: 999px; padding: 7px 12px;
background: rgba(207,54,54,.18); color: #f08080;
font-size: 12px; font-weight: 600;
transition: background .3s, color .3s;
}
.ws-pill.connected { background: rgba(14,165,165,.16); color: #8ce8e3; }
.ws-dot { width: 8px; height: 8px; border-radius: 50%; background: currentColor; }
.logout-btn {
display: flex; align-items: center; gap: 8px; padding: 8px 12px;
border-radius: 8px; color: #93a4b8; font-size: 12.5px; font-weight: 600;
transition: background 140ms ease, color 140ms ease;
width: 100%; text-align: left;
}
.logout-btn:hover { background: rgba(255,255,255,.06); color: #fff; }
/* ── Main ───────────────────────────────────────────────── */
main {
padding: 26px 30px 0;
display: grid;
grid-template-rows: auto auto auto minmax(0,1fr);
gap: 20px;
overflow: hidden;
}
.topbar { display: flex; justify-content: space-between; gap: 18px; align-items: flex-start; flex-wrap: wrap; }
h1 { font-size: 24px; line-height: 1.2; letter-spacing: -.2px; }
.subtle { color: var(--muted); font-size: 13px; }
.actions { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
.button {
min-height: 40px; border-radius: 11px; padding: 0 18px;
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
background: var(--brand-grad); color: #fff; font-weight: 700; font-size: 13.5px;
box-shadow: 0 8px 20px rgba(37,99,235,.25);
transition: transform 120ms ease, box-shadow 120ms ease, filter 120ms ease;
}
.button:hover { transform: translateY(-1px); box-shadow: 0 12px 28px rgba(37,99,235,.32); }
.button:active { transform: translateY(0); filter: brightness(.97); }
.button:disabled { opacity: .45; cursor: not-allowed; transform: none; }
.button.secondary {
background: var(--surface); color: var(--ink-2);
border: 1px solid var(--line-strong); box-shadow: var(--shadow-sm);
}
.button.secondary:hover { background: var(--surface-2); box-shadow: var(--shadow-sm); }
.icon-button {
width: 40px; height: 40px; border-radius: 11px;
display: inline-grid; place-items: center;
background: var(--surface); color: var(--ink-2);
border: 1px solid var(--line-strong); box-shadow: var(--shadow-sm);
font-size: 16px; font-weight: 700;
transition: background 120ms ease, transform 120ms ease;
}
.icon-button:hover { background: var(--surface-2); }
/* ── Metrics ────────────────────────────────────────────── */
.summary-grid { display: grid; grid-template-columns: repeat(4, minmax(0,1fr)); gap: 16px; }
.metric {
position: relative; overflow: hidden;
min-height: 100px; background: var(--surface);
border: 1px solid var(--line); border-radius: var(--radius);
padding: 18px; box-shadow: var(--shadow);
display: grid; align-content: space-between; gap: 4px;
}
.metric::before {
content: ""; position: absolute; inset: 0 auto 0 0; width: 4px;
background: var(--brand-grad);
}
.metric:nth-child(2)::before { background: linear-gradient(135deg,#b5710a,#e0a83a); }
.metric:nth-child(3)::before { background: linear-gradient(135deg,#16895a,#34c98a); }
.metric:nth-child(4)::before { background: linear-gradient(135deg,#cf3636,#f08080); }
.metric span { color: var(--muted); font-size: 12px; font-weight: 600; }
.metric strong { font-size: 30px; line-height: 1; letter-spacing: -.5px; font-variant-numeric: tabular-nums; }
/* ── Page + Panel ───────────────────────────────────────── */
.page { display: none; overflow: auto; padding-bottom: 20px; }
.page.active { display: block; }
.panel {
background: var(--surface);
border: 1px solid var(--line); border-radius: var(--radius);
box-shadow: var(--shadow); overflow: hidden;
margin-bottom: 20px;
}
.panel-head {
min-height: 56px; padding: 14px 18px;
border-bottom: 1px solid var(--line);
display: flex; align-items: center; justify-content: space-between; gap: 12px;
background: linear-gradient(180deg, #fbfcfe, #f6f8fb);
}
.panel-head h2 { font-size: 15px; font-weight: 700; }
.panel-body { padding: 18px; }
/* ── Table ──────────────────────────────────────────────── */
.table-wrap { overflow: auto; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 11px 16px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: middle; font-size: 13.5px; }
th { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: .5px; background: var(--surface-2); font-weight: 700; position: sticky; top: 0; }
tbody tr:last-child td { border-bottom: 0; }
tr.selectable { cursor: pointer; transition: background 110ms ease; }
tr.selectable:hover { background: #f1f6ff; }
tr.selectable.active { background: linear-gradient(90deg, #eaf2ff, #f6fbfb); box-shadow: inset 3px 0 0 var(--brand); }
/* ── Badge ──────────────────────────────────────────────── */
.badge {
display: inline-flex; align-items: center; gap: 5px;
min-height: 22px; border-radius: 999px; padding: 2px 10px;
font-size: 11.5px; font-weight: 700; letter-spacing: .3px;
color: var(--info); background: var(--info-bg);
}
.badge.good { color: var(--good); background: var(--good-bg); }
.badge.warn { color: var(--warn); background: var(--warn-bg); }
.badge.bad { color: var(--bad); background: var(--bad-bg); }
.badge.neutral { color: var(--neutral); background: var(--neutral-bg); }
.bdot { width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
/* ── Progress ───────────────────────────────────────────── */
.prog-track {
height: 5px; background: var(--line); border-radius: 3px; overflow: hidden;
}
.prog-fill {
height: 100%; border-radius: 3px; transition: width .4s ease;
background: var(--brand-grad);
}
.prog-fill.good { background: linear-gradient(90deg,#16895a,#34c98a); }
.prog-fill.bad { background: linear-gradient(90deg,#cf3636,#f08080); }
.prog-fill.dl { background: linear-gradient(90deg,#8956ff,#bf8cff); }
.phase-rows { display: grid; gap: 4px; margin-top: 8px; }
.phase-row { display: flex; align-items: center; gap: 8px; }
.phase-label { font-size: 11px; color: var(--muted); width: 32px; flex-shrink: 0; }
.phase-track { flex: 1; height: 4px; background: var(--line); border-radius: 2px; overflow: hidden; }
.phase-fill { height: 100%; border-radius: 2px; transition: width .4s ease; width: 0; }
.phase-fill.detect { background: linear-gradient(90deg,#1f6feb,#58a6ff); }
.phase-fill.process { background: linear-gradient(90deg,#8956ff,#bf8cff); }
.phase-fill.merge { background: linear-gradient(90deg,#16895a,#34c98a); }
.phase-pct { font-size: 11px; color: var(--muted); width: 28px; text-align: right; }
/* ── Form ───────────────────────────────────────────────── */
.field { display: grid; gap: 6px; }
.field.full { grid-column: 1 / -1; }
.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 14px; }
label { font-size: 11.5px; color: var(--muted); font-weight: 700; text-transform: uppercase; letter-spacing: .4px; }
input[type=text], input[type=url], input[type=number], select, textarea {
width: 100%; min-height: 40px; border-radius: var(--radius-sm);
border: 1px solid var(--line-strong); background: var(--surface-2);
padding: 8px 12px; outline: none;
transition: border-color 120ms ease, box-shadow 120ms ease, background 120ms ease;
font-size: 13px;
}
input[type=text]:focus, input[type=url]:focus, input[type=number]:focus, select:focus, textarea:focus {
border-color: var(--brand); background: #fff;
box-shadow: 0 0 0 3px rgba(37,99,235,.12);
}
input[type=number] { -moz-appearance: textfield; }
input[type=number]::-webkit-inner-spin-button { display: none; }
textarea { min-height: 72px; resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12.5px; }
select { appearance: none; cursor: pointer;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='%23707f90'%3E%3Cpath d='M7 10l5 5 5-5z'/%3E%3C/svg%3E");
background-repeat: no-repeat; background-position: right 10px center; padding-right: 28px; }
.hint { font-size: 12px; color: var(--muted); line-height: 1.5; }
/* Radio as toggle cards */
.mode-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.mode-card {
border: 1.5px solid var(--line-strong); border-radius: var(--radius-sm);
padding: 10px 12px; cursor: pointer;
transition: border-color .15s, background .15s;
background: var(--surface-2);
}
.mode-card:hover { border-color: var(--brand); }
.mode-card.active { border-color: var(--brand); background: rgba(37,99,235,.05); }
.mode-card input[type=radio] { display: none; }
.mode-name { font-size: 13px; font-weight: 700; color: var(--ink); }
.mode-desc { font-size: 11.5px; color: var(--muted); margin-top: 2px; }
.mode-tag { display: inline-block; font-size: 10px; padding: 1px 6px; border-radius: 4px; margin-top: 4px; font-weight: 600; }
.tag-fast { background: var(--good-bg); color: var(--good); }
.tag-medium { background: var(--warn-bg); color: var(--warn); }
.tag-slow { background: var(--bad-bg); color: var(--bad); }
.tag-gpu { background: var(--info-bg); color: var(--info); }
/* Toggle switch */
.toggle-row { display: flex; align-items: center; gap: 10px; }
.toggle-row label.text { font-size: 13px; color: var(--ink); font-weight: 500; text-transform: none; letter-spacing: 0; }
.switch { position: relative; display: inline-block; width: 36px; height: 20px; flex-shrink: 0; }
.switch input { opacity: 0; width: 0; height: 0; }
.slider {
position: absolute; inset: 0; background: var(--line-strong); border-radius: 20px;
transition: .2s; cursor: pointer;
}
.slider::before {
content: ""; position: absolute; height: 14px; width: 14px;
left: 3px; bottom: 3px; background: white; border-radius: 50%;
transition: .2s; box-shadow: 0 1px 3px rgba(0,0,0,.2);
}
input:checked + .slider { background: var(--brand); }
input:checked + .slider::before { transform: translateX(16px); }
/* ── Region editor ──────────────────────────────────────── */
.region-preview {
position: relative; width: 100%; aspect-ratio: 16/9;
background: #1a1a2e; border-radius: var(--radius-sm);
overflow: hidden; border: 1px solid var(--line-strong);
}
.region-preview::after {
content: "16:9"; position: absolute; bottom: 6px; right: 8px;
font-size: 10px; color: rgba(255,255,255,.35);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.region-overlay {
position: absolute;
background: rgba(37,99,235,.2); border: 1.5px solid #2563eb;
pointer-events: none;
}
.region-item {
background: var(--surface-2); border: 1px solid var(--line-strong);
border-radius: var(--radius-sm); padding: 10px 12px; display: grid; gap: 8px;
}
.region-row { display: grid; grid-template-columns: 20px 1fr auto 1fr; gap: 6px; align-items: center; }
.region-axis { font-size: 11px; color: var(--muted); font-weight: 700; font-family: ui-monospace; }
.pct-wrap { position: relative; }
.pct-wrap input { padding-right: 24px; }
.pct-suffix { position: absolute; right: 8px; top: 50%; transform: translateY(-50%); font-size: 11px; color: var(--muted); pointer-events: none; }
/* ── Console (bottom strip) ─────────────────────────────── */
.console-strip {
background: #0f1923;
border-top: 1px solid rgba(255,255,255,.07);
display: flex; flex-direction: column;
height: 180px;
}
.console-head {
display: flex; align-items: center; justify-content: space-between;
padding: 6px 16px; border-bottom: 1px solid rgba(255,255,255,.07);
flex-shrink: 0;
}
.console-head-title { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .8px; color: #5a7a9a; }
.console-body {
flex: 1; overflow-y: auto; padding: 6px 16px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 11.5px; line-height: 1.7; color: #8aabb8;
}
.log-line { display: flex; gap: 10px; }
.log-ts { color: #3d5a6a; flex-shrink: 0; }
.log-lbl {
font-size: 10px; background: rgba(255,255,255,.05); border: 1px solid rgba(255,255,255,.08);
padding: 0 5px; border-radius: 4px; color: #5a7a9a;
align-self: flex-start; margin-top: 3px; flex-shrink: 0; white-space: nowrap;
}
.log-msg { color: #aac4d4; word-break: break-all; }
.log-msg.err { color: #f08080; }
/* ── Modal ──────────────────────────────────────────────── */
.modal-overlay {
position: fixed; inset: 0; z-index: 60;
background: rgba(15,22,33,.55);
backdrop-filter: blur(3px);
display: none; align-items: flex-start; justify-content: center;
padding: 48px 20px; overflow-y: auto;
}
.modal-overlay.open { display: flex; }
.modal {
width: 100%; max-width: 560px;
background: var(--surface); border-radius: var(--radius);
box-shadow: var(--shadow-lg); overflow: hidden;
animation: modalIn 200ms cubic-bezier(.2,.8,.2,1);
}
.modal.wide { max-width: 720px; }
@keyframes modalIn { from { opacity: 0; transform: translateY(-12px) scale(.98); } to { opacity: 1; transform: none; } }
.modal-head {
padding: 16px 20px; border-bottom: 1px solid var(--line);
display: flex; align-items: center; justify-content: space-between; gap: 12px;
background: linear-gradient(180deg, #fbfcfe, #f6f8fb);
}
.modal-head h2 { font-size: 16px; font-weight: 700; }
.modal-close {
width: 32px; height: 32px; border-radius: 8px;
display: grid; place-items: center; color: var(--muted);
font-size: 18px; transition: background 120ms ease;
}
.modal-close:hover { background: var(--neutral-bg); color: var(--ink); }
.modal-body { padding: 20px; display: grid; gap: 16px; max-height: 72vh; overflow-y: auto; }
.modal-foot { padding: 14px 20px; border-top: 1px solid var(--line); display: flex; justify-content: flex-end; gap: 10px; }
/* ── Empty / Item ───────────────────────────────────────── */
.empty {
border: 1px dashed var(--line-strong); border-radius: var(--radius-sm);
padding: 22px 18px; color: var(--muted); text-align: center; font-size: 13px;
background: var(--surface-2);
}
.item {
border: 1px solid var(--line); border-radius: var(--radius-sm);
padding: 12px 14px; display: grid; gap: 6px; background: var(--surface-2);
}
.item-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.item-title { font-weight: 700; font-size: 13.5px; overflow-wrap: anywhere; }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; }
/* ── Toast ──────────────────────────────────────────────── */
.toast {
position: fixed; right: 24px; bottom: 24px;
max-width: min(440px, calc(100vw - 48px));
border-radius: 12px; padding: 13px 16px;
background: #111826; color: #fff; font-size: 13.5px; font-weight: 500;
box-shadow: var(--shadow-lg);
transform: translateY(24px); opacity: 0; pointer-events: none;
transition: transform 200ms cubic-bezier(.2,.8,.2,1), opacity 200ms ease;
z-index: 100;
}
.toast.visible { transform: translateY(0); opacity: 1; }
/* ── Scrollbar ──────────────────────────────────────────── */
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-thumb { background: #cdd7e3; border-radius: 999px; border: 2px solid transparent; background-clip: padding-box; }
::-webkit-scrollbar-thumb:hover { background: #b3c0d0; background-clip: padding-box; }
/* ── Alert ──────────────────────────────────────────────── */
.alert { padding: 10px 14px; border-radius: 8px; font-size: 13px; }
.alert.warn { background: var(--warn-bg); border: 1px solid rgba(181,113,10,.25); color: var(--warn); }
.alert.bad { background: var(--bad-bg); border: 1px solid rgba(207,54,54,.25); color: var(--bad); }
/* ── Divider ────────────────────────────────────────────── */
.divider { border: none; border-top: 1px solid var(--line); }
/* ── Responsive ─────────────────────────────────────────── */
@media (max-width: 1100px) {
.shell { grid-template-columns: 1fr; }
.sidebar { position: static; height: auto; flex-direction: row; align-items: center; flex-wrap: wrap; }
.nav-block { grid-auto-flow: column; }
.nav-label, .sidebar-footer { display: none; }
main { padding: 16px; }
.summary-grid { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 720px) {
.summary-grid { grid-template-columns: 1fr; }
.form-grid { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<div class="shell">
<!-- ── Sidebar ─────────────────────────────────────────────── -->
<aside class="sidebar">
<div class="brand">
<div class="brand-mark">VSR</div>
<div>
<strong>字幕去除器</strong>
<span>视频智能去水印平台</span>
</div>
</div>
<nav class="nav-block">
<span class="nav-label">工作区</span>
<button class="nav-item active" type="button" data-page="page-queue">
<span class="nav-icon">▤</span>任务队列
</button>
<button class="nav-item" type="button" data-page="page-settings">
<span class="nav-icon">⚙</span>系统设置
</button>
</nav>
<div class="sidebar-footer">
<div id="wsPill" class="ws-pill"><span class="ws-dot"></span><span>连接中…</span></div>
<button class="logout-btn" onclick="location.href='/auth/logout'">
<span>⎋</span><span>退出登录</span>
</button>
</div>
</aside>
<!-- ── Main ───────────────────────────────────────────────── -->
<main>
<!-- Topbar -->
<section class="topbar">
<div>
<h1 id="page-title">任务队列</h1>
<p class="subtle" id="page-subtitle" style="margin-top:4px">提交视频链接,去除字幕或水印,实时查看处理进度。</p>
</div>
<div class="actions" id="page-actions">
<button class="icon-button" title="刷新" onclick="loadTasks()">↻</button>
<button class="button" id="newTaskBtn" onclick="openModal('modal-new')">+ 新建任务</button>
</div>
</section>
<!-- Metrics -->
<section class="summary-grid">
<div class="metric"><span>任务总数</span><strong id="m-total">0</strong></div>
<div class="metric"><span>处理中</span><strong id="m-active">0</strong></div>
<div class="metric"><span>已完成</span><strong id="m-done">0</strong></div>
<div class="metric"><span>失败</span><strong id="m-error">0</strong></div>
</section>
<!-- Alert strip -->
<div id="vsrAlert" style="display:none"></div>
<!-- Page: Task Queue -->
<div class="page active" id="page-queue">
<div class="panel">
<div class="panel-head">
<h2>任务队列</h2>
<span id="taskCount" class="subtle">0 个任务</span>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>视频</th>
<th>状态</th>
<th>算法</th>
<th>来源</th>
<th>创建时间</th>
<th>开始处理</th>
<th></th>
</tr>
</thead>
<tbody id="taskBody"></tbody>
</table>
</div>
</div>
</div>
<!-- Page: Settings -->
<div class="page" id="page-settings">
<div class="panel" style="margin-bottom:20px">
<div class="panel-head">
<h2>平台 Worker 配置</h2>
<div id="workerStatus" class="badge neutral"><span class="bdot"></span>未运行</div>
</div>
<div class="panel-body">
<div class="form-grid">
<div class="field full">
<label>平台地址 (Base URL)</label>
<input type="url" id="s-base-url" placeholder="https://your-platform.com" />
</div>
<div class="field">
<label>App ID</label>
<input type="text" id="s-app-id" placeholder="your_app_id" autocomplete="off" />
</div>
<div class="field">
<label>App Secret</label>
<input type="text" id="s-app-secret" placeholder="your_app_secret" autocomplete="new-password" />
</div>
<div class="field">
<label>并发槽数 (Concurrency)</label>
<input type="number" id="s-concurrency" min="0" max="8" value="1" />
<span class="hint">0 = 关闭平台拉取,仅保留手动提交</span>
</div>
<div class="field">
<label>轮询间隔(秒)</label>
<input type="number" id="s-poll-interval" min="1" max="60" value="5" />
</div>
</div>
<div style="margin-top:16px;display:flex;gap:10px">
<button class="button" onclick="saveSettings()">保存并重启 Worker</button>
</div>
</div>
</div>
<div class="panel">
<div class="panel-head">
<h2>Worker 运行日志</h2>
<button class="button secondary" style="min-height:32px;padding:0 12px;font-size:12px" onclick="refreshWorkerLogs()">刷新</button>
</div>
<div class="panel-body" style="padding:0">
<div id="workerLogs" style="
font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
font-size:12px; line-height:1.7; color:#3d4856;
background:#f7f9fc; padding:14px 16px;
max-height:280px; overflow-y:auto;
">
<div class="empty" style="border:none;background:none">尚无日志。Worker 运行后日志会出现在这里。</div>
</div>
</div>
</div>
</div>
</main>
</div>
<!-- ── Modal: New Task ───────────────────────────────────────── -->
<div class="modal-overlay" id="modal-new" role="dialog" aria-modal="true" onclick="if(event.target===this)closeModal('modal-new')">
<div class="modal">
<div class="modal-head">
<h2>新建任务</h2>
<button class="modal-close" type="button" onclick="closeModal('modal-new')">×</button>
</div>
<div class="modal-body">
<div id="modalAlert" style="display:none"></div>
<div class="field">
<label>视频链接</label>
<input type="url" id="urlInput" placeholder="https://example.com/video.mp4"
autocomplete="off" spellcheck="false" />
</div>
<!-- Recent cache -->
<div id="cacheBlock" style="display:none">
<label style="margin-bottom:6px">最近上传</label>
<div id="cacheList" style="display:flex;flex-direction:column;gap:4px;max-height:100px;overflow-y:auto"></div>
</div>
<hr class="divider" />
<div style="display:grid;gap:10px">
<div class="toggle-row">
<label class="switch"><input type="checkbox" id="previewMode"><span class="slider"></span></label>
<label class="text" for="previewMode">预览模式(仅处理前 10 秒)</label>
</div>
</div>
<div class="field">
<label>检测模式</label>
<select id="detectMode">
<option value="text">文字(OCR — 快速)</option>
<option value="smart">水印模式(动态/装饰水印 — 每帧强制去除)</option>
</select>
</div>
<hr class="divider" />
<div class="field">
<label>去除算法</label>
<div class="mode-grid" id="modeGrid">
<label class="mode-card active" onclick="selMode('sttn-det',this)">
<input type="radio" name="mode" value="sttn-det" checked>
<div class="mode-name">STTN</div>
<div class="mode-desc">带字幕检测</div>
<span class="mode-tag tag-fast">快</span>
</label>
<label class="mode-card" onclick="selMode('lama',this)">
<input type="radio" name="mode" value="lama">
<div class="mode-name">LaMa</div>
<div class="mode-desc">适合动画</div>
<span class="mode-tag tag-medium">中</span>
</label>
<label class="mode-card" onclick="selMode('propainter',this)">
<input type="radio" name="mode" value="propainter">
<div class="mode-name">ProPainter</div>
<div class="mode-desc">最佳运动</div>
<span class="mode-tag tag-slow">慢</span>
<span class="mode-tag tag-gpu">吃显存</span>
</label>
<label class="mode-card" onclick="selMode('opencv',this)">
<input type="radio" name="mode" value="opencv">
<div class="mode-name">OpenCV</div>
<div class="mode-desc">轻量补帧</div>
<span class="mode-tag tag-fast">最快</span>
</label>
</div>
</div>
<hr class="divider" />
<!-- Region editor (collapsible) -->
<div>
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:10px">
<label style="margin:0">字幕区域(可选,不设则自动检测)</label>
<button class="button secondary" style="min-height:28px;padding:0 10px;font-size:12px" onclick="addRegion()">+ 添加</button>
</div>
<div class="region-preview" id="regionPreview" style="margin-bottom:10px"></div>
<div id="regionList" style="display:grid;gap:8px"></div>
</div>
</div>
<div class="modal-foot">
<button class="button secondary" type="button" onclick="closeModal('modal-new')">取消</button>
<button class="button" id="submitBtn" onclick="submitTask()">▶ 开始处理</button>
</div>
</div>
</div>
<!-- ── Modal: Task Detail ────────────────────────────────────── -->
<div class="modal-overlay" id="modal-detail" role="dialog" aria-modal="true" onclick="if(event.target===this)closeModal('modal-detail')">
<div class="modal wide">
<div class="modal-head">
<h2>任务详情 <span id="detailStatus" class="badge neutral"><span class="bdot"></span>未知</span></h2>
<button class="modal-close" type="button" onclick="closeModal('modal-detail')">×</button>
</div>
<div class="modal-body" id="detailBody">
<div class="empty">选择一个任务查看详情。</div>
</div>
</div>
</div>
<!-- ── Modal: Video Player ───────────────────────────────────── -->
<div class="modal-overlay" id="modal-player" role="dialog" aria-modal="true" onclick="if(event.target===this){stopPlayer();closeModal('modal-player')}">
<div class="modal wide">
<div class="modal-head">
<h2 id="playerTitle">视频播放</h2>
<button class="modal-close" type="button" data-close="modal-player" onclick="stopPlayer()">×</button>
</div>
<div class="modal-body" style="padding:0;gap:0">
<div style="background:#000;position:relative;width:100%;padding-bottom:56.25%">
<video id="playerVideo" style="position:absolute;top:0;left:0;width:100%;height:100%"
controls preload="metadata"></video>
</div>
<div style="padding:12px 16px;display:flex;justify-content:flex-end;gap:8px">
<a id="playerDownload" href="#" download class="button secondary" style="min-height:34px;padding:0 14px;font-size:13px;text-decoration:none">↓ 下载</a>
<button class="button secondary" style="min-height:34px;padding:0 14px;font-size:13px" data-close="modal-player" onclick="stopPlayer()">关闭</button>
</div>
</div>
</div>
</div>
<!-- ── Modal: Task Logs ───────────────────────────────────────── -->
<div class="modal-overlay" id="modal-logs" role="dialog" aria-modal="true" onclick="if(event.target===this)closeModal('modal-logs')">
<div class="modal wide">
<div class="modal-head">
<div>
<h2 id="logsTitle">处理日志</h2>
<div id="logsSubtitle" style="font-size:12px;color:var(--muted);margin-top:2px"></div>
</div>
<div style="display:flex;gap:8px;align-items:center">
<button class="button secondary" style="min-height:32px;padding:0 12px;font-size:12px" onclick="clearModalLogs()">清除</button>
<button class="modal-close" type="button" onclick="closeModal('modal-logs')">×</button>
</div>
</div>
<div class="modal-body" style="padding:0;gap:0;max-height:none">
<div id="modalLogsBody" style="
font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
font-size:12px; line-height:1.7; color:#3d4856;
background:#f7f9fc; padding:14px 16px;
height:420px; overflow-y:auto;
"></div>
</div>
</div>
</div>
<div id="toast" class="toast" role="status"></div>
<script>
// ── State ──────────────────────────────────────────────────────
const S = {
tasks: {},
logs: {}, // taskId → [{ts, message}]
logsTaskId: null,
ws: null,
wsRetry: 1000,
regions: [],
mode: 'sttn-det',
workerLogSeq: 0,
};
// ── Init ───────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
connectWS();
checkStatus();
loadCacheList();
document.getElementById('urlInput').addEventListener('input', () => {
document.getElementById('modalAlert').style.display = 'none';
});
const cb = document.getElementById('consoleBody');
cb.addEventListener('scroll', () => {
S.autoScroll = cb.scrollTop + cb.clientHeight >= cb.scrollHeight - 10;
});
document.addEventListener('keydown', e => {
if (e.key === 'Escape') document.querySelectorAll('.modal-overlay.open').forEach(m => closeModal(m.id));
});
// nav
document.querySelectorAll('.nav-item[data-page]').forEach(btn => {
btn.addEventListener('click', () => switchPage(btn.dataset.page));
});
});
// ── Page switching ─────────────────────────────────────────────
const PAGE_META = {
'page-queue': ['任务队列', '提交视频链接,去除字幕或水印,实时查看处理进度。', true],
'page-settings': ['系统设置', '配置平台 Worker 连接,查看运行日志。', false],
};
function switchPage(id) {
document.querySelectorAll('.nav-item').forEach(b => b.classList.remove('active'));
document.querySelector(`.nav-item[data-page="${id}"]`)?.classList.add('active');
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
document.getElementById(id)?.classList.add('active');
const [title, sub, showActions] = PAGE_META[id] || [id, '', true];
document.getElementById('page-title').textContent = title;
document.getElementById('page-subtitle').textContent = sub;
document.getElementById('page-actions').style.display = showActions ? '' : 'none';
if (id === 'page-settings') loadSettings();
}
// ── Status / Alert ─────────────────────────────────────────────
async function checkStatus() {
try {
const d = await api('/api/status');
if (!d.vsr_available) showAlert('warn', `⚠ VSR 后端未加载,请确认依赖已安装。路径:${d.vsr_path}`);
} catch(e) {}
}
function showAlert(type, msg) {
const el = document.getElementById('vsrAlert');
el.className = `alert ${type}`;
el.textContent = msg;
el.style.display = '';
}
// ── WebSocket ──────────────────────────────────────────────────
function connectWS() {
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
const ws = new WebSocket(`${proto}://${location.host}/ws`);
S.ws = ws;
const pill = document.getElementById('wsPill');
ws.onopen = () => {
pill.className = 'ws-pill connected';
pill.querySelector('span:last-child').textContent = '已连接';
S.wsRetry = 1000;
};
ws.onclose = () => {
pill.className = 'ws-pill';
pill.querySelector('span:last-child').textContent = '已断开';
setTimeout(connectWS, S.wsRetry);
S.wsRetry = Math.min(S.wsRetry * 1.5, 10000);
};
ws.onmessage = e => {
const msg = JSON.parse(e.data);
if (msg.type === 'init') {
msg.tasks.forEach(t => S.tasks[t.id] = t);
renderTasks(); updateMetrics();
} else if (msg.type === 'task_update') {
S.tasks[msg.task.id] = { ...S.tasks[msg.task.id], ...msg.task };
renderTasks(); updateMetrics();
} else if (msg.type === 'log') {
appendLog(msg.task_id, msg.message);
}
};
}
// ── Metrics ────────────────────────────────────────────────────
function updateMetrics() {
const list = Object.values(S.tasks);
document.getElementById('m-total').textContent = list.length;
document.getElementById('m-active').textContent = list.filter(t => ['downloading','processing'].includes(t.status)).length;
document.getElementById('m-done').textContent = list.filter(t => t.status === 'done').length;
document.getElementById('m-error').textContent = list.filter(t => t.status === 'error').length;
}
// ── Tasks ──────────────────────────────────────────────────────
async function loadTasks() {
const list = await api('/api/tasks');
list.forEach(t => S.tasks[t.id] = t);
renderTasks(); updateMetrics();
showToast('已刷新');
}
function renderTasks() {
const tasks = Object.values(S.tasks).sort((a,b) => (b.created_at||0) - (a.created_at||0));
document.getElementById('taskCount').textContent = `${tasks.length} 个任务`;
const body = document.getElementById('taskBody');
if (!tasks.length) {
body.innerHTML = '<tr><td colspan="7"><div class="empty">暂无任务。点「新建任务」开始处理。</div></td></tr>';
return;
}
const modeLabel = { 'sttn-auto':'STTN 自动','sttn-det':'STTN','lama':'LaMa','propainter':'ProPainter','opencv':'OpenCV' };
body.innerHTML = tasks.map(t => {
const { cls, label } = taskStatus(t);
const name = urlName(t.url);
const src = t.platform_task_id ? `<span class="badge neutral">平台 #${t.platform_task_id}</span>` : '<span class="badge neutral">手动</span>';
const fmtTime = ts => ts ? new Date(ts*1000).toLocaleTimeString('zh-CN', {hour:'2-digit',minute:'2-digit',second:'2-digit'}) : '-';
return `<tr class="selectable" data-id="${t.id}" onclick="openDetail('${t.id}')">
<td><div style="font-weight:600;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(t.url)}">${esc(name)}</div></td>
<td><span class="badge ${cls}"><span class="bdot"></span>${label}</span></td>
<td>${modeLabel[t.inpaint_mode]||t.inpaint_mode}</td>
<td>${src}</td>
<td style="white-space:nowrap;font-size:12px;color:var(--muted)">${fmtTime(t.created_at)}</td>
<td style="white-space:nowrap;font-size:12px;color:var(--muted)">${fmtTime(t.started_at)}</td>
<td>
<div style="display:flex;gap:6px">
${t.status==='done' ? `
<button class="button secondary" style="min-height:28px;padding:0 10px;font-size:12px" onclick="event.stopPropagation();playVideo('${t.id}')">▶ 播放</button>
<a href="/api/tasks/${t.id}/download" download onclick="event.stopPropagation()" class="button secondary" style="min-height:28px;padding:0 10px;font-size:12px;text-decoration:none">↓ 下载</a>
` : ''}
<button class="button secondary" style="min-height:28px;padding:0 10px;font-size:12px" onclick="event.stopPropagation();openLogs('${t.id}')">日志</button>
<button class="button secondary" style="min-height:28px;padding:0 10px;font-size:12px;color:var(--bad)" onclick="event.stopPropagation();deleteTask('${t.id}')">✕</button>
</div>
</td>
</tr>`;
}).join('');
}
function taskStatus(t) {
const map = {
pending: { cls:'neutral', label:'等待中' },
downloading: { cls:'warn', label:'下载中' },
processing: { cls:'warn', label:'处理中' },
done: { cls:'good', label:'完成' },
error: { cls:'bad', label:'失败' },
};
return map[t.status] || { cls:'neutral', label:t.status };
}
function progressCell(t) {
if (t.status === 'downloading') {
const p = t.download_progress||0;
return `<div class="prog-track"><div class="prog-fill dl" style="width:${p}%"></div></div>
<div style="font-size:11px;color:var(--muted);margin-top:2px">${p}%</div>`;
}
if (t.status === 'processing') {
return `
<div class="phase-rows">
<div class="phase-row">
<span class="phase-label">检测</span>
<div class="phase-track"><div class="phase-fill detect" style="width:${t.detect_pct||0}%"></div></div>
<span class="phase-pct">${t.detect_pct||0}%</span>
</div>
<div class="phase-row">
<span class="phase-label">处理</span>
<div class="phase-track"><div class="phase-fill process" style="width:${t.process_pct||0}%"></div></div>
<span class="phase-pct">${t.process_pct||0}%</span>
</div>
<div class="phase-row">
<span class="phase-label">合并</span>
<div class="phase-track"><div class="phase-fill merge" style="width:${t.merge_pct||0}%"></div></div>
<span class="phase-pct">${t.merge_pct||0}%</span>
</div>
</div>`;
}
if (t.status === 'done') {
return `<div class="prog-track"><div class="prog-fill good" style="width:100%"></div></div>`;
}
if (t.status === 'error') {
return `<div style="font-size:11px;color:var(--bad);max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(t.error||'')}">${esc((t.error||'').slice(0,60))}</div>`;
}
return '-';
}
function openDetail(id) {
const t = S.tasks[id];
if (!t) return;
const { cls, label } = taskStatus(t);
const el = document.getElementById('detailStatus');
el.className = `badge ${cls}`;
el.innerHTML = `<span class="bdot"></span>${label}`;
const dur = (t.started_at && t.completed_at)
? `${Math.round(t.completed_at - t.started_at)}s`
: (t.started_at ? '进行中…' : '-');
const body = document.getElementById('detailBody');
body.innerHTML = `
<div class="item">
<div class="item-row">
<span class="item-title" style="word-break:break-all">${esc(t.url)}</span>
</div>
<div class="subtle mono">${esc(t.id)}</div>
<div class="subtle">算法: ${t.inpaint_mode} · 检测: ${t.detect_mode} · 耗时: ${dur}</div>
${t.error ? `<div class="badge bad" style="align-self:start;word-break:break-all">${esc(t.error)}</div>` : ''}
${t.status==='downloading' ? `
<div style="margin-top:4px">
<div style="font-size:11px;color:var(--muted);margin-bottom:4px">下载进度 ${t.download_progress||0}%</div>
<div class="prog-track"><div class="prog-fill dl" style="width:${t.download_progress||0}%"></div></div>
</div>
` : ''}
${t.status==='processing' ? `
<div style="margin-top:4px;display:grid;gap:5px">
<div class="phase-row"><span class="phase-label">检测</span><div class="phase-track"><div class="phase-fill detect" style="width:${t.detect_pct||0}%"></div></div><span class="phase-pct">${t.detect_pct||0}%</span></div>
<div class="phase-row"><span class="phase-label">处理</span><div class="phase-track"><div class="phase-fill process" style="width:${t.process_pct||0}%"></div></div><span class="phase-pct">${t.process_pct||0}%</span></div>
<div class="phase-row"><span class="phase-label">合并</span><div class="phase-track"><div class="phase-fill merge" style="width:${t.merge_pct||0}%"></div></div><span class="phase-pct">${t.merge_pct||0}%</span></div>
</div>
` : ''}
${t.status==='done' ? `
<div style="display:flex;gap:8px;margin-top:4px">
<button class="button" style="min-height:32px;padding:0 14px;font-size:13px" onclick="playVideo('${t.id}')">▶ 播放</button>
<a href="/api/tasks/${t.id}/download" download class="button secondary" style="min-height:32px;padding:0 14px;font-size:13px;text-decoration:none">↓ 下载</a>
</div>
` : ''}
</div>
<div>
<h3 style="font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--muted);margin-bottom:8px">日志</h3>
<div style="
background:#f7f9fc;border:1px solid var(--line);border-radius:var(--radius-sm);
padding:12px 14px;max-height:240px;overflow-y:auto;
font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;line-height:1.7;
">${(t.logs||[]).map(l => `<div>${esc(l)}</div>`).join('') || '<div style="color:var(--muted)">暂无日志</div>'}</div>
</div>
`;
openModal('modal-detail');
}
// ── Submit ─────────────────────────────────────────────────────
async function submitTask() {
const url = document.getElementById('urlInput').value.trim();
if (!url) {
showModalAlert('bad', '请输入视频链接。');
return;
}
const btn = document.getElementById('submitBtn');
btn.disabled = true;
btn.textContent = '提交中…';
try {
await api('/api/tasks', {
method: 'POST',
body: JSON.stringify({
url,
inpaint_mode: S.mode,
subtitle_areas: S.regions.map(r => [r.ymin, r.ymax, r.xmin, r.xmax]),
preview_mode: document.getElementById('previewMode').checked,
detect_mode: document.getElementById('detectMode').value,
}),
});
closeModal('modal-new');
document.getElementById('urlInput').value = '';
S.regions = []; renderRegions();
showToast('任务已提交,正在处理…');
await loadTasks();
} catch(e) {
showModalAlert('bad', '提交失败: ' + e.message);
} finally {
btn.disabled = false;
btn.textContent = '▶ 开始处理';
}
}
async function deleteTask(id) {
if (!confirm('删除此任务记录?')) return;
await api(`/api/tasks/${id}`, { method: 'DELETE' });
delete S.tasks[id];
renderTasks(); updateMetrics();
if (S.filterTaskId === id) clearFilter();
showToast('已删除');
}
// ── Settings ───────────────────────────────────────────────────
async function loadSettings() {
try {
const d = await api('/api/settings');
document.getElementById('s-base-url').value = d.platform_base_url || '';
document.getElementById('s-app-id').value = d.platform_app_id || '';
document.getElementById('s-app-secret').value = d.platform_app_secret || '';
document.getElementById('s-concurrency').value = d.worker_concurrency ?? 1;
document.getElementById('s-poll-interval').value = d.worker_poll_interval ?? 5;
const status = await api('/api/status');
const el = document.getElementById('workerStatus');
if (status.worker_running) {
el.className = 'badge good';
el.innerHTML = '<span class="bdot"></span>运行中';
} else {
el.className = 'badge neutral';
el.innerHTML = '<span class="bdot"></span>未运行';
}
} catch(e) {}
refreshWorkerLogs();
}
async function saveSettings() {
const body = {
platform_base_url: document.getElementById('s-base-url').value.trim(),
platform_app_id: document.getElementById('s-app-id').value.trim(),
platform_app_secret: document.getElementById('s-app-secret').value.trim(),
worker_concurrency: parseInt(document.getElementById('s-concurrency').value)||0,
worker_poll_interval: parseFloat(document.getElementById('s-poll-interval').value)||5,
};
try {
const r = await api('/api/settings', { method: 'PUT', body: JSON.stringify(body) });
const el = document.getElementById('workerStatus');
if (r.worker_running) {
el.className = 'badge good';
el.innerHTML = '<span class="bdot"></span>运行中';
} else {
el.className = 'badge neutral';
el.innerHTML = '<span class="bdot"></span>未运行';
}
showToast(r.worker_running ? 'Worker 已启动' : '设置已保存(Worker 未启动)');
refreshWorkerLogs();
} catch(e) { showToast('保存失败: ' + e.message); }
}
async function refreshWorkerLogs() {
try {
const d = await api(`/api/worker/logs?since=${S.workerLogSeq}`);
S.workerLogSeq = d.next_seq || S.workerLogSeq;
const el = document.getElementById('workerLogs');
if (d.lines && d.lines.length) {
if (el.querySelector('.empty')) el.innerHTML = '';
d.lines.forEach(l => {
const div = document.createElement('div');
div.style.color = l.level === 'WARNING' ? 'var(--warn)' : l.level === 'ERROR' ? 'var(--bad)' : 'var(--ink-2)';
div.textContent = `${new Date(l.ts*1000).toLocaleTimeString('zh-CN')} ${l.msg}`;
el.appendChild(div);
});
el.scrollTop = el.scrollHeight;
}
} catch(e) {}
}
// ── Mode select ────────────────────────────────────────────────
function selMode(val, el) {
S.mode = val;
document.querySelectorAll('.mode-card').forEach(e => e.classList.remove('active'));
el.classList.add('active');
}
// ── Region editor ──────────────────────────────────────────────
let _rid = 0;
function addRegion(ymin=85, ymax=98, xmin=10, xmax=90) {
S.regions.push({ id: ++_rid, ymin, ymax, xmin, xmax });
renderRegions();
}
function removeRegion(id) {
S.regions = S.regions.filter(r => r.id !== id);
renderRegions();
}
function updateRegion(id, field, val) {
const r = S.regions.find(r => r.id === id);
if (r) r[field] = parseFloat(val)||0;
renderOverlays();
}
function renderRegions() {
const list = document.getElementById('regionList');
list.innerHTML = S.regions.map((r,i) => `
<div class="region-item">
<div style="display:flex;align-items:center;justify-content:space-between">
<span style="font-size:12px;font-weight:700;color:var(--muted)">区域 ${i+1}</span>
<button class="button secondary" style="min-height:24px;padding:0 8px;font-size:11px;color:var(--bad)" onclick="removeRegion(${r.id})">✕</button>
</div>
<div class="region-row">
<span class="region-axis">Y</span>
<div class="pct-wrap"><input type="number" min="0" max="100" value="${r.ymin}" oninput="updateRegion(${r.id},'ymin',this.value)"><span class="pct-suffix">%</span></div>
<span style="color:var(--muted);text-align:center;font-size:12px">—</span>
<div class="pct-wrap"><input type="number" min="0" max="100" value="${r.ymax}" oninput="updateRegion(${r.id},'ymax',this.value)"><span class="pct-suffix">%</span></div>
</div>
<div class="region-row">
<span class="region-axis">X</span>
<div class="pct-wrap"><input type="number" min="0" max="100" value="${r.xmin}" oninput="updateRegion(${r.id},'xmin',this.value)"><span class="pct-suffix">%</span></div>
<span style="color:var(--muted);text-align:center;font-size:12px">—</span>
<div class="pct-wrap"><input type="number" min="0" max="100" value="${r.xmax}" oninput="updateRegion(${r.id},'xmax',this.value)"><span class="pct-suffix">%</span></div>
</div>
</div>
`).join('');
renderOverlays();
}
function renderOverlays() {
const prev = document.getElementById('regionPreview');
prev.querySelectorAll('.region-overlay').forEach(e => e.remove());
const colors = ['rgba(37,99,235,.2)','rgba(22,137,90,.2)','rgba(181,113,10,.2)','rgba(207,54,54,.2)'];
const borders = ['#2563eb','#16895a','#b5710a','#cf3636'];
S.regions.forEach((r,i) => {
const ov = document.createElement('div');
ov.className = 'region-overlay';
ov.style.cssText = `top:${r.ymin}%;bottom:${100-r.ymax}%;left:${r.xmin}%;right:${100-r.xmax}%;
background:${colors[i%4]};border-color:${borders[i%4]};`;
prev.appendChild(ov);
});
}
// ── Cache list ─────────────────────────────────────────────────
async function loadCacheList() {
try {
const d = await api('/api/cache-list');
if (!d.files?.length) return;
const block = document.getElementById('cacheBlock');
const list = document.getElementById('cacheList');
block.style.display = '';
list.innerHTML = d.files.slice(0,8).map(f => {
const name = f.name.includes('_') ? f.name.substring(13) : f.name;
const size = (f.size/1048576).toFixed(1);
return `<button class="button secondary" style="text-align:left;min-height:32px;padding:0 10px;font-size:12px"
onclick="document.getElementById('urlInput').value='cache_${f.name}'" title="${f.name}">
📁 ${esc(name)} (${size} MB)
</button>`;
}).join('');
} catch(e) {}
}
// ── Video player ───────────────────────────────────────────────
function playVideo(taskId) {
const t = S.tasks[taskId];
if (!t) return;
const url = `/api/tasks/${taskId}/stream`;
const name = urlName(t.url);
document.getElementById('playerTitle').textContent = name;
const vid = document.getElementById('playerVideo');
vid.src = url;
document.getElementById('playerDownload').href = `/api/tasks/${taskId}/download`;
document.getElementById('playerDownload').download = name;
openModal('modal-player');
setTimeout(() => vid.play().catch(()=>{}), 100);
}
function stopPlayer() {
const vid = document.getElementById('playerVideo');
vid.pause();
vid.src = '';
}
// ── Logs ───────────────────────────────────────────────────────
// 保存所有任务的日志(内存)
function appendLog(taskId, message) {
if (!S.logs[taskId]) S.logs[taskId] = [];
S.logs[taskId].push({ ts: new Date(), message });
// 如果日志 Modal 当前打开且匹配该任务,实时追加
if (S.logsTaskId === taskId) {
appendModalLogLine({ ts: new Date(), message });
}
}
function appendModalLogLine(entry) {
const el = document.getElementById('modalLogsBody');
const ts = entry.ts.toTimeString().slice(0,8);
const isErr = /error|failed|exception/i.test(entry.message);
const div = document.createElement('div');
div.style.color = isErr ? 'var(--bad)' : 'var(--ink-2)';
div.innerHTML = `<span style="color:var(--muted);margin-right:10px">${ts}</span>${esc(entry.message)}`;
el.appendChild(div);
el.scrollTop = el.scrollHeight;
}
async function openLogs(taskId) {
S.logsTaskId = taskId;
const t = S.tasks[taskId];
document.getElementById('logsTitle').textContent = '处理日志';
document.getElementById('logsSubtitle').textContent = t ? urlName(t.url, 60) : taskId;
const el = document.getElementById('modalLogsBody');
el.innerHTML = '<div style="color:var(--muted)">加载中…</div>';
openModal('modal-logs');
// 优先从 DB 拉取完整历史
try {
const d = await api(`/api/tasks/${taskId}/logs`);
el.innerHTML = '';
if (!d.logs?.length) {
el.innerHTML = '<div style="color:var(--muted);padding:8px 0">暂无日志</div>';
} else {
d.logs.forEach(l => {
const ts = l.ts ? new Date(l.ts + (l.ts.includes('Z') ? '' : 'Z')).toLocaleTimeString('zh-CN') : '';
appendModalLogLine({ ts: { toTimeString: () => ts + ' ' }, message: l.message });
});
}
// 同步到内存
S.logs[taskId] = d.logs?.map(l => ({ ts: new Date(l.ts || 0), message: l.message })) || [];
} catch(e) {
// 降级到内存
el.innerHTML = '';
const history = S.logs[taskId] || [];
if (!history.length) {
el.innerHTML = '<div style="color:var(--muted);padding:8px 0">暂无日志</div>';
} else {
history.forEach(appendModalLogLine);
}
}
}
function clearModalLogs() {
if (S.logsTaskId) S.logs[S.logsTaskId] = [];
document.getElementById('modalLogsBody').innerHTML = '';
}
// ── Modal ──────────────────────────────────────────────────────
function openModal(id) { document.getElementById(id)?.classList.add('open'); }
function closeModal(id) {
document.getElementById(id)?.classList.remove('open');
if (id === 'modal-new') {
document.getElementById('modalAlert').style.display = 'none';
}
}
function showModalAlert(type, msg) {
const el = document.getElementById('modalAlert');
el.className = `alert ${type}`;
el.textContent = msg;
el.style.display = '';
}
// ── Toast ──────────────────────────────────────────────────────
function showToast(msg) {
const t = document.getElementById('toast');
t.textContent = msg;
t.classList.add('visible');
clearTimeout(showToast._t);
showToast._t = setTimeout(() => t.classList.remove('visible'), 2800);
}
// ── Helpers ────────────────────────────────────────────────────
async function api(path, opts={}) {
const r = await fetch(path, {
headers: { 'Content-Type': 'application/json', ...(opts.headers||{}) },
...opts,
});
if (r.status === 401) { location.href = '/login'; throw new Error('Unauthorized'); }
if (!r.ok) {
const b = await r.json().catch(() => ({}));
throw new Error(b.detail || `${r.status}`);
}
if (r.status === 204) return null;
return r.json();
}
function urlName(url, max=40) {
try {
const u = new URL(url);
const p = u.pathname.split('/').pop() || u.hostname;
return p.length > max ? p.slice(0, max) + '…' : p;
} catch { return (url||'').slice(0, max); }
}
function esc(s) {
return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// Enter on URL input
document.addEventListener('keydown', e => {
if (e.key === 'Enter' && document.activeElement?.id === 'urlInput') submitTask();
});
</script>
</body>
</html>