7eb6511169
- deploy/cca_route.py: Flask 蓝图(6个API端点),WAV自动转MP3 - deploy/cca.html: 4步单页流程(上传→处理→审稿→下载),查找替换(Ctrl+H) - src/term_normalizer.py: 新增正则层(同音字/引号/书名号/小数点/波浪号) - src/ai_proofreader.py: speaker角色识别+专家段增强Prompt+的地得加强 - src/ai_line_breaker.py: 引号不跨屏+极短行合并+短句合并间隔放宽 - cca_pipeline.py: Step 2.5 校对后二次正则兜底 - 已部署至 http://101.42.29.217/cca.html Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
911 lines
33 KiB
HTML
911 lines
33 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>CCA 唱词助手 - 军事科技</title>
|
|
<link rel="stylesheet" href="styles.css">
|
|
<style>
|
|
/* CCA 专用样式 */
|
|
.cca-container {
|
|
max-width: 1200px;
|
|
margin: 0 auto;
|
|
padding: 16px;
|
|
min-height: 100vh;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.step-indicator {
|
|
display: flex;
|
|
justify-content: center;
|
|
gap: 8px;
|
|
margin-bottom: 24px;
|
|
padding: 12px;
|
|
}
|
|
.step-dot {
|
|
width: 10px; height: 10px;
|
|
border-radius: 50%;
|
|
background: var(--border-color);
|
|
transition: all 0.3s;
|
|
}
|
|
.step-dot.active { background: var(--accent-primary); transform: scale(1.3); }
|
|
.step-dot.done { background: var(--success); }
|
|
|
|
.step-panel { display: none; flex: 1; flex-direction: column; }
|
|
.step-panel.active { display: flex; }
|
|
|
|
/* 上传区 */
|
|
.upload-zone {
|
|
border: 2px dashed var(--border-color);
|
|
border-radius: var(--radius-lg);
|
|
padding: 40px;
|
|
text-align: center;
|
|
cursor: pointer;
|
|
transition: all 0.3s;
|
|
background: var(--bg-secondary);
|
|
margin-bottom: 16px;
|
|
}
|
|
.upload-zone:hover, .upload-zone.dragover {
|
|
border-color: var(--accent-primary);
|
|
background: rgba(99,102,241,0.05);
|
|
}
|
|
.upload-zone.has-file {
|
|
border-color: var(--success);
|
|
border-style: solid;
|
|
}
|
|
.upload-icon { font-size: 48px; margin-bottom: 12px; opacity: 0.6; }
|
|
.upload-label { color: var(--text-secondary); font-size: 0.95rem; margin-bottom: 4px; }
|
|
.upload-hint { color: var(--text-muted); font-size: 0.8rem; }
|
|
.upload-filename {
|
|
color: var(--success); font-weight: 600;
|
|
font-size: 1rem; margin-top: 8px;
|
|
}
|
|
|
|
.upload-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 24px; }
|
|
|
|
/* 进度 */
|
|
.progress-container {
|
|
text-align: center;
|
|
padding: 60px 20px;
|
|
}
|
|
.progress-spinner {
|
|
width: 60px; height: 60px;
|
|
border: 4px solid var(--border-color);
|
|
border-top-color: var(--accent-primary);
|
|
border-radius: 50%;
|
|
animation: spin 1s linear infinite;
|
|
margin: 0 auto 24px;
|
|
}
|
|
.progress-text {
|
|
color: var(--text-secondary);
|
|
font-size: 1.1rem;
|
|
margin-bottom: 8px;
|
|
}
|
|
.progress-detail { color: var(--text-muted); font-size: 0.85rem; }
|
|
|
|
/* 审稿台 */
|
|
.review-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 12px 16px;
|
|
background: var(--bg-secondary);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: var(--radius-md);
|
|
margin-bottom: 16px;
|
|
}
|
|
.review-stats { display: flex; gap: 16px; font-size: 0.85rem; }
|
|
.review-stats span { color: var(--text-muted); }
|
|
.review-stats .count { color: var(--accent-secondary); font-weight: 600; }
|
|
.review-stats .changed { color: var(--warning); }
|
|
|
|
.review-controls {
|
|
display: flex; gap: 8px;
|
|
}
|
|
|
|
.review-list {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
max-height: calc(100vh - 320px);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: var(--radius-md);
|
|
background: var(--bg-secondary);
|
|
}
|
|
|
|
.review-item {
|
|
display: grid;
|
|
grid-template-columns: 60px 1fr 1fr 80px;
|
|
gap: 12px;
|
|
padding: 10px 16px;
|
|
border-bottom: 1px solid var(--border-color);
|
|
align-items: center;
|
|
font-size: 0.9rem;
|
|
transition: background 0.15s;
|
|
}
|
|
.review-item:hover { background: var(--bg-tertiary); }
|
|
.review-item.changed { background: rgba(245,158,11,0.05); }
|
|
.review-item.confirmed { opacity: 0.7; }
|
|
|
|
.review-time {
|
|
font-size: 0.75rem;
|
|
color: var(--text-muted);
|
|
font-family: monospace;
|
|
}
|
|
.review-original {
|
|
color: var(--text-muted);
|
|
font-size: 0.85rem;
|
|
}
|
|
.review-edited {
|
|
color: var(--text-primary);
|
|
padding: 4px 8px;
|
|
background: var(--bg-tertiary);
|
|
border: 1px solid transparent;
|
|
border-radius: 4px;
|
|
outline: none;
|
|
font-size: 0.9rem;
|
|
font-family: inherit;
|
|
width: 100%;
|
|
}
|
|
.review-edited:focus {
|
|
border-color: var(--accent-primary);
|
|
background: var(--bg-card);
|
|
}
|
|
.review-item.changed .review-original {
|
|
text-decoration: line-through;
|
|
color: var(--error);
|
|
}
|
|
.review-item.changed .review-edited {
|
|
color: var(--success);
|
|
}
|
|
|
|
.review-confirm-btn {
|
|
width: 28px; height: 28px;
|
|
border: 2px solid var(--border-color);
|
|
border-radius: 4px;
|
|
background: transparent;
|
|
cursor: pointer;
|
|
display: flex; align-items: center; justify-content: center;
|
|
color: var(--text-muted);
|
|
transition: all 0.15s;
|
|
}
|
|
.review-confirm-btn.checked {
|
|
background: var(--success);
|
|
border-color: var(--success);
|
|
color: white;
|
|
}
|
|
.review-confirm-btn:hover { border-color: var(--success); }
|
|
|
|
.review-legend {
|
|
display: flex; gap: 16px; font-size: 0.8rem; color: var(--text-muted);
|
|
padding: 8px 0;
|
|
}
|
|
.legend-dot {
|
|
display: inline-block; width: 8px; height: 8px;
|
|
border-radius: 50%; margin-right: 4px; vertical-align: middle;
|
|
}
|
|
|
|
/* 查找替换面板 */
|
|
.replace-panel {
|
|
display: none;
|
|
background: var(--bg-secondary);
|
|
border: 1px solid var(--accent-primary);
|
|
border-radius: var(--radius-md);
|
|
padding: 16px;
|
|
margin-bottom: 12px;
|
|
animation: fadeIn 0.2s;
|
|
}
|
|
.replace-panel.active { display: block; }
|
|
@keyframes fadeIn { from { opacity: 0; transform: translateY(-8px); } to { opacity: 1; transform: translateY(0); } }
|
|
.replace-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
margin-bottom: 8px;
|
|
}
|
|
.replace-row:last-child { margin-bottom: 0; }
|
|
.replace-label {
|
|
font-size: 0.8rem;
|
|
color: var(--text-muted);
|
|
width: 40px;
|
|
flex-shrink: 0;
|
|
}
|
|
.replace-input {
|
|
flex: 1;
|
|
padding: 6px 10px;
|
|
background: var(--bg-tertiary);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 4px;
|
|
color: var(--text-primary);
|
|
font-size: 0.85rem;
|
|
font-family: inherit;
|
|
outline: none;
|
|
}
|
|
.replace-input:focus { border-color: var(--accent-primary); }
|
|
.replace-actions {
|
|
display: flex;
|
|
gap: 6px;
|
|
align-items: center;
|
|
}
|
|
.replace-btn {
|
|
padding: 5px 10px;
|
|
border-radius: 4px;
|
|
font-size: 0.78rem;
|
|
cursor: pointer;
|
|
border: 1px solid var(--border-color);
|
|
background: var(--bg-tertiary);
|
|
color: var(--text-secondary);
|
|
transition: all 0.15s;
|
|
white-space: nowrap;
|
|
}
|
|
.replace-btn:hover { border-color: var(--accent-primary); color: var(--text-primary); }
|
|
.replace-btn.primary { background: var(--accent-primary); color: white; border-color: var(--accent-primary); }
|
|
.replace-btn.primary:hover { opacity: 0.85; }
|
|
.replace-btn.danger { background: var(--warning); color: white; border-color: var(--warning); }
|
|
.replace-btn.danger:hover { opacity: 0.85; }
|
|
.replace-info {
|
|
font-size: 0.78rem;
|
|
color: var(--text-muted);
|
|
margin-left: 4px;
|
|
}
|
|
.replace-highlight {
|
|
background: rgba(245,158,11,0.3);
|
|
border-radius: 2px;
|
|
padding: 0 1px;
|
|
}
|
|
.replace-current {
|
|
background: rgba(99,102,241,0.4);
|
|
outline: 2px solid var(--accent-primary);
|
|
border-radius: 2px;
|
|
padding: 0 1px;
|
|
}
|
|
|
|
/* 过滤按钮 */
|
|
.filter-btn {
|
|
padding: 6px 12px;
|
|
background: var(--bg-tertiary);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: var(--radius-sm);
|
|
color: var(--text-secondary);
|
|
font-size: 0.8rem;
|
|
cursor: pointer;
|
|
transition: all 0.15s;
|
|
}
|
|
.filter-btn:hover { border-color: var(--accent-primary); color: var(--text-primary); }
|
|
.filter-btn.active { background: var(--accent-primary); color: white; border-color: var(--accent-primary); }
|
|
|
|
/* 完成页 */
|
|
.done-container { text-align: center; padding: 60px 20px; }
|
|
.done-icon { font-size: 64px; margin-bottom: 16px; }
|
|
.done-title { font-size: 1.3rem; margin-bottom: 8px; }
|
|
.done-detail { color: var(--text-muted); margin-bottom: 24px; }
|
|
|
|
/* 错误 */
|
|
.error-box {
|
|
background: rgba(239,68,68,0.1);
|
|
border: 1px solid var(--error);
|
|
border-radius: var(--radius-md);
|
|
padding: 16px;
|
|
color: var(--error);
|
|
text-align: center;
|
|
margin-top: 16px;
|
|
}
|
|
|
|
.back-link {
|
|
color: var(--text-muted);
|
|
text-decoration: none;
|
|
font-size: 0.85rem;
|
|
}
|
|
.back-link:hover { color: var(--text-primary); }
|
|
|
|
.cca-badge {
|
|
padding: 8px 16px;
|
|
background: linear-gradient(135deg, #f59e0b 0%, #ef4444 100%);
|
|
color: white;
|
|
font-size: 0.875rem;
|
|
font-weight: 600;
|
|
border-radius: var(--radius-full);
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.upload-grid { grid-template-columns: 1fr; }
|
|
.review-item { grid-template-columns: 50px 1fr 40px; }
|
|
.review-original { display: none; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="cca-container">
|
|
<header class="header">
|
|
<div class="logo">
|
|
<a href="index.html" class="back-link" style="margin-right:12px">← 返回配音</a>
|
|
<span class="logo-text">CCA <span class="logo-sub">唱词助手</span></span>
|
|
</div>
|
|
<div class="header-info">
|
|
<span class="cca-badge">唱词字幕</span>
|
|
</div>
|
|
</header>
|
|
|
|
<!-- 步骤指示器 -->
|
|
<div class="step-indicator">
|
|
<div class="step-dot active" id="dot-0"></div>
|
|
<div class="step-dot" id="dot-1"></div>
|
|
<div class="step-dot" id="dot-2"></div>
|
|
<div class="step-dot" id="dot-3"></div>
|
|
</div>
|
|
|
|
<!-- Step 0: 上传 -->
|
|
<div class="step-panel active" id="step-upload">
|
|
<div class="panel" style="flex:1; padding:24px;">
|
|
<h2 style="margin-bottom:8px;">上传素材</h2>
|
|
<p style="color:var(--text-muted); font-size:0.85rem; margin-bottom:24px;">
|
|
上传编导 A 稿和粗编人声音频,AI 将自动完成 ASR 转写、术语校对、的地得纠错和口头语清除
|
|
</p>
|
|
<div class="upload-grid">
|
|
<div class="upload-zone" id="zone-audio" onclick="document.getElementById('input-audio').click()">
|
|
<div class="upload-icon">🎤</div>
|
|
<div class="upload-label">人声音频</div>
|
|
<div class="upload-hint">MP3 / WAV,纯人声(必传)</div>
|
|
<div class="upload-filename" id="name-audio"></div>
|
|
<input type="file" id="input-audio" accept=".mp3,.wav,.m4a" style="display:none">
|
|
</div>
|
|
<div class="upload-zone" id="zone-script" onclick="document.getElementById('input-script').click()">
|
|
<div class="upload-icon">📄</div>
|
|
<div class="upload-label">A 稿文件</div>
|
|
<div class="upload-hint">DOCX / TXT(强烈建议上传,用于术语校对)</div>
|
|
<div class="upload-filename" id="name-script"></div>
|
|
<input type="file" id="input-script" accept=".docx,.doc,.txt" style="display:none">
|
|
</div>
|
|
</div>
|
|
<div class="action-buttons" style="justify-content:center;">
|
|
<button class="btn btn-primary" id="btn-start" disabled style="max-width:300px;">
|
|
开始处理
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Step 1: 处理中 -->
|
|
<div class="step-panel" id="step-processing">
|
|
<div class="panel" style="flex:1;">
|
|
<div class="progress-container">
|
|
<div class="progress-spinner"></div>
|
|
<div class="progress-text" id="progress-text">准备中...</div>
|
|
<div class="progress-detail" id="progress-detail">请勿关闭页面,ASR 转写通常需要 1-2 分钟</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Step 2: 审稿台 -->
|
|
<div class="step-panel" id="step-review">
|
|
<div class="review-header">
|
|
<div>
|
|
<span style="font-weight:600;">审稿台</span>
|
|
<span class="review-stats" style="margin-left:16px;">
|
|
总计 <span class="count" id="stat-total">0</span> 句 |
|
|
AI 修改 <span class="changed" id="stat-changed">0</span> 处 |
|
|
已确认 <span class="count" id="stat-confirmed">0</span>
|
|
</span>
|
|
</div>
|
|
<div class="review-controls">
|
|
<button class="filter-btn active" id="filter-all" onclick="filterItems('all')">全部</button>
|
|
<button class="filter-btn" id="filter-changed" onclick="filterItems('changed')">仅看修改</button>
|
|
<button class="replace-btn" onclick="toggleReplace()" id="btn-toggle-replace" title="Ctrl+H">查找替换</button>
|
|
<button class="btn btn-secondary" style="padding:6px 12px; font-size:0.8rem;" onclick="confirmAll()">全部确认</button>
|
|
<button class="btn btn-primary" id="btn-generate" style="padding:6px 16px; font-size:0.85rem;">
|
|
生成 SRT
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<!-- 查找替换面板 -->
|
|
<div class="replace-panel" id="replace-panel">
|
|
<div class="replace-row">
|
|
<span class="replace-label">查找</span>
|
|
<input class="replace-input" id="replace-find" placeholder="输入要查找的文字..." oninput="onFindInput()">
|
|
<div class="replace-actions">
|
|
<span class="replace-info" id="replace-match-info"></span>
|
|
<button class="replace-btn" onclick="findPrev()" title="上一个">▲</button>
|
|
<button class="replace-btn" onclick="findNext()" title="下一个">▼</button>
|
|
</div>
|
|
</div>
|
|
<div class="replace-row">
|
|
<span class="replace-label">替换</span>
|
|
<input class="replace-input" id="replace-to" placeholder="替换为...">
|
|
<div class="replace-actions">
|
|
<button class="replace-btn primary" onclick="replaceCurrent()">替换当前</button>
|
|
<button class="replace-btn danger" onclick="replaceAll()">全部替换</button>
|
|
<button class="replace-btn" onclick="toggleReplace()">关闭</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="review-legend">
|
|
<span><span class="legend-dot" style="background:var(--warning);"></span>AI 已修改</span>
|
|
<span><span class="legend-dot" style="background:var(--success);"></span>已确认</span>
|
|
<span>点击文字可直接编辑,右侧勾选确认</span>
|
|
</div>
|
|
<div class="review-list" id="review-list"></div>
|
|
</div>
|
|
|
|
<!-- Step 3: 完成 -->
|
|
<div class="step-panel" id="step-done">
|
|
<div class="panel" style="flex:1;">
|
|
<div class="done-container">
|
|
<div class="done-icon">✅</div>
|
|
<div class="done-title">SRT 字幕文件生成完成</div>
|
|
<div class="done-detail" id="done-detail"></div>
|
|
<div class="action-buttons" style="justify-content:center; gap:12px;">
|
|
<button class="btn btn-primary" id="btn-download" style="max-width:200px;">
|
|
下载字幕包
|
|
</button>
|
|
<button class="btn btn-secondary" onclick="location.reload()" style="max-width:200px;">
|
|
处理下一期
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 错误展示 -->
|
|
<div class="error-box" id="error-box" style="display:none;"></div>
|
|
</div>
|
|
|
|
<script>
|
|
const API = '/api/cca';
|
|
let currentTaskId = null;
|
|
let reviewData = [];
|
|
let currentFilter = 'all';
|
|
let pollTimer = null;
|
|
|
|
// === 文件上传 ===
|
|
const audioInput = document.getElementById('input-audio');
|
|
const scriptInput = document.getElementById('input-script');
|
|
const btnStart = document.getElementById('btn-start');
|
|
|
|
audioInput.addEventListener('change', () => {
|
|
const f = audioInput.files[0];
|
|
if (f) {
|
|
document.getElementById('name-audio').textContent = f.name;
|
|
document.getElementById('zone-audio').classList.add('has-file');
|
|
}
|
|
checkReady();
|
|
});
|
|
|
|
scriptInput.addEventListener('change', () => {
|
|
const f = scriptInput.files[0];
|
|
if (f) {
|
|
document.getElementById('name-script').textContent = f.name;
|
|
document.getElementById('zone-script').classList.add('has-file');
|
|
}
|
|
});
|
|
|
|
// 拖拽
|
|
['zone-audio', 'zone-script'].forEach(id => {
|
|
const zone = document.getElementById(id);
|
|
zone.addEventListener('dragover', e => { e.preventDefault(); zone.classList.add('dragover'); });
|
|
zone.addEventListener('dragleave', () => zone.classList.remove('dragover'));
|
|
zone.addEventListener('drop', e => {
|
|
e.preventDefault();
|
|
zone.classList.remove('dragover');
|
|
const inputId = id === 'zone-audio' ? 'input-audio' : 'input-script';
|
|
const input = document.getElementById(inputId);
|
|
input.files = e.dataTransfer.files;
|
|
input.dispatchEvent(new Event('change'));
|
|
});
|
|
});
|
|
|
|
function checkReady() {
|
|
btnStart.disabled = !audioInput.files[0];
|
|
}
|
|
|
|
btnStart.addEventListener('click', async () => {
|
|
if (!audioInput.files[0]) return;
|
|
const formData = new FormData();
|
|
formData.append('audio', audioInput.files[0]);
|
|
if (scriptInput.files[0]) formData.append('script', scriptInput.files[0]);
|
|
|
|
btnStart.disabled = true;
|
|
btnStart.textContent = '上传中...';
|
|
|
|
try {
|
|
const res = await fetch(`${API}/upload`, { method: 'POST', body: formData });
|
|
const data = await res.json();
|
|
if (data.error) { showError(data.error); return; }
|
|
currentTaskId = data.task_id;
|
|
goToStep(1);
|
|
startPolling();
|
|
} catch (e) {
|
|
showError('上传失败: ' + e.message);
|
|
}
|
|
});
|
|
|
|
// === 轮询状态 ===
|
|
function startPolling() {
|
|
pollTimer = setInterval(async () => {
|
|
try {
|
|
const res = await fetch(`${API}/status/${currentTaskId}`);
|
|
const data = await res.json();
|
|
document.getElementById('progress-text').textContent = data.progress || '处理中...';
|
|
|
|
if (data.status === 'review') {
|
|
clearInterval(pollTimer);
|
|
await loadReview();
|
|
goToStep(2);
|
|
} else if (data.status === 'error') {
|
|
clearInterval(pollTimer);
|
|
showError(data.error || '处理出错');
|
|
}
|
|
} catch (e) {
|
|
console.error('poll error', e);
|
|
}
|
|
}, 2000);
|
|
}
|
|
|
|
// === 审稿台 ===
|
|
async function loadReview() {
|
|
const res = await fetch(`${API}/review/${currentTaskId}`);
|
|
const data = await res.json();
|
|
reviewData = data.items;
|
|
renderReview();
|
|
}
|
|
|
|
function msToTime(ms) {
|
|
const s = Math.floor(ms / 1000);
|
|
const m = Math.floor(s / 60);
|
|
const h = Math.floor(m / 60);
|
|
const ss = String(s % 60).padStart(2, '0');
|
|
const mm = String(m % 60).padStart(2, '0');
|
|
const hh = String(h).padStart(2, '0');
|
|
return `${hh}:${mm}:${ss}`;
|
|
}
|
|
|
|
function renderReview() {
|
|
const list = document.getElementById('review-list');
|
|
list.innerHTML = '';
|
|
|
|
let totalChanged = 0;
|
|
let totalConfirmed = 0;
|
|
|
|
reviewData.forEach((item, i) => {
|
|
if (item.has_change) totalChanged++;
|
|
if (item.confirmed) totalConfirmed++;
|
|
|
|
if (currentFilter === 'changed' && !item.has_change) return;
|
|
|
|
const row = document.createElement('div');
|
|
row.className = 'review-item' + (item.has_change ? ' changed' : '') + (item.confirmed ? ' confirmed' : '');
|
|
row.dataset.index = i;
|
|
|
|
row.innerHTML = `
|
|
<div class="review-time">${msToTime(item.start_ms)}</div>
|
|
<div class="review-original">${escHtml(item.original)}</div>
|
|
<input class="review-edited" value="${escAttr(item.edited)}" data-idx="${i}"
|
|
onfocus="this.parentElement.classList.remove('confirmed')"
|
|
onblur="onEditBlur(this)">
|
|
<button class="review-confirm-btn ${item.confirmed ? 'checked' : ''}"
|
|
onclick="toggleConfirm(${i}, this)" title="确认">
|
|
${item.confirmed ? '✓' : ''}
|
|
</button>
|
|
`;
|
|
list.appendChild(row);
|
|
});
|
|
|
|
document.getElementById('stat-total').textContent = reviewData.length;
|
|
document.getElementById('stat-changed').textContent = totalChanged;
|
|
document.getElementById('stat-confirmed').textContent = totalConfirmed;
|
|
}
|
|
|
|
function escHtml(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
|
function escAttr(s) { return s.replace(/"/g, '"').replace(/</g, '<'); }
|
|
|
|
function onEditBlur(input) {
|
|
const idx = parseInt(input.dataset.idx);
|
|
reviewData[idx].edited = input.value;
|
|
reviewData[idx].confirmed = true;
|
|
updateStats();
|
|
autoSave();
|
|
const keyword = document.getElementById('replace-find') ? document.getElementById('replace-find').value : '';
|
|
if (keyword) {
|
|
onFindInput();
|
|
} else {
|
|
renderReview();
|
|
}
|
|
}
|
|
|
|
function toggleConfirm(idx, btn) {
|
|
reviewData[idx].confirmed = !reviewData[idx].confirmed;
|
|
btn.classList.toggle('checked');
|
|
btn.innerHTML = reviewData[idx].confirmed ? '✓' : '';
|
|
btn.parentElement.classList.toggle('confirmed', reviewData[idx].confirmed);
|
|
updateStats();
|
|
autoSave();
|
|
}
|
|
|
|
function confirmAll() {
|
|
reviewData.forEach(item => item.confirmed = true);
|
|
renderReview();
|
|
autoSave();
|
|
}
|
|
|
|
function filterItems(filter) {
|
|
currentFilter = filter;
|
|
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
|
|
document.getElementById('filter-' + filter).classList.add('active');
|
|
renderReview();
|
|
}
|
|
|
|
function updateStats() {
|
|
let confirmed = reviewData.filter(i => i.confirmed).length;
|
|
document.getElementById('stat-confirmed').textContent = confirmed;
|
|
}
|
|
|
|
let saveTimer = null;
|
|
function autoSave() {
|
|
clearTimeout(saveTimer);
|
|
saveTimer = setTimeout(() => {
|
|
fetch(`${API}/save/${currentTaskId}`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ items: reviewData }),
|
|
}).catch(console.error);
|
|
}, 1000);
|
|
}
|
|
|
|
// === 生成 SRT ===
|
|
document.getElementById('btn-generate').addEventListener('click', async () => {
|
|
const btn = document.getElementById('btn-generate');
|
|
btn.disabled = true;
|
|
btn.textContent = '生成中...';
|
|
|
|
// 先保存最新编辑
|
|
await fetch(`${API}/save/${currentTaskId}`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ items: reviewData }),
|
|
});
|
|
|
|
try {
|
|
const res = await fetch(`${API}/generate/${currentTaskId}`, { method: 'POST' });
|
|
const data = await res.json();
|
|
if (data.error) { showError(data.error); btn.disabled = false; btn.textContent = '生成 SRT'; return; }
|
|
document.getElementById('done-detail').textContent = data.message;
|
|
goToStep(3);
|
|
} catch (e) {
|
|
showError('生成失败: ' + e.message);
|
|
btn.disabled = false;
|
|
btn.textContent = '生成 SRT';
|
|
}
|
|
});
|
|
|
|
// === 下载 ===
|
|
document.getElementById('btn-download').addEventListener('click', () => {
|
|
window.location.href = `${API}/download/${currentTaskId}`;
|
|
});
|
|
|
|
// === 步骤控制 ===
|
|
function goToStep(n) {
|
|
document.querySelectorAll('.step-panel').forEach(p => p.classList.remove('active'));
|
|
const panels = ['step-upload', 'step-processing', 'step-review', 'step-done'];
|
|
document.getElementById(panels[n]).classList.add('active');
|
|
|
|
for (let i = 0; i < 4; i++) {
|
|
const dot = document.getElementById('dot-' + i);
|
|
dot.className = 'step-dot' + (i === n ? ' active' : (i < n ? ' done' : ''));
|
|
}
|
|
document.getElementById('error-box').style.display = 'none';
|
|
}
|
|
|
|
function showError(msg) {
|
|
const box = document.getElementById('error-box');
|
|
box.textContent = msg;
|
|
box.style.display = 'block';
|
|
}
|
|
|
|
// === 查找替换 ===
|
|
let findMatches = []; // [{idx, pos}] — idx=reviewData index, pos=match position in text
|
|
let findCursor = -1;
|
|
|
|
function toggleReplace() {
|
|
const panel = document.getElementById('replace-panel');
|
|
const btn = document.getElementById('btn-toggle-replace');
|
|
panel.classList.toggle('active');
|
|
btn.classList.toggle('active');
|
|
if (panel.classList.contains('active')) {
|
|
document.getElementById('replace-find').focus();
|
|
} else {
|
|
clearFindHighlights();
|
|
findMatches = [];
|
|
findCursor = -1;
|
|
document.getElementById('replace-match-info').textContent = '';
|
|
}
|
|
}
|
|
|
|
document.addEventListener('keydown', e => {
|
|
if ((e.ctrlKey || e.metaKey) && e.key === 'h') {
|
|
e.preventDefault();
|
|
const panel = document.getElementById('replace-panel');
|
|
if (!panel.classList.contains('active') && document.getElementById('step-review').classList.contains('active')) {
|
|
toggleReplace();
|
|
} else if (panel.classList.contains('active')) {
|
|
document.getElementById('replace-find').focus();
|
|
}
|
|
}
|
|
});
|
|
|
|
function onFindInput() {
|
|
const keyword = document.getElementById('replace-find').value;
|
|
if (!keyword) {
|
|
findMatches = [];
|
|
findCursor = -1;
|
|
document.getElementById('replace-match-info').textContent = '';
|
|
renderReview();
|
|
return;
|
|
}
|
|
findMatches = [];
|
|
reviewData.forEach((item, idx) => {
|
|
let pos = 0;
|
|
const text = item.edited;
|
|
while (true) {
|
|
const found = text.indexOf(keyword, pos);
|
|
if (found === -1) break;
|
|
findMatches.push({ idx, pos: found });
|
|
pos = found + keyword.length;
|
|
}
|
|
});
|
|
findCursor = findMatches.length > 0 ? 0 : -1;
|
|
updateFindInfo();
|
|
renderReview();
|
|
scrollToCurrentMatch();
|
|
}
|
|
|
|
function findNext() {
|
|
if (findMatches.length === 0) return;
|
|
findCursor = (findCursor + 1) % findMatches.length;
|
|
updateFindInfo();
|
|
renderReview();
|
|
scrollToCurrentMatch();
|
|
}
|
|
|
|
function findPrev() {
|
|
if (findMatches.length === 0) return;
|
|
findCursor = (findCursor - 1 + findMatches.length) % findMatches.length;
|
|
updateFindInfo();
|
|
renderReview();
|
|
scrollToCurrentMatch();
|
|
}
|
|
|
|
function updateFindInfo() {
|
|
const info = document.getElementById('replace-match-info');
|
|
if (findMatches.length === 0) {
|
|
info.textContent = '无匹配';
|
|
info.style.color = 'var(--error)';
|
|
} else {
|
|
info.textContent = `${findCursor + 1} / ${findMatches.length}`;
|
|
info.style.color = 'var(--text-muted)';
|
|
}
|
|
}
|
|
|
|
function replaceCurrent() {
|
|
if (findCursor < 0 || findCursor >= findMatches.length) return;
|
|
const keyword = document.getElementById('replace-find').value;
|
|
const replacement = document.getElementById('replace-to').value;
|
|
if (!keyword) return;
|
|
|
|
const match = findMatches[findCursor];
|
|
const item = reviewData[match.idx];
|
|
item.edited = item.edited.substring(0, match.pos) + replacement + item.edited.substring(match.pos + keyword.length);
|
|
item.confirmed = true;
|
|
|
|
onFindInput();
|
|
autoSave();
|
|
}
|
|
|
|
function replaceAll() {
|
|
const keyword = document.getElementById('replace-find').value;
|
|
const replacement = document.getElementById('replace-to').value;
|
|
if (!keyword) return;
|
|
if (findMatches.length === 0) return;
|
|
|
|
const count = findMatches.length;
|
|
const affectedIdxs = new Set(findMatches.map(m => m.idx));
|
|
|
|
affectedIdxs.forEach(idx => {
|
|
const item = reviewData[idx];
|
|
item.edited = item.edited.split(keyword).join(replacement);
|
|
item.confirmed = true;
|
|
});
|
|
|
|
onFindInput();
|
|
autoSave();
|
|
document.getElementById('replace-match-info').textContent = `已替换 ${count} 处`;
|
|
document.getElementById('replace-match-info').style.color = 'var(--success)';
|
|
}
|
|
|
|
function clearFindHighlights() {
|
|
renderReview();
|
|
}
|
|
|
|
function scrollToCurrentMatch() {
|
|
if (findCursor < 0 || findCursor >= findMatches.length) return;
|
|
const match = findMatches[findCursor];
|
|
const row = document.querySelector(`.review-item[data-index="${match.idx}"]`);
|
|
if (row) {
|
|
row.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
}
|
|
}
|
|
|
|
// 重写 renderReview 支持查找高亮
|
|
const _origRenderReview = renderReview;
|
|
renderReview = function() {
|
|
const list = document.getElementById('review-list');
|
|
list.innerHTML = '';
|
|
|
|
let totalChanged = 0;
|
|
let totalConfirmed = 0;
|
|
const keyword = document.getElementById('replace-find') ? document.getElementById('replace-find').value : '';
|
|
const currentMatch = findCursor >= 0 && findCursor < findMatches.length ? findMatches[findCursor] : null;
|
|
|
|
// 建一个快速查找表:哪些 (idx, pos) 是当前光标
|
|
let globalMatchIdx = 0;
|
|
|
|
reviewData.forEach((item, i) => {
|
|
if (item.has_change) totalChanged++;
|
|
if (item.confirmed) totalConfirmed++;
|
|
|
|
if (currentFilter === 'changed' && !item.has_change) return;
|
|
|
|
const row = document.createElement('div');
|
|
row.className = 'review-item' + (item.has_change ? ' changed' : '') + (item.confirmed ? ' confirmed' : '');
|
|
row.dataset.index = i;
|
|
|
|
// 高亮匹配关键词
|
|
let editedDisplay = escHtml(item.edited);
|
|
if (keyword && item.edited.includes(keyword)) {
|
|
let result = '';
|
|
let searchPos = 0;
|
|
const text = item.edited;
|
|
while (true) {
|
|
const found = text.indexOf(keyword, searchPos);
|
|
if (found === -1) {
|
|
result += escHtml(text.substring(searchPos));
|
|
break;
|
|
}
|
|
result += escHtml(text.substring(searchPos, found));
|
|
const isCurrent = currentMatch && currentMatch.idx === i && currentMatch.pos === found;
|
|
result += `<span class="${isCurrent ? 'replace-current' : 'replace-highlight'}">${escHtml(keyword)}</span>`;
|
|
searchPos = found + keyword.length;
|
|
}
|
|
editedDisplay = result;
|
|
}
|
|
|
|
row.innerHTML = `
|
|
<div class="review-time">${msToTime(item.start_ms)}</div>
|
|
<div class="review-original">${escHtml(item.original)}</div>
|
|
<div style="position:relative;">
|
|
<div class="review-edited-display" style="padding:4px 8px;font-size:0.9rem;min-height:1.5em;cursor:text;border:1px solid transparent;border-radius:4px;background:var(--bg-tertiary);"
|
|
onclick="startEdit(this)">${editedDisplay}</div>
|
|
<input class="review-edited" value="${escAttr(item.edited)}" data-idx="${i}" style="display:none;"
|
|
onblur="onEditBlur(this)">
|
|
</div>
|
|
<button class="review-confirm-btn ${item.confirmed ? 'checked' : ''}"
|
|
onclick="toggleConfirm(${i}, this)" title="确认">
|
|
${item.confirmed ? '✓' : ''}
|
|
</button>
|
|
`;
|
|
list.appendChild(row);
|
|
});
|
|
|
|
document.getElementById('stat-total').textContent = reviewData.length;
|
|
document.getElementById('stat-changed').textContent = totalChanged;
|
|
document.getElementById('stat-confirmed').textContent = totalConfirmed;
|
|
};
|
|
|
|
function startEdit(displayDiv) {
|
|
displayDiv.style.display = 'none';
|
|
const input = displayDiv.nextElementSibling;
|
|
input.style.display = '';
|
|
input.focus();
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|