crosssubtitle-ai/src/components/SubtitleEditor.vue
2026-05-01 23:38:09 +08:00

178 lines
6.3 KiB
Vue
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.

<script setup lang="ts">
import { computed, ref, nextTick, watch } from 'vue'
import type { SubtitleSegment, SubtitleTask } from '../lib/types'
const PAGE_SIZE = 100
const props = defineProps<{
task: SubtitleTask | null
logs: string[]
}>()
const isProcessing = computed(() => {
if (!props.task) return false
return !['completed', 'failed'].includes(props.task.status)
})
const canExport = computed(() => {
return props.task?.status === 'completed' && (props.task.segments?.length ?? 0) > 0
})
const canTranslate = computed(() => {
if (!props.task) return false
return (
props.task.segments.length > 0 &&
props.task.segments.some((s) => s.sourceText)
)
})
const emit = defineEmits<{
save: [segment: SubtitleSegment]
export: [format: 'srt' | 'vtt' | 'ass']
translate: []
}>()
const sortedSegments = computed(() =>
[...(props.task?.segments ?? [])].sort((left, right) => {
if (left.start !== right.start) return left.start - right.start
if (left.end !== right.end) return left.end - right.end
return left.id.localeCompare(right.id)
}),
)
const currentPage = ref(1)
const totalPages = computed(() => Math.max(1, Math.ceil(sortedSegments.value.length / PAGE_SIZE)))
const hasPagination = computed(() => sortedSegments.value.length > PAGE_SIZE)
const visibleSegments = computed(() => {
const start = (currentPage.value - 1) * PAGE_SIZE
return sortedSegments.value.slice(start, start + PAGE_SIZE)
})
watch(totalPages, (newTotal) => {
if (currentPage.value > newTotal) {
currentPage.value = newTotal
}
})
function goToPage(page: number) {
currentPage.value = Math.max(1, Math.min(page, totalPages.value))
}
function prevPage() {
goToPage(currentPage.value - 1)
}
function nextPage() {
goToPage(currentPage.value + 1)
}
const logsExpanded = ref(false)
const logContainerRef = ref<HTMLElement | null>(null)
watch(() => props.logs.length, () => {
nextTick(() => {
if (logContainerRef.value) {
logContainerRef.value.scrollTop = logContainerRef.value.scrollHeight
}
})
})
function formatTime(seconds: number) {
const ms = Math.round(seconds * 1000)
const h = Math.floor(ms / 3600000)
const m = Math.floor((ms % 3600000) / 60000)
const s = Math.floor((ms % 60000) / 1000)
const milli = ms % 1000
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}.${String(milli).padStart(3, '0')}`
}
function updateTranslatedText(segment: SubtitleSegment, value: string) {
emit('save', { ...segment, translatedText: value })
}
</script>
<template>
<section class="panel workspace-panel">
<div class="workspace-header">
<div>
<strong>{{ task?.fileName ?? $t('editor.title') }}</strong>
<p class="panel-subtitle">
{{ task ? $t('editor.segments', { count: sortedSegments.length }) : $t('editor.selectTask') }}
</p>
</div>
<div v-if="task" class="export-actions">
<button
v-if="canTranslate"
class="button primary small"
:disabled="isProcessing"
@click="emit('translate')"
>{{ $t('editor.translate') }}</button>
<button class="button secondary small" :disabled="!canExport" @click="emit('export', 'srt')">SRT</button>
<button class="button secondary small" :disabled="!canExport" @click="emit('export', 'vtt')">VTT</button>
<button class="button secondary small" :disabled="!canExport" @click="emit('export', 'ass')">ASS</button>
</div>
</div>
<div v-if="!task" class="empty-state">
<p>{{ $t('editor.empty') }}</p>
<p style="margin-top: 6px; font-size: 11px;">{{ $t('editor.emptyHint') }}</p>
</div>
<div v-else-if="sortedSegments.length === 0" class="empty-state">
<template v-if="isProcessing">{{ $t('editor.processing') }}</template>
<template v-else-if="task?.status === 'failed'">{{ $t('editor.failed') }}</template>
<template v-else>{{ $t('editor.noSegments') }}</template>
</div>
<div v-else class="segment-list">
<div v-if="hasPagination" class="pagination-bar">
<button class="button small" :disabled="currentPage <= 1" @click="prevPage"></button>
<span class="pagination-info">{{ $t('editor.pageInfo', { current: currentPage, total: totalPages }) }}</span>
<button class="button small" :disabled="currentPage >= totalPages" @click="nextPage"></button>
<span class="pagination-count">{{ $t('editor.segments', { count: sortedSegments.length }) }}</span>
</div>
<article
v-for="segment in visibleSegments"
:key="segment.id"
class="segment-item"
>
<div class="task-row subtle">
<span>{{ formatTime(segment.start) }} - {{ formatTime(segment.end) }}</span>
<span class="segment-id">{{ segment.id }}</span>
</div>
<p class="source-text">{{ segment.sourceText || $t('editor.waiting') }}</p>
<textarea
class="editor-input"
:value="segment.translatedText ?? ''"
:placeholder="task.outputMode === 'translate' ? $t('editor.translation') : $t('editor.source')"
:disabled="task.outputMode === 'source'"
@change="updateTranslatedText(segment, ($event.target as HTMLTextAreaElement).value)"
/>
</article>
<div v-if="hasPagination" class="pagination-bar bottom">
<button class="button small" :disabled="currentPage <= 1" @click="prevPage"></button>
<span class="pagination-info">{{ $t('editor.pageInfo', { current: currentPage, total: totalPages }) }}</span>
<button class="button small" :disabled="currentPage >= totalPages" @click="nextPage"></button>
</div>
</div>
<div class="log-drawer" :class="{ expanded: logsExpanded }">
<button class="log-toggle" type="button" @click="logsExpanded = !logsExpanded">
<span>{{ $t('editor.logs') }}</span>
<span class="subtle">{{ logs.length }}</span>
<span class="log-chevron">{{ logsExpanded ? '' : '+' }}</span>
</button>
<div v-if="logsExpanded" class="log-panel">
<div v-if="logs.length === 0" class="empty-state">
{{ $t('editor.noLogs') }}
</div>
<div v-else ref="logContainerRef" class="log-list">
<pre v-for="(line, i) in logs" :key="i" class="log-line">{{ line }}</pre>
</div>
</div>
</div>
</section>
</template>