Files
simonkoson 7eb6511169 feat: CCA v6 腾讯云部署 + 审稿台(含查找替换)
- 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>
2026-07-05 21:44:52 +08:00

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">&#127908;</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">&#128196;</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="上一个">&#9650;</button>
<button class="replace-btn" onclick="findNext()" title="下一个">&#9660;</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">&#9989;</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 ? '&#10003;' : ''}
</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, '&quot;').replace(/</g, '&lt;'); }
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 ? '&#10003;' : '';
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 ? '&#10003;' : ''}
</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>