3803 lines
280 KiB
HTML
3803 lines
280 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en" class="h-full">
|
||
|
||
<!-- ==================== 功能块:页面头部资源区 Start ==================== -->
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||
<title>智能(会议)文秘 - Audio Transcription & Summarization</title>
|
||
<script>
|
||
//------------------- 深色模式初始化 start------------------
|
||
// 检查是否已经设置过偏好,如果没有则默认使用深色模式
|
||
(function () {
|
||
const savedMode = localStorage.getItem('darkMode');
|
||
if (savedMode === null) {
|
||
// 首次访问,默认深色模式
|
||
localStorage.setItem('darkMode', 'true');
|
||
document.documentElement.classList.add('dark');
|
||
} else if (savedMode === 'true') {
|
||
document.documentElement.classList.add('dark');
|
||
} else {
|
||
document.documentElement.classList.remove('dark');
|
||
}
|
||
})();
|
||
//--------------------深色模式初始化 end------------------
|
||
</script>
|
||
{% include 'includes/loading_overlay.html' %}
|
||
<!-- ==================== 功能块:基础依赖资源 Start ==================== -->
|
||
<!-- All dependencies bundled locally for offline support -->
|
||
<script src="/tool/speakr/static/vendor/js/tailwind.min.js"></script>
|
||
<script src="/tool/speakr/static/vendor/js/vue.global.js"></script>
|
||
<script src="/tool/speakr/static/vendor/js/marked.min.js"></script>
|
||
<link rel="stylesheet" href="/tool/speakr/static/vendor/css/easymde.min.css">
|
||
<script src="/tool/speakr/static/vendor/js/easymde.min.js"></script>
|
||
<script src="/tool/speakr/static/js/recorder-core.js" charset="UTF-8"></script>
|
||
<script src="/tool/speakr/static/js/wav.js" charset="UTF-8"></script>
|
||
<script src="/tool/speakr/static/js/pcm.js" charset="UTF-8"></script>
|
||
<link rel="stylesheet" href="/tool/speakr/static/vendor/css/fontawesome.min.css">
|
||
<link rel="stylesheet" href="/tool/speakr/static/css/styles.css">
|
||
<link rel="icon" href="/tool/speakr/static/img/favicon.ico">
|
||
<link rel="icon" type="image/png" sizes="32x32" href="/tool/speakr/static/img/icon-32x32.png">
|
||
<link rel="icon" type="image/png" sizes="16x16" href="/tool/speakr/static/img/icon-16x16.png">
|
||
<link rel="apple-touch-icon" sizes="180x180" href="/tool/speakr/static/img/icon-180x180.png">
|
||
<link rel="manifest" href="/tool/speakr/static/manifest.json">
|
||
<meta name="theme-color" content="#2563eb">
|
||
<!-- ==================== 功能块:基础依赖资源 End ==================== -->
|
||
<script>
|
||
//------------------- Tailwind 页面主题扩展 start------------------
|
||
tailwind.config = {
|
||
darkMode: 'class',
|
||
theme: {
|
||
extend: {
|
||
maxHeight: {
|
||
'85vh': '85vh',
|
||
'90vh': '90vh'
|
||
},
|
||
colors: {
|
||
primary: 'var(--bg-primary)',
|
||
secondary: 'var(--bg-secondary)',
|
||
accent: 'var(--bg-accent)'
|
||
}
|
||
}
|
||
}
|
||
}
|
||
//--------------------Tailwind 页面主题扩展 end------------------
|
||
</script>
|
||
</head>
|
||
<!-- ==================== 功能块:页面头部资源区 End ==================== -->
|
||
|
||
<!-- ==================== 功能块:页面主体 Start ==================== -->
|
||
<body class="h-full bg-[var(--bg-primary)] text-[var(--text-primary)] transition-colors duration-300">
|
||
<!-- ==================== 功能块:全局加载遮罩 Start ==================== -->
|
||
<div id="loader" class="fixed inset-0 bg-gray-800 flex items-center justify-center z-50">
|
||
<div class="text-white text-lg">Loading...</div>
|
||
</div>
|
||
<!-- ==================== 功能块:全局加载遮罩 End ==================== -->
|
||
|
||
<!-- ==================== 功能块:Vue 应用根容器 Start ==================== -->
|
||
<div id="app" v-cloak data-use-asr-endpoint="{{ use_asr_endpoint }}"
|
||
data-current-user-name="{{ current_user.name if current_user.is_authenticated else '' }}"
|
||
data-user-language="{{ user_language }}" class="h-full flex flex-col opacity-0 transition-opacity duration-500">
|
||
|
||
<!-- ==================== 功能块:顶部导航区 Start ==================== -->
|
||
<!-- Header -->
|
||
<header
|
||
class="bg-[var(--bg-secondary)] border-b border-[var(--border-primary)] px-3 sm:px-4 py-3 flex items-center justify-between flex-shrink-0 z-50">
|
||
<!-- Left side: Menu toggle and logo -->
|
||
<div class="flex items-center gap-2 sm:gap-4 min-w-0 flex-shrink">
|
||
<!-- Menu Toggle Button -->
|
||
<button @click="toggleSidebar"
|
||
class="p-2 rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors duration-200 flex-shrink-0 flex items-center justify-center">
|
||
<i class="fas fa-bars text-lg"></i>
|
||
</button>
|
||
|
||
<!-- Logo and Title -->
|
||
<div class="flex items-center gap-2 sm:gap-3 min-w-0">
|
||
<img src="/tool/speakr/static/img/icon-32x32.png" alt="智能(会议)文秘"
|
||
class="w-7 h-7 sm:w-8 sm:h-8 flex-shrink-0">
|
||
<h1 class="text-lg sm:text-xl font-bold text-[var(--text-primary)] truncate">智能(会议)文秘</h1>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Right side: User menu and controls -->
|
||
<div class="flex items-center gap-3">
|
||
<!-- Inquire Mode Button -->
|
||
{% if inquire_mode_enabled %}
|
||
<a href="/tool/speakr/inquire"
|
||
class="px-2 py-1.5 bg-[var(--bg-accent)] text-[var(--text-accent)] rounded-lg hover:opacity-80 transition-opacity text-sm sm:px-4 sm:py-2 sm:min-w-[120px] sm:flex sm:items-center sm:gap-2 sm:justify-center">
|
||
<i class="fas fa-search"></i>
|
||
<span class="hidden sm:inline" v-text="t('inquire.title')"></span>
|
||
</a>
|
||
{% endif %}
|
||
|
||
<!-- New Recording Button -->
|
||
<button @click="switchToUploadView"
|
||
class="px-3 py-1.5 bg-[var(--bg-button)] text-[var(--text-button)] rounded-lg hover:bg-[var(--bg-button-hover)] transition-colors text-sm sm:px-4 sm:py-2 sm:min-w-[120px] sm:flex sm:items-center sm:gap-2 sm:justify-center">
|
||
<i class="fas fa-plus mr-1"></i><span v-text="t('nav.newRecording')"></span>
|
||
</button>
|
||
|
||
<!-- User menu -->
|
||
{% if current_user.is_authenticated %}
|
||
<div class="relative">
|
||
<button @click="isUserMenuOpen = !isUserMenuOpen"
|
||
class="flex items-center gap-1 sm:gap-2 p-2 rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors duration-200"
|
||
:title="t('admin.userMenu')">
|
||
<i class="fas fa-user-circle text-lg"></i>
|
||
<span class="hidden lg:inline text-sm">{{ current_user.name if current_user.is_authenticated
|
||
else 'User' }}</span>
|
||
<i class="fas fa-chevron-down text-xs hidden sm:inline"></i>
|
||
</button>
|
||
|
||
<!-- User dropdown -->
|
||
<div v-if="isUserMenuOpen"
|
||
class="absolute right-0 mt-2 w-56 bg-[var(--bg-secondary)] border border-[var(--border-primary)] rounded-lg shadow-lg z-50">
|
||
<a href="/tool/speakr/account"
|
||
class="block px-4 py-2 hover:bg-[var(--bg-tertiary)] transition-colors">
|
||
<i class="fas fa-cog mr-2 w-4 text-center"></i><span v-text="t('nav.settings')"></span>
|
||
</a>
|
||
<a href="/tool/speakr/account#tags"
|
||
class="block px-4 py-2 hover:bg-[var(--bg-tertiary)] transition-colors">
|
||
<i class="fas fa-tags mr-2 w-4 text-center"></i><span
|
||
v-text="t('help.tagManagement')"></span>
|
||
</a>
|
||
<button @click="openSharesList"
|
||
class="w-full text-left px-4 py-2 hover:bg-[var(--bg-tertiary)] transition-colors flex items-center">
|
||
<i class="fas fa-share-alt mr-2 w-4 text-center"></i><span
|
||
v-text="t('modal.sharedTranscripts')"></span>
|
||
</button>
|
||
{% if current_user.is_admin %}
|
||
<a href="/tool/speakr/admin"
|
||
class="block px-4 py-2 hover:bg-[var(--bg-tertiary)] transition-colors">
|
||
<i class="fas fa-user-shield mr-2 w-4 text-center"></i><span
|
||
v-text="t('admin.title')"></span>
|
||
</a>
|
||
{% endif %}
|
||
<div class="border-t border-[var(--border-primary)] my-1"></div>
|
||
<button @click="toggleDarkMode"
|
||
class="w-full text-left px-4 py-2 hover:bg-[var(--bg-tertiary)] transition-colors flex items-center">
|
||
<i :class="isDarkMode ? 'fas fa-sun' : 'fas fa-moon'" class="mr-2 w-4 text-center"></i>
|
||
<span v-text="isDarkMode ? t('nav.lightMode') : t('nav.darkMode')"></span>
|
||
</button>
|
||
<button @click="openColorSchemeModal"
|
||
class="w-full text-left px-4 py-2 hover:bg-[var(--bg-tertiary)] transition-colors flex items-center">
|
||
<i class="fas fa-palette mr-2 w-4 text-center"></i>
|
||
<span v-text="t('modal.colorScheme')"></span>
|
||
</button>
|
||
<div class="border-t border-[var(--border-primary)] my-1"></div>
|
||
<a href="/tool/speakr/logout"
|
||
class="block px-4 py-2 hover:bg-[var(--bg-tertiary)] transition-colors">
|
||
<i class="fas fa-sign-out-alt mr-2 w-4 text-center"></i><span
|
||
v-text="t('nav.signOut')"></span>
|
||
</a>
|
||
</div>
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
</header>
|
||
<!-- ==================== 功能块:顶部导航区 End ==================== -->
|
||
|
||
<!-- ==================== 功能块:应用主工作区 Start ==================== -->
|
||
<div class="flex-1 flex overflow-hidden relative">
|
||
<!-- Mobile Sidebar Backdrop -->
|
||
<div v-if="!isSidebarCollapsed && isMobileScreen" @click="toggleSidebar"
|
||
class="fixed inset-0 bg-black bg-opacity-50 z-40 lg:hidden">
|
||
</div>
|
||
|
||
<!-- ==================== 功能块:录音列表侧边栏 Start ==================== -->
|
||
<!-- Sidebar -->
|
||
<aside :class="['sidebar', isSidebarCollapsed ? 'collapsed' : '']">
|
||
<div class="sidebar-content-wrapper">
|
||
<!-- Sidebar Header -->
|
||
<div class="p-4 border-b border-[var(--border-primary)] flex-shrink-0">
|
||
<div class="flex items-center justify-between mb-4">
|
||
<h2 class="text-lg font-semibold" v-text="t('nav.recording')"></h2>
|
||
<div class="flex gap-2">
|
||
{% if inquire_mode_enabled %}
|
||
<a href="/tool/speakr/inquire"
|
||
class="px-2 py-1.5 bg-[var(--bg-accent)] text-[var(--text-accent)] rounded-lg hover:opacity-80 transition-opacity text-sm">
|
||
<i class="fas fa-search"></i>
|
||
</a>
|
||
{% endif %}
|
||
<button @click="switchToUploadView"
|
||
class="px-3 py-1.5 bg-[var(--bg-button)] text-[var(--text-button)] rounded-lg hover:bg-[var(--bg-button-hover)] transition-colors text-sm">
|
||
<i class="fas fa-plus mr-1"></i><span v-text="t('common.new')"></span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Search and Sort Controls -->
|
||
<div class="space-y-3">
|
||
<!-- Filter Toggle Button -->
|
||
<div class="flex items-center space-x-2">
|
||
<button @click="showAdvancedFilters = !showAdvancedFilters"
|
||
class="flex-1 flex items-center justify-between px-3 py-2 bg-[var(--bg-input)] border border-[var(--border-secondary)] rounded-lg text-sm hover:bg-[var(--bg-tertiary)] transition-colors">
|
||
<span class="flex items-center">
|
||
<i class="fas fa-filter mr-2 text-[var(--text-muted)]"></i>
|
||
<span
|
||
v-if="!searchQuery && filterTags.length === 0 && !filterDatePreset && !filterDateRange.start && !filterDateRange.end && !filterTextQuery"
|
||
v-text="t('sidebar.searchRecordings')">
|
||
</span>
|
||
<span v-else class="text-[var(--text-accent)]">
|
||
活动过滤器 (${ (filterTags.length > 0 ? 1 : 0) + (filterDatePreset ||
|
||
filterDateRange.start || filterDateRange.end ? 1 : 0) + (filterTextQuery ? 1
|
||
: 0) })
|
||
</span>
|
||
</span>
|
||
<i
|
||
:class="['fas fa-chevron-down transition-transform text-[var(--text-muted)]', showAdvancedFilters ? 'rotate-180' : '']"></i>
|
||
</button>
|
||
<button
|
||
v-if="searchQuery || filterTags.length > 0 || filterDatePreset || filterDateRange.start || filterDateRange.end || filterTextQuery"
|
||
@click="clearAllFilters"
|
||
class="px-3 py-2 bg-[var(--bg-input)] border border-[var(--border-secondary)] rounded-lg text-sm hover:bg-[var(--bg-tertiary)] hover:text-[var(--text-primary)] transition-colors"
|
||
:title="t('buttons.clearAllFilters')">
|
||
<i class="fas fa-times text-[var(--text-muted)]"></i>
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Advanced Filters Panel -->
|
||
<div v-show="showAdvancedFilters"
|
||
class="p-3 bg-[var(--bg-tertiary)] border border-[var(--border-secondary)] rounded-lg space-y-3">
|
||
<!-- Text Search -->
|
||
<div>
|
||
<label class="block text-xs font-medium text-[var(--text-muted)] mb-1"
|
||
v-text="t('sidebar.filters')"></label>
|
||
<div class="relative">
|
||
<input v-model="filterTextQuery" type="text"
|
||
:placeholder="t('sidebar.searchRecordings')"
|
||
class="w-full px-3 py-1.5 pl-8 pr-8 bg-[var(--bg-input)] border border-[var(--border-secondary)] rounded-md text-sm focus:outline-none focus:ring-1 focus:ring-[var(--ring-focus)]">
|
||
<i
|
||
class="fas fa-search absolute left-2.5 top-1/2 transform -translate-y-1/2 text-[var(--text-muted)] text-xs"></i>
|
||
<button v-if="filterTextQuery" @click="filterTextQuery = ''"
|
||
class="absolute right-2.5 top-1/2 transform -translate-y-1/2 text-[var(--text-muted)] hover:text-[var(--text-primary)] text-xs"
|
||
:title="t('buttons.clearSearchText')">
|
||
<i class="fas fa-times"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Tag Filter -->
|
||
<div>
|
||
<label class="block text-xs font-medium text-[var(--text-muted)] mb-1"
|
||
v-text="t('tags.filterByTag')"></label>
|
||
<div class="flex flex-wrap gap-1.5">
|
||
<button v-for="tag in availableTags" :key="tag.id"
|
||
@click="filterTags.includes(tag.id) ? filterTags = filterTags.filter(id => id !== tag.id) : filterTags.push(tag.id)"
|
||
:class="[
|
||
'px-2 py-1 rounded-full text-xs font-medium transition-all',
|
||
filterTags.includes(tag.id)
|
||
? 'bg-[var(--bg-accent)] text-[var(--text-accent)] ring-1 ring-[var(--border-accent)]'
|
||
: 'bg-[var(--bg-secondary)] text-[var(--text-secondary)] hover:bg-[var(--bg-input)]'
|
||
]">
|
||
<span class="inline-block w-2 h-2 rounded-full mr-1"
|
||
:style="{ backgroundColor: tag.color || '#6B7280' }"></span>
|
||
${ tag.name }
|
||
</button>
|
||
</div>
|
||
<p v-if="availableTags.length === 0" class="text-xs text-[var(--text-muted)] italic"
|
||
v-text="t('tags.noTags')"></p>
|
||
</div>
|
||
|
||
<!-- Date Filter -->
|
||
<div>
|
||
<label class="block text-xs font-medium text-[var(--text-muted)] mb-1"
|
||
v-text="t('sidebar.dateRange')"></label>
|
||
<div class="grid grid-cols-3 gap-1.5 mb-2">
|
||
<button v-for="preset in datePresetOptions" :key="preset.value"
|
||
@click="filterDatePreset = filterDatePreset === preset.value ? '' : preset.value; filterDateRange = { start: '', end: '' }"
|
||
:class="[
|
||
'px-2 py-1 rounded-md text-xs transition-all',
|
||
filterDatePreset === preset.value
|
||
? 'bg-[var(--bg-accent)] text-[var(--text-accent)]'
|
||
: 'bg-[var(--bg-secondary)] text-[var(--text-secondary)] hover:bg-[var(--bg-input)]'
|
||
]" :title="preset.label">
|
||
${ preset.label }
|
||
</button>
|
||
</div>
|
||
<div class="grid grid-cols-2 gap-2">
|
||
<div>
|
||
<input v-model="filterDateRange.start" @change="filterDatePreset = ''"
|
||
type="date"
|
||
class="w-full px-2 py-1 bg-[var(--bg-input)] border border-[var(--border-secondary)] rounded-md text-xs focus:outline-none focus:ring-1 focus:ring-[var(--ring-focus)]"
|
||
:placeholder="t('form.dateFrom')">
|
||
</div>
|
||
<div>
|
||
<input v-model="filterDateRange.end" @change="filterDatePreset = ''"
|
||
type="date"
|
||
class="w-full px-2 py-1 bg-[var(--bg-input)] border border-[var(--border-secondary)] rounded-md text-xs focus:outline-none focus:ring-1 focus:ring-[var(--ring-focus)]"
|
||
:placeholder="t('form.dateTo')">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Sort Toggle -->
|
||
<button @click="sortBy = sortBy === 'created_at' ? 'meeting_date' : 'created_at'"
|
||
class="w-full flex items-center justify-between px-3 py-2 bg-[var(--bg-input)] border border-[var(--border-secondary)] rounded-lg text-sm hover:bg-[var(--bg-tertiary)] transition-colors">
|
||
<span class="flex items-center">
|
||
<i
|
||
:class="['fas mr-2 text-[var(--text-muted)]', sortBy === 'meeting_date' ? 'fa-calendar' : 'fa-upload']"></i>
|
||
<span v-text="t('sidebar.sortBy')"></span>: <span
|
||
v-text="sortBy === 'meeting_date' ? t('sidebar.sortByMeetingDate') : t('sidebar.sortByDate')"></span>
|
||
</span>
|
||
<i class="fas fa-exchange-alt text-[var(--text-muted)]"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Recordings List -->
|
||
|
||
<!-- Draft Recordings (草稿箱) -->
|
||
<div v-if="draftRecordings.length > 0" class="px-4 pt-4">
|
||
<div @click="draftsExpanded = !draftsExpanded"
|
||
class="flex items-center justify-between cursor-pointer p-2 bg-[var(--bg-tertiary)] rounded-lg border border-[var(--border-secondary)] hover:bg-[var(--bg-accent-hover)] transition-colors">
|
||
<span class="font-medium text-sm text-[var(--text-primary)]">
|
||
<i class="fas fa-edit mr-2 text-yellow-500"></i>未完成的录音 (${draftRecordings.length})
|
||
</span>
|
||
<i :class="draftsExpanded ? 'fa-chevron-up' : 'fa-chevron-down'" class="fas text-[var(--text-muted)] text-xs"></i>
|
||
</div>
|
||
|
||
<div v-show="draftsExpanded" class="mt-2 space-y-2">
|
||
<div v-for="draft in draftRecordings" :key="draft.id"
|
||
class="p-3 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-secondary)] hover:border-[var(--border-accent)] transition-colors">
|
||
<div class="flex justify-between items-center mb-2">
|
||
<div class="flex-1 mr-2 min-w-0">
|
||
<input v-if="editingDraftId === draft.id"
|
||
v-model="editingDraftName"
|
||
@blur="saveDraftRename(draft)"
|
||
@keyup.enter="saveDraftRename(draft)"
|
||
@click.stop
|
||
ref="draftNameInput"
|
||
class="w-full bg-[var(--bg-input)] text-[var(--text-primary)] border border-[var(--border-accent)] rounded px-1 py-0.5 text-sm focus:outline-none focus:ring-1 focus:ring-[var(--ring-focus)]">
|
||
<span v-else
|
||
@dblclick="startDraftRename(draft)"
|
||
class="text-sm font-medium text-[var(--text-primary)] truncate block cursor-text hover:text-[var(--text-accent)] transition-colors"
|
||
title="双击重命名">${draft.name}</span>
|
||
</div>
|
||
<span class="text-xs text-[var(--text-muted)] ml-2">${formatTime(Math.round(draft.total_duration || 0))}</span>
|
||
</div>
|
||
<div class="flex items-center justify-between">
|
||
<span class="text-xs text-[var(--text-muted)]">
|
||
<i class="fas fa-layer-group mr-1"></i>${draft.segment_count || 0} 个片段
|
||
</span>
|
||
<div class="flex gap-2">
|
||
<button @click="continueDraftRecording(draft.id)"
|
||
class="text-xs px-2 py-1 bg-green-500 text-white rounded hover:bg-green-600 transition-colors"
|
||
title="继续录音">
|
||
<i class="fas fa-play mr-1"></i>继续
|
||
</button>
|
||
<button @click="deleteDraft(draft.id)"
|
||
class="text-xs px-2 py-1 bg-red-500 text-white rounded hover:bg-red-600 transition-colors"
|
||
title="删除草稿">
|
||
<i class="fas fa-trash"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex-1 overflow-y-auto p-4" @scroll="(e) => {
|
||
const element = e.target;
|
||
const threshold = 100; // Load more when 100px from bottom
|
||
if (element.scrollHeight - element.scrollTop - element.clientHeight < threshold) {
|
||
loadMoreRecordings();
|
||
}
|
||
}">
|
||
<div v-if="isLoadingRecordings && recordings.length === 0" class="text-center py-8">
|
||
<i class="fas fa-spinner fa-spin text-2xl text-[var(--text-muted)]"></i>
|
||
<p class="mt-2 text-[var(--text-muted)]" v-text="t('help.loadingRecordings')"></p>
|
||
</div>
|
||
|
||
<div v-else-if="recordings.length === 0 && !isLoadingRecordings" class="text-center py-8">
|
||
<i class="fas fa-microphone-slash text-3xl text-[var(--text-muted)] mb-3"></i>
|
||
<p class="text-[var(--text-muted)]" v-text="t('sidebar.noRecordings')"></p>
|
||
<p v-if="searchQuery.trim()" class="text-sm text-[var(--text-muted)] mt-1">
|
||
<span v-text="t('help.tryAdjustingSearch')"></span> <button @click="clearAllFilters()"
|
||
class="text-[var(--text-accent)] hover:underline"
|
||
v-text="t('help.clearFilters')"></button>
|
||
</p>
|
||
</div>
|
||
|
||
<div v-if="recordings.length > 0" class="space-y-2">
|
||
<div v-for="group in groupedRecordings" :key="group.title" class="mb-6">
|
||
<h3 class="text-xs font-semibold text-[var(--text-muted)] uppercase tracking-wide mb-2">
|
||
${group.title}
|
||
</h3>
|
||
<div class="space-y-1">
|
||
<div v-for="recording in group.items" :key="recording.id"
|
||
@click="selectRecording(recording)" :class="[
|
||
'p-3 rounded-lg cursor-pointer transition-all duration-200 border',
|
||
selectedRecording?.id === recording.id
|
||
? 'bg-[var(--bg-accent)] border-[var(--border-accent)]'
|
||
: 'bg-[var(--bg-tertiary)] border-transparent hover:bg-[var(--bg-accent-hover)]'
|
||
]">
|
||
<!-- Title and Status Row -->
|
||
<div class="flex items-center justify-between mb-1">
|
||
<h4 class="font-medium text-sm truncate flex-1 mr-2"
|
||
:class="selectedRecording?.id === recording.id ? 'text-[var(--text-accent)]' : 'text-[var(--text-primary)]'">
|
||
${ recording.title || t('common.untitled') }
|
||
</h4>
|
||
<div class="flex items-center gap-1 flex-shrink-0">
|
||
<!-- Show Failed status for failed recordings -->
|
||
<span v-if="recording.status === 'FAILED'"
|
||
class="status-badge status-failed">
|
||
<i class="fas fa-exclamation-circle mr-1"></i>
|
||
${ t('common.failed') }
|
||
</span>
|
||
|
||
<!-- Show Processing status for non-completed recordings -->
|
||
<span v-else-if="recording.status !== 'COMPLETED'"
|
||
:class="getStatusClass(recording.status)" class="status-badge">
|
||
${formatStatus(recording.status)}
|
||
</span>
|
||
|
||
<!-- For completed recordings, show highlight and inbox badges -->
|
||
<template v-else>
|
||
<span v-if="recording.is_highlighted"
|
||
@click.stop="toggleHighlight(recording)"
|
||
class="status-badge status-highlighted clickable-badge"
|
||
:title="t('sidebar.removeFromHighlighted')">
|
||
<i class="fas fa-star"></i>
|
||
</span>
|
||
<span v-if="recording.is_inbox" @click.stop="toggleInbox(recording)"
|
||
class="status-badge status-inbox clickable-badge"
|
||
:title="t('sidebar.markAsRead')">
|
||
<i class="fas fa-inbox"></i>
|
||
</span>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Date -->
|
||
<p class="text-xs text-[var(--text-muted)] mt-1 flex items-center">
|
||
<i class="fas fa-calendar-alt mr-1.5 text-[var(--text-muted)]"
|
||
style="font-size: 10px;"></i>
|
||
${formatDisplayDate(sortBy === 'meeting_date' ? recording.meeting_date :
|
||
recording.created_at)}
|
||
</p>
|
||
|
||
<!-- Participants -->
|
||
<div v-if="recording.participants"
|
||
class="text-xs text-[var(--text-muted)] mt-1 flex items-start">
|
||
<i class="fas fa-users mr-1.5 text-[var(--text-muted)] flex-shrink-0"
|
||
style="font-size: 10px; margin-top: 2px;"></i>
|
||
<span class="truncate">
|
||
${recording.participants}
|
||
</span>
|
||
</div>
|
||
|
||
<!-- Tags -->
|
||
<div v-if="getRecordingTags(recording).length > 0"
|
||
class="flex flex-wrap gap-1 mt-1">
|
||
<button v-for="tag in getRecordingTags(recording).slice(0, 2)" :key="tag.id"
|
||
@click.stop="filterByTag(tag)"
|
||
class="inline-flex items-center px-1.5 py-0.5 rounded-md text-xs font-medium text-white hover:opacity-80 transition-opacity cursor-pointer"
|
||
:style="{ backgroundColor: tag.color }"
|
||
:title="'Filter by ' + tag.name">
|
||
<span v-text="tag.name"></span>
|
||
</button>
|
||
<span v-if="getRecordingTags(recording).length > 2"
|
||
class="inline-flex items-center px-1.5 py-0.5 rounded-md text-xs text-[var(--text-muted)]"
|
||
:title="getRecordingTags(recording).slice(2).map(t => t.name).join(', ')">
|
||
+${getRecordingTags(recording).length - 2} more
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Load More Indicator -->
|
||
<div v-if="isLoadingMore" class="text-center py-4">
|
||
<i class="fas fa-spinner fa-spin text-lg text-[var(--text-muted)]"></i>
|
||
<p class="text-sm text-[var(--text-muted)] mt-1" v-text="t('help.loadingMore')"></p>
|
||
</div>
|
||
|
||
<!-- End of Results Indicator -->
|
||
<div v-else-if="!hasNextPage && totalRecordings > 0"
|
||
class="text-center py-4 text-xs text-[var(--text-muted)]">
|
||
<i class="fas fa-check-circle mr-1"></i>
|
||
<span v-text="t('help.allRecordingsLoaded')"></span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</aside>
|
||
<!-- ==================== 功能块:录音列表侧边栏 End ==================== -->
|
||
|
||
<!-- ==================== 功能块:主内容视图区 Start ==================== -->
|
||
<!-- Main Content -->
|
||
<main class="main-content" :class="{ 'sidebar-open': !isSidebarCollapsed }">
|
||
|
||
<div class="flex-1 flex flex-col overflow-hidden">
|
||
<!-- ==================== 功能块:上传与新建录音视图 Start ==================== -->
|
||
<!-- Upload View -->
|
||
<div v-if="currentView === 'upload'"
|
||
class="flex-1 flex items-center justify-center p-4 md:p-8 overflow-y-auto">
|
||
<div class="max-w-lg w-full my-auto">
|
||
<div class="text-center mb-8">
|
||
<i class="fas fa-microphone text-5xl md:text-6xl text-[var(--text-accent)] mb-4"></i>
|
||
<h2 class="text-2xl md:text-3xl font-bold mb-2" v-text="t('upload.title')"></h2>
|
||
<p class="text-[var(--text-muted)]" v-text="t('upload.dropzone')"></p>
|
||
</div>
|
||
|
||
<div class="bg-[var(--bg-secondary)] p-6 rounded-xl border border-[var(--border-primary)]">
|
||
<!-- File Upload Area -->
|
||
<div @drop="handleDrop" @dragover="handleDragOver" @dragleave="handleDragLeave"
|
||
@click="$refs.fileInput.click()"
|
||
:class="['border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-all duration-300', dragover ? 'border-[var(--border-accent)] bg-[var(--bg-accent)]' : 'border-[var(--border-secondary)] hover:border-[var(--border-accent)]']">
|
||
<i class="fas fa-cloud-upload-alt text-3xl text-[var(--text-muted)] mb-3"></i>
|
||
<p class="text-[var(--text-secondary)] mb-2" v-text="t('upload.dropzone')"></p>
|
||
<p class="text-xs text-[var(--text-muted)]" v-text="t('upload.supportedFormats')">
|
||
</p>
|
||
</div>
|
||
<input ref="fileInput" type="file" @change="handleFileSelect"
|
||
accept="audio/*,video/mp4,video/quicktime,video/x-msvideo,video/webm,.mp4,.mov,.avi,.mkv,.webm"
|
||
multiple class="hidden">
|
||
|
||
<!-- Divider -->
|
||
<div class="my-6 flex items-center">
|
||
<div class="flex-grow border-t border-[var(--border-secondary)]"></div>
|
||
<span class="flex-shrink mx-4 text-xs text-[var(--text-muted)] uppercase"
|
||
v-text="t('common.or')"></span>
|
||
<div class="flex-grow border-t border-[var(--border-secondary)]"></div>
|
||
</div>
|
||
|
||
<!-- Audio Recording Options -->
|
||
<div class="space-y-3">
|
||
<h3 class="text-sm font-medium text-[var(--text-secondary)] text-center"
|
||
v-text="t('recording.title')"></h3>
|
||
|
||
<!-- Microphone Recording -->
|
||
<button @click="startRecording('microphone')"
|
||
class="w-full px-4 py-3 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors flex items-center justify-center gap-2">
|
||
<i class="fas fa-microphone"></i>
|
||
<span v-text="t('recording.microphone')"></span>
|
||
</button>
|
||
|
||
<!-- System Audio Recording -->
|
||
<button @click="startRecording('system')" v-if="canRecordSystemAudio"
|
||
class="w-full px-4 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors flex items-center justify-center gap-2">
|
||
<i class="fas fa-desktop"></i>
|
||
<span v-text="t('recording.systemAudio')"></span>
|
||
</button>
|
||
|
||
<!-- Both Audio Sources -->
|
||
<button @click="startRecording('both')" v-if="canRecordSystemAudio"
|
||
class="w-full px-4 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors flex items-center justify-center gap-2">
|
||
<i class="fas fa-microphone"></i>
|
||
<i class="fas fa-desktop ml-1"></i>
|
||
<span v-text="t('recording.microphoneAndSystem')"></span>
|
||
</button>
|
||
|
||
<!-- Help Text -->
|
||
<div class="text-xs text-[var(--text-muted)] space-y-1 mt-3">
|
||
<div class="flex items-center justify-between">
|
||
<span><strong v-text="t('recording.microphone') + ':'"></strong> <span
|
||
v-text="t('help.microphoneDesc')"></span></span>
|
||
<i class="fas fa-microphone text-red-500 flex-shrink-0"></i>
|
||
</div>
|
||
<div v-if="canRecordSystemAudio" class="flex items-center justify-between">
|
||
<span><strong v-text="t('recording.systemAudio') + ':'"></strong> <span
|
||
v-text="t('help.systemAudioDesc')"></span></span>
|
||
<i class="fas fa-desktop text-blue-500 flex-shrink-0"></i>
|
||
</div>
|
||
<div v-if="canRecordSystemAudio" class="flex items-center justify-between">
|
||
<span><strong v-text="t('recording.microphoneAndSystem') + ':'"></strong>
|
||
<span v-text="t('help.bothAudioDesc')"></span></span>
|
||
<div class="flex items-center gap-1 flex-shrink-0">
|
||
<i class="fas fa-microphone text-purple-500"></i>
|
||
<i class="fas fa-desktop text-purple-500"></i>
|
||
</div>
|
||
</div>
|
||
<div v-if="!canRecordSystemAudio" class="text-amber-600 dark:text-amber-400">
|
||
<i class="fas fa-exclamation-triangle mr-1"></i>
|
||
${ t('recording.systemAudioNotSupported') }
|
||
<button @click="showSystemAudioHelp = true"
|
||
class="ml-2 text-blue-500 hover:text-blue-600 underline text-sm">
|
||
<span v-text="t('buttons.help')"></span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Tag Selection -->
|
||
<div class="mt-4">
|
||
<label class="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||
<i class="fas fa-tags mr-1 text-[var(--text-muted)]"></i>
|
||
${ t('tags.title') }
|
||
</label>
|
||
|
||
<!-- Selected Tags Display - Compact -->
|
||
<div v-if="selectedTags.length > 0" class="mb-2">
|
||
<div
|
||
class="p-1.5 bg-[var(--bg-tertiary)] rounded-md border border-[var(--border-secondary)] max-h-16 overflow-y-auto">
|
||
<div class="flex flex-wrap gap-1">
|
||
<span v-for="(tag, index) in selectedTags" :key="tag.id"
|
||
class="inline-flex items-center px-1.5 py-0.5 rounded-full text-[11px] font-medium text-white"
|
||
:style="{ backgroundColor: tag.color }">
|
||
<span class="opacity-75 mr-0.5 text-[9px]">${index + 1}.</span>
|
||
<span>${tag.name}</span>
|
||
<button @click="removeTagFromSelection(tag.id)"
|
||
class="ml-1 hover:opacity-100 opacity-70">
|
||
<i class="fas fa-times" style="font-size: 9px;"></i>
|
||
</button>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<p class="text-[10px] text-[var(--text-muted)] mt-0.5">
|
||
<i class="fas fa-info-circle mr-0.5" style="font-size: 9px;"></i>
|
||
Tags ordered by priority
|
||
</p>
|
||
</div>
|
||
|
||
<!-- Tag Selection Container - Compact -->
|
||
<div v-if="availableTags.filter(tag => !selectedTagIds.includes(tag.id)).length > 0"
|
||
class="border border-[var(--border-secondary)] rounded-md bg-[var(--bg-tertiary)] p-2">
|
||
<!-- Search Filter (always visible if tags available) -->
|
||
<div class="mb-1.5">
|
||
<div class="relative">
|
||
<input v-model="uploadTagSearchFilter" type="text"
|
||
:placeholder="t('tagsModal.searchTags')"
|
||
class="w-full px-2 py-1 pl-6 text-xs bg-[var(--bg-primary)] text-[var(--text-primary)] border border-[var(--border-primary)] rounded focus:outline-none focus:ring-1 focus:ring-[var(--ring-focus)]">
|
||
<i class="fas fa-search absolute left-2 top-1/2 transform -translate-y-1/2 text-[var(--text-muted)]"
|
||
style="font-size: 9px;"></i>
|
||
<button v-if="uploadTagSearchFilter" @click="uploadTagSearchFilter = ''"
|
||
class="absolute right-2 top-1/2 transform -translate-y-1/2 text-[var(--text-muted)] hover:text-[var(--text-primary)]">
|
||
<i class="fas fa-times" style="font-size: 9px;"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Available Tags Grid - Fixed Height -->
|
||
<div v-if="filteredAvailableTagsForUpload.length > 0" class="overflow-y-auto"
|
||
style="max-height: 100px;">
|
||
<div class="grid grid-cols-2 gap-1">
|
||
<button v-for="tag in filteredAvailableTagsForUpload" :key="tag.id"
|
||
@click="addTagToSelection(tag.id)"
|
||
class="group flex items-center justify-between px-1.5 py-1 rounded border border-[var(--border-secondary)] hover:border-[var(--border-focus)] bg-[var(--bg-primary)] hover:bg-[var(--bg-secondary)] transition-all">
|
||
<div class="flex items-center gap-1 min-w-0">
|
||
<span class="w-2 h-2 rounded-full flex-shrink-0"
|
||
:style="{ backgroundColor: tag.color || '#6B7280' }"></span>
|
||
<span class="text-[11px] text-[var(--text-primary)] truncate"
|
||
v-text="tag.name"></span>
|
||
<span class="text-[11px] text-[var(--text-primary)] truncate"
|
||
v-text="tag.visibility=='global'?'(全局)':'(个人)'"></span>
|
||
</div>
|
||
<i class="fas fa-plus text-[var(--text-muted)] group-hover:text-[var(--text-accent)] transition-colors"
|
||
style="font-size: 9px;"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div v-else class="py-2 text-center">
|
||
<p class="text-[11px] text-[var(--text-muted)]">
|
||
<i class="fas fa-search mr-1" style="font-size: 9px;"></i>
|
||
No matching tags
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<!-- Empty States -->
|
||
<div v-else-if="availableTags.length === 0"
|
||
class="p-2 bg-[var(--bg-tertiary)] rounded-md border border-[var(--border-secondary)]">
|
||
<p class="text-xs text-[var(--text-muted)] text-center">
|
||
<i class="fas fa-info-circle mr-1" style="font-size: 10px;"></i>
|
||
<span v-text="t('help.noTagsCreated')"></span> <a
|
||
href="/tool/speakr/account#tags"
|
||
class="text-[var(--text-accent)] hover:underline"
|
||
v-text="t('help.createTags')"></a>
|
||
</p>
|
||
</div>
|
||
<div v-else
|
||
class="p-2 bg-[var(--bg-tertiary)] rounded-md border border-[var(--border-secondary)]">
|
||
<p class="text-xs text-[var(--text-muted)] text-center">
|
||
<i class="fas fa-check-circle mr-1" style="font-size: 10px;"></i>
|
||
All tags selected
|
||
</p>
|
||
</div>
|
||
|
||
<p v-if="selectedTags.some(tag => tag.custom_prompt)"
|
||
class="text-xs text-[var(--text-muted)] mt-1">
|
||
<i class="fas fa-info-circle mr-1 text-[var(--text-accent)]"></i>
|
||
Selected tags include custom summary prompts
|
||
</p>
|
||
<p v-if="selectedTags.length > 0 && useAsrEndpoint && selectedTags.some(tag => tag.default_language || tag.default_min_speakers || tag.default_max_speakers)"
|
||
class="text-xs text-[var(--text-muted)] mt-1">
|
||
<i class="fas fa-cog mr-1 text-[var(--text-accent)]"></i>
|
||
First tag's ASR settings will be applied: ${selectedTags[0].name}
|
||
</p>
|
||
</div>
|
||
|
||
<!-- Advanced Options for ASR (collapsible) -->
|
||
<div v-if="useAsrEndpoint" class="mt-4">
|
||
<button @click="showAdvancedOptions = !showAdvancedOptions"
|
||
class="w-full flex items-center justify-between px-3 py-2 bg-[var(--bg-tertiary)] rounded-md hover:bg-[var(--bg-accent)] transition-colors text-xs font-medium">
|
||
<span class="flex items-center gap-2 text-[var(--text-secondary)]">
|
||
<i class="fas fa-cog text-[var(--text-muted)]"></i>
|
||
<span v-text="t('help.advancedAsrOptions')"></span>
|
||
</span>
|
||
<i
|
||
:class="['fas text-[var(--text-muted)]', showAdvancedOptions ? 'fa-chevron-up' : 'fa-chevron-down']"></i>
|
||
</button>
|
||
|
||
<div v-show="showAdvancedOptions"
|
||
class="mt-2 p-3 bg-[var(--bg-tertiary)] rounded-md space-y-3 border border-[var(--border-secondary)]">
|
||
<!-- Language Selection -->
|
||
<div>
|
||
<label class="block text-xs font-medium text-[var(--text-secondary)] mb-1">
|
||
转录语言
|
||
</label>
|
||
<select v-model="uploadLanguage"
|
||
class="w-full px-2 py-1 text-sm bg-[var(--bg-primary)] text-[var(--text-primary)] border border-[var(--border-primary)] rounded focus:outline-none focus:ring-1 focus:ring-[var(--ring-focus)]">
|
||
<option v-for="lang in languageOptions" :key="lang.value"
|
||
:value="lang.value" v-text="lang.label"></option>
|
||
</select>
|
||
</div>
|
||
|
||
<!-- Speaker Settings -->
|
||
<div class="grid grid-cols-2 gap-3">
|
||
<div>
|
||
<label
|
||
class="block text-xs font-medium text-[var(--text-secondary)] mb-1"
|
||
v-text="t('form.minSpeakers')">
|
||
</label>
|
||
<input v-model.number="uploadMinSpeakers" type="number" min="1" max="20"
|
||
class="w-full px-2 py-1 text-sm bg-[var(--bg-primary)] text-[var(--text-primary)] border border-[var(--border-primary)] rounded focus:outline-none focus:ring-1 focus:ring-[var(--ring-focus)]">
|
||
</div>
|
||
<div>
|
||
<label
|
||
class="block text-xs font-medium text-[var(--text-secondary)] mb-1"
|
||
v-text="t('form.maxSpeakers')">
|
||
</label>
|
||
<input v-model.number="uploadMaxSpeakers" type="number" min="1" max="20"
|
||
class="w-full px-2 py-1 text-sm bg-[var(--bg-primary)] text-[var(--text-primary)] border border-[var(--border-primary)] rounded focus:outline-none focus:ring-1 focus:ring-[var(--ring-focus)]">
|
||
</div>
|
||
</div>
|
||
|
||
<p class="text-xs text-[var(--text-muted)] pt-1">
|
||
<i class="fas fa-info-circle mr-1 text-[var(--text-accent)]"></i>
|
||
设置适用于此会话中的所有文件
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ==================== 功能块:上传与新建录音视图 End ==================== -->
|
||
|
||
<!-- ==================== 功能块:实时录音视图 Start ==================== -->
|
||
<!-- Recording View -->
|
||
<div v-else-if="currentView === 'recording'" class="index_m_cot">
|
||
<div class="websockt_box border border-[var(--border-primary)] min-h-0">
|
||
<!-- Top: Visualizer and Status -->
|
||
<div
|
||
class="flex-shrink-0 p-4 md:p-6 text-center bg-[var(--bg-tertiary)] border-b border-[var(--border-primary)]">
|
||
<div v-if="isRecording || isPaused" class="w-full mx-auto mb-4">
|
||
<!-- Dual visualizer for 'both' mode -->
|
||
<div v-if="recordingMode === 'both'" class="max-w-4xl mx-auto flex gap-4">
|
||
<div
|
||
class="w-1/2 min-h-[12rem] h-48 md:h-40 flex flex-col bg-[var(--bg-primary)] rounded-lg p-2">
|
||
<div class="flex-1 w-full overflow-hidden rounded-md"
|
||
style="min-height: 10rem;">
|
||
<canvas ref="micVisualizer" class="w-full h-full"></canvas>
|
||
</div>
|
||
<p class="text-xs text-[var(--text-muted)] pt-1"
|
||
v-text="t('recording.microphone')"></p>
|
||
</div>
|
||
<div
|
||
class="w-1/2 min-h-[12rem] h-48 md:h-40 flex flex-col bg-[var(--bg-primary)] rounded-lg p-2">
|
||
<div class="flex-1 w-full overflow-hidden rounded-md"
|
||
style="min-height: 10rem;">
|
||
<canvas ref="systemVisualizer" class="w-full h-full"></canvas>
|
||
</div>
|
||
<p class="text-xs text-[var(--text-muted)] pt-1"
|
||
v-text="t('recording.systemAudio')"></p>
|
||
</div>
|
||
</div>
|
||
<!-- Single visualizer for other modes -->
|
||
<div v-else
|
||
class="min-h-[12rem] h-48 md:h-40 max-w-2xl mx-auto bg-[var(--bg-primary)] rounded-lg p-2 flex flex-col">
|
||
<div class="flex-1 w-full overflow-hidden rounded-md"
|
||
style="min-height: 10rem;">
|
||
<canvas ref="visualizer" class="w-full h-full"></canvas>
|
||
</div>
|
||
<p class="text-xs text-[var(--text-muted)] pt-1 capitalize">${recordingMode}</p>
|
||
</div>
|
||
</div>
|
||
<div v-if="!isRecording && !isPaused && audioBlobURL" class="w-full mb-4">
|
||
<div class="audio-player-container">
|
||
<audio :src="audioBlobURL" controls class="w-full">
|
||
Your browser does not support the audio element.
|
||
</audio>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div class="text-2xl font-mono text-[var(--text-accent)]">
|
||
${formatTime(recordingTime)}</div>
|
||
<p class="text-sm text-[var(--text-muted)]">
|
||
${ isRecording ? '正在录制...' : (isPaused ? '录音已暂停' : (audioBlobURL ? '录制完成' : '准备录制')) }
|
||
</p>
|
||
<!-- Real-time file size display -->
|
||
<div v-if="(isRecording || isPaused) && estimatedFileSize > 0" class="mt-2">
|
||
<p class="text-xs text-[var(--text-muted)]">
|
||
预估大小: <span class="font-mono">${formatFileSize(estimatedFileSize)}</span>
|
||
<span v-if="actualBitrate > 0" class="ml-2">
|
||
(${Math.round(actualBitrate / 1000)}kbps)
|
||
</span>
|
||
</p>
|
||
<!-- Size warning indicator -->
|
||
<div v-if="estimatedFileSize > (maxRecordingMB * 1024 * 1024 * 0.8)"
|
||
class="mt-1">
|
||
<div class="flex items-center text-xs text-amber-600 dark:text-amber-400">
|
||
<i class="fas fa-exclamation-triangle mr-1"></i>
|
||
<span
|
||
v-text="t('help.approachingLimit', { limit: maxRecordingMB })"></span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Middle: Notepad -->
|
||
<div class="flex-1 p-4 md:p-6 min-h-0 overflow-hidden flex flex-col">
|
||
<label for="recordingNotes"
|
||
class="block text-sm font-medium text-[var(--text-secondary)] mb-2 flex-shrink-0">
|
||
<i class="fas fa-pencil-alt mr-1"></i>
|
||
编写笔记
|
||
</label>
|
||
<div class="flex-1 min-h-0">
|
||
<!-- <textarea ref="recordingNotesEditor" v-model="recordingNotes"
|
||
class="w-full h-80 p-2 border border-[var(--border-primary)] rounded-md bg-[var(--bg-secondary)] text-[var(--text-primary)] resize-none overflow-y-auto"
|
||
:placeholder="t('form.notesPlaceholder')"
|
||
rows="10"></textarea> -->
|
||
<div>
|
||
<textarea style="height: 322px;"
|
||
class="w-full h-180 p-2 border border-[var(--border-primary)] rounded-md bg-[var(--bg-secondary)] text-[var(--text-primary)] resize-none overflow-y-auto"
|
||
v-model="recordingNotes"></textarea>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Bottom: Action Buttons -->
|
||
<div class="flex-shrink-0 p-4 md:p-6 bg-[var(--bg-tertiary)] border-[var(--border-primary)]"
|
||
style="
|
||
background: none;">
|
||
<div v-if="showRecordingControls" class="text-center flex justify-center gap-3">
|
||
<!-- 暂停按钮 (录音中显示) -->
|
||
<button v-if="isRecording" @click="pauseRecording"
|
||
class="px-6 py-3 bg-yellow-500 text-white rounded-lg hover:bg-yellow-600 transition-colors">
|
||
<i class="fas fa-pause mr-2"></i>暂停录音
|
||
</button>
|
||
<!-- 恢复按钮 (暂停时显示) -->
|
||
<button v-if="isPaused" @click="resumeRecording"
|
||
class="px-6 py-3 bg-green-500 text-white rounded-lg hover:bg-green-600 transition-colors">
|
||
<i class="fas fa-play mr-2"></i>继续录音
|
||
</button>
|
||
<!-- 停止按钮 -->
|
||
<button @click="stopRecording"
|
||
class="px-6 py-3 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
|
||
:class="{
|
||
'animate-pulse': isRecording,
|
||
'opacity-75 cursor-not-allowed': isStoppingRecording
|
||
}"
|
||
:disabled="isStoppingRecording">
|
||
<i class="fas fa-stop mr-2"></i>停止录音
|
||
</button>
|
||
</div>
|
||
|
||
<div v-if="!isRecording && !isPaused && (audioBlobURL || currentDraftId)" class="flex flex-col gap-4">
|
||
<!-- Tag Selection -->
|
||
<div>
|
||
<label for="recordingTagSelect"
|
||
class="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||
<i class="fas fa-tags mr-1"></i>
|
||
选择标签(可选,按优先级顺序)
|
||
</label>
|
||
|
||
<!-- Selected Tags Display - Compact -->
|
||
<div v-if="selectedTags.length > 0" class="mb-2">
|
||
<div
|
||
class="p-1.5 bg-[var(--bg-tertiary)] rounded-md border border-[var(--border-secondary)] max-h-16 overflow-y-auto">
|
||
<div class="flex flex-wrap gap-1">
|
||
<span v-for="(tag, index) in selectedTags" :key="tag.id"
|
||
class="inline-flex items-center px-1.5 py-0.5 rounded-full text-[11px] font-medium text-white"
|
||
:style="{ backgroundColor: tag.color }">
|
||
<span class="opacity-75 mr-0.5 text-[9px]">${index + 1}.</span>
|
||
<span>${tag.name}</span>
|
||
<button @click="removeTagFromSelection(tag.id)"
|
||
class="ml-1 hover:opacity-100 opacity-70">
|
||
<i class="fas fa-times" style="font-size: 9px;"></i>
|
||
</button>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Tag Selection Container - Compact -->
|
||
<div v-if="availableTags.filter(tag => !selectedTagIds.includes(tag.id)).length > 0"
|
||
class="border border-[var(--border-secondary)] rounded-md bg-[var(--bg-tertiary)] p-2">
|
||
<!-- Search Filter -->
|
||
<div class="mb-1.5">
|
||
<div class="relative">
|
||
<input v-model="uploadTagSearchFilter" type="text"
|
||
:placeholder="t('tagsModal.searchTags')"
|
||
class="w-full px-2 py-1 pl-6 text-xs bg-[var(--bg-primary)] text-[var(--text-primary)] border border-[var(--border-primary)] rounded focus:outline-none focus:ring-1 focus:ring-[var(--ring-focus)]">
|
||
<i class="fas fa-search absolute left-2 top-1/2 transform -translate-y-1/2 text-[var(--text-muted)]"
|
||
style="font-size: 9px;"></i>
|
||
<button v-if="uploadTagSearchFilter"
|
||
@click="uploadTagSearchFilter = ''"
|
||
class="absolute right-2 top-1/2 transform -translate-y-1/2 text-[var(--text-muted)] hover:text-[var(--text-primary)]">
|
||
<i class="fas fa-times" style="font-size: 9px;"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<!-- Available Tags Grid -->
|
||
<div v-if="filteredAvailableTagsForUpload.length > 0"
|
||
class="overflow-y-auto" style="max-height: 100px;">
|
||
<div class="grid grid-cols-2 gap-1">
|
||
<button v-for="tag in filteredAvailableTagsForUpload" :key="tag.id"
|
||
@click="addTagToSelection(tag.id)"
|
||
class="group flex items-center justify-between px-1.5 py-1 rounded border border-[var(--border-secondary)] hover:border-[var(--border-focus)] bg-[var(--bg-primary)] hover:bg-[var(--bg-secondary)] transition-all">
|
||
<div class="flex items-center gap-1 min-w-0">
|
||
<span class="w-2 h-2 rounded-full flex-shrink-0"
|
||
:style="{ backgroundColor: tag.color || '#6B7280' }"></span>
|
||
<span
|
||
class="text-[11px] text-[var(--text-primary)] truncate"
|
||
v-text="tag.name"></span>
|
||
</div>
|
||
<i class="fas fa-plus text-[var(--text-muted)] group-hover:text-[var(--text-accent)] transition-colors"
|
||
style="font-size: 9px;"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div v-else class="py-2 text-center">
|
||
<p class="text-[11px] text-[var(--text-muted)]">
|
||
<i class="fas fa-search mr-1" style="font-size: 9px;"></i>
|
||
No matching tags
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<!-- Empty States -->
|
||
<div v-else-if="availableTags.length === 0"
|
||
class="p-2 bg-[var(--bg-tertiary)] rounded-md border border-[var(--border-secondary)]">
|
||
<p class="text-xs text-[var(--text-muted)] text-center">
|
||
<i class="fas fa-info-circle mr-1" style="font-size: 10px;"></i>
|
||
<span v-text="t('help.noTagsCreated')"></span> <a
|
||
href="/tool/speakr/account#tags"
|
||
class="text-[var(--text-accent)] hover:underline"
|
||
v-text="t('help.createTags')"></a>
|
||
</p>
|
||
</div>
|
||
<div v-else
|
||
class="p-2 bg-[var(--bg-tertiary)] rounded-md border border-[var(--border-secondary)]">
|
||
<p class="text-xs text-[var(--text-muted)] text-center">
|
||
<i class="fas fa-check-circle mr-1" style="font-size: 10px;"></i>
|
||
All tags selected
|
||
</p>
|
||
</div>
|
||
|
||
<p v-if="selectedTags.some(tag => tag.custom_prompt)"
|
||
class="text-xs text-[var(--text-muted)] mt-1">
|
||
<i class="fas fa-info-circle mr-1 text-[var(--text-accent)]"></i>
|
||
Selected tags include custom summary prompts
|
||
</p>
|
||
<p v-if="selectedTags.length > 0 && useAsrEndpoint && selectedTags.some(tag => tag.default_language || tag.default_min_speakers || tag.default_max_speakers)"
|
||
class="text-xs text-[var(--text-muted)] mt-1">
|
||
<i class="fas fa-cog mr-1 text-[var(--text-accent)]"></i>
|
||
First tag's ASR settings will be applied: ${selectedTags[0].name}
|
||
</p>
|
||
</div>
|
||
|
||
<!-- Advanced Options for ASR (collapsible) -->
|
||
<div v-if="useAsrEndpoint">
|
||
<button @click="showAdvancedOptions = !showAdvancedOptions"
|
||
class="w-full flex items-center justify-between px-3 py-2 bg-[var(--bg-tertiary)] rounded-md hover:bg-[var(--bg-accent)] transition-colors text-xs font-medium">
|
||
<span class="flex items-center gap-2 text-[var(--text-secondary)]">
|
||
<i class="fas fa-cog text-[var(--text-muted)]"></i>
|
||
<span v-text="t('help.advancedAsrOptions')"></span>
|
||
</span>
|
||
<i
|
||
:class="['fas text-[var(--text-muted)]', showAdvancedOptions ? 'fa-chevron-up' : 'fa-chevron-down']"></i>
|
||
</button>
|
||
|
||
<div v-if="showAdvancedOptions"
|
||
class="mt-2 p-3 bg-[var(--bg-tertiary)] rounded-md border border-[var(--border-secondary)] space-y-3">
|
||
<div>
|
||
<label for="recordingAsrLanguage"
|
||
class="block text-xs font-medium text-[var(--text-secondary)] mb-1">
|
||
语言
|
||
</label>
|
||
<select id="recordingAsrLanguage" v-model="asrLanguage"
|
||
class="w-full px-2 py-1 bg-[var(--bg-primary)] text-[var(--text-primary)] border border-[var(--border-secondary)] rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-[var(--ring-focus)] text-xs">
|
||
<option v-for="lang in languageOptions" :key="lang.value"
|
||
:value="lang.value" v-text="lang.label"></option>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="grid grid-cols-2 gap-3">
|
||
<div>
|
||
<label for="recordingAsrMinSpeakers"
|
||
class="block text-xs font-medium text-[var(--text-secondary)] mb-1">
|
||
{% raw %}最少发言人数{% endraw %}
|
||
</label>
|
||
<input type="number" id="recordingAsrMinSpeakers"
|
||
v-model="asrMinSpeakers" min="1" max="20"
|
||
:placeholder="t('form.auto')"
|
||
class="w-full px-2 py-1 bg-[var(--bg-primary)] text-[var(--text-primary)] border border-[var(--border-secondary)] rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-[var(--ring-focus)] text-xs">
|
||
</div>
|
||
|
||
<div>
|
||
<label for="recordingAsrMaxSpeakers"
|
||
class="block text-xs font-medium text-[var(--text-secondary)] mb-1">
|
||
{% raw %}最多发言人{% endraw %}
|
||
</label>
|
||
<input type="number" id="recordingAsrMaxSpeakers"
|
||
v-model="asrMaxSpeakers" min="1" max="20"
|
||
:placeholder="t('form.auto')"
|
||
class="w-full px-2 py-1 bg-[var(--bg-primary)] text-[var(--text-primary)] border border-[var(--border-secondary)] rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-[var(--ring-focus)] text-xs">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Upload/Discard Buttons -->
|
||
<div class="flex flex-col sm:flex-row gap-3 justify-center">
|
||
<button @click="uploadRecordedAudio"
|
||
class="flex-1 px-6 py-3 bg-[var(--bg-button)] text-[var(--text-button)] rounded-lg hover:bg-[var(--bg-button-hover)] transition-colors flex items-center justify-center gap-2">
|
||
<i class="fas fa-upload"></i>
|
||
<span v-text="t('help.uploadRecordingNotes')"></span>
|
||
</button>
|
||
<button @click="downloadRecordedAudio" v-if="audioBlobURL"
|
||
class="px-6 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors flex items-center justify-center gap-2"
|
||
title="Download Recording">
|
||
<i class="fas fa-download"></i>
|
||
<span>下载录音</span>
|
||
</button>
|
||
<button @click="discardRecording"
|
||
class="px-6 py-3 bg-[var(--bg-danger)] text-white rounded-lg hover:bg-[var(--bg-danger-hover)] transition-colors flex items-center justify-center gap-2">
|
||
<i class="fas fa-trash"></i>
|
||
<span v-text="t('help.discard')"></span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div
|
||
class="websockt_box border border-[var(--border-primary)] min-h-0 bg-[var(--bg-tertiary)] p-4 space-y-6 rounded-lg">
|
||
<!-- 第一个区域:语音转文字结果 -->
|
||
<div>
|
||
<h2 class="text-lg font-semibold mb-2">语音实时转录 <button style=" margin-left: 20px;"
|
||
@click="isyjDialogVisible = true" class="text-gray-300 hover:text-white">
|
||
<i class="fas fa-expand"></i>
|
||
</button>
|
||
<button v-if="!isEditingTranscription" @click="enterEditMode()"
|
||
class="ml-4 px-3 py-1 text-sm text-blue-400 hover:text-blue-300 border border-blue-400/30 rounded hover:border-blue-300/50 transition-colors">
|
||
<i class="fas fa-pen mr-1"></i>编辑
|
||
</button>
|
||
<button v-else @click="exitEditMode()"
|
||
class="ml-4 px-3 py-1 text-sm text-green-400 hover:text-green-300 border border-green-400/30 rounded hover:border-green-300/50 transition-colors">
|
||
<i class="fas fa-check mr-1"></i>完成编辑
|
||
</button>
|
||
<label class="inline-flex items-center ml-4 cursor-pointer">
|
||
<span class="text-gray-300 mr-2">是否显示说话人</span>
|
||
<input type="checkbox" v-model="showSpeaker" class="hidden">
|
||
<span
|
||
class="w-10 h-5 flex items-center bg-gray-500 rounded-full p-1 duration-300 ease-in-out"
|
||
:class="{'bg-green-500': showSpeaker}">
|
||
<span
|
||
class="bg-white w-4 h-4 rounded-full shadow-md transform duration-300 ease-in-out"
|
||
:class="{'translate-x-5': showSpeaker}">
|
||
</span>
|
||
</span>
|
||
</label>
|
||
</h2>
|
||
|
||
<textarea v-if="isEditingTranscription" style="height: 650px;"
|
||
class="w-full p-2 border-2 border-blue-500 rounded-md bg-[var(--bg-secondary)] text-[var(--text-primary)] resize-none overflow-y-auto"
|
||
v-model="editableText"></textarea>
|
||
<textarea v-else ref="asrResultTextareaOnline" style="height: 650px;"
|
||
class="w-full p-2 border border-[var(--border-primary)] rounded-md bg-[var(--bg-secondary)] text-[var(--text-primary)] resize-none overflow-y-auto"
|
||
:value="fullRealtimeTranscription" readonly></textarea>
|
||
|
||
|
||
<!-- <textarea
|
||
ref="asrTextarea"
|
||
style="height: 320px;"
|
||
class="w-full p-2 border border-[var(--border-primary)] rounded-md bg-[var(--bg-secondary)] text-[var(--text-primary)] resize-none overflow-y-auto"
|
||
v-model="asrResult+asrResultOnline" readonly></textarea> -->
|
||
</div>
|
||
|
||
<!-- 第二个区域:分析结果 -->
|
||
<!-- <div>
|
||
<h2 class="text-lg font-semibold mb-2">总结结果</h2>
|
||
<textarea
|
||
class="w-full h-60 p-2 border border-[var(--border-primary)] rounded-md bg-[var(--bg-secondary)] text-[var(--text-primary)] resize-none overflow-y-auto"
|
||
v-model="analysisResult" readonly></textarea>
|
||
</div> -->
|
||
<div class="flex-shrink-0 bg-[var(--bg-tertiary)] border-[var(--border-primary)]">
|
||
<div class="text-center" :style="{ position:'relative',top:'23px' }">
|
||
<button @click="openPromptModal('zj')"
|
||
class="px-8 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 ">
|
||
<i class="fas fa-sync mr-2"></i>${baseInfo.name}
|
||
</button>
|
||
<!-- <button @click="openmodel" -->
|
||
<button @click="openPromptModal('tp')"
|
||
class="px-8 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 "
|
||
style="margin-left: 10px;">
|
||
<i class="fas"></i>推送至指挥员
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- 提示词弹框 -->
|
||
<div v-if="promptVisible"
|
||
style="z-index: 999;"
|
||
class="fixed inset-0 bg-black bg-opacity-50 z-50 flex justify-center items-center">
|
||
<div class="bg-[#1f2937] text-white w-1/2 p-6 rounded-lg shadow-xl border border-gray-600 space-y-4">
|
||
<h3 class="text-xl font-semibold">选择提示词</h3>
|
||
|
||
|
||
<!-- 推荐提示词标题 -->
|
||
<div class="flex items-center justify-between">
|
||
<p class="text-base font-medium">推荐提示词</p>
|
||
<span class="text-xs text-gray-400">根据你的内容智能推荐</span>
|
||
</div>
|
||
|
||
|
||
<!-- 推荐提示词卡片 -->
|
||
<div class="bg-[#111827] rounded-lg p-4 shadow-inner hover:shadow-md transition cursor-pointer border border-gray-700 hover:border-blue-500"
|
||
@click="selectedPromptId = recommendPromptId">
|
||
<div class="flex items-start gap-4">
|
||
<input type="radio" :value="recommendPromptId" v-model="selectedPromptId"
|
||
class="mt-1 h-4 w-4 accent-blue-500" />
|
||
|
||
|
||
<div class="flex-1 space-y-1">
|
||
<div class="font-semibold text-lg leading-6 text-blue-400">
|
||
${recommendPromptName}
|
||
</div>
|
||
<div class="text-sm text-gray-300 leading-5 bg-[#1f2937] p-2 rounded border border-gray-700">
|
||
${recommendPromptReason}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
|
||
<!-- 提示词列表区域 -->
|
||
<div class="bg-[#111827] p-3 rounded max-h-72 overflow-y-auto text-white space-y-2">
|
||
<div class="grid grid-cols-[50px_350px] px-2 py-2 border-b border-gray-600 text-sm font-semibold">
|
||
<div class="text-center">选择</div>
|
||
<div>提示词</div>
|
||
</div>
|
||
|
||
|
||
<div id="promptList" class="space-y-2 mt-2">
|
||
<div v-for="item in promptList" :key="item.id"
|
||
class="grid grid-cols-[50px_350px] px-2 py-2 items-start gap-2 bg-[#1f2937] rounded cursor-pointer border border-transparent hover:border-blue-500 hover:bg-[#1e2531] transition"
|
||
@click="selectedPromptId = item.id">
|
||
<div class="flex justify-center items-start pt-1">
|
||
<input type="radio" :value="item.id" v-model="selectedPromptId" class="h-4 w-4 accent-blue-500" />
|
||
</div>
|
||
|
||
|
||
<div class="whitespace-normal break-words leading-5">
|
||
${item.name}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
|
||
<!-- 按钮区域 -->
|
||
<div class="flex justify-end mt-4 gap-3">
|
||
<button type="button" class="px-4 py-2 bg-gray-500 rounded hover:bg-gray-600"
|
||
@click="closePromptModal">取消</button>
|
||
|
||
|
||
<button type="button" class="px-4 py-2 bg-blue-600 rounded hover:bg-blue-700"
|
||
@click="confirmPromptSelection">确定</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- 投屏弹框 -->
|
||
<div v-if="isModalOpen" class="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||
<!-- 弹框容器添加相对定位,用于图标绝对定位 -->
|
||
<div class="bg-white p-8 rounded-lg w-96 text-center relative">
|
||
<!-- 右上角关闭图标 -->
|
||
<button @click="closeModal"
|
||
class="absolute top-4 right-4 text-gray-500 hover:text-gray-700 transition-colors"
|
||
aria-label="关闭">
|
||
<i class="fas fa-times text-xl"></i>
|
||
</button>
|
||
<!--
|
||
<h3 class="text-xl font-bold mb-4">投屏链接</h3> -->
|
||
<a :href="urlinfo" target="_blank" rel="noopener noreferrer"
|
||
class="text-blue-600 hover:text-blue-800 underline text-lg">
|
||
${urlmsg}
|
||
<!-- 点击打开投屏页面 -->
|
||
</a>
|
||
</div>
|
||
</div>
|
||
<!-- 弹框组件 -->
|
||
<div v-if="isDialogVisible" class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||
<!-- 遮罩层 -->
|
||
<div @click="isDialogVisible = false" class="absolute inset-0 bg-black/50 backdrop-blur-sm">
|
||
</div>
|
||
|
||
<!-- 弹框内容(修改这里的class) -->
|
||
<div
|
||
class="relative w-full bg-[var(--bg-secondary)] border border-[var(--border-primary)] rounded-lg shadow-xl overflow-hidden">
|
||
<!-- 弹框头部 -->
|
||
<div
|
||
class="p-4 border-b border-[var(--border-primary)] flex items-center justify-between">
|
||
<h2 class="text-lg font-semibold">${baseInfo.name}</h2>
|
||
<button @click="isDialogVisible = false" class="text-gray-500 hover:text-gray-700">
|
||
<i class="fas fa-times"></i>
|
||
</button>
|
||
</div>
|
||
|
||
<!-- 弹框主体 -->
|
||
<div class="p-4">
|
||
<!-- <textarea ref="sumupResultTextarea"
|
||
:style="{ fontSize: baseInfo.fontSize + 'px',height:'700px',backgroundColor:'rgba(0, 94, 170, 0.8)' }"
|
||
class="w-full h-60 p-2 border border-[var(--border-primary)] rounded-md bg-[var(--bg-secondary)] text-[var(--text-primary)] resize-none overflow-y-auto"
|
||
v-model="analysisResult" readonly></textarea> -->
|
||
<textarea
|
||
v-if="!isMarkdown"
|
||
ref="sumupResultTextarea"
|
||
:style="{ fontSize: baseInfo.fontSize + 'px', height:'700px', backgroundColor:'rgba(0, 94, 170, 0.8)' }"
|
||
class="w-full p-2 border border-[var(--border-primary)] rounded-md bg-[var(--bg-secondary)] text-[var(--text-primary)] resize-none overflow-y-auto"
|
||
v-model="analysisResult"
|
||
readonly
|
||
></textarea>
|
||
|
||
<div
|
||
v-else
|
||
ref="sumupResultTextarea"
|
||
:style="{ fontSize: baseInfo.fontSize + 'px', height:'700px', backgroundColor:'rgba(0, 94, 170, 0.8)' }"
|
||
class="w-full p-2 border border-[var(--border-primary)] rounded-md bg-[var(--bg-secondary)] text-[var(--text-primary)] overflow-y-auto markdown-body"
|
||
v-html="renderedMarkdown"
|
||
></div>
|
||
</div>
|
||
|
||
<!-- 弹框底部 -->
|
||
<div class="p-4 border-t border-[var(--border-primary)] flex justify-center">
|
||
<button @click="openPromptModal('zj')"
|
||
class="px-8 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
|
||
<i class="fas fa-sync mr-2"></i>重新总结
|
||
</button>
|
||
<button @click="openzjmodel" :disabled="zjloding"
|
||
class="px-8 py-3 rounded-lg text-white transition-colors ml-2 flex items-center justify-center gap-2"
|
||
:class="zjloding ? 'bg-blue-400' : 'bg-blue-600 hover:bg-blue-700'">
|
||
<i class="fas"></i>
|
||
|
||
<!-- 默认内容 -->
|
||
<span v-if="!zjloding">投屏</span>
|
||
|
||
<!-- loading 内容 -->
|
||
<span v-else class="flex items-center gap-2">
|
||
<svg class="animate-spin h-5 w-5 text-white"
|
||
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor"
|
||
stroke-width="4"></circle>
|
||
<path class="opacity-75" fill="currentColor"
|
||
d="M4 12a8 8 0 018-8v4l3.5-3.5L12 0v4a8 8 0 100 16v4l3.5-3.5L12 20v4a8 8 0 01-8-8z">
|
||
</path>
|
||
</svg>
|
||
加载中...
|
||
</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- 演讲内容弹框组件 -->
|
||
<div v-if="isyjDialogVisible" class="fixed inset-0 z-50 flex items-center justify-center ">
|
||
<!-- 遮罩层 -->
|
||
<div @click="isyjDialogVisible = false"
|
||
class="absolute inset-0 bg-black/50 backdrop-blur-sm"></div>
|
||
|
||
<!-- 弹框内容(修改这里的class) -->
|
||
<div
|
||
class="relative w-full bg-[var(--bg-secondary)] border border-[var(--border-primary)] rounded-lg shadow-xl overflow-hidden">
|
||
<!-- 弹框头部 -->
|
||
<div
|
||
class="p-4 border-b border-[var(--border-primary)] flex items-center justify-between gap-4">
|
||
<div class="flex items-center gap-3">
|
||
<h2 class="text-lg font-semibold">语音实时转录</h2>
|
||
<button v-if="!isEditingTranscription" @click="enterEditMode()"
|
||
class="px-3 py-1 text-sm text-blue-400 hover:text-blue-300 border border-blue-400/30 rounded hover:border-blue-300/50 transition-colors">
|
||
<i class="fas fa-pen mr-1"></i>编辑
|
||
</button>
|
||
<button v-else @click="exitEditMode()"
|
||
class="px-3 py-1 text-sm text-green-400 hover:text-green-300 border border-green-400/30 rounded hover:border-green-300/50 transition-colors">
|
||
<i class="fas fa-check mr-1"></i>完成编辑
|
||
</button>
|
||
</div>
|
||
<button @click="isyjDialogVisible = false"
|
||
class="text-gray-500 hover:text-gray-700">
|
||
<i class="fas fa-times"></i>
|
||
</button>
|
||
</div>
|
||
|
||
<!-- 弹框主体 -->
|
||
<div class="p-4">
|
||
<textarea v-if="isEditingTranscription"
|
||
:style="{ fontSize: baseInfo.fontSize + 'px',height:'90vh',backgroundColor:'rgba(0, 94, 170, 0.8)' }"
|
||
class="w-full h-60 p-2 border-2 border-blue-500 rounded-md bg-[var(--bg-secondary)] text-[var(--text-primary)] resize-none overflow-y-auto"
|
||
v-model="editableText"></textarea>
|
||
<textarea v-else ref="asrResultTextarea"
|
||
:style="{ fontSize: baseInfo.fontSize + 'px',height:'90vh',backgroundColor:'rgba(0, 94, 170, 0.8)' }"
|
||
class="w-full h-60 p-2 border border-[var(--border-primary)] rounded-md bg-[var(--bg-secondary)] text-[var(--text-primary)] resize-none overflow-y-auto"
|
||
:value="fullRealtimeTranscription" readonly></textarea>
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<!-- ==================== 功能块:实时录音视图 End ==================== -->
|
||
|
||
<!-- ==================== 功能块:录音详情视图 Start ==================== -->
|
||
<!-- Recording Detail View -->
|
||
<div v-else-if="currentView === 'detail' && selectedRecording"
|
||
class="flex-1 flex flex-col overflow-hidden">
|
||
<!-- Mobile View -->
|
||
<div v-if="isMobileScreen" class="flex-1 flex flex-col overflow-hidden">
|
||
<!-- Mobile Header -->
|
||
<div
|
||
class="bg-[var(--bg-secondary)] border-b border-[var(--border-primary)] p-4 flex-shrink-0">
|
||
<div @click="isMetadataExpanded = !isMetadataExpanded" class="cursor-pointer">
|
||
<div class="flex items-start justify-between">
|
||
<div class="flex-1 min-w-0">
|
||
<h1 class="text-lg font-bold text-[var(--text-primary)] truncate">
|
||
${selectedRecording.title || 'Untitled Recording'}
|
||
</h1>
|
||
<p class="text-sm text-[var(--text-muted)] truncate">
|
||
${selectedRecording.participants || t('help.noParticipants')}
|
||
</p>
|
||
<div v-if="getRecordingTags(selectedRecording).length > 0"
|
||
class="flex flex-wrap gap-1 mt-2">
|
||
<button v-for="tag in getRecordingTags(selectedRecording)" :key="tag.id"
|
||
@click="filterByTag(tag)"
|
||
class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium text-white hover:opacity-80 transition-opacity cursor-pointer"
|
||
:style="{ backgroundColor: tag.color }"
|
||
:title="'Filter by ' + tag.name">
|
||
<i class="fas fa-tag mr-1"></i>
|
||
<span v-text="tag.name"></span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<i
|
||
:class="['fas', 'fa-chevron-down', 'transition-transform', { 'rotate-180': isMetadataExpanded }]"></i>
|
||
</div>
|
||
</div>
|
||
<!-- Expandable Metadata and Actions -->
|
||
<div v-if="isMetadataExpanded" class="mt-4 space-y-4">
|
||
<div class="space-y-2 text-sm text-[var(--text-muted)]">
|
||
<div class="flex items-center gap-2">
|
||
<i class="fas fa-calendar text-[var(--text-accent)]"></i>
|
||
<span>${selectedRecording.meeting_date ? formatDisplayDate(selectedRecording.meeting_date) : 'No date set'}</span>
|
||
</div>
|
||
<div v-if="activeRecordingMetadata"
|
||
class="flex flex-wrap items-center gap-x-4 gap-y-2">
|
||
<template v-for="(item, index) in activeRecordingMetadata" :key="index">
|
||
<!-- Special rendering for tags -->
|
||
<div v-if="item.isTagItem" class="flex items-center gap-1.5">
|
||
<i :class="item.icon" class="text-[var(--text-muted)]"></i>
|
||
<div class="flex flex-wrap items-center gap-1">
|
||
<span v-for="tag in item.tags" :key="tag.id"
|
||
class="inline-flex items-center px-2 py-0.5 text-xs font-medium rounded-full text-white"
|
||
:style="{ backgroundColor: tag.color || '#6B7280' }">
|
||
${tag.name}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<!-- Regular metadata items -->
|
||
<span v-else class="flex items-center gap-1.5">
|
||
<i :class="item.icon"></i>
|
||
<span :title="item.fullText || item.text">${item.text}</span>
|
||
</span>
|
||
</template>
|
||
</div>
|
||
<div v-if="selectedRecording.status !== 'COMPLETED'"
|
||
class="flex items-center gap-2">
|
||
<span :class="getStatusClass(selectedRecording.status)"
|
||
class="status-badge">
|
||
${formatStatus(selectedRecording.status)}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<!-- Action Buttons -->
|
||
<div class="flex items-center gap-2 overflow-x-auto pb-2">
|
||
<button @click="toggleInbox(selectedRecording)"
|
||
class="p-2 rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors"
|
||
:class="selectedRecording.is_inbox ? 'text-blue-500' : ''"
|
||
:title="selectedRecording.is_inbox ? t('sidebar.markAsRead') : t('sidebar.moveToInbox')"><i
|
||
class="fas fa-inbox"></i></button>
|
||
<button @click="toggleHighlight(selectedRecording)"
|
||
class="p-2 rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors"
|
||
:class="selectedRecording.is_highlighted ? 'text-yellow-500' : ''"
|
||
:title="selectedRecording.is_highlighted ? t('sidebar.removeFromHighlighted') : t('sidebar.highlighted')"><i
|
||
class="fas fa-star"></i></button>
|
||
<button @click="editRecording(selectedRecording)"
|
||
class="p-2 rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors"
|
||
:title="t('common.edit')"><i class="fas fa-edit"></i></button>
|
||
<button @click="editRecordingTags(selectedRecording)"
|
||
class="p-2 rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors"
|
||
:title="t('tags.title')"><i class="fas fa-tags"></i></button>
|
||
<button @click="reprocessTranscription(selectedRecording.id)"
|
||
v-if="selectedRecording && (selectedRecording.status === 'COMPLETED' || selectedRecording.status === 'FAILED')"
|
||
class="p-2 rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors"
|
||
:title="useAsrEndpoint ? 'Reprocess with ASR' : 'Reprocess transcription'"><i
|
||
class="fas fa-redo-alt"></i></button>
|
||
<button @click="reprocessSummary(selectedRecording.id)"
|
||
v-if="selectedRecording && (selectedRecording.status === 'COMPLETED' || selectedRecording.status === 'FAILED')"
|
||
class="p-2 rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors"
|
||
:title="t('buttons.reprocessSummary')"><i
|
||
class="fas fa-sync-alt"></i></button>
|
||
<button @click="confirmReset(selectedRecording)"
|
||
v-if="['PROCESSING', 'SUMMARIZING', 'FAILED'].includes(selectedRecording.status)"
|
||
class="p-2 rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors text-orange-500"
|
||
:title="t('buttons.resetStuckProcessing')"><i
|
||
class="fas fa-undo"></i></button>
|
||
<button @click="openSpeakerModal"
|
||
v-if="selectedRecording.transcription && useAsrEndpoint"
|
||
class="p-2 rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors"
|
||
:title="t('buttons.identifySpeakers')"><i
|
||
class="fas fa-user-tag"></i></button>
|
||
<button @click="openShareModal(selectedRecording)"
|
||
class="p-2 rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors"
|
||
:title="t('buttons.shareRecording')"><i
|
||
class="fas fa-share-alt"></i></button>
|
||
<button @click="confirmDelete(selectedRecording)"
|
||
class="p-2 rounded-lg hover:bg-[var(--bg-danger-light)] text-[var(--text-danger)] transition-colors"><i
|
||
class="fas fa-trash"></i></button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Mobile Audio Player -->
|
||
<div
|
||
class="bg-[var(--bg-secondary)] p-4 border-b border-[var(--border-primary)] flex-shrink-0">
|
||
<div class="audio-player-container">
|
||
<audio controls :src="'/tool/speakr/audio/' + selectedRecording.id"
|
||
:volume="playerVolume" @volumechange="onPlayerVolumeChange" class="w-full">
|
||
Your browser does not support the audio element.
|
||
</audio>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Mobile Tab Navigation -->
|
||
<div
|
||
class="flex-shrink-0 bg-[var(--bg-tertiary)] border-b border-[var(--border-primary)] flex overflow-x-auto">
|
||
<button @click="mobileTab = 'transcript'"
|
||
:class="['flex-1 px-4 py-3 text-sm font-medium transition-colors', mobileTab === 'transcript' ? 'bg-[var(--bg-secondary)] text-[var(--text-accent)] border-b-2 border-[var(--border-focus)]' : 'text-[var(--text-muted)] hover:text-[var(--text-secondary)]']"
|
||
v-text="t('transcription.title')"></button>
|
||
<button @click="mobileTab = 'summary'"
|
||
:class="['flex-1 px-4 py-3 text-sm font-medium transition-colors', mobileTab === 'summary' ? 'bg-[var(--bg-secondary)] text-[var(--text-accent)] border-b-2 border-[var(--border-focus)]' : 'text-[var(--text-muted)] hover:text-[var(--text-secondary)]']"
|
||
v-text="t('summary.title')"></button>
|
||
<button @click="mobileTab = 'notes'"
|
||
:class="['flex-1 px-4 py-3 text-sm font-medium transition-colors', mobileTab === 'notes' ? 'bg-[var(--bg-secondary)] text-[var(--text-accent)] border-b-2 border-[var(--border-focus)]' : 'text-[var(--text-muted)] hover:text-[var(--text-secondary)]']"
|
||
v-text="t('notes.title')"></button>
|
||
<button @click="mobileTab = 'chat'"
|
||
:class="['flex-1 px-4 py-3 text-sm font-medium transition-colors', mobileTab === 'chat' ? 'bg-[var(--bg-secondary)] text-[var(--text-accent)] border-b-2 border-[var(--border-focus)]' : 'text-[var(--text-muted)] hover:text-[var(--text-secondary)]']"
|
||
v-text="t('chat.title')"></button>
|
||
<button v-if="selectedRecording.events && selectedRecording.events.length > 0"
|
||
@click="mobileTab = 'events'"
|
||
:class="['flex-1 px-4 py-3 text-sm font-medium transition-colors whitespace-nowrap', mobileTab === 'events' ? 'bg-[var(--bg-secondary)] text-[var(--text-accent)] border-b-2 border-[var(--border-focus)]' : 'text-[var(--text-muted)] hover:text-[var(--text-secondary)]']">
|
||
<i class="fas fa-calendar-alt mr-1"></i>${ selectedRecording.events.length }
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Mobile Tab Content -->
|
||
<div class="flex-1 overflow-y-auto p-4">
|
||
<!-- Transcript Panel -->
|
||
<div v-if="mobileTab === 'transcript'" class="h-full flex flex-col space-y-4">
|
||
<div class="flex items-center justify-end gap-2 flex-shrink-0 pr-3">
|
||
<div v-if="processedTranscription.hasDialogue" class="view-mode-toggle">
|
||
<button @click="toggleTranscriptionViewMode"
|
||
:class="['toggle-button', transcriptionViewMode === 'simple' ? 'active' : '']"><i
|
||
class="fas fa-list"></i></button>
|
||
<button @click="toggleTranscriptionViewMode"
|
||
:class="['toggle-button', transcriptionViewMode === 'bubble' ? 'active' : '']"><i
|
||
class="fas fa-comments"></i></button>
|
||
</div>
|
||
<button @click="copyTranscription" class="copy-btn" title="Copy transcript"><i
|
||
class="fas fa-copy"></i></button>
|
||
<button @click="downloadTranscript"
|
||
v-if="selectedRecording && selectedRecording.transcription" class="copy-btn"
|
||
title="Download transcript with template"><i
|
||
class="fas fa-download"></i></button>
|
||
<button @click="openTranscriptionEditor"
|
||
v-if="selectedRecording && selectedRecording.transcription" class="copy-btn"
|
||
title="Edit transcript"><i class="fas fa-edit"></i></button>
|
||
</div>
|
||
<div class="flex-grow overflow-y-auto mobile-content-box">
|
||
<div v-if="selectedRecording.status === 'PROCESSING'" class="text-center py-8">
|
||
<i class="fas fa-spinner fa-spin text-2xl text-[var(--text-muted)]"></i>
|
||
<p class="mt-2 text-[var(--text-muted)]"
|
||
v-text="t('help.processingTranscription')"></p>
|
||
</div>
|
||
<div v-else-if="!selectedRecording.transcription" class="text-center py-8">
|
||
<i class="fas fa-file-text text-3xl text-[var(--text-muted)] mb-3"></i>
|
||
<p class="text-[var(--text-muted)]"
|
||
v-text="t('transcription.noTranscription')"></p>
|
||
</div>
|
||
<div v-else>
|
||
<div v-if="!processedTranscription.hasDialogue || transcriptionViewMode === 'simple'"
|
||
class="transcription-simple-view">
|
||
<div v-if="processedTranscription.hasDialogue">
|
||
<div v-for="segment in processedTranscription.simpleSegments"
|
||
:key="segment.startTime || Math.random()"
|
||
class="speaker-segment" @click="seekAudioFromEvent"
|
||
:data-start-time="segment.startTime">
|
||
<div v-if="segment.showSpeaker"
|
||
:class="['speaker-tablet', segment.color]">
|
||
${segment.speaker}</div>
|
||
<div class="speaker-text">${segment.sentence}</div>
|
||
</div>
|
||
</div>
|
||
<div v-else>
|
||
<div
|
||
v-if="processedTranscription.simpleSegments && processedTranscription.simpleSegments.length > 0">
|
||
<div v-for="segment in processedTranscription.simpleSegments"
|
||
:key="segment.startTime || Math.random()"
|
||
class="transcript-segment cursor-pointer hover:bg-[var(--bg-accent-hover)] p-2 rounded transition-colors"
|
||
@click="seekAudioFromEvent"
|
||
:data-start-time="segment.startTime">${segment.sentence}
|
||
</div>
|
||
</div>
|
||
<div v-else class="whitespace-pre-wrap">
|
||
${processedTranscription.content}</div>
|
||
</div>
|
||
</div>
|
||
<div v-else-if="transcriptionViewMode === 'bubble'"
|
||
class="transcription-with-speakers">
|
||
<div v-for="(row, rowIndex) in processedTranscription.bubbleRows"
|
||
:key="`${selectedRecording.id}-bubble-row-${rowIndex}`"
|
||
:class="['bubble-row', row.isMe ? 'speaker-me' : '']">
|
||
<div v-for="bubble in row.bubbles"
|
||
:key="bubble.startTime || Math.random()"
|
||
:class="['speaker-bubble', bubble.color, row.isMe ? 'speaker-me' : '']"
|
||
@click="seekAudioFromEvent" :data-start-time="bubble.startTime">
|
||
<div class="speaker-bubble-content">${bubble.sentence}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- Summary Panel -->
|
||
<div v-if="mobileTab === 'summary'" class="h-full flex flex-col space-y-4">
|
||
<div class="flex items-center justify-end gap-2 flex-shrink-0 pr-3">
|
||
<button @click="copySummary" class="copy-btn"
|
||
:title="t('buttons.copySummary')"><i class="fas fa-copy"></i></button>
|
||
<button @click="downloadSummary" class="copy-btn"
|
||
:title="t('buttons.downloadAsWord')"><i
|
||
class="fas fa-download"></i></button>
|
||
<button @click="toggleEditSummary" class="copy-btn"
|
||
:title="t('buttons.editSummary')"><i class="fas fa-edit"></i></button>
|
||
</div>
|
||
<div class="flex-grow overflow-y-auto mobile-content-box">
|
||
<div v-if="editingSummary" class="h-full flex flex-col">
|
||
<div class="markdown-editor-container flex-1">
|
||
<textarea ref="summaryMarkdownEditor"
|
||
v-model="selectedRecording.summary"></textarea>
|
||
</div>
|
||
<div class="flex justify-end gap-2 mt-2">
|
||
<button @click="cancelEditSummary"
|
||
class="px-3 py-1 text-sm bg-[var(--bg-secondary)] text-[var(--text-secondary)] border border-[var(--border-secondary)] rounded hover:bg-[var(--bg-tertiary)]"
|
||
v-text="t('common.cancel')"></button>
|
||
<button @click="saveEditSummary"
|
||
class="px-3 py-1 text-sm bg-[var(--bg-button)] text-[var(--text-button)] rounded hover:bg-[var(--bg-button-hover)]"
|
||
v-text="t('common.save')"></button>
|
||
</div>
|
||
</div>
|
||
<div v-else>
|
||
<div v-if="selectedRecording.status === 'SUMMARIZING'"
|
||
class="text-center py-8">
|
||
<i class="fas fa-spinner fa-spin text-2xl text-[var(--text-muted)]"></i>
|
||
<p class="mt-2 text-[var(--text-muted)]"
|
||
v-text="t('help.generatingSummary')"></p>
|
||
</div>
|
||
<div v-else-if="!selectedRecording.summary" class="text-center py-8">
|
||
<i class="fas fa-file-alt text-3xl text-[var(--text-muted)] mb-3"></i>
|
||
<p class="text-[var(--text-muted)] mb-4"
|
||
v-text="t('summary.noSummary')"></p>
|
||
<button @click="generateSummary"
|
||
class="px-6 py-3 bg-gradient-to-r from-blue-600 to-purple-600 text-white font-medium rounded-lg shadow-lg hover:shadow-xl hover:from-blue-700 hover:to-purple-700 transition-all duration-200 transform hover:scale-105">
|
||
<i class="fas fa-magic mr-2"></i>Generate Summary
|
||
</button>
|
||
</div>
|
||
<div v-else class="summary-box h-full"
|
||
v-html="selectedRecording.summary_html || selectedRecording.summary">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- Notes Panel -->
|
||
<div v-if="mobileTab === 'notes'" class="h-full flex flex-col space-y-4">
|
||
<div class="flex items-center justify-end gap-2 flex-shrink-0 pr-3">
|
||
<button @click="copyNotes" class="copy-btn" :title="t('buttons.copyNotes')"><i
|
||
class="fas fa-copy"></i></button>
|
||
<button @click="downloadNotes" class="copy-btn"
|
||
:title="t('buttons.downloadAsWord')"><i
|
||
class="fas fa-download"></i></button>
|
||
<button @click="toggleEditNotes" class="copy-btn"
|
||
:title="t('buttons.editNotes')"><i class="fas fa-edit"></i></button>
|
||
</div>
|
||
<div class="flex-grow overflow-y-auto mobile-content-box">
|
||
<div v-if="editingNotes" class="h-full flex flex-col">
|
||
<div class="markdown-editor-container flex-1">
|
||
<textarea ref="notesMarkdownEditor"
|
||
v-model="selectedRecording.notes"></textarea>
|
||
</div>
|
||
<div class="flex justify-end gap-2 mt-2">
|
||
<button @click="cancelEditNotes"
|
||
class="px-3 py-1 text-sm bg-[var(--bg-secondary)] text-[var(--text-secondary)] border border-[var(--border-secondary)] rounded hover:bg-[var(--bg-tertiary)]"
|
||
v-text="t('common.cancel')"></button>
|
||
<button @click="saveEditNotes"
|
||
class="px-3 py-1 text-sm bg-[var(--bg-button)] text-[var(--text-button)] rounded hover:bg-[var(--bg-button-hover)]"
|
||
v-text="t('common.save')"></button>
|
||
</div>
|
||
</div>
|
||
<div v-else @click="clickToEditNotes">
|
||
<div v-if="!selectedRecording.notes"
|
||
class="text-center py-8 cursor-pointer hover:text-[var(--text-secondary)]">
|
||
<i
|
||
class="fas fa-sticky-note text-3xl text-[var(--text-muted)] mb-3"></i>
|
||
<p class="text-[var(--text-muted)]" v-text="t('help.clickToAddNotes')">
|
||
</p>
|
||
</div>
|
||
<div v-else class="notes-box h-full"
|
||
v-html="selectedRecording.notes_html || selectedRecording.notes"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- Chat Panel -->
|
||
<div v-if="mobileTab === 'chat'"
|
||
class="h-full flex flex-col rounded-lg border border-[var(--border-primary)] overflow-hidden relative">
|
||
<!-- Clear button - fixed at top right -->
|
||
<button v-if="chatMessages.length > 0" @click="clearChat"
|
||
class="absolute top-2 right-2 p-1 text-[var(--text-muted)] opacity-60 hover:opacity-100 hover:text-[var(--text-danger)] rounded transition-all duration-200 z-20"
|
||
title="Clear chat">
|
||
<i class="fas fa-times text-xs"></i>
|
||
</button>
|
||
|
||
<div ref="chatMessagesRef"
|
||
class="flex-1 overflow-y-auto p-4 space-y-4 bg-[var(--bg-secondary)]">
|
||
<div v-if="chatMessages.length === 0" class="text-center py-8">
|
||
<i class="fas fa-robot text-3xl text-[var(--text-muted)] mb-3"></i>
|
||
<p class="text-[var(--text-muted)]"
|
||
v-text="t('help.askAboutTranscription')"></p>
|
||
</div>
|
||
<div v-for="(message, index) in chatMessages" :key="index" :class="['message relative group', message.role === 'user' ? 'user-message ml-auto' : 'ai-message',
|
||
message.role === 'assistant' ? 'pr-10' : '']">
|
||
<!-- Copy button for assistant messages -->
|
||
<button v-if="message.role === 'assistant'"
|
||
@click="copyMessage(message.content, $event)"
|
||
class="absolute top-2 right-2 p-1 text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] rounded transition-all duration-200"
|
||
:title="t('buttons.copyMessage')">
|
||
<i class="fas fa-copy text-sm"></i>
|
||
</button>
|
||
<!-- Show thinking content if available -->
|
||
<div v-if="message.thinking && message.role === 'assistant'" class="mb-2">
|
||
<button @click="message.thinkingExpanded = !message.thinkingExpanded"
|
||
class="text-xs text-[var(--text-muted)] hover:text-[var(--text-secondary)] flex items-center gap-1">
|
||
<i
|
||
:class="['fas', message.thinkingExpanded ? 'fa-chevron-down' : 'fa-chevron-right']"></i>
|
||
<span v-text="t('help.modelReasoning')"></span>
|
||
<span
|
||
class="text-[var(--text-muted)]">(${message.thinking.split('\n').length}
|
||
lines)</span>
|
||
</button>
|
||
<div v-if="message.thinkingExpanded"
|
||
class="mt-2 p-3 bg-[var(--bg-tertiary)] border border-[var(--border-secondary)] rounded-lg text-xs text-[var(--text-muted)] max-h-64 overflow-y-auto">
|
||
<pre class="whitespace-pre-wrap font-mono">${message.thinking}</pre>
|
||
</div>
|
||
</div>
|
||
<!-- Main message content -->
|
||
<div v-if="message.html" v-html="message.html"></div>
|
||
<div v-else class="whitespace-pre-wrap">${message.content}</div>
|
||
</div>
|
||
<div v-if="isChatLoading" class="ai-message">
|
||
<i class="fas fa-spinner fa-spin mr-2"></i> Thinking...
|
||
</div>
|
||
</div>
|
||
<div class="border-t border-[var(--border-primary)] p-4 bg-[var(--bg-tertiary)]">
|
||
<div class="flex gap-2">
|
||
<textarea v-model="chatInput" @keydown="handleChatKeydown"
|
||
:disabled="isChatLoading || selectedRecording.status !== 'COMPLETED'"
|
||
:placeholder="t('chat.placeholder')"
|
||
class="flex-1 px-3 py-2 bg-[var(--bg-input)] border border-[var(--border-secondary)] rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-[var(--ring-focus)] text-sm"
|
||
rows="2"></textarea>
|
||
<button @click="sendChatMessage"
|
||
:disabled="!chatInput.trim() || isChatLoading || selectedRecording.status !== 'COMPLETED'"
|
||
class="px-4 py-2 bg-[var(--bg-button)] text-[var(--text-button)] rounded-lg hover:bg-[var(--bg-button-hover)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
|
||
<i class="fas fa-paper-plane"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- Events Panel (Mobile) -->
|
||
<div v-if="mobileTab === 'events' && selectedRecording.events && selectedRecording.events.length > 0"
|
||
class="h-full flex flex-col space-y-4">
|
||
<div class="flex items-center justify-end gap-2 flex-shrink-0 pr-3">
|
||
<button @click="downloadICS"
|
||
v-if="selectedRecording.events && selectedRecording.events.length > 0"
|
||
class="copy-btn" :title="t('buttons.exportCalendar')">
|
||
<i class="fas fa-calendar-plus"></i>
|
||
</button>
|
||
</div>
|
||
<div class="flex-grow overflow-y-auto mobile-content-box">
|
||
<div class="space-y-3">
|
||
<div v-for="event in selectedRecording.events" :key="event.id"
|
||
class="bg-[var(--bg-tertiary)] rounded-lg p-3 border border-[var(--border-primary)]">
|
||
<div class="flex justify-between items-start mb-2">
|
||
<div class="flex-1">
|
||
<h3
|
||
class="text-base font-semibold text-[var(--text-primary)] mb-1">
|
||
<i
|
||
class="fas fa-calendar-check mr-2 text-[var(--text-accent)] text-sm"></i>
|
||
${ event.title }
|
||
</h3>
|
||
<p v-if="event.description"
|
||
class="text-xs text-[var(--text-secondary)] mb-2">
|
||
${ event.description }
|
||
</p>
|
||
</div>
|
||
<button @click="downloadEventICS(event)"
|
||
class="px-2 py-1 bg-[var(--bg-button)] text-[var(--text-button)] rounded-lg hover:bg-[var(--bg-button-hover)] transition-colors text-xs flex items-center gap-1"
|
||
:title="t('events.addToCalendar')">
|
||
<i class="fas fa-download text-xs"></i>
|
||
<span>Add</span>
|
||
</button>
|
||
</div>
|
||
<div class="grid grid-cols-1 gap-2 text-xs">
|
||
<div class="flex items-center text-[var(--text-muted)]">
|
||
<i class="fas fa-clock mr-2 text-[var(--text-accent)]"></i>
|
||
<span>
|
||
${ formatEventDateTime(event.start_datetime) }
|
||
<template v-if="event.end_datetime">
|
||
- ${ formatEventDateTime(event.end_datetime, true) }
|
||
</template>
|
||
</span>
|
||
</div>
|
||
<div v-if="event.location"
|
||
class="flex items-center text-[var(--text-muted)]">
|
||
<i
|
||
class="fas fa-map-marker-alt mr-2 text-[var(--text-accent)]"></i>
|
||
<span>${ event.location }</span>
|
||
</div>
|
||
<div v-if="event.attendees && event.attendees.length > 0"
|
||
class="flex items-start text-[var(--text-muted)]">
|
||
<i
|
||
class="fas fa-users mr-2 text-[var(--text-accent)] mt-0.5"></i>
|
||
<div class="flex-1">
|
||
<span v-for="(attendee, index) in event.attendees"
|
||
:key="index"
|
||
class="inline-block bg-[var(--bg-secondary)] px-2 py-0.5 rounded mr-1 mb-1">
|
||
${ attendee }
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- Desktop View -->
|
||
<div v-else class="flex-1 flex flex-col overflow-hidden">
|
||
<!-- Recording Header -->
|
||
<div
|
||
class="bg-[var(--bg-secondary)] border-b border-[var(--border-primary)] p-6 flex-shrink-0">
|
||
<div class="flex flex-col">
|
||
<div class="flex items-start justify-between">
|
||
<div class="flex-1 min-w-0">
|
||
<h1 class="text-2xl font-bold text-[var(--text-primary)] mb-2">
|
||
${selectedRecording.title || 'Untitled Recording'}
|
||
</h1>
|
||
</div>
|
||
|
||
<!-- Action Buttons -->
|
||
<div class="flex items-center gap-2 ml-4">
|
||
<button @click="toggleInbox(selectedRecording)"
|
||
class="p-2 rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors"
|
||
:class="selectedRecording.is_inbox ? 'text-blue-500' : ''"
|
||
:title="selectedRecording.is_inbox ? '标记为已读' : '移到收件箱'">
|
||
<i class="fas fa-inbox"></i>
|
||
</button>
|
||
<button @click="toggleHighlight(selectedRecording)"
|
||
class="p-2 rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors"
|
||
:class="selectedRecording.is_highlighted ? 'text-yellow-500' : ''"
|
||
:title="selectedRecording.is_highlighted ? '取消高亮' : '设为高亮'">
|
||
<i class="fas fa-star"></i>
|
||
</button>
|
||
<button @click="editRecording(selectedRecording)"
|
||
class="p-2 rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors"
|
||
title="编辑录音">
|
||
<i class="fas fa-edit"></i>
|
||
</button>
|
||
<button @click="editRecordingTags(selectedRecording)"
|
||
class="p-2 rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors"
|
||
:title="t('buttons.editTags')">
|
||
<i class="fas fa-tags"></i>
|
||
</button>
|
||
<button @click="reprocessTranscription(selectedRecording.id)"
|
||
v-if="selectedRecording && (selectedRecording.status === 'COMPLETED' || selectedRecording.status === 'FAILED')"
|
||
class="p-2 rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors"
|
||
:title="useAsrEndpoint ? '重新处理录音' : '重新处理转录'">
|
||
<i class="fas fa-redo-alt"></i>
|
||
</button>
|
||
<button @click="reprocessSummary(selectedRecording.id)"
|
||
v-if="selectedRecording && (selectedRecording.status === 'COMPLETED' || selectedRecording.status === 'FAILED')"
|
||
class="p-2 rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors"
|
||
:title="t('buttons.reprocessSummary')">
|
||
<i class="fas fa-sync-alt"></i>
|
||
</button>
|
||
<button @click="confirmReset(selectedRecording)"
|
||
v-if="['PROCESSING', 'SUMMARIZING', 'FAILED'].includes(selectedRecording.status)"
|
||
class="p-2 rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors text-orange-500"
|
||
:title="t('buttons.resetStuckProcessing')">
|
||
<i class="fas fa-undo"></i>
|
||
</button>
|
||
<button @click="openSpeakerModal"
|
||
v-if="selectedRecording.transcription && useAsrEndpoint"
|
||
class="p-2 rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors"
|
||
:title="t('buttons.identifySpeakers')">
|
||
<i class="fas fa-user-tag"></i>
|
||
</button>
|
||
<button @click="openShareModal(selectedRecording)"
|
||
class="p-2 rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors"
|
||
:title="t('buttons.shareRecording')">
|
||
<i class="fas fa-share-alt"></i>
|
||
</button>
|
||
<button @click="confirmDelete(selectedRecording)"
|
||
class="p-2 rounded-lg hover:bg-[var(--bg-danger-light)] text-[var(--text-danger)] transition-colors"
|
||
title="删除录音">
|
||
<i class="fas fa-trash"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Metadata Row -->
|
||
<div
|
||
class="flex flex-wrap items-center gap-x-6 gap-y-2 text-sm text-[var(--text-muted)] mt-2">
|
||
<!-- Participants -->
|
||
<div class="flex items-center gap-2">
|
||
<i class="fas fa-users text-[var(--text-accent)]"></i>
|
||
<span v-if="!editingParticipants" @click="toggleEditParticipants"
|
||
class="cursor-pointer hover:text-[var(--text-accent)] transition-colors">
|
||
${selectedRecording.participants || t('help.noParticipants')}
|
||
</span>
|
||
<input v-else v-model="selectedRecording.participants"
|
||
@blur="toggleEditParticipants" @keyup.enter="toggleEditParticipants"
|
||
class="bg-transparent border-b border-[var(--border-secondary)] focus:border-[var(--border-focus)] outline-none px-1">
|
||
</div>
|
||
|
||
<!-- Meeting Date -->
|
||
<div class="flex items-center gap-2">
|
||
<i class="fas fa-calendar text-[var(--text-accent)]"></i>
|
||
<span v-if="!editingMeetingDate" @click="toggleEditMeetingDate"
|
||
class="cursor-pointer hover:text-[var(--text-accent)] transition-colors">
|
||
${selectedRecording.meeting_date ?
|
||
formatDisplayDate(selectedRecording.meeting_date) : 'No date set'}
|
||
</span>
|
||
<input v-else v-model="selectedRecording.meeting_date"
|
||
@blur="toggleEditMeetingDate" @keyup.enter="toggleEditMeetingDate"
|
||
type="date"
|
||
class="bg-transparent border-b border-[var(--border-secondary)] focus:border-[var(--border-focus)] outline-none px-1">
|
||
</div>
|
||
|
||
<!-- Other Metadata -->
|
||
<div v-if="activeRecordingMetadata"
|
||
class="flex flex-wrap items-center gap-x-6 gap-y-2">
|
||
<template v-for="(item, index) in activeRecordingMetadata" :key="index">
|
||
<!-- Special rendering for tags -->
|
||
<div v-if="item.isTagItem" class="flex items-center gap-1.5">
|
||
<i :class="item.icon" class="text-[var(--text-muted)]"></i>
|
||
<div class="flex flex-wrap items-center gap-1">
|
||
<span v-for="tag in item.tags" :key="tag.id"
|
||
class="inline-flex items-center px-2 py-0.5 text-xs font-medium rounded-full text-white"
|
||
:style="{ backgroundColor: tag.color || '#6B7280' }">
|
||
${tag.name}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<!-- Regular metadata items -->
|
||
<span v-else class="flex items-center gap-1.5">
|
||
<i :class="item.icon"></i>
|
||
<span :title="item.fullText || item.text">${item.text}</span>
|
||
</span>
|
||
</template>
|
||
</div>
|
||
|
||
<!-- Status -->
|
||
<div v-if="selectedRecording.status !== 'COMPLETED'"
|
||
class="flex items-center gap-2">
|
||
<span :class="getStatusClass(selectedRecording.status)"
|
||
class="status-badge">
|
||
${formatStatus(selectedRecording.status)}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Main Content Split View -->
|
||
<div id="mainContentColumns" class="flex-1 flex overflow-hidden">
|
||
<!-- Left Panel: Transcription -->
|
||
<div id="leftMainColumn" class="flex flex-col overflow-hidden"
|
||
:style="{width: leftColumnWidth + '%'}">
|
||
<!-- Transcription Header -->
|
||
<div
|
||
class="bg-[var(--bg-tertiary)] px-4 py-3 border-b border-[var(--border-primary)] flex items-center justify-between">
|
||
<h3 class="font-semibold flex items-center">
|
||
<i class="fas fa-file-text mr-2"></i>
|
||
<span v-text="t('transcription.title')"></span>
|
||
</h3>
|
||
<div class="flex items-center gap-2">
|
||
<!-- View Mode Toggle -->
|
||
<div v-if="processedTranscription.hasDialogue" class="view-mode-toggle">
|
||
<button @click="toggleTranscriptionViewMode"
|
||
:class="['toggle-button', transcriptionViewMode === 'simple' ? 'active' : '']">
|
||
<i class="fas fa-list"></i><span
|
||
v-text="t('transcription.simple')"></span>
|
||
</button>
|
||
<button @click="toggleTranscriptionViewMode"
|
||
:class="['toggle-button', transcriptionViewMode === 'bubble' ? 'active' : '']">
|
||
<i class="fas fa-comments"></i><span
|
||
v-text="t('transcription.bubble')"></span>
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Copy Button -->
|
||
<button @click="copyTranscription" class="copy-btn">
|
||
<i class="fas fa-copy mr-1"></i><span
|
||
v-text="t('transcription.copy')"></span>
|
||
</button>
|
||
<!-- Download Button -->
|
||
<button @click="downloadTranscript"
|
||
v-if="selectedRecording && selectedRecording.transcription"
|
||
class="copy-btn" title="下载带有模板的字幕">
|
||
<i class="fas fa-download mr-1"></i><span
|
||
v-text="t('transcription.download')"></span>
|
||
</button>
|
||
<!-- Edit Transcription Button -->
|
||
<button @click="openTranscriptionEditor"
|
||
v-if="selectedRecording && selectedRecording.transcription"
|
||
class="copy-btn">
|
||
<i class="fas fa-edit mr-1"></i><span
|
||
v-text="t('transcription.edit')"></span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Transcription Content -->
|
||
<div class="flex-1 overflow-y-auto p-4">
|
||
<div v-if="selectedRecording.status === 'PROCESSING'" class="text-center py-8">
|
||
<i class="fas fa-spinner fa-spin text-2xl text-[var(--text-muted)]"></i>
|
||
<p class="mt-2 text-[var(--text-muted)]"
|
||
v-text="t('help.processingTranscription')"></p>
|
||
</div>
|
||
|
||
<div v-else-if="!selectedRecording.transcription" class="text-center py-8">
|
||
<i class="fas fa-file-text text-3xl text-[var(--text-muted)] mb-3"></i>
|
||
<p class="text-[var(--text-muted)]"
|
||
v-text="t('transcription.noTranscription')"></p>
|
||
</div>
|
||
|
||
<!-- Speaker Legend (for dialogue transcriptions in bubble view only) -->
|
||
<div v-if="processedTranscription.hasDialogue && processedTranscription.speakers.length > 0 && transcriptionViewMode === 'bubble'"
|
||
:class="['speaker-legend', legendExpanded ? 'expanded' : '']">
|
||
<div class="speaker-legend-header"
|
||
@click="legendExpanded = !legendExpanded">
|
||
<div class="speaker-legend-title">
|
||
<i class="fas fa-users"></i>
|
||
<span v-text="t('help.speakers')"></span>
|
||
<span
|
||
class="speaker-count-indicator">(${processedTranscription.speakers.length})</span>
|
||
</div>
|
||
<i class="fas fa-chevron-down speaker-legend-toggle"></i>
|
||
</div>
|
||
<div class="speaker-legend-content">
|
||
<div v-for="(speaker, index) in processedTranscription.speakers"
|
||
:key="`${selectedRecording.id}-speaker-legend-${index}`"
|
||
:class="['speaker-legend-item', speaker.color]">
|
||
<span class="speaker-name">${speaker.name}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Transcription Display -->
|
||
<div v-if="selectedRecording.transcription">
|
||
<!-- Simple View -->
|
||
<div v-if="!processedTranscription.hasDialogue || transcriptionViewMode === 'simple'"
|
||
class="transcription-simple-view">
|
||
<div v-if="processedTranscription.hasDialogue">
|
||
<div v-for="segment in processedTranscription.simpleSegments"
|
||
:key="segment.startTime || Math.random()"
|
||
class="speaker-segment" @click="seekAudioFromEvent"
|
||
:data-start-time="segment.startTime">
|
||
<div v-if="segment.showSpeaker"
|
||
:class="['speaker-tablet', segment.color]">
|
||
${segment.speaker}
|
||
</div>
|
||
<div class="speaker-text">
|
||
${segment.sentence}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div v-else>
|
||
<div
|
||
v-if="processedTranscription.simpleSegments && processedTranscription.simpleSegments.length > 0">
|
||
<div v-for="segment in processedTranscription.simpleSegments"
|
||
:key="segment.startTime || Math.random()"
|
||
class="transcript-segment cursor-pointer hover:bg-[var(--bg-accent-hover)] p-2 rounded transition-colors"
|
||
@click="seekAudioFromEvent"
|
||
:data-start-time="segment.startTime">
|
||
${segment.sentence}
|
||
</div>
|
||
</div>
|
||
<div v-else class="whitespace-pre-wrap">
|
||
${processedTranscription.content}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Bubble View -->
|
||
<div v-else-if="transcriptionViewMode === 'bubble'"
|
||
class="transcription-with-speakers">
|
||
<div v-for="(row, rowIndex) in processedTranscription.bubbleRows"
|
||
:key="`${selectedRecording.id}-bubble-row-${rowIndex}`"
|
||
:class="['bubble-row', row.isMe ? 'speaker-me' : '']">
|
||
<div v-for="bubble in row.bubbles"
|
||
:key="bubble.startTime || Math.random()"
|
||
:class="['speaker-bubble', bubble.color, row.isMe ? 'speaker-me' : '']"
|
||
@click="seekAudioFromEvent" :data-start-time="bubble.startTime">
|
||
<div class="speaker-bubble-content">
|
||
${bubble.sentence}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Resizable Divider - Minimal but functional -->
|
||
<div id="mainColumnResizer" @mousedown="startColumnResize"></div>
|
||
|
||
<!-- Right Panel: Summary/Notes and Chat -->
|
||
<div id="rightMainColumn" class="flex flex-col overflow-hidden"
|
||
:style="{width: rightColumnWidth + '%'}">
|
||
<!-- Audio Player -->
|
||
<div
|
||
class="bg-[var(--bg-secondary)] p-4 border-b border-[var(--border-primary)] flex-shrink-0">
|
||
<div class="audio-player-container">
|
||
<audio controls :src="'/tool/speakr/audio/' + selectedRecording.id"
|
||
:volume="playerVolume" @volumechange="onPlayerVolumeChange"
|
||
class="w-full">
|
||
Your browser does not support the audio element.
|
||
</audio>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Summary/Notes Tabs -->
|
||
<div :class="{'flex-1': !isChatMaximized, 'flex-none': isChatMaximized}"
|
||
class="flex flex-col overflow-hidden">
|
||
<!-- Tab Navigation -->
|
||
<div
|
||
class="bg-[var(--bg-tertiary)] border-b border-[var(--border-primary)] flex">
|
||
<button @click="selectedTab = 'summary'" :class="[
|
||
'flex-1 px-4 py-3 text-sm font-medium transition-colors',
|
||
selectedTab === 'summary'
|
||
? 'bg-[var(--bg-secondary)] text-[var(--text-accent)] border-b-2 border-[var(--border-focus)]'
|
||
: 'text-[var(--text-muted)] hover:text-[var(--text-secondary)]'
|
||
]">
|
||
${ t('summary.title') }
|
||
</button>
|
||
<button @click="selectedTab = 'notes'" :class="[
|
||
'flex-1 px-4 py-3 text-sm font-medium transition-colors',
|
||
selectedTab === 'notes'
|
||
? 'bg-[var(--bg-secondary)] text-[var(--text-accent)] border-b-2 border-[var(--border-focus)]'
|
||
: 'text-[var(--text-muted)] hover:text-[var(--text-secondary)]'
|
||
]">
|
||
${ t('notes.title') }
|
||
</button>
|
||
<button
|
||
v-if="selectedRecording.events && selectedRecording.events.length > 0"
|
||
@click="selectedTab = 'events'" :class="[
|
||
'flex-1 px-4 py-3 text-sm font-medium transition-colors',
|
||
selectedTab === 'events'
|
||
? 'bg-[var(--bg-secondary)] text-[var(--text-accent)] border-b-2 border-[var(--border-focus)]'
|
||
: 'text-[var(--text-muted)] hover:text-[var(--text-secondary)]'
|
||
]">
|
||
<i class="fas fa-calendar-alt mr-1"></i>
|
||
Events (${ selectedRecording.events.length })
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Tab Content -->
|
||
<div v-if="!isChatMaximized" class="flex-1 overflow-hidden">
|
||
<!-- Summary Tab -->
|
||
<div v-if="selectedTab === 'summary'" class="h-full p-4 overflow-y-auto">
|
||
<div v-if="selectedRecording.status === 'SUMMARIZING'"
|
||
class="text-center py-8">
|
||
<i
|
||
class="fas fa-spinner fa-spin text-2xl text-[var(--text-muted)]"></i>
|
||
<p class="mt-2 text-[var(--text-muted)]"
|
||
v-text="t('summary.summaryInProgress')"></p>
|
||
</div>
|
||
|
||
<div v-else-if="!selectedRecording.summary" class="text-center py-8">
|
||
<i
|
||
class="fas fa-file-alt text-3xl text-[var(--text-muted)] mb-3"></i>
|
||
<p class="text-[var(--text-muted)] mb-4"
|
||
v-text="t('summary.noSummary')"></p>
|
||
<button @click="generateSummary"
|
||
class="px-6 py-3 bg-gradient-to-r from-blue-600 to-purple-600 text-white font-medium rounded-lg shadow-lg hover:shadow-xl hover:from-blue-700 hover:to-purple-700 transition-all duration-200 transform hover:scale-105">
|
||
<i class="fas fa-magic mr-2"></i>${ t('summary.generateSummary')
|
||
}
|
||
</button>
|
||
</div>
|
||
|
||
<div v-else class="content-box relative">
|
||
<div v-if="!editingSummary"
|
||
class="absolute top-2 right-4 flex gap-1 z-10">
|
||
<button @click="copySummary" :title="t('buttons.copySummary')"
|
||
class="w-8 h-8 flex items-center justify-center bg-transparent hover:bg-[var(--bg-secondary)] text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded-md border border-transparent hover:border-[var(--border-secondary)] transition-all duration-200 opacity-60 hover:opacity-100">
|
||
<i class="fas fa-copy text-sm"></i>
|
||
</button>
|
||
<button @click="downloadSummary"
|
||
:title="t('buttons.downloadSummary')"
|
||
class="w-8 h-8 flex items-center justify-center bg-transparent hover:bg-[var(--bg-secondary)] text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded-md border border-transparent hover:border-[var(--border-secondary)] transition-all duration-200 opacity-60 hover:opacity-100">
|
||
<i class="fas fa-download text-sm"></i>
|
||
</button>
|
||
<button @click="toggleEditSummary"
|
||
:title="t('buttons.editSummary')"
|
||
class="w-8 h-8 flex items-center justify-center bg-transparent hover:bg-[var(--bg-secondary)] text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded-md border border-transparent hover:border-[var(--border-secondary)] transition-all duration-200 opacity-60 hover:opacity-100">
|
||
<i class="fas fa-edit text-sm"></i>
|
||
</button>
|
||
</div>
|
||
|
||
<div v-if="!editingSummary" class="summary-box"
|
||
v-html="selectedRecording.summary_html || selectedRecording.summary">
|
||
</div>
|
||
<div v-else class="h-full flex flex-col">
|
||
<div class="markdown-editor-container flex-1">
|
||
<textarea ref="summaryMarkdownEditor"
|
||
v-model="selectedRecording.summary"
|
||
class="w-full h-full p-4 bg-[var(--bg-input)] border border-[var(--border-secondary)] rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-[var(--ring-focus)]"
|
||
:placeholder="t('form.enterSummaryMarkdown')">
|
||
</textarea>
|
||
</div>
|
||
<div class="flex justify-end gap-2 mt-2">
|
||
<button @click="cancelEditSummary"
|
||
class="px-3 py-1 text-sm bg-[var(--bg-secondary)] text-[var(--text-secondary)] border border-[var(--border-secondary)] rounded hover:bg-[var(--bg-tertiary)]">
|
||
取消
|
||
</button>
|
||
<button @click="saveEditSummary"
|
||
class="px-3 py-1 text-sm bg-[var(--bg-button)] text-[var(--text-button)] rounded hover:bg-[var(--bg-button-hover)]">
|
||
保存
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Notes Tab -->
|
||
<div v-if="selectedTab === 'notes'" class="h-full p-4 overflow-y-auto">
|
||
<div class="content-box h-full relative">
|
||
<div v-if="!editingNotes"
|
||
class="absolute top-2 right-4 flex gap-1 z-10">
|
||
<button @click="copyNotes" :title="t('buttons.copyNotes')"
|
||
class="w-8 h-8 flex items-center justify-center bg-transparent hover:bg-[var(--bg-secondary)] text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded-md border border-transparent hover:border-[var(--border-secondary)] transition-all duration-200 opacity-60 hover:opacity-100">
|
||
<i class="fas fa-copy text-sm"></i>
|
||
</button>
|
||
<button @click="downloadNotes"
|
||
:title="t('buttons.downloadNotes')"
|
||
class="w-8 h-8 flex items-center justify-center bg-transparent hover:bg-[var(--bg-secondary)] text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded-md border border-transparent hover:border-[var(--border-secondary)] transition-all duration-200 opacity-60 hover:opacity-100">
|
||
<i class="fas fa-download text-sm"></i>
|
||
</button>
|
||
<button @click="toggleEditNotes" :title="t('buttons.editNotes')"
|
||
class="w-8 h-8 flex items-center justify-center bg-transparent hover:bg-[var(--bg-secondary)] text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded-md border border-transparent hover:border-[var(--border-secondary)] transition-all duration-200 opacity-60 hover:opacity-100">
|
||
<i class="fas fa-edit text-sm"></i>
|
||
</button>
|
||
</div>
|
||
|
||
<div v-if="!editingNotes" class="notes-box h-full"
|
||
@click="clickToEditNotes">
|
||
<div v-if="selectedRecording.notes_html"
|
||
v-html="selectedRecording.notes_html">
|
||
</div>
|
||
<div v-else-if="selectedRecording.notes"
|
||
class="whitespace-pre-wrap">
|
||
${selectedRecording.notes}
|
||
</div>
|
||
<div v-else
|
||
class="text-[var(--text-muted)] italic cursor-pointer hover:text-[var(--text-secondary)]">
|
||
${ t('help.clickToAddNotes') }
|
||
</div>
|
||
</div>
|
||
|
||
<div v-else class="h-full flex flex-col">
|
||
<div class="markdown-editor-container flex-1">
|
||
<textarea ref="notesMarkdownEditor"
|
||
v-model="selectedRecording.notes"
|
||
class="w-full h-full p-4 bg-[var(--bg-input)] border border-[var(--border-secondary)] rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-[var(--ring-focus)]"
|
||
:placeholder="t('form.enterNotesMarkdown')">
|
||
</textarea>
|
||
</div>
|
||
<div class="flex justify-end gap-2 mt-2">
|
||
<button @click="cancelEditNotes"
|
||
class="px-3 py-1 text-sm bg-[var(--bg-secondary)] text-[var(--text-secondary)] border border-[var(--border-secondary)] rounded hover:bg-[var(--bg-tertiary)]">
|
||
取消
|
||
</button>
|
||
<button @click="saveEditNotes"
|
||
class="px-3 py-1 text-sm bg-[var(--bg-button)] text-[var(--text-button)] rounded hover:bg-[var(--bg-button-hover)]">
|
||
保存
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Events Tab -->
|
||
<div v-if="selectedTab === 'events' && selectedRecording.events && selectedRecording.events.length > 0"
|
||
class="h-full p-4 overflow-y-auto">
|
||
<div class="space-y-4">
|
||
<div v-for="event in selectedRecording.events" :key="event.id"
|
||
class="bg-[var(--bg-tertiary)] rounded-lg p-4 border border-[var(--border-primary)] hover:border-[var(--border-accent)] transition-colors">
|
||
<div class="flex justify-between items-start mb-3">
|
||
<div class="flex-1">
|
||
<h3
|
||
class="text-lg font-semibold text-[var(--text-primary)] mb-1">
|
||
<i
|
||
class="fas fa-calendar-check mr-2 text-[var(--text-accent)]"></i>
|
||
${ event.title }
|
||
</h3>
|
||
<p v-if="event.description"
|
||
class="text-sm text-[var(--text-secondary)] mb-2">
|
||
${ event.description }
|
||
</p>
|
||
</div>
|
||
<button @click="downloadEventICS(event)"
|
||
class="px-3 py-1.5 bg-[var(--bg-button)] text-[var(--text-button)] rounded-lg hover:bg-[var(--bg-button-hover)] transition-colors text-sm flex items-center gap-1.5"
|
||
:title="t('events.addToCalendar')">
|
||
<i class="fas fa-download"></i>
|
||
<span>Add to Calendar</span>
|
||
</button>
|
||
</div>
|
||
|
||
<div class="space-y-2 text-sm">
|
||
<div class="flex items-center gap-2"
|
||
v-if="event.start_datetime">
|
||
<i
|
||
class="fas fa-clock text-[var(--text-muted)] w-4"></i>
|
||
<span class="text-[var(--text-secondary)]">
|
||
<strong>Start:</strong> ${
|
||
formatEventDateTime(event.start_datetime) }
|
||
</span>
|
||
</div>
|
||
<div class="flex items-center gap-2"
|
||
v-if="event.end_datetime">
|
||
<i
|
||
class="fas fa-clock text-[var(--text-muted)] w-4"></i>
|
||
<span class="text-[var(--text-secondary)]">
|
||
<strong>End:</strong> ${
|
||
formatEventDateTime(event.end_datetime) }
|
||
</span>
|
||
</div>
|
||
<div class="flex items-center gap-2" v-if="event.location">
|
||
<i
|
||
class="fas fa-map-marker-alt text-[var(--text-muted)] w-4"></i>
|
||
<span class="text-[var(--text-secondary)]">
|
||
<strong>Location:</strong> ${ event.location }
|
||
</span>
|
||
</div>
|
||
<div class="flex items-start gap-2"
|
||
v-if="event.attendees && event.attendees.length > 0">
|
||
<i
|
||
class="fas fa-users text-[var(--text-muted)] w-4 mt-0.5"></i>
|
||
<div class="text-[var(--text-secondary)]">
|
||
<strong>Attendees:</strong>
|
||
<span class="ml-1">${ event.attendees.join(', ')
|
||
}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="selectedRecording.events.length === 0"
|
||
class="text-center py-8">
|
||
<i
|
||
class="fas fa-calendar-times text-3xl text-[var(--text-muted)] mb-3"></i>
|
||
<p class="text-[var(--text-muted)]">No events detected in this
|
||
recording</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Chat Section -->
|
||
<div class="border-t border-[var(--border-primary)] flex flex-col"
|
||
:class="{'flex-1 overflow-hidden': isChatMaximized}">
|
||
<!-- Chat Toggle -->
|
||
<div
|
||
class="bg-[var(--bg-tertiary)] px-4 py-3 hover:bg-[var(--bg-accent-hover)] transition-colors flex items-center">
|
||
<button
|
||
@click="() => { showChat = !showChat; if (!showChat) isChatMaximized = false; }"
|
||
class="flex-1 text-left flex items-center">
|
||
<span class="font-medium flex items-center">
|
||
<i class="fas fa-comments mr-2"></i>
|
||
${ t('chat.chatWithTranscription') }
|
||
</span>
|
||
</button>
|
||
<div class="flex items-center gap-2">
|
||
<button v-if="chatMessages.length > 0" @click="downloadChat"
|
||
class="p-1 text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors"
|
||
:title="t('buttons.downloadChat')">
|
||
<i class="fas fa-download text-sm"></i>
|
||
</button>
|
||
<button @click="toggleChatMaximize"
|
||
class="p-1 text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors"
|
||
:title="isChatMaximized ? 'Restore chat' : 'Maximize chat'">
|
||
<i
|
||
:class="['fas text-sm', isChatMaximized ? 'fa-compress' : 'fa-expand']"></i>
|
||
</button>
|
||
<button
|
||
@click="() => { showChat = !showChat; if (!showChat) isChatMaximized = false; }"
|
||
class="p-1 text-[var(--text-muted)] hover:text-[var(--text-primary)]">
|
||
<i
|
||
:class="['fas transition-transform duration-200', showChat ? 'fa-chevron-down' : 'fa-chevron-up']"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Chat Content -->
|
||
<div v-if="showChat" class="flex flex-col relative"
|
||
:class="isChatMaximized ? 'flex-1 overflow-hidden' : 'max-h-96'">
|
||
<!-- Clear button - fixed at top right -->
|
||
<button v-if="chatMessages.length > 0" @click="clearChat"
|
||
class="absolute top-2 right-2 p-1 text-[var(--text-muted)] opacity-60 hover:opacity-100 hover:text-[var(--text-danger)] rounded transition-all duration-200 z-20"
|
||
title="Clear chat">
|
||
<i class="fas fa-times text-xs"></i>
|
||
</button>
|
||
|
||
<!-- Chat Messages -->
|
||
<div ref="chatMessagesRef"
|
||
class="flex-1 overflow-y-auto p-4 space-y-4 bg-[var(--bg-secondary)]">
|
||
<div v-if="chatMessages.length === 0" class="text-center py-8">
|
||
<i class="fas fa-robot text-3xl text-[var(--text-muted)] mb-3"></i>
|
||
<p class="text-[var(--text-muted)]"
|
||
v-text="t('help.askAboutTranscription')"></p>
|
||
</div>
|
||
|
||
<div v-for="(message, index) in chatMessages" :key="index" :class="[
|
||
'message relative group',
|
||
message.role === 'user' ? 'user-message ml-auto' : 'ai-message',
|
||
message.role === 'assistant' ? 'pr-10' : ''
|
||
]">
|
||
<!-- Copy button for assistant messages -->
|
||
<button v-if="message.role === 'assistant'"
|
||
@click="copyMessage(message.content, $event)"
|
||
class="absolute top-2 right-2 p-1 text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] rounded transition-all duration-200"
|
||
:title="t('buttons.copyMessage')">
|
||
<i class="fas fa-copy text-sm"></i>
|
||
</button>
|
||
<!-- Show thinking content if available -->
|
||
<div v-if="message.thinking && message.role === 'assistant'"
|
||
class="mb-2">
|
||
<button
|
||
@click="message.thinkingExpanded = !message.thinkingExpanded"
|
||
class="text-xs text-[var(--text-muted)] hover:text-[var(--text-secondary)] flex items-center gap-1">
|
||
<i
|
||
:class="['fas', message.thinkingExpanded ? 'fa-chevron-down' : 'fa-chevron-right']"></i>
|
||
<span v-text="t('help.modelReasoning')"></span>
|
||
<span
|
||
class="text-[var(--text-muted)]">(${message.thinking.split('\n').length}
|
||
lines)</span>
|
||
</button>
|
||
<div v-if="message.thinkingExpanded"
|
||
class="mt-2 p-3 bg-[var(--bg-tertiary)] border border-[var(--border-secondary)] rounded-lg text-xs text-[var(--text-muted)] max-h-64 overflow-y-auto">
|
||
<pre
|
||
class="whitespace-pre-wrap font-mono">${message.thinking}</pre>
|
||
</div>
|
||
</div>
|
||
<!-- Main message content -->
|
||
<div v-if="message.html" v-html="message.html"></div>
|
||
<div v-else class="whitespace-pre-wrap">${message.content}</div>
|
||
</div>
|
||
|
||
<div v-if="isChatLoading" class="ai-message">
|
||
<i class="fas fa-spinner fa-spin mr-2"></i>
|
||
${ t('chat.thinking') }
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Chat Input -->
|
||
<div
|
||
class="border-t border-[var(--border-primary)] p-4 bg-[var(--bg-tertiary)]">
|
||
<div class="flex gap-2">
|
||
<textarea v-model="chatInput" @keydown="handleChatKeydown"
|
||
:disabled="isChatLoading || selectedRecording.status !== 'COMPLETED'"
|
||
:placeholder="t('chat.placeholderWithHint')"
|
||
class="flex-1 px-3 py-2 bg-[var(--bg-input)] border border-[var(--border-secondary)] rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-[var(--ring-focus)] text-sm"
|
||
rows="2">
|
||
</textarea>
|
||
<button @click="sendChatMessage"
|
||
:disabled="!chatInput.trim() || isChatLoading || selectedRecording.status !== 'COMPLETED'"
|
||
class="px-4 py-2 bg-[var(--bg-button)] text-[var(--text-button)] rounded-lg hover:bg-[var(--bg-button-hover)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
|
||
<i class="fas fa-paper-plane"></i>
|
||
</button>
|
||
</div>
|
||
<p v-if="selectedRecording.status !== 'COMPLETED'"
|
||
class="text-xs text-[var(--text-muted)] mt-2">
|
||
${ t('chat.availableAfterTranscription') }
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ==================== 功能块:录音详情视图 End ==================== -->
|
||
|
||
<!-- ==================== 功能块:无选中录音空态视图 Start ==================== -->
|
||
<!-- Empty State -->
|
||
<div v-else-if="currentView === 'detail' && !selectedRecording"
|
||
class="flex-1 flex items-center justify-center">
|
||
<div class="text-center">
|
||
<i class="fas fa-microphone text-6xl text-[var(--text-muted)] mb-4"></i>
|
||
<h2 class="text-2xl font-bold mb-2" v-text="t('colorScheme.selectRecording')"></h2>
|
||
<p class="text-[var(--text-muted)] mb-6" v-text="t('colorScheme.chooseRecording')"></p>
|
||
<button @click="switchToUploadView"
|
||
class="px-6 py-3 bg-[var(--bg-button)] text-[var(--text-button)] rounded-lg hover:bg-[var(--bg-button-hover)] transition-colors">
|
||
<i class="fas fa-plus mr-2"></i>Upload New Recording
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ==================== 功能块:无选中录音空态视图 End ==================== -->
|
||
<!-- ==================== 功能块:主内容视图区 End ==================== -->
|
||
<!-- ==================== 功能块:应用主工作区 End ==================== -->
|
||
|
||
<!-- ==================== 功能块:上传队列进度弹层 Start ==================== -->
|
||
<!-- Upload Progress Popup -->
|
||
<div v-if="uploadQueue.length > 0 && !progressPopupClosed" :class="[
|
||
'fixed bottom-4 left-4 z-[100] w-96 transition-all duration-300',
|
||
progressPopupMinimized ? 'minimized' : ''
|
||
]">
|
||
<div class="bg-[var(--bg-secondary)] border-2 border-[var(--bg-accent)] rounded-lg shadow-2xl overflow-hidden backdrop-blur-sm bg-opacity-95
|
||
ring-4 ring-[var(--bg-accent)] ring-opacity-20"
|
||
style="box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04), 0 0 0 1px var(--bg-accent), 0 0 20px rgba(var(--bg-accent-rgb), 0.3)">
|
||
<!-- Progress Header -->
|
||
<div class="bg-gradient-to-r from-[var(--bg-accent)] to-[var(--bg-accent-hover)] px-4 py-3">
|
||
<!-- Top row: Title and buttons -->
|
||
<div class="flex items-center justify-between mb-2">
|
||
<div class="flex items-center gap-2 min-w-0">
|
||
<i class="fas fa-upload text-gray-800 dark:text-white animate-pulse"></i>
|
||
<span class="font-semibold text-gray-800 dark:text-white truncate"
|
||
v-text="t('uploadProgress.title')"></span>
|
||
</div>
|
||
<div class="flex items-center gap-2 flex-shrink-0">
|
||
<button v-if="completedInQueue > 0" @click="clearCompletedUploads()"
|
||
class="px-2 py-0.5 text-xs bg-black dark:bg-white bg-opacity-10 dark:bg-opacity-20 hover:bg-opacity-20 dark:hover:bg-opacity-30 rounded transition-all duration-200 text-gray-800 dark:text-white font-medium"
|
||
:title="t('buttons.clearCompleted')">
|
||
Clear
|
||
</button>
|
||
<button @click="progressPopupMinimized = !progressPopupMinimized"
|
||
class="p-1 rounded hover:bg-black dark:hover:bg-white hover:bg-opacity-10 dark:hover:bg-opacity-20 transition-colors text-gray-800 dark:text-white">
|
||
<i :class="progressPopupMinimized ? 'fas fa-chevron-up' : 'fas fa-chevron-down'"></i>
|
||
</button>
|
||
<button @click="progressPopupClosed = true"
|
||
class="p-1 rounded hover:bg-red-600 dark:hover:bg-red-500 hover:bg-opacity-20 dark:hover:bg-opacity-30 text-gray-800 dark:text-white hover:text-white transition-colors">
|
||
<i class="fas fa-times"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<!-- Overall Progress Bar -->
|
||
<div class="space-y-1">
|
||
<div class="flex justify-between items-center text-xs text-gray-800 dark:text-white">
|
||
<span class="font-medium">${completedInQueue} of ${totalInQueue} files</span>
|
||
<span class="font-bold">${Math.round((completedInQueue / Math.max(totalInQueue, 1)) *
|
||
100)}%</span>
|
||
</div>
|
||
<div
|
||
class="w-full bg-black dark:bg-white bg-opacity-10 dark:bg-opacity-20 rounded-full h-2 overflow-hidden">
|
||
<div class="bg-white dark:bg-white bg-opacity-80 dark:bg-opacity-90 h-full rounded-full transition-all duration-500 ease-out relative overflow-hidden"
|
||
:style="{width: (completedInQueue / Math.max(totalInQueue, 1) * 100) + '%'}">
|
||
<div class="absolute inset-0 bg-white opacity-30 animate-pulse"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Progress Content -->
|
||
<div v-if="!progressPopupMinimized" class="p-3 max-h-64 overflow-y-auto">
|
||
<div v-if="currentlyProcessingFile"
|
||
class="mb-3 p-3 bg-[var(--bg-accent)] bg-opacity-10 border border-[var(--border-accent)] rounded-lg">
|
||
<div class="flex items-center justify-between mb-2">
|
||
<span
|
||
class="font-semibold truncate min-w-0 text-sm text-[var(--text-primary)]">${currentlyProcessingFile.file.name}</span>
|
||
<span
|
||
class="text-sm flex-shrink-0 ml-2 font-bold text-[var(--text-accent)]">${processingProgress}%</span>
|
||
</div>
|
||
<div class="w-full bg-[var(--bg-tertiary)] rounded-full h-2 mb-2 overflow-hidden">
|
||
<div class="bg-gradient-to-r from-[var(--bg-accent)] to-[var(--bg-accent-hover)] h-full rounded-full transition-all duration-300 relative"
|
||
:style="{width: processingProgress + '%'}">
|
||
<div class="absolute inset-0 bg-white opacity-20 animate-pulse"></div>
|
||
</div>
|
||
</div>
|
||
<p class="text-xs text-[var(--text-secondary)] font-medium">${processingMessage}</p>
|
||
</div>
|
||
|
||
<div v-if="finishedFilesInQueue.length > 0" class="space-y-2">
|
||
<h4 class="text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider opacity-70"
|
||
v-text="t('help.completedFiles')"></h4>
|
||
<div v-for="item in finishedFilesInQueue" :key="item.clientId"
|
||
class="progress-list-item p-2 bg-[var(--bg-tertiary)] bg-opacity-50 rounded-lg flex items-center gap-2 hover:bg-opacity-70 transition-all duration-200">
|
||
<i :class="[
|
||
'fas',
|
||
item.status === 'completed' ? 'fa-check-circle text-green-500' : 'fa-exclamation-circle text-red-500'
|
||
]"></i>
|
||
<span class="truncate text-sm flex-1 text-[var(--text-primary)]">${item.file.name}</span>
|
||
<span :class="[
|
||
'text-xs px-2 py-1 rounded-full font-semibold',
|
||
item.status === 'completed' ? 'bg-green-500 bg-opacity-20 text-green-600 dark:text-green-400' : 'bg-red-500 bg-opacity-20 text-red-600 dark:text-red-400'
|
||
]">
|
||
${item.status === 'completed' ? t('tagsModal.done') : t('status.failed')}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ==================== 功能块:上传队列进度弹层 End ==================== -->
|
||
|
||
<!-- ==================== 功能块:共享弹窗区 Start ==================== -->
|
||
<!-- Modals -->
|
||
<!-- ==================== 功能块:录音基础信息编辑弹窗 Start ==================== -->
|
||
<!-- Edit Modal -->
|
||
<div v-if="showEditModal"
|
||
class="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center z-50 p-4 backdrop-blur-sm">
|
||
<div class="bg-[var(--bg-secondary)] rounded-lg shadow-xl w-full max-w-lg">
|
||
<div class="p-6 border-b border-[var(--border-primary)]">
|
||
<h3 class="text-lg font-semibold" v-text="t('modal.editRecording')"></h3>
|
||
</div>
|
||
<div class="p-6 space-y-4">
|
||
<div>
|
||
<label class="block text-sm font-medium mb-2" v-text="t('form.title')"></label>
|
||
<input v-model="editingRecording.title"
|
||
class="w-full px-3 py-2 bg-[var(--bg-input)] border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--ring-focus)]">
|
||
</div>
|
||
<div>
|
||
<label class="block text-sm font-medium mb-2" v-text="t('form.participants')"></label>
|
||
<input v-model="editingRecording.participants"
|
||
class="w-full px-3 py-2 bg-[var(--bg-input)] border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--ring-focus)]">
|
||
</div>
|
||
<div>
|
||
<label class="block text-sm font-medium mb-2" v-text="t('form.meetingDate')"></label>
|
||
<input v-model="editingRecording.meeting_date" type="date"
|
||
class="w-full px-3 py-2 bg-[var(--bg-input)] border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--ring-focus)]">
|
||
</div>
|
||
<div>
|
||
<label class="block text-sm font-medium mb-2" v-text="t('form.notes')"></label>
|
||
<textarea v-model="editingRecording.notes" rows="4"
|
||
class="w-full px-3 py-2 bg-[var(--bg-input)] border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--ring-focus)]">
|
||
</textarea>
|
||
</div>
|
||
</div>
|
||
<div class="p-6 border-t border-[var(--border-primary)] flex justify-end gap-3">
|
||
<button @click="cancelEdit"
|
||
class="px-4 py-2 text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)] rounded-lg transition-colors">
|
||
取消
|
||
</button>
|
||
<button @click="saveEdit"
|
||
class="px-4 py-2 bg-[var(--bg-button)] text-[var(--text-button)] rounded-lg hover:bg-[var(--bg-button-hover)] transition-colors">
|
||
保存更改
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ==================== 功能块:录音基础信息编辑弹窗 End ==================== -->
|
||
|
||
<!-- ==================== 功能块:录音删除确认弹窗 Start ==================== -->
|
||
<!-- Delete Confirmation Modal -->
|
||
<div v-if="showDeleteModal"
|
||
class="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center z-50 p-4 backdrop-blur-sm">
|
||
<div class="bg-[var(--bg-secondary)] rounded-lg shadow-xl w-full max-w-md">
|
||
<div class="p-6">
|
||
<div class="flex items-center gap-4 mb-4">
|
||
<i class="fas fa-exclamation-triangle text-3xl text-[var(--text-danger)]"></i>
|
||
<div>
|
||
<h3 class="text-lg font-semibold" v-text="t('modal.deleteRecording')"></h3>
|
||
<p class="text-[var(--text-muted)]" v-text="t('help.thisActionCannotBeUndone')"></p>
|
||
</div>
|
||
</div>
|
||
<p class="text-[var(--text-secondary)] mb-6">
|
||
Are you sure you want to delete "${recordingToDelete?.title || 'this recording'}"?
|
||
</p>
|
||
<div class="flex justify-end gap-3">
|
||
<button @click="cancelDelete"
|
||
class="px-4 py-2 text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)] rounded-lg transition-colors">
|
||
取消
|
||
</button>
|
||
<button @click="deleteRecording"
|
||
class="px-4 py-2 bg-[var(--bg-danger)] text-white rounded-lg hover:bg-[var(--bg-danger-hover)] transition-colors">
|
||
Delete
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ==================== 功能块:录音删除确认弹窗 End ==================== -->
|
||
|
||
<!-- ==================== 功能块:录音标签编辑弹窗 Start ==================== -->
|
||
<!-- Edit Recording Tags Modal -->
|
||
<div v-if="showEditTagsModal" @click="closeEditTagsModal"
|
||
class="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center z-50 p-4 backdrop-blur-sm">
|
||
<div @click.stop
|
||
class="bg-[var(--bg-secondary)] rounded-lg shadow-xl w-full max-w-lg max-h-[80vh] flex flex-col">
|
||
<div class="flex-shrink-0 p-6 border-b border-[var(--border-primary)]">
|
||
<div class="flex items-center justify-between">
|
||
<h3 class="text-lg font-medium text-[var(--text-primary)]" v-text="t('modal.editTags')"></h3>
|
||
<button @click="closeEditTagsModal"
|
||
class="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors">
|
||
<i class="fas fa-times"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="flex-1 overflow-y-auto p-6 space-y-4">
|
||
<!-- Current Tags Section -->
|
||
<div>
|
||
<label class="block text-sm font-medium text-[var(--text-secondary)] mb-3">
|
||
<i class="fas fa-tags mr-1 text-[var(--text-muted)]"></i>
|
||
<span v-text="t('tagsModal.currentTags')"></span>
|
||
</label>
|
||
<div v-if="getRecordingTags(editingRecording).length > 0"
|
||
class="p-2 bg-[var(--bg-primary)] rounded-md border border-[var(--border-secondary)] max-h-20 overflow-y-auto">
|
||
<div class="flex flex-wrap gap-1">
|
||
<span v-for="tag in getRecordingTags(editingRecording)" :key="tag.id"
|
||
class="group inline-flex items-center px-1.5 py-0.5 rounded-full text-[11px] font-medium text-white transition-all"
|
||
:style="{ backgroundColor: tag.color }">
|
||
<span v-text="tag.name"></span>
|
||
<button @click="removeTagFromRecording(tag.id)"
|
||
class="ml-1 opacity-70 hover:opacity-100 transition-opacity">
|
||
<i class="fas fa-times" style="font-size: 9px;"></i>
|
||
</button>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div v-else
|
||
class="p-4 bg-[var(--bg-tertiary)] rounded-lg border border-[var(--border-secondary)]">
|
||
<p class="text-sm text-[var(--text-muted)] text-center">
|
||
<i class="fas fa-inbox mr-1"></i>
|
||
<span v-text="t('tagsModal.noTagsAssigned')"></span>
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Add Tags Section -->
|
||
<div>
|
||
<label class="block text-sm font-medium text-[var(--text-secondary)] mb-3">
|
||
<i class="fas fa-plus-circle mr-1 text-[var(--text-muted)]"></i>
|
||
<span v-text="t('tagsModal.addTags')"></span>
|
||
</label>
|
||
|
||
<!-- Search Filter (always visible if tags available) -->
|
||
<div v-if="getAvailableTagsForRecording(editingRecording).length > 0" class="mb-2">
|
||
<div class="relative">
|
||
<input v-model="tagSearchFilter" type="text" :placeholder="t('tagsModal.searchTags')"
|
||
class="w-full px-2 py-1 pl-6 text-xs bg-[var(--bg-primary)] text-[var(--text-primary)] border border-[var(--border-primary)] rounded focus:outline-none focus:ring-1 focus:ring-[var(--ring-focus)]">
|
||
<i class="fas fa-search absolute left-2 top-1/2 transform -translate-y-1/2 text-[var(--text-muted)]"
|
||
style="font-size: 9px;"></i>
|
||
<button v-if="tagSearchFilter" @click="tagSearchFilter = ''"
|
||
class="absolute right-2 top-1/2 transform -translate-y-1/2 text-[var(--text-muted)] hover:text-[var(--text-primary)]">
|
||
<i class="fas fa-times" style="font-size: 9px;"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Available Tags Grid - Fixed Height -->
|
||
<div v-if="filteredAvailableTagsForModal.length > 0"
|
||
class="border border-[var(--border-secondary)] rounded-md p-1.5 bg-[var(--bg-primary)]"
|
||
style="max-height: 150px; overflow-y: auto;">
|
||
<div class="grid grid-cols-2 gap-1">
|
||
<button v-for="tag in filteredAvailableTagsForModal" :key="tag.id"
|
||
@click="addTagToRecording(tag.id)"
|
||
class="group flex items-center justify-between px-1.5 py-1 rounded border border-[var(--border-secondary)] hover:border-[var(--border-focus)] bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] transition-all">
|
||
<div class="flex items-center gap-1 min-w-0">
|
||
<span class="w-2 h-2 rounded-full flex-shrink-0"
|
||
:style="{ backgroundColor: tag.color || '#6B7280' }"></span>
|
||
<span class="text-[11px] text-[var(--text-primary)] truncate"
|
||
v-text="tag.name"></span>
|
||
</div>
|
||
<i class="fas fa-plus text-[var(--text-muted)] group-hover:text-[var(--text-accent)] transition-colors"
|
||
style="font-size: 9px;"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div v-else-if="getAvailableTagsForRecording(editingRecording).length > 0"
|
||
class="p-4 bg-[var(--bg-tertiary)] rounded-lg border border-[var(--border-secondary)]">
|
||
<p class="text-sm text-[var(--text-muted)] text-center">
|
||
<i class="fas fa-search mr-1"></i>
|
||
No tags match your filter
|
||
</p>
|
||
</div>
|
||
<div v-else
|
||
class="p-4 bg-[var(--bg-tertiary)] rounded-lg border border-[var(--border-secondary)]">
|
||
<p class="text-sm text-[var(--text-muted)] text-center">
|
||
<i class="fas fa-check-circle mr-1"></i>
|
||
所有可用标签已添加
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div
|
||
class="flex-shrink-0 px-6 py-4 bg-[var(--bg-tertiary)] border-t border-[var(--border-primary)] flex justify-end gap-3 rounded-b-lg">
|
||
<button @click="closeEditTagsModal"
|
||
class="px-4 py-2 bg-[var(--bg-secondary)] text-[var(--text-primary)] rounded-md hover:bg-[var(--bg-button-hover)] transition-colors">
|
||
<span v-text="t('tagsModal.done')"></span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ==================== 功能块:录音标签编辑弹窗 End ==================== -->
|
||
|
||
<!-- ==================== 功能块:配色方案设置弹窗 Start ==================== -->
|
||
<!-- Color Scheme Modal -->
|
||
<div v-if="showColorSchemeModal" class="color-scheme-modal" @click.self="closeColorSchemeModal">
|
||
<div class="color-scheme-modal-content">
|
||
<div class="color-scheme-header">
|
||
<h2 class="color-scheme-title">
|
||
<i class="fas fa-palette"></i>
|
||
Choose Color Scheme
|
||
</h2>
|
||
<p class="color-scheme-subtitle" v-text="t('colorScheme.subtitle')"></p>
|
||
</div>
|
||
|
||
<div class="color-scheme-body">
|
||
<div class="color-scheme-section">
|
||
<h3 class="color-scheme-section-title">
|
||
<i :class="isDarkMode ? 'fas fa-moon' : 'fas fa-sun'"></i>
|
||
${isDarkMode ? 'Dark' : 'Light'} Themes
|
||
</h3>
|
||
<div class="color-scheme-grid">
|
||
<div v-for="scheme in colorSchemes[isDarkMode ? 'dark' : 'light']" :key="scheme.id"
|
||
@click="selectColorScheme(scheme.id)"
|
||
:class="['color-scheme-option', currentColorScheme === scheme.id ? 'active' : '']">
|
||
<div class="color-scheme-preview">
|
||
<div
|
||
:class="`preview-${isDarkMode ? 'dark-' : ''}${scheme.id}-primary color-scheme-preview-segment`">
|
||
</div>
|
||
<div
|
||
:class="`preview-${isDarkMode ? 'dark-' : ''}${scheme.id}-secondary color-scheme-preview-segment`">
|
||
</div>
|
||
<div
|
||
:class="`preview-${isDarkMode ? 'dark-' : ''}${scheme.id}-tertiary color-scheme-preview-segment`">
|
||
</div>
|
||
</div>
|
||
<div class="color-scheme-name">${scheme.name}</div>
|
||
<div class="color-scheme-description">${scheme.description}</div>
|
||
<div v-if="currentColorScheme === scheme.id" class="color-scheme-check">
|
||
<i class="fas fa-check"></i>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="color-scheme-footer">
|
||
<button @click="resetColorScheme" class="color-scheme-reset-btn">
|
||
<i class="fas fa-undo mr-2"></i>Reset to Default
|
||
</button>
|
||
<button @click="closeColorSchemeModal" class="color-scheme-close-btn">
|
||
Close
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ==================== 功能块:配色方案设置弹窗 End ==================== -->
|
||
|
||
<!-- ==================== 功能块:重新处理确认弹窗 Start ==================== -->
|
||
<!-- Reprocess Confirmation Modal -->
|
||
<div v-if="showReprocessModal" @click="cancelReprocess"
|
||
class="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center z-50 p-4 backdrop-blur-sm transition-all duration-300 ease-in-out">
|
||
<div @click.stop
|
||
class="bg-[var(--bg-secondary)] rounded-xl shadow-2xl w-full max-w-md transform transition-all duration-300 ease-in-out">
|
||
<!-- Header with gradient background -->
|
||
<div
|
||
class="bg-gradient-to-r from-[var(--bg-accent)] to-[var(--bg-secondary)] p-5 rounded-t-xl flex items-center">
|
||
<div class="flex items-center">
|
||
<div
|
||
class="w-12 h-12 bg-gradient-to-br from-blue-500 to-purple-600 rounded-full flex items-center justify-center mr-4 shadow-lg">
|
||
<i class="fas fa-sync-alt text-white text-lg"></i>
|
||
</div>
|
||
<div>
|
||
<h3 class="text-xl font-bold text-[var(--text-primary)] mb-1"
|
||
v-text="t('help.confirmReprocessingTitle')"></h3>
|
||
<p class="text-sm text-[var(--text-muted)] capitalize">${ reprocessType } reprocessing</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Content -->
|
||
<div class="p-6 modal-content">
|
||
<div v-if="reprocessRecording" class="mb-6">
|
||
<div class="bg-[var(--bg-tertiary)] rounded-lg p-4 border border-[var(--border-primary)]">
|
||
<div class="flex items-start space-x-3">
|
||
<i class="fas fa-file-audio text-[var(--text-accent)] mt-1"></i>
|
||
<div class="flex-1 min-w-0">
|
||
<h4 class="font-medium text-[var(--text-primary)] truncate"
|
||
:title="reprocessRecording.title">
|
||
${ reprocessRecording.title || 'Untitled Recording' }
|
||
</h4>
|
||
<p class="text-sm text-[var(--text-muted)] mt-1">
|
||
Created: ${ new Date(reprocessRecording.created_at).toLocaleDateString() }
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="space-y-4">
|
||
<div class="flex items-start space-x-3">
|
||
<div
|
||
class="w-6 h-6 bg-amber-100 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5">
|
||
<i class="fas fa-exclamation-triangle text-amber-600 text-xs"></i>
|
||
</div>
|
||
<div>
|
||
<p class="text-[var(--text-secondary)] font-medium mb-1"
|
||
v-text="t('help.whatWillHappen')"></p>
|
||
<p class="text-sm text-[var(--text-muted)]"
|
||
v-if="reprocessType === 'transcription' && !useAsrEndpoint">
|
||
<span v-text="t('reprocessModal.audioReTranscribedFromScratch')"></span>
|
||
</p>
|
||
<p class="text-sm text-[var(--text-muted)]"
|
||
v-else-if="reprocessType === 'transcription' && useAsrEndpoint">
|
||
<span v-text="t('reprocessModal.audioReTranscribedWithAsr')"></span>
|
||
</p>
|
||
<p class="text-sm text-[var(--text-muted)]" v-else-if="reprocessType === 'summary'">
|
||
<span v-text="t('reprocessModal.newTitleAndSummary')"></span>
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ASR Options -->
|
||
<div v-if="reprocessType === 'transcription' && useAsrEndpoint"
|
||
class="space-y-4 pt-4 border-t border-[var(--border-primary)]">
|
||
<h4 class="text-md font-semibold text-[var(--text-secondary)]"
|
||
v-text="t('help.advancedAsrOptions')"></h4>
|
||
<div>
|
||
<label for="asr-language-reprocess"
|
||
class="block text-sm font-medium text-[var(--text-muted)]"
|
||
v-text="t('form.language')"></label>
|
||
<input type="text" id="asr-language-reprocess" v-model="asrReprocessOptions.language"
|
||
class="mt-1 block w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md shadow-sm focus:outline-none focus:ring-[var(--border-focus)] focus:border-[var(--border-focus)] sm:text-sm bg-[var(--bg-input)] text-[var(--text-primary)]"
|
||
placeholder="e.g., en, es, zh (optional)">
|
||
</div>
|
||
<div>
|
||
<label for="asr-min-speakers-reprocess"
|
||
class="block text-sm font-medium text-[var(--text-muted)]"
|
||
v-text="t('form.minSpeakers')"></label>
|
||
<input type="number" id="asr-min-speakers-reprocess"
|
||
v-model.number="asrReprocessOptions.min_speakers"
|
||
class="mt-1 block w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md shadow-sm focus:outline-none focus:ring-[var(--border-focus)] focus:border-[var(--border-focus)] sm:text-sm bg-[var(--bg-input)] text-[var(--text-primary)]"
|
||
:placeholder="t('form.optional')">
|
||
</div>
|
||
<div>
|
||
<label for="asr-max-speakers-reprocess"
|
||
class="block text-sm font-medium text-[var(--text-muted)]"
|
||
v-text="t('form.maxSpeakers')"></label>
|
||
<input type="number" id="asr-max-speakers-reprocess"
|
||
v-model.number="asrReprocessOptions.max_speakers"
|
||
class="mt-1 block w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md shadow-sm focus:outline-none focus:ring-[var(--border-focus)] focus:border-[var(--border-focus)] sm:text-sm bg-[var(--bg-input)] text-[var(--text-primary)]"
|
||
:placeholder="t('form.optional')">
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex items-start space-x-3">
|
||
<div
|
||
class="w-6 h-6 bg-blue-100 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5">
|
||
<i class="fas fa-clock text-blue-600 text-xs"></i>
|
||
</div>
|
||
<div>
|
||
<p class="text-[var(--text-secondary)] font-medium mb-1"
|
||
v-text="t('help.processingTime')"></p>
|
||
<p class="text-sm text-[var(--text-muted)]">
|
||
<span v-text="t('help.processingTimeDescription')"></span>
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex items-start space-x-3" v-if="reprocessType === 'transcription'">
|
||
<div
|
||
class="w-6 h-6 bg-red-100 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5">
|
||
<i class="fas fa-info-circle text-red-600 text-xs"></i>
|
||
</div>
|
||
<div>
|
||
<p class="text-[var(--text-secondary)] font-medium mb-1"
|
||
v-text="t('help.importantNote')"></p>
|
||
<p class="text-sm text-[var(--text-muted)]">
|
||
<span v-text="t('reprocessModal.manualEditsOverwritten')"></span>
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex items-start space-x-3" v-else-if="reprocessType === 'summary'">
|
||
<div
|
||
class="w-6 h-6 bg-red-100 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5">
|
||
<i class="fas fa-info-circle text-red-600 text-xs"></i>
|
||
</div>
|
||
<div>
|
||
<p class="text-[var(--text-secondary)] font-medium mb-1"
|
||
v-text="t('help.importantNote')"></p>
|
||
<p class="text-sm text-[var(--text-muted)]">
|
||
<span v-text="t('reprocessModal.manualEditsOverwrittenSummary')"></span>
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Footer with action buttons -->
|
||
<div
|
||
class="bg-[var(--bg-tertiary)] px-6 py-4 rounded-b-xl flex justify-end space-x-3 border-t border-[var(--border-primary)]">
|
||
<button @click="cancelReprocess"
|
||
class="px-5 py-2.5 bg-[var(--bg-secondary)] text-[var(--text-secondary)] rounded-lg border border-[var(--border-secondary)] hover:bg-[var(--bg-tertiary)] transition-all duration-200 flex items-center shadow-sm font-medium">
|
||
<i class="fas fa-times mr-2"></i>
|
||
取消
|
||
</button>
|
||
<button @click="executeReprocess"
|
||
class="px-5 py-2.5 bg-gradient-to-r from-blue-500 to-purple-600 text-white rounded-lg hover:from-blue-600 hover:to-purple-700 transition-all duration-200 flex items-center shadow-lg font-medium transform hover:scale-105">
|
||
<i class="fas fa-sync-alt mr-2"></i>
|
||
开始重新处理
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- ==================== 功能块:重新处理确认弹窗 End ==================== -->
|
||
|
||
<!-- ==================== 功能块:状态重置确认弹窗 Start ==================== -->
|
||
<!-- Reset Status Confirmation Modal -->
|
||
<div v-if="showResetModal" @click="cancelReset"
|
||
class="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center z-50 p-4 backdrop-blur-sm">
|
||
<div @click.stop class="bg-[var(--bg-secondary)] rounded-xl shadow-2xl w-full max-w-md">
|
||
<div class="p-6 text-center">
|
||
<div
|
||
class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-orange-100 dark:bg-orange-800 mb-4">
|
||
<i class="fas fa-exclamation-triangle text-2xl text-orange-500 dark:text-orange-300"></i>
|
||
</div>
|
||
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-2" v-text="t('modal.resetStatus')">
|
||
</h3>
|
||
<p class="text-sm text-[var(--text-muted)] mb-6">
|
||
This will mark the recording as 'Failed'. This is useful if processing is stuck. You will be
|
||
able to reprocess it afterwards.
|
||
</p>
|
||
</div>
|
||
<div class="bg-[var(--bg-tertiary)] px-6 py-4 rounded-b-xl flex justify-end space-x-3">
|
||
<button @click="cancelReset"
|
||
class="px-4 py-2 bg-[var(--bg-secondary)] text-[var(--text-secondary)] rounded-lg border border-[var(--border-secondary)] hover:bg-[var(--bg-tertiary)]">
|
||
取消
|
||
</button>
|
||
<button @click="executeReset"
|
||
class="px-4 py-2 bg-orange-500 text-white rounded-lg hover:bg-orange-600">
|
||
Yes, Reset Status
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- ==================== 功能块:状态重置确认弹窗 End ==================== -->
|
||
|
||
<!-- ==================== 功能块:纯文本转写编辑弹窗 Start ==================== -->
|
||
<!-- Simple Transcription Editor Modal -->
|
||
<div v-if="showTextEditorModal"
|
||
class="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center z-50 p-4 backdrop-blur-sm">
|
||
<div class="bg-[var(--bg-secondary)] rounded-lg shadow-xl w-full max-w-2xl max-h-[90vh] flex flex-col">
|
||
<div class="p-6 border-b border-[var(--border-primary)]">
|
||
<h3 class="text-lg font-semibold" v-text="t('modal.editTranscription')"></h3>
|
||
</div>
|
||
<div class="p-6 space-y-4 overflow-y-auto flex-1">
|
||
<div>
|
||
<label class="block text-sm font-medium mb-2" v-text="t('transcription.title')"></label>
|
||
<textarea v-model="editingTranscriptionContent" rows="15"
|
||
class="w-full px-3 py-2 bg-[var(--bg-input)] border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--ring-focus)] font-mono text-sm">
|
||
</textarea>
|
||
</div>
|
||
</div>
|
||
<div class="p-6 border-t border-[var(--border-primary)] flex justify-end gap-3">
|
||
<button @click="closeTextEditorModal"
|
||
class="px-4 py-2 text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)] rounded-lg transition-colors">
|
||
取消
|
||
</button>
|
||
<button @click="saveTranscription"
|
||
class="px-4 py-2 bg-[var(--bg-button)] text-[var(--text-button)] rounded-lg hover:bg-[var(--bg-button-hover)] transition-colors">
|
||
保存更改
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ==================== 功能块:纯文本转写编辑弹窗 End ==================== -->
|
||
|
||
<!-- ==================== 功能块:ASR 分段转写编辑弹窗 Start ==================== -->
|
||
<!-- ASR Transcription Editor Modal -->
|
||
<div v-if="showAsrEditorModal"
|
||
class="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center z-50 p-4 backdrop-blur-sm">
|
||
<div @click.stop
|
||
class="bg-[var(--bg-secondary)] rounded-xl shadow-2xl w-full max-w-6xl flex flex-col max-h-[90vh]">
|
||
<div
|
||
class="p-5 border-b border-[var(--border-primary)] flex-shrink-0 flex justify-between items-center">
|
||
<h3 class="text-xl font-bold text-[var(--text-primary)]" v-text="t('modal.editAsrTranscription')">
|
||
</h3>
|
||
<button @click="closeAsrEditorModal"
|
||
class="p-2 rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors">
|
||
<i class="fas fa-times"></i>
|
||
</button>
|
||
</div>
|
||
<div class="flex-grow overflow-y-auto custom-scrollbar p-6">
|
||
<table class="min-w-full asr-editor-table">
|
||
<thead>
|
||
<tr>
|
||
<th class="w-1/4" v-text="t('help.speakerCount')"></th>
|
||
<th class="w-24" v-text="t('help.startTime')"></th>
|
||
<th class="w-24" v-text="t('help.endTime')"></th>
|
||
<th v-text="t('help.sentence')"></th>
|
||
<th class="w-12"><span class="sr-only" v-text="t('help.actions')"></span></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="(segment, index) in editingSegments" :key="segment.id || index">
|
||
<td>
|
||
<div class="speaker-combobox" @mouseleave="closeSpeakerSuggestions(index)">
|
||
<input type="text" v-model="segment.speaker" @input="filterSpeakers(index)"
|
||
@focus="openSpeakerSuggestions(index)"
|
||
class="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md shadow-sm focus:outline-none focus:ring-[var(--border-focus)] focus:border-[var(--border-focus)] sm:text-sm bg-[var(--bg-input)] text-[var(--text-primary)] speaker-combobox-input" />
|
||
<div class="speaker-combobox-arrow">
|
||
<i class="fas fa-chevron-down text-xs"></i>
|
||
</div>
|
||
<div v-if="segment.showSuggestions" class="speaker-combobox-suggestions">
|
||
<div v-for="speaker in segment.filteredSpeakers"
|
||
@mousedown="selectSpeaker(index, speaker)"
|
||
class="speaker-suggestion-item">${speaker}</div>
|
||
</div>
|
||
</div>
|
||
</td>
|
||
<td>
|
||
<div class="custom-number-input">
|
||
<input type="number" step="0.01" v-model.number="segment.start_time"
|
||
class="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md shadow-sm focus:outline-none focus:ring-[var(--border-focus)] focus:border-[var(--border-focus)] sm:text-sm bg-[var(--bg-input)] text-[var(--text-primary)]" />
|
||
<div class="number-spinners">
|
||
<button @click="adjustTime(index, 'start_time', 0.1)"><svg
|
||
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
|
||
stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
|
||
<path stroke-linecap="round" stroke-linejoin="round"
|
||
d="M4.5 15.75l7.5-7.5 7.5 7.5" />
|
||
</svg></button>
|
||
<button @click="adjustTime(index, 'start_time', -0.1)"><svg
|
||
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
|
||
stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
|
||
<path stroke-linecap="round" stroke-linejoin="round"
|
||
d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||
</svg></button>
|
||
</div>
|
||
</div>
|
||
</td>
|
||
<td>
|
||
<div class="custom-number-input">
|
||
<input type="number" step="0.01" v-model.number="segment.end_time"
|
||
class="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md shadow-sm focus:outline-none focus:ring-[var(--border-focus)] focus:border-[var(--border-focus)] sm:text-sm bg-[var(--bg-input)] text-[var(--text-primary)]" />
|
||
<div class="number-spinners">
|
||
<button @click="adjustTime(index, 'end_time', 0.1)"><svg
|
||
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
|
||
stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
|
||
<path stroke-linecap="round" stroke-linejoin="round"
|
||
d="M4.5 15.75l7.5-7.5 7.5 7.5" />
|
||
</svg></button>
|
||
<button @click="adjustTime(index, 'end_time', -0.1)"><svg
|
||
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
|
||
stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
|
||
<path stroke-linecap="round" stroke-linejoin="round"
|
||
d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||
</svg></button>
|
||
</div>
|
||
</div>
|
||
</td>
|
||
<td>
|
||
<textarea v-model="segment.sentence" rows="1"
|
||
class="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md shadow-sm focus:outline-none focus:ring-[var(--border-focus)] focus:border-[var(--border-focus)] sm:text-sm bg-[var(--bg-input)] text-[var(--text-primary)] resize-y"></textarea>
|
||
</td>
|
||
<td class="text-center">
|
||
<button @click="removeSegment(index)" class="text-red-500 hover:text-red-700">
|
||
<i class="fas fa-trash"></i>
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
<div class="mt-4">
|
||
<button @click="addSegment"
|
||
class="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 flex items-center">
|
||
<i class="fas fa-plus mr-2"></i> 添加片段
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div
|
||
class="bg-[var(--bg-tertiary)] px-6 py-4 flex justify-end space-x-3 border-t border-[var(--border-primary)] flex-shrink-0 rounded-b-xl">
|
||
<button @click="closeAsrEditorModal"
|
||
class="px-4 py-2 bg-[var(--bg-secondary)] text-[var(--text-secondary)] rounded-lg border border-[var(--border-secondary)] hover:bg-[var(--bg-tertiary)]"
|
||
v-text="t('common.cancel')"></button>
|
||
<button @click="saveAsrTranscription"
|
||
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||
v-text="t('buttons.saveChanges')"></button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ==================== 功能块:ASR 分段转写编辑弹窗 End ==================== -->
|
||
|
||
<!-- ==================== 功能块:说话人识别弹窗 Start ==================== -->
|
||
<!-- Speaker Identification Modal -->
|
||
<div v-if="showSpeakerModal" @click="closeSpeakerModal"
|
||
class="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center z-50 p-4 backdrop-blur-sm">
|
||
<div @click.stop @click="closeSpeakerSuggestionsOnClick"
|
||
class="bg-[var(--bg-secondary)] rounded-xl shadow-2xl w-full max-w-4xl flex flex-col h-[85vh]">
|
||
<div class="p-5 border-b border-[var(--border-primary)] flex-shrink-0">
|
||
<h3 class="text-xl font-bold text-[var(--text-primary)]" v-text="t('modal.identifySpeakers')"></h3>
|
||
<!-- Warning for too many speakers -->
|
||
<div v-if="modalSpeakers.length > 8"
|
||
class="mt-3 p-3 bg-[var(--bg-warn-light)] border border-amber-300 dark:border-amber-800 rounded-lg">
|
||
<div class="flex items-start">
|
||
<i class="fas fa-exclamation-triangle text-[var(--text-warn-strong)] mt-0.5 mr-2"></i>
|
||
<div class="text-sm">
|
||
<p class="font-medium text-[var(--text-warn-strong)]"
|
||
v-text="t('help.moreSpeakersThanColors')"></p>
|
||
<p class="text-[var(--text-warn-strong)] mt-1"
|
||
v-text="t('help.youHaveXSpeakers', { count: modalSpeakers.length })">
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="flex-grow flex overflow-hidden modal-content">
|
||
<div
|
||
class="w-1/3 p-6 space-y-4 overflow-y-auto custom-scrollbar border-r border-[var(--border-primary)]">
|
||
<div v-for="(speaker, index) in modalSpeakers"
|
||
:key="`${selectedRecording.id}-speaker-${index}-${speaker}`" class="space-y-3">
|
||
<!-- Speaker label with color indicator and "This is Me" checkbox on same line -->
|
||
<div class="flex items-center justify-between">
|
||
<div class="flex items-center space-x-2">
|
||
<div :class="speakerMap[speaker].color"
|
||
class="w-4 h-4 rounded-full border border-white/30 shadow-sm flex-shrink-0">
|
||
</div>
|
||
<span class="font-mono text-sm text-[var(--text-muted)]">${
|
||
speakerDisplayMap[speaker] || speaker }</span>
|
||
<span v-if="index >= 8"
|
||
class="text-xs text-[var(--text-warn-strong)] bg-[var(--bg-warn-light)] px-2 py-0.5 rounded-full"
|
||
title="Color repeats from speaker ${ ((index % 8) + 1) }">
|
||
Repeat
|
||
</span>
|
||
</div>
|
||
<label
|
||
class="flex items-center text-sm text-[var(--text-muted)] cursor-pointer hover:text-[var(--text-primary)] transition-colors">
|
||
<input type="checkbox" v-model="speakerMap[speaker].isMe"
|
||
@focus="highlightSpeakerInTranscript(speaker)" @blur="clearSpeakerHighlight"
|
||
class="speaker-checkbox">
|
||
<span class="ml-2 select-none" v-text="t('help.me')"></span>
|
||
</label>
|
||
</div>
|
||
|
||
<!-- Autocomplete input field - disabled when "This is Me" is checked -->
|
||
<div class="relative">
|
||
<input type="text" v-model="speakerMap[speaker].name"
|
||
@input="searchSpeakers($event.target.value, speaker)" @focus="focusSpeaker(speaker)"
|
||
@blur="blurSpeaker()" :disabled="speakerMap[speaker].isMe"
|
||
class="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md shadow-sm focus:outline-none focus:ring-[var(--border-focus)] focus:border-[var(--border-focus)] sm:text-sm bg-[var(--bg-input)] text-[var(--text-primary)] disabled:bg-[var(--bg-tertiary)] disabled:text-[var(--text-muted)] disabled:cursor-not-allowed"
|
||
:placeholder="speakerMap[speaker].isMe ? t('help.me') : t('help.enterNameFor') + ' ' + (speakerDisplayMap[speaker] || speaker)"
|
||
autocomplete="off">
|
||
|
||
<!-- Loading indicator -->
|
||
<div v-if="loadingSuggestions[speaker] && !speakerMap[speaker].isMe"
|
||
class="absolute right-3 top-1/2 transform -translate-y-1/2">
|
||
<i class="fas fa-spinner fa-spin text-[var(--text-muted)] text-sm"></i>
|
||
</div>
|
||
|
||
<!-- Suggestions dropdown - Only show when this input is active -->
|
||
<div v-if="activeSpeakerInput === speaker && speakerSuggestions[speaker] && speakerSuggestions[speaker].length > 0 && !speakerMap[speaker].isMe"
|
||
@click.stop
|
||
class="absolute z-10 w-full mt-1 bg-[var(--bg-secondary)] border border-[var(--border-primary)] rounded-md shadow-lg max-h-48 overflow-y-auto">
|
||
<div class="py-1">
|
||
<div v-for="suggestion in speakerSuggestions[speaker]" :key="suggestion.id"
|
||
@click="selectSpeakerSuggestion(speaker, suggestion)"
|
||
class="px-3 py-2 cursor-pointer hover:bg-[var(--bg-tertiary)] flex items-center justify-between">
|
||
<div class="flex-grow">
|
||
<div class="text-sm font-medium text-[var(--text-primary)]">${
|
||
suggestion.name }</div>
|
||
<div class="text-xs text-[var(--text-muted)]">
|
||
Used ${ suggestion.use_count } time${ suggestion.use_count !== 1 ?
|
||
's' : '' }
|
||
<span v-if="suggestion.last_used">
|
||
• Last: ${ new Date(suggestion.last_used).toLocaleDateString() }
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<i class="fas fa-user text-[var(--text-muted)] ml-2"></i>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="w-2/3 p-6 flex flex-col overflow-hidden">
|
||
<!-- Audio Player Section -->
|
||
<div class="mb-4 flex-shrink-0">
|
||
<h4 class="font-semibold text-[var(--text-secondary)] mb-2" v-text="t('help.audioPlayer')">
|
||
</h4>
|
||
<div class="audio-player-container">
|
||
<audio controls class="w-full speaker-modal-transcript" :key="selectedRecording.id"
|
||
:src="'/tool/speakr/audio/' + selectedRecording.id" :volume="playerVolume"
|
||
@volumechange="onPlayerVolumeChange">
|
||
Your browser does not support the audio element.
|
||
</audio>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Transcript Section -->
|
||
<div class="flex-grow overflow-y-auto custom-scrollbar speaker-modal-transcript"
|
||
@click="seekAudioFromEvent">
|
||
<div v-if="processedTranscription.isJson">
|
||
<div v-for="(segment, index) in processedTranscription.simpleSegments" :key="index"
|
||
class="speaker-segment mb-2" :data-start-time="segment.startTime">
|
||
<p>
|
||
<span
|
||
:class="[segment.color, 'speaker-tag', { 'speaker-highlight': highlightedSpeaker === segment.speakerId }]"
|
||
:data-speaker-id="segment.speakerId">[${ segment.speaker }]</span>:
|
||
<span
|
||
class="word cursor-pointer hover:bg-[var(--bg-accent)] hover:text-[var(--text-accent)] rounded px-0.5">${
|
||
segment.sentence }</span>
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<div v-else v-html="highlightedTranscript"></div>
|
||
</div>
|
||
|
||
<!-- Speaker Navigation Controls (at bottom) - Always visible for consistent height -->
|
||
<div class="mt-3 p-3 bg-[var(--bg-tertiary)] rounded-lg flex items-center justify-between flex-shrink-0"
|
||
:class="{'opacity-50': !highlightedSpeaker || speakerGroups.length <= 1}">
|
||
<div class="flex items-center space-x-2">
|
||
<span class="text-sm text-[var(--text-muted)]">
|
||
Navigate <span class="font-semibold">${ speakerDisplayMap[highlightedSpeaker] ||
|
||
highlightedSpeaker }</span> groups:
|
||
</span>
|
||
<span class="text-xs text-[var(--text-muted)]">
|
||
(${ currentSpeakerGroupIndex + 1 } of ${ speakerGroups.length })
|
||
</span>
|
||
</div>
|
||
<div class="flex items-center space-x-2">
|
||
<button @mousedown.prevent @click="navigateToPrevSpeakerGroup"
|
||
class="px-3 py-1.5 bg-[var(--bg-secondary)] hover:bg-[var(--bg-primary)] text-[var(--text-secondary)] rounded-md border border-[var(--border-secondary)] transition-all flex items-center text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||
:disabled="!highlightedSpeaker || speakerGroups.length <= 1">
|
||
<i class="fas fa-chevron-up mr-1.5"></i>
|
||
Previous
|
||
</button>
|
||
<button @mousedown.prevent @click="navigateToNextSpeakerGroup"
|
||
class="px-3 py-1.5 bg-[var(--bg-secondary)] hover:bg-[var(--bg-primary)] text-[var(--text-secondary)] rounded-md border border-[var(--border-secondary)] transition-all flex items-center text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||
:disabled="!highlightedSpeaker || speakerGroups.length <= 1">
|
||
Next
|
||
<i class="fas fa-chevron-down ml-1.5"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="p-4 bg-[var(--bg-tertiary)] border-t border-[var(--border-primary)] flex-shrink-0">
|
||
<label
|
||
class="flex items-center text-sm text-[var(--text-muted)] cursor-pointer hover:text-[var(--text-primary)] transition-colors">
|
||
<input type="checkbox" v-model="regenerateSummaryAfterSpeakerUpdate" class="speaker-checkbox">
|
||
<span class="ml-2 select-none" v-text="t('help.regenerateSummaryAfterNames')"></span>
|
||
</label>
|
||
</div>
|
||
<div
|
||
class="bg-[var(--bg-tertiary)] px-6 py-4 flex justify-between items-center border-t border-[var(--border-primary)] flex-shrink-0 rounded-b-xl">
|
||
<div>
|
||
<button @click="autoIdentifySpeakers"
|
||
class="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 flex items-center"
|
||
:disabled="isAutoIdentifying">
|
||
<i class="fas fa-magic mr-2"></i>
|
||
<span v-if="!isAutoIdentifying" v-text="t('help.autoIdentify')"></span>
|
||
<span v-else>
|
||
<i class="fas fa-spinner fa-spin mr-2"></i>
|
||
Identifying...
|
||
</span>
|
||
</button>
|
||
</div>
|
||
<div class="flex space-x-3">
|
||
<button @click="closeSpeakerModal"
|
||
class="px-4 py-2 bg-[var(--bg-secondary)] text-[var(--text-secondary)] rounded-lg border border-[var(--border-secondary)] hover:bg-[var(--bg-tertiary)]"
|
||
v-text="t('common.cancel')"></button>
|
||
<button @click="saveSpeakerNames" :disabled="!hasSpeakerNames" :class="hasSpeakerNames
|
||
? 'px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 cursor-pointer'
|
||
: 'px-4 py-2 bg-gray-400 text-gray-200 rounded-lg cursor-not-allowed'">
|
||
保存名称
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ==================== 功能块:说话人识别弹窗 End ==================== -->
|
||
|
||
<!-- ==================== 功能块:全局错误提示区 Start ==================== -->
|
||
<!-- Global Error Display -->
|
||
<div v-if="globalError"
|
||
class="fixed top-4 right-4 bg-[var(--bg-danger)] text-white p-4 rounded-lg shadow-lg z-50 max-w-md">
|
||
<div class="flex items-start gap-3">
|
||
<i class="fas fa-exclamation-circle text-lg flex-shrink-0 mt-0.5"></i>
|
||
<div class="flex-1">
|
||
<p class="font-medium" v-text="t('common.error')"></p>
|
||
<p class="text-sm opacity-90">${globalError}</p>
|
||
</div>
|
||
<button @click="globalError = null" class="text-white hover:text-gray-200 transition-colors">
|
||
<i class="fas fa-times"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ==================== 功能块:全局错误提示区 End ==================== -->
|
||
|
||
<!-- ==================== 功能块:系统音频录制帮助弹窗 Start ==================== -->
|
||
<!-- System Audio Help Modal -->
|
||
<div v-if="showSystemAudioHelp"
|
||
class="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center z-50 backdrop-blur-sm">
|
||
<div
|
||
class="bg-[var(--bg-secondary)] w-full h-full md:h-auto md:max-h-[90vh] md:w-full md:max-w-2xl md:rounded-lg md:shadow-xl flex flex-col">
|
||
<div class="p-4 border-b border-[var(--border-primary)] flex-shrink-0">
|
||
<div class="flex items-center justify-between">
|
||
<h3 class="text-lg font-semibold flex items-center">
|
||
<i class="fas fa-desktop mr-2 text-blue-500"></i>
|
||
System Audio Recording
|
||
</h3>
|
||
<button @click="showSystemAudioHelp = false"
|
||
class="text-[var(--text-muted)] hover:text-[var(--text-primary)]">
|
||
<i class="fas fa-times"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="p-6 space-y-6 overflow-y-auto flex-grow">
|
||
<div class="bg-[var(--bg-tertiary)] p-4 rounded-lg border border-[var(--border-primary)]">
|
||
<h4 class="font-medium text-[var(--text-primary)] mb-2"
|
||
v-text="t('help.howToRecordSystemAudio')"></h4>
|
||
<p class="text-sm text-[var(--text-muted)]" v-text="t('help.toCaptureAudioFromMeetings')">
|
||
</p>
|
||
<ol class="list-decimal list-inside space-y-2 text-sm text-[var(--text-secondary)] mt-3">
|
||
<li v-text="t('help.recordSystemSteps1')"></li>
|
||
<li><span v-text="t('help.recordSystemSteps2')"></span> <strong>"${ t('help.entireScreen')
|
||
}"</strong> or a <strong v-text="t('help.specificBrowserTab')"></strong>.</li>
|
||
<li><span v-text="t('help.recordSystemSteps3')"></span> <strong>"${ t('help.shareTabAudio')
|
||
}"</strong> or <strong>"${ t('help.shareSystemAudio') }"</strong>.</li>
|
||
</ol>
|
||
<p class="text-xs text-amber-600 dark:text-amber-400 mt-3"><i
|
||
class="fas fa-exclamation-triangle mr-1"></i><span
|
||
v-text="t('help.sharingWindowNoAudio')"></span></p>
|
||
</div>
|
||
|
||
<div class="space-y-4">
|
||
<h4 class="font-medium text-[var(--text-primary)]" v-text="t('help.troubleshooting')"></h4>
|
||
|
||
<div class="bg-[var(--bg-tertiary)] p-4 rounded-lg border border-[var(--border-primary)]">
|
||
<h5 class="font-medium text-[var(--text-primary)] mb-2" v-text="t('help.whyNotWorking')">
|
||
</h5>
|
||
<p class="text-sm text-[var(--text-muted)]">
|
||
System audio recording requires a secure (HTTPS) connection. If you are developing
|
||
locally, you may need to enable a browser flag.
|
||
</p>
|
||
</div>
|
||
|
||
<!-- Chrome Solution -->
|
||
<div v-if="browser === 'chrome'"
|
||
class="bg-[var(--bg-accent)] p-4 rounded-lg border border-[var(--border-accent)]">
|
||
<h5 class="font-medium text-[var(--text-accent)] mb-3 flex items-center">
|
||
<i class="fab fa-chrome mr-2"></i>
|
||
For Chrome on Localhost
|
||
</h5>
|
||
<ol class="list-decimal list-inside space-y-2 text-sm text-[var(--text-secondary)]">
|
||
<li>Go to: <code
|
||
class="bg-[var(--bg-secondary)] px-2 py-1 rounded text-xs ml-1">chrome://flags/#unsafely-treat-insecure-origin-as-secure</code>
|
||
</li>
|
||
<li>Add your local URL (e.g., <code
|
||
class="bg-[var(--bg-secondary)] px-2 py-1 rounded text-xs ml-1">http://localhost:8899</code>)
|
||
</li>
|
||
<li>Set to <strong>"Enabled"</strong> and relaunch Chrome.</li>
|
||
</ol>
|
||
</div>
|
||
|
||
<!-- Firefox Solution -->
|
||
<div v-else-if="browser === 'firefox'"
|
||
class="bg-[var(--bg-accent)] p-4 rounded-lg border border-[var(--border-accent)]">
|
||
<h5 class="font-medium text-[var(--text-accent)] mb-3 flex items-center">
|
||
<i class="fab fa-firefox-browser mr-2"></i>
|
||
For Firefox on Localhost
|
||
</h5>
|
||
<p class="text-sm text-[var(--text-secondary)]">
|
||
System audio recording is not well-supported on Firefox, even with these changes. For
|
||
best results, please use Chrome.
|
||
</p>
|
||
<ol class="list-decimal list-inside space-y-2 text-sm text-[var(--text-secondary)] mt-2">
|
||
<li>Go to: <code
|
||
class="bg-[var(--bg-secondary)] px-2 py-1 rounded text-xs ml-1">about:config</code>
|
||
</li>
|
||
<li>Search for <code
|
||
class="bg-[var(--bg-secondary)] px-2 py-1 rounded text-xs ml-1">media.devices.insecure.enabled</code>
|
||
and set it to `true`.</li>
|
||
<li>Restart Firefox.</li>
|
||
</ol>
|
||
</div>
|
||
|
||
<!-- Other Browsers -->
|
||
<div v-else
|
||
class="bg-[var(--bg-tertiary)] p-4 rounded-lg border border-[var(--border-primary)]">
|
||
<h5 class="font-medium text-[var(--text-primary)] mb-2"
|
||
v-text="t('help.unsupportedBrowser')"></h5>
|
||
<p class="text-sm text-[var(--text-muted)]">
|
||
For the best experience with system audio recording, we recommend using Google Chrome.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="p-4 border-t border-[var(--border-primary)] flex justify-end flex-shrink-0">
|
||
<button @click="showSystemAudioHelp = false"
|
||
class="px-4 py-2 bg-[var(--bg-button)] text-[var(--text-button)] rounded-lg hover:bg-[var(--bg-button-hover)] transition-colors">
|
||
Got it
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ==================== 功能块:系统音频录制帮助弹窗 End ==================== -->
|
||
|
||
<!-- ==================== 功能块:录音免责声明弹窗 Start ==================== -->
|
||
<!-- Recording Disclaimer Modal -->
|
||
<div v-if="showRecordingDisclaimerModal"
|
||
class="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center z-50 p-4 backdrop-blur-sm">
|
||
<div
|
||
class="bg-[var(--bg-secondary)] rounded-lg shadow-xl w-full max-w-2xl max-h-[80vh] flex flex-col overflow-hidden">
|
||
<div class="flex-shrink-0 p-6 border-b border-[var(--border-primary)]">
|
||
<div class="flex items-center gap-3">
|
||
<i class="fas fa-info-circle text-2xl text-[var(--text-accent)]"></i>
|
||
<h3 class="text-xl font-semibold text-[var(--text-primary)]"
|
||
v-text="t('modal.recordingNotice')"></h3>
|
||
</div>
|
||
</div>
|
||
<div class="flex-1 overflow-y-auto p-6">
|
||
<!-- Render markdown content -->
|
||
<div class="prose prose-sm max-w-none text-[var(--text-secondary)]"
|
||
style="color: var(--text-secondary); line-height: 1.6;">
|
||
<div v-html="recordingDisclaimer"></div>
|
||
</div>
|
||
</div>
|
||
<div
|
||
class="flex-shrink-0 p-6 border-t border-[var(--border-primary)] bg-[var(--bg-tertiary)] rounded-b-lg">
|
||
<div class="flex justify-end gap-3">
|
||
<button @click="cancelRecordingDisclaimer"
|
||
class="px-6 py-2 text-[var(--text-secondary)] hover:bg-[var(--bg-secondary)] rounded-lg transition-colors border border-[var(--border-secondary)]">
|
||
取消
|
||
</button>
|
||
<button @click="acceptRecordingDisclaimer"
|
||
class="px-6 py-2 bg-[var(--bg-accent)] text-white rounded-lg hover:bg-[var(--bg-accent-hover)] transition-colors">
|
||
<i class="fas fa-microphone mr-2"></i>
|
||
Start Recording
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ==================== 功能块:录音免责声明弹窗 End ==================== -->
|
||
|
||
<!-- ==================== 功能块:页面 Toast 提示容器 Start ==================== -->
|
||
<!-- Toast Container -->
|
||
<div id="toastContainer" class="fixed bottom-4 right-4 z-50 space-y-2"></div>
|
||
|
||
<!-- ==================== 功能块:页面 Toast 提示容器 End ==================== -->
|
||
|
||
<!-- ==================== 功能块:录音分享弹窗 Start ==================== -->
|
||
<!-- Share Modal -->
|
||
<div v-if="showShareModal"
|
||
class="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center z-50 p-4 backdrop-blur-sm">
|
||
<div class="bg-[var(--bg-secondary)] rounded-lg shadow-xl w-full max-w-lg">
|
||
<div class="p-6 border-b border-[var(--border-primary)]">
|
||
<h3 class="text-lg font-semibold" v-text="t('modal.shareRecording')"></h3>
|
||
</div>
|
||
<div class="p-6 space-y-4">
|
||
<p class="text-sm text-[var(--text-muted)]" v-text="t('help.createPublicLink')"></p>
|
||
<div v-if="!generatedShareLink">
|
||
<div class="flex items-center mb-2">
|
||
<input type="checkbox" v-model="shareOptions.share_summary" id="share_summary"
|
||
class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500">
|
||
<label for="share_summary" class="ml-2 block text-sm text-[var(--text-secondary)]"
|
||
v-text="t('form.shareSummary')"></label>
|
||
</div>
|
||
<div class="flex items-center">
|
||
<input type="checkbox" v-model="shareOptions.share_notes" id="share_notes"
|
||
class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500">
|
||
<label for="share_notes" class="ml-2 block text-sm text-[var(--text-secondary)]"
|
||
v-text="t('form.shareNotes')"></label>
|
||
</div>
|
||
</div>
|
||
<div v-if="generatedShareLink" class="mt-4">
|
||
<div v-if="existingShareDetected"
|
||
class="mb-3 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
|
||
<div class="flex items-start gap-2">
|
||
<i class="fas fa-info-circle text-blue-600 dark:text-blue-400 mt-0.5"></i>
|
||
<div class="text-sm text-blue-800 dark:text-blue-300">
|
||
<p class="font-medium">Using existing share link</p>
|
||
<p class="mt-1 text-xs opacity-90">This recording was already shared. The same link
|
||
has been retrieved.</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<label class="block text-sm font-medium mb-2" v-text="t('form.shareableLink')"></label>
|
||
<div class="flex items-center gap-2">
|
||
<input :value="generatedShareLink" readonly
|
||
class="w-full px-3 py-2 bg-[var(--bg-input)] border border-[var(--border-secondary)] rounded-lg">
|
||
<button @click="copyShareLink"
|
||
class="px-3 py-2 bg-[var(--bg-button)] text-[var(--text-button)] rounded-lg hover:bg-[var(--bg-button-hover)]">
|
||
<i class="fas fa-copy"></i>
|
||
</button>
|
||
</div>
|
||
<div v-if="existingShareDetected" class="mt-3">
|
||
<button @click="createShare(true)"
|
||
class="text-sm text-[var(--text-accent)] hover:underline">
|
||
<i class="fas fa-plus-circle mr-1"></i>Create a new share link instead
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="p-6 border-t border-[var(--border-primary)] flex justify-end gap-3">
|
||
<button @click="closeShareModal"
|
||
class="px-4 py-2 text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)] rounded-lg transition-colors">
|
||
取消
|
||
</button>
|
||
<button @click="createShare()" v-if="!generatedShareLink"
|
||
class="px-4 py-2 bg-[var(--bg-button)] text-[var(--text-button)] rounded-lg hover:bg-[var(--bg-button-hover)] transition-colors">
|
||
创建链接
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ==================== 功能块:录音分享弹窗 End ==================== -->
|
||
|
||
<!-- ==================== 功能块:共享链接列表弹窗 Start ==================== -->
|
||
<!-- Shares List Modal -->
|
||
<div v-if="showSharesListModal"
|
||
class="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center z-50 p-4 backdrop-blur-sm">
|
||
<div class="bg-[var(--bg-secondary)] rounded-lg shadow-xl w-full max-w-2xl max-h-[80vh] flex flex-col">
|
||
<div class="p-6 border-b border-[var(--border-primary)] flex justify-between items-center">
|
||
<h3 class="text-lg font-semibold" v-text="t('modal.sharedTranscripts')"></h3>
|
||
<button @click="closeSharesList"
|
||
class="p-2 rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors">
|
||
<i class="fas fa-times"></i>
|
||
</button>
|
||
</div>
|
||
<div class="p-6 space-y-4 overflow-y-auto">
|
||
<div v-if="isLoadingShares" class="text-center">
|
||
<i class="fas fa-spinner fa-spin text-2xl text-[var(--text-muted)]"></i>
|
||
</div>
|
||
<div v-else-if="userShares.length === 0" class="text-center text-[var(--text-muted)]">
|
||
<span v-text="t('sharedTranscripts.noSharedTranscripts')"></span>
|
||
</div>
|
||
<div v-else class="space-y-3">
|
||
<div v-for="share in userShares" :key="share.id"
|
||
class="bg-[var(--bg-tertiary)] p-4 rounded-lg border border-[var(--border-primary)]">
|
||
<div class="flex justify-between items-start">
|
||
<div>
|
||
<p class="font-semibold">${share.recording_title}</p>
|
||
<p class="text-sm text-[var(--text-muted)]">${ t('help.sharedOn') }:
|
||
${share.created_at}</p>
|
||
</div>
|
||
<button @click="confirmDeleteShare(share)"
|
||
class="text-red-500 hover:text-red-700 p-1"><i class="fas fa-trash"></i></button>
|
||
</div>
|
||
<div class="mt-4 flex items-center gap-4">
|
||
<label class="flex items-center text-sm">
|
||
<input type="checkbox" v-model="share.share_summary" @change="updateShare(share)"
|
||
class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500">
|
||
<span class="ml-2" v-text="t('form.shareSummary')"></span>
|
||
</label>
|
||
<label class="flex items-center text-sm">
|
||
<input type="checkbox" v-model="share.share_notes" @change="updateShare(share)"
|
||
class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500">
|
||
<span class="ml-2" v-text="t('form.shareNotes')"></span>
|
||
</label>
|
||
</div>
|
||
<div class="mt-4">
|
||
<input :value="'{{ request.url_root }}share/' + share.public_id" readonly
|
||
class="w-full px-3 py-2 bg-[var(--bg-input)] border border-[var(--border-secondary)] rounded-lg text-sm">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ==================== 功能块:共享链接列表弹窗 End ==================== -->
|
||
|
||
<!-- ==================== 功能块:共享链接删除确认弹窗 Start ==================== -->
|
||
<!-- Share Delete Confirmation Modal -->
|
||
<div v-if="showShareDeleteModal"
|
||
class="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center z-50 p-4 backdrop-blur-sm">
|
||
<div
|
||
class="bg-[var(--bg-secondary)] rounded-xl shadow-2xl w-full max-w-md transform transition-all duration-300 ease-in-out">
|
||
<!-- Header with gradient background -->
|
||
<div class="bg-gradient-to-r from-red-500 to-red-600 p-5 rounded-t-xl">
|
||
<div class="flex items-center">
|
||
<div
|
||
class="w-12 h-12 bg-white/20 backdrop-blur rounded-full flex items-center justify-center mr-4">
|
||
<i class="fas fa-trash-alt text-white text-lg"></i>
|
||
</div>
|
||
<div>
|
||
<h3 class="text-lg font-bold text-white">Delete Shared Link</h3>
|
||
<p class="text-red-100 text-sm mt-1">This action cannot be undone</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Content -->
|
||
<div class="p-6">
|
||
<p class="text-[var(--text-secondary)] mb-4">
|
||
Are you sure you want to delete the shared link for:
|
||
</p>
|
||
<div v-if="shareToDelete"
|
||
class="bg-[var(--bg-tertiary)] p-3 rounded-lg border border-[var(--border-primary)]">
|
||
<p class="font-medium text-[var(--text-primary)]">${shareToDelete.recording_title}</p>
|
||
<p class="text-sm text-[var(--text-muted)] mt-1">Shared on: ${shareToDelete.created_at}</p>
|
||
</div>
|
||
<div
|
||
class="mt-4 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||
<div class="flex items-start">
|
||
<i class="fas fa-exclamation-triangle text-red-600 dark:text-red-400 mt-0.5 mr-2"></i>
|
||
<p class="text-sm text-red-800 dark:text-red-300">
|
||
The share link will be permanently deleted and anyone with this link will no longer be
|
||
able to access the recording.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Actions -->
|
||
<div class="p-6 pt-0 flex justify-end gap-3">
|
||
<button @click="cancelDeleteShare"
|
||
class="px-4 py-2 text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)] rounded-lg transition-colors">
|
||
取消
|
||
</button>
|
||
<button @click="deleteShare"
|
||
class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors flex items-center gap-2">
|
||
<i class="fas fa-trash"></i>
|
||
Delete Share Link
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- ==================== 功能块:共享链接删除确认弹窗 End ==================== -->
|
||
<!-- ==================== 功能块:共享弹窗区 End ==================== -->
|
||
</div>
|
||
<!-- ==================== 功能块:Vue 应用根容器 End ==================== -->
|
||
|
||
<!-- ==================== 功能块:页面底部脚本区 Start ==================== -->
|
||
<!-- CSRF Token Management -->
|
||
<script src="/tool/speakr/static/js/csrf-refresh.js"></script>
|
||
<script src="/tool/speakr/static/js/i18n.js"></script>
|
||
<!-- ==================== 功能块:国际化初始化脚本 Start ==================== -->
|
||
<!-- i18n System -->
|
||
<!-- Initialize i18n immediately with fallback -->
|
||
<script>
|
||
//------------------- i18n 预初始化 start------------------
|
||
// Ensure i18n is initialized before Vue app
|
||
(async function () {
|
||
if (window.i18n && window.i18n.init) {
|
||
const userLang = localStorage.getItem('preferredLanguage') || '{{ user_language }}' || 'en';
|
||
try {
|
||
await window.i18n.init(userLang);
|
||
console.log('i18n pre-initialized with language:', userLang);
|
||
} catch (error) {
|
||
console.error('Failed to initialize i18n:', error);
|
||
}
|
||
}
|
||
|
||
// Hide loader after i18n is ready
|
||
const loader = document.getElementById('loader');
|
||
if (loader) {
|
||
loader.style.display = 'none';
|
||
}
|
||
})();
|
||
//--------------------i18n 预初始化 end------------------
|
||
</script>
|
||
|
||
<!-- ==================== 功能块:国际化初始化脚本 End ==================== -->
|
||
|
||
<!-- ==================== 功能块:Vue 应用脚本加载 Start ==================== -->
|
||
<!-- Vue.js Application Script -->
|
||
<script src="/tool/speakr/static/js/wsconnecter.js"></script>
|
||
|
||
<script src="/tool/speakr/static/js/app.js"></script>
|
||
<!-- ==================== 功能块:Vue 应用脚本加载 End ==================== -->
|
||
<!-- ==================== 功能块:页面底部脚本区 End ==================== -->
|
||
<!-- ==================== 功能块:页面主体 End ==================== -->
|
||
</body>
|
||
<!-- ==================== 功能块:页面局部样式区 Start ==================== -->
|
||
<style>
|
||
/* ==================== 功能块:录音页左侧实时录音面板样式 Start ==================== */
|
||
.websockt_box {
|
||
width: 50%;
|
||
border-radius: 10px;
|
||
overflow: hidden;
|
||
/* height: 500px; */
|
||
position: relative;
|
||
}
|
||
|
||
/* ==================== 功能块:录音页左侧实时录音面板样式 End ==================== */
|
||
|
||
/* ==================== 功能块:录音页双栏容器样式 Start ==================== */
|
||
.index_m_cot {
|
||
width: 100%;
|
||
display: flex;
|
||
gap: 20px;
|
||
height: 100%;
|
||
padding: 20px;
|
||
border-radius: 10px;
|
||
overflow: hidden;
|
||
|
||
}
|
||
/* ==================== 功能块:录音页双栏容器样式 End ==================== */
|
||
|
||
/* ==================== 功能块:富文本与代码块显示样式 Start ==================== */
|
||
.prose {
|
||
white-space: pre-wrap; /* 保留换行并自动换行 */
|
||
word-wrap: break-word; /* 超长单词自动换行 */
|
||
overflow-wrap: break-word; /* 兼容老浏览器 */
|
||
color: #fff; /* 根据你背景色调整 */
|
||
}
|
||
|
||
/* 代码块样式可选 */
|
||
.prose pre {
|
||
width: 100%;
|
||
background: rgba(255, 255, 255, 0.1);
|
||
padding: 8px;
|
||
border-radius: 6px;
|
||
overflow-y: auto;
|
||
}
|
||
.prose pre {
|
||
white-space: pre-wrap !important; /* 自动换行 */
|
||
word-break: break-word !important; /* 长字符串也换行 */
|
||
overflow-wrap: break-word !important;
|
||
}
|
||
|
||
.prose code {
|
||
white-space: pre-wrap !important;
|
||
word-break: break-word !important;
|
||
}
|
||
|
||
/* ==================== 功能块:富文本与代码块显示样式 End ==================== */
|
||
</style>
|
||
<!-- ==================== 功能块:页面局部样式区 End ==================== -->
|
||
|
||
</html>
|