4762 lines
245 KiB
HTML
4762 lines
245 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||
<title>{{ title }} - {{ html_title }}</title>
|
||
<link rel="icon" href="/tool/speakr/static/img/favicon.ico" type="image/svg+xml">
|
||
<!-- All dependencies bundled locally for offline support -->
|
||
<script src="/tool/speakr/static/vendor/js/tailwind.min.js"></script>
|
||
<!-- All dependencies bundled locally for offline support -->
|
||
<link rel="stylesheet" href="/tool/speakr/static/vendor/css/fontawesome.min.css">
|
||
<link rel="stylesheet" href="/tool/speakr/static/css/styles.css">
|
||
<script src="/tool/speakr/static/js/i18n.js"></script>
|
||
|
||
<!-- Loading overlay to prevent FOUC -->
|
||
{% include 'includes/loading_overlay.html' %}
|
||
<script src="/tool/speakr/static/js/loading.js"></script>
|
||
|
||
<script>
|
||
// Initialize i18n
|
||
let t = (key) => key; // Default fallback
|
||
|
||
async function initializeI18n(language) {
|
||
const userLanguage = language || "{{ user_language }}";
|
||
|
||
// Initialize the global i18n instance
|
||
if (window.i18n) {
|
||
await window.i18n.init(userLanguage);
|
||
|
||
// Create a shorthand translation function
|
||
t = (key, params) => window.i18n.t(key, params);
|
||
|
||
// Update all elements with data-i18n attribute
|
||
document.querySelectorAll('[data-i18n]').forEach(el => {
|
||
const key = el.getAttribute('data-i18n');
|
||
el.textContent = t(key);
|
||
});
|
||
|
||
// Update all elements with data-i18n-placeholder attribute
|
||
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
|
||
const key = el.getAttribute('data-i18n-placeholder');
|
||
el.placeholder = t(key);
|
||
});
|
||
}
|
||
}
|
||
|
||
// Function to update all UI elements after language change
|
||
function updateAllUIElements() {
|
||
// Update all elements with data-i18n attribute
|
||
document.querySelectorAll('[data-i18n]').forEach(el => {
|
||
const key = el.getAttribute('data-i18n');
|
||
el.textContent = t(key);
|
||
});
|
||
|
||
// Update all elements with data-i18n-placeholder attribute
|
||
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
|
||
const key = el.getAttribute('data-i18n-placeholder');
|
||
el.placeholder = t(key);
|
||
});
|
||
|
||
// Update language options in dropdown to show native names
|
||
const langSelect = document.getElementById('ui_language');
|
||
if (langSelect) {
|
||
const langOptions = {
|
||
'en': 'English',
|
||
'es': 'Español (Spanish)',
|
||
'fr': 'Français (French)',
|
||
'zh': '中文 (Chinese)',
|
||
'de': 'Deutsch (German)'
|
||
};
|
||
|
||
Array.from(langSelect.options).forEach(option => {
|
||
if (langOptions[option.value]) {
|
||
option.textContent = langOptions[option.value];
|
||
}
|
||
});
|
||
}
|
||
|
||
// Force update any Vue or dynamic content by dispatching a custom event
|
||
window.dispatchEvent(new CustomEvent('language-changed', { detail: { language: window.i18n.currentLanguage } }));
|
||
}
|
||
|
||
// Function to change language immediately
|
||
async function changeLanguageImmediately(newLanguage) {
|
||
try {
|
||
// Show loading indicator
|
||
const uiLanguageSelect = document.getElementById('ui_language');
|
||
uiLanguageSelect.disabled = true;
|
||
|
||
// Add a loading message
|
||
const originalText = uiLanguageSelect.parentElement.querySelector('p').textContent;
|
||
const loadingMsg = uiLanguageSelect.parentElement.querySelector('p');
|
||
if (loadingMsg) {
|
||
loadingMsg.textContent = 'Applying language change...';
|
||
}
|
||
|
||
// Get CSRF token
|
||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
|
||
|
||
// Create form data with just the language change
|
||
const formData = new FormData();
|
||
formData.append('csrf_token', csrfToken);
|
||
formData.append('user_name', document.getElementById('user_name').value || '');
|
||
formData.append('user_job_title', document.getElementById('user_job_title').value || '');
|
||
formData.append('user_company', document.getElementById('user_company').value || '');
|
||
formData.append('ui_language', newLanguage);
|
||
formData.append('transcription_language', document.getElementById('transcription_language').value || '');
|
||
formData.append('output_language', document.getElementById('output_language').value || '');
|
||
|
||
// Send AJAX request to update language preference
|
||
const response = await fetch('/tool/speakr/account', {
|
||
method: 'POST',
|
||
headers: {
|
||
'X-Requested-With': 'XMLHttpRequest',
|
||
'X-CSRFToken': csrfToken
|
||
},
|
||
body: formData
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||
}
|
||
|
||
// Store the new language in localStorage for other pages
|
||
localStorage.setItem('preferredLanguage', newLanguage);
|
||
localStorage.setItem('ui_language_changed', 'true');
|
||
|
||
// Store current tab to restore after reload
|
||
const activeTab = document.querySelector('.tab-link.border-\\[var\\(--border-accent\\)\\]');
|
||
if (activeTab) {
|
||
const tabName = activeTab.getAttribute('onclick')?.match(/show(\w+)Tab/)?.[1]?.toLowerCase();
|
||
if (tabName) {
|
||
localStorage.setItem('activeAccountTab', tabName);
|
||
}
|
||
}
|
||
|
||
// Reload the page to apply the new language completely
|
||
// This ensures all server-side rendered content is also updated
|
||
window.location.reload();
|
||
|
||
} catch (error) {
|
||
console.error('Error updating language:', error);
|
||
alert('Failed to update language. Please try again.');
|
||
// Revert the dropdown to previous value if update failed
|
||
document.getElementById('ui_language').value = currentUILanguage;
|
||
document.getElementById('ui_language').disabled = false;
|
||
|
||
// Restore original text if loading message was shown
|
||
const loadingMsg = document.getElementById('ui_language').parentElement.querySelector('p');
|
||
if (loadingMsg) {
|
||
loadingMsg.textContent = originalText;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Function to apply the theme based on localStorage
|
||
function applyTheme() {
|
||
// Guard against early execution
|
||
if (!document.documentElement) return;
|
||
|
||
// Apply dark mode
|
||
const savedMode = localStorage.getItem('darkMode');
|
||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||
if (savedMode === 'true' || (savedMode === null && prefersDark)) {
|
||
document.documentElement.classList.add('dark');
|
||
} else {
|
||
document.documentElement.classList.remove('dark');
|
||
}
|
||
|
||
// Apply color scheme
|
||
const savedScheme = localStorage.getItem('colorScheme') || 'blue';
|
||
const isDark = document.documentElement.classList.contains('dark');
|
||
const themePrefix = isDark ? 'theme-dark-' : 'theme-light-';
|
||
|
||
// Remove all other theme classes
|
||
const themeClasses = ['blue', 'emerald', 'purple', 'rose', 'amber', 'teal'];
|
||
themeClasses.forEach(theme => {
|
||
document.documentElement.classList.remove(`theme-light-${theme}`);
|
||
document.documentElement.classList.remove(`theme-dark-${theme}`);
|
||
});
|
||
|
||
// Add the correct theme class
|
||
if (savedScheme !== 'blue') {
|
||
document.documentElement.classList.add(themePrefix + savedScheme);
|
||
}
|
||
}
|
||
applyTheme();
|
||
</script>
|
||
<style>
|
||
/* Make the page take full height */
|
||
html,
|
||
body {
|
||
height: 100%;
|
||
margin: 0;
|
||
padding: 0;
|
||
}
|
||
|
||
/* Container layout for full height */
|
||
.main-container {
|
||
min-height: 100vh;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
/* Tab content area with fixed height and scrolling */
|
||
.tab-content-wrapper {
|
||
height: calc(100vh - 280px);
|
||
/* Adjust based on header and tab nav height */
|
||
overflow-y: auto;
|
||
overflow-x: hidden;
|
||
}
|
||
|
||
@media (max-width: 640px) {
|
||
.tab-content-wrapper {
|
||
height: calc(100vh - 320px);
|
||
/* Slightly less height on mobile */
|
||
}
|
||
}
|
||
|
||
/* Ensure all tab content divs take full height */
|
||
.tab-content {
|
||
min-height: 100%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
/* Custom scrollbar for content areas */
|
||
.tab-content-wrapper::-webkit-scrollbar {
|
||
width: 8px;
|
||
}
|
||
|
||
.tab-content-wrapper::-webkit-scrollbar-track {
|
||
background: transparent;
|
||
}
|
||
|
||
.tab-content-wrapper::-webkit-scrollbar-thumb {
|
||
background-color: var(--border-secondary);
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.tab-content-wrapper::-webkit-scrollbar-thumb:hover {
|
||
background-color: var(--border-accent);
|
||
}
|
||
|
||
/* Horizontal scrollbar for tab navigation */
|
||
.tab-nav {
|
||
scrollbar-width: thin;
|
||
scrollbar-color: var(--border-secondary) transparent;
|
||
}
|
||
|
||
.tab-nav::-webkit-scrollbar {
|
||
height: 6px;
|
||
}
|
||
|
||
.tab-nav::-webkit-scrollbar-track {
|
||
background: transparent;
|
||
}
|
||
|
||
.tab-nav::-webkit-scrollbar-thumb {
|
||
background-color: var(--border-secondary);
|
||
border-radius: 3px;
|
||
}
|
||
|
||
.tab-nav::-webkit-scrollbar-thumb:hover {
|
||
background-color: var(--border-accent);
|
||
}
|
||
|
||
|
||
|
||
/* Custom scrollbar styling for Speakers Management */
|
||
#content-speakers .overflow-y-auto::-webkit-scrollbar {
|
||
width: 8px;
|
||
}
|
||
|
||
#content-speakers .overflow-y-auto::-webkit-scrollbar-track {
|
||
background: transparent;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
#content-speakers .overflow-y-auto::-webkit-scrollbar-thumb {
|
||
background-color: var(--border-secondary);
|
||
border-radius: 4px;
|
||
transition: background-color 0.2s;
|
||
}
|
||
|
||
#content-speakers .overflow-y-auto::-webkit-scrollbar-thumb:hover {
|
||
background-color: var(--border-accent);
|
||
}
|
||
|
||
/* Ensure the content fits properly */
|
||
#content-speakers {
|
||
container-type: inline-size;
|
||
}
|
||
|
||
/* Responsive grid adjustments */
|
||
@container (max-width: 640px) {
|
||
#speakersTabGrid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
|
||
@container (min-width: 641px) and (max-width: 768px) {
|
||
#speakersTabGrid {
|
||
grid-template-columns: repeat(2, 1fr);
|
||
}
|
||
}
|
||
|
||
@container (min-width: 769px) and (max-width: 1024px) {
|
||
#speakersTabGrid {
|
||
grid-template-columns: repeat(3, 1fr);
|
||
}
|
||
}
|
||
|
||
@container (min-width: 1025px) {
|
||
#speakersTabGrid {
|
||
grid-template-columns: repeat(4, 1fr);
|
||
}
|
||
}
|
||
|
||
/* Hide scrollbar for tabs */
|
||
.scrollbar-hide {
|
||
-ms-overflow-style: none;
|
||
scrollbar-width: none;
|
||
}
|
||
|
||
.scrollbar-hide::-webkit-scrollbar {
|
||
display: none;
|
||
}
|
||
</style>
|
||
</head>
|
||
|
||
<body class="bg-[var(--bg-primary)] text-[var(--text-primary)]">
|
||
<div class="main-container container mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||
<header class="flex justify-between items-center mb-6 pb-4 border-b border-[var(--border-primary)]">
|
||
<h1 class="text-3xl font-bold text-[var(--text-primary)]">
|
||
<a href="/tool/speakr" class="flex items-center">
|
||
<img src="/tool/speakr/static/img/icon-192x192.png" alt="{{ html_title }} Logo" class="h-9 w-9 mr-2">
|
||
{{ html_title }}
|
||
</a>
|
||
</h1>
|
||
<div class="flex items-center space-x-2">
|
||
<button id="darkModeToggle"
|
||
class="p-2 rounded-full text-[var(--text-muted)] hover:bg-[var(--bg-tertiary)] dark:text-gray-400 dark:hover:bg-gray-700 transition-colors duration-200">
|
||
<i id="darkModeIcon" class="fas fa-moon"></i>
|
||
</button>
|
||
<div class="relative" id="userDropdown">
|
||
<button id="userDropdownButton"
|
||
class="flex items-center px-3 py-2 border border-[var(--border-secondary)] rounded-lg text-[var(--text-secondary)] hover:text-[var(--text-accent)] focus:outline-none">
|
||
<i class="fas fa-user mr-2"></i>
|
||
<span>{{ current_user.username }}</span>
|
||
<i class="fas fa-chevron-down ml-2"></i>
|
||
</button>
|
||
<div id="userDropdownMenu"
|
||
class="hidden absolute right-0 mt-2 w-48 bg-[var(--bg-secondary)] border border-[var(--border-primary)] rounded-lg shadow-lg z-10">
|
||
<a href="/tool/speakr"
|
||
class="block px-4 py-2 text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)] hover:text-[var(--text-accent)]">
|
||
<i class="fas fa-home mr-2"></i> <span data-i18n="nav.home">Home</span>
|
||
</a>
|
||
<a href="/tool/speakr/account"
|
||
class="block px-4 py-2 text-[var(--text-accent)] bg-[var(--bg-accent)]">
|
||
<i class="fas fa-user-circle mr-2"></i> <span data-i18n="nav.account">Account</span>
|
||
</a>
|
||
{% if current_user.is_admin %}
|
||
<a href="/tool/speakr/admin"
|
||
class="block px-4 py-2 text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)] hover:text-[var(--text-accent)]">
|
||
<i class="fas fa-user-shield mr-2"></i> <span data-i18n="nav.admin">Admin</span>
|
||
</a>
|
||
{% endif %}
|
||
<a href="/tool/speakr/logout"
|
||
class="block px-4 py-2 text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)] hover:text-[var(--text-danger)]">
|
||
<i class="fas fa-sign-out-alt mr-2"></i> <span data-i18n="nav.signOut">Logout</span>
|
||
</a>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
<main class="flex-grow flex flex-col">
|
||
<div
|
||
class="bg-[var(--bg-secondary)] rounded-xl shadow-lg border border-[var(--border-primary)] flex flex-col flex-grow overflow-hidden">
|
||
<div class="border-b border-[var(--border-primary)]">
|
||
<div class="relative">
|
||
<!-- Scroll gradient indicators -->
|
||
<div class="absolute left-0 top-0 bottom-0 w-12 bg-gradient-to-r from-[var(--bg-secondary)] to-transparent z-[1] pointer-events-none opacity-0 transition-opacity duration-200 flex items-center justify-start"
|
||
id="left-scroll-indicator">
|
||
<i class="fas fa-chevron-left text-[var(--text-muted)] ml-1"></i>
|
||
</div>
|
||
<div class="absolute right-0 top-0 bottom-0 w-12 bg-gradient-to-l from-[var(--bg-secondary)] to-transparent z-[1] pointer-events-none opacity-100 transition-opacity duration-200 flex items-center justify-end"
|
||
id="right-scroll-indicator">
|
||
<i class="fas fa-chevron-right text-[var(--text-muted)] mr-1"></i>
|
||
</div>
|
||
|
||
<nav class="tab-nav px-1 sm:px-4 lg:px-6 pt-3 sm:pt-4 lg:pt-6 pb-4 sm:pb-6 -mb-px overflow-x-auto scrollbar-hide"
|
||
aria-label="Tabs" id="tabs-container">
|
||
<div class="inline-flex gap-1 sm:gap-2">
|
||
<a href="#" id="tab-account"
|
||
class="inline-flex items-center whitespace-nowrap py-1 px-1.5 sm:py-3 sm:px-3 border-b-2 font-medium text-xs sm:text-sm border-[var(--border-accent)] text-[var(--text-accent)] rounded-t-md sm:rounded-t-lg hover:bg-[var(--bg-tertiary)]">
|
||
<i class="fas fa-user-circle mr-0.5 sm:mr-2 text-xs sm:text-sm"></i><span
|
||
data-i18n="account.title">Account</span>
|
||
</a>
|
||
<a href="#" id="tab-prompts"
|
||
class="inline-flex items-center whitespace-nowrap py-1 px-1.5 sm:py-3 sm:px-3 border-b-2 font-medium text-xs sm:text-sm border-transparent text-[var(--text-muted)] hover:text-[var(--text-secondary)] hover:border-[var(--border-secondary)] rounded-t-md sm:rounded-t-lg hover:bg-[var(--bg-tertiary)]">
|
||
<i class="fas fa-cog mr-0.5 sm:mr-2 text-xs sm:text-sm"></i><span
|
||
data-i18n="accountTabs.promptOptions">Prompts</span>
|
||
</a>
|
||
<a href="#" id="tab-shares"
|
||
class="inline-flex items-center whitespace-nowrap py-1 px-1.5 sm:py-3 sm:px-3 border-b-2 font-medium text-xs sm:text-sm border-transparent text-[var(--text-muted)] hover:text-[var(--text-secondary)] hover:border-[var(--border-secondary)] rounded-t-md sm:rounded-t-lg hover:bg-[var(--bg-tertiary)]">
|
||
<i class="fas fa-share-alt mr-0.5 sm:mr-2 text-xs sm:text-sm"></i><span
|
||
data-i18n="accountTabs.sharedTranscripts">Shares</span>
|
||
</a>
|
||
<a href="#" id="tab-speakers"
|
||
class="inline-flex items-center whitespace-nowrap py-1 px-1.5 sm:py-3 sm:px-3 border-b-2 font-medium text-xs sm:text-sm border-transparent text-[var(--text-muted)] hover:text-[var(--text-secondary)] hover:border-[var(--border-secondary)] rounded-t-md sm:rounded-t-lg hover:bg-[var(--bg-tertiary)]">
|
||
<i class="fas fa-users mr-0.5 sm:mr-2 text-xs sm:text-sm"></i><span
|
||
data-i18n="accountTabs.speakersManagement">Speakers</span>
|
||
</a>
|
||
<a href="#" id="tab-tags"
|
||
class="inline-flex items-center whitespace-nowrap py-1 px-1.5 sm:py-3 sm:px-3 border-b-2 font-medium text-xs sm:text-sm border-transparent text-[var(--text-muted)] hover:text-[var(--text-secondary)] hover:border-[var(--border-secondary)] rounded-t-md sm:rounded-t-lg hover:bg-[var(--bg-tertiary)]">
|
||
<i class="fas fa-tags mr-0.5 sm:mr-2 text-xs sm:text-sm"></i><span
|
||
data-i18n="accountTabs.tagManagement">Tags</span>
|
||
</a>
|
||
<a href="#" id="tab-voiceprint"
|
||
class="inline-flex items-center whitespace-nowrap py-1 px-1.5 sm:py-3 sm:px-3 border-b-2 font-medium text-xs sm:text-sm border-transparent text-[var(--text-muted)] hover:text-[var(--text-secondary)] hover:border-[var(--border-secondary)] rounded-t-md sm:rounded-t-lg hover:bg-[var(--bg-tertiary)]">
|
||
<i class="fas fa-tags mr-0.5 sm:mr-2 text-xs sm:text-sm"></i><span>声纹</span>
|
||
</a>
|
||
<a href="#" id="tab-templates"
|
||
class="inline-flex items-center whitespace-nowrap py-1 px-1.5 sm:py-3 sm:px-3 border-b-2 font-medium text-xs sm:text-sm border-transparent text-[var(--text-muted)] hover:text-[var(--text-secondary)] hover:border-[var(--border-secondary)] rounded-t-md sm:rounded-t-lg hover:bg-[var(--bg-tertiary)]">
|
||
<i class="fas fa-file-alt mr-0.5 sm:mr-2 text-xs sm:text-sm"></i><span
|
||
data-i18n="accountTabs.transcriptTemplates">Templates</span>
|
||
</a>
|
||
<a href="#" id="tab-about"
|
||
class="inline-flex items-center whitespace-nowrap py-1 px-1.5 sm:py-3 sm:px-3 border-b-2 font-medium text-xs sm:text-sm border-transparent text-[var(--text-muted)] hover:text-[var(--text-secondary)] hover:border-[var(--border-secondary)] rounded-t-md sm:rounded-t-lg hover:bg-[var(--bg-tertiary)]">
|
||
<i class="fas fa-info-circle mr-0.5 sm:mr-2 text-xs sm:text-sm"></i><span
|
||
data-i18n="accountTabs.about">About</span>
|
||
</a>
|
||
</div>
|
||
</nav>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="tab-content-wrapper px-4 sm:px-6 lg:px-8 pb-4 sm:pb-6 lg:pb-8">
|
||
<div id="content-account" class="tab-content pt-6">
|
||
<h2 class="text-2xl font-semibold text-[var(--text-primary)] mb-6" data-i18n="account.title">
|
||
Account Information</h2>
|
||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||
{% if messages %}
|
||
{% for category, message in messages %}
|
||
<div
|
||
class="mb-4 p-3 rounded-lg {% if category == 'success' %}bg-[var(--bg-success-light)] text-[var(--text-success-strong)]{% elif category == 'danger' %}bg-[var(--bg-danger-light)] text-[var(--text-danger-strong)]{% else %}bg-[var(--bg-info-light)] text-[var(--text-info-strong)]{% endif %}">
|
||
{{ message }}
|
||
</div>
|
||
{% endfor %}
|
||
{% endif %}
|
||
{% endwith %}
|
||
|
||
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||
<!-- Left Column: Form -->
|
||
<div>
|
||
<form id="accountInfoForm" method="POST" action="/tool/speakr/account"
|
||
class="space-y-6">
|
||
<div class="mb-6">
|
||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||
<h3 class="text-lg font-medium text-[var(--text-secondary)] mb-2"
|
||
data-i18n="account.personalInfo">Personal Information</h3>
|
||
<div
|
||
class="bg-[var(--bg-tertiary)] p-4 rounded-lg border border-[var(--border-primary)] space-y-4">
|
||
<div>
|
||
<label for="user_name"
|
||
class="block text-sm font-medium text-[var(--text-muted)]"
|
||
data-i18n="account.fullName">Full Name</label>
|
||
<input type="text" name="user_name" id="user_name"
|
||
value="{{ current_user.name or '' }}"
|
||
data-i18n-placeholder="form.yourFullName"
|
||
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)]">
|
||
</div>
|
||
<div>
|
||
<label for="user_job_title"
|
||
class="block text-sm font-medium text-[var(--text-muted)]"
|
||
data-i18n="account.jobTitle">Job Title</label>
|
||
<input type="text" name="user_job_title" id="user_job_title"
|
||
value="{{ current_user.job_title or '' }}"
|
||
placeholder="e.g., Software Engineer"
|
||
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)]">
|
||
</div>
|
||
<div>
|
||
<label for="user_company"
|
||
class="block text-sm font-medium text-[var(--text-muted)]"
|
||
data-i18n="account.companyOrganization">Company /
|
||
Organization</label>
|
||
<input type="text" name="user_company" id="user_company"
|
||
value="{{ current_user.company or '' }}"
|
||
placeholder="e.g., Acme Corp"
|
||
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)]">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="mb-6">
|
||
<h3 class="text-lg font-medium text-[var(--text-secondary)] mb-2"
|
||
data-i18n="account.languagePreferences">Language Preferences</h3>
|
||
<div
|
||
class="bg-[var(--bg-tertiary)] p-4 rounded-lg border border-[var(--border-primary)] space-y-4">
|
||
<div>
|
||
<label for="ui_language"
|
||
class="block text-sm font-medium text-[var(--text-muted)]"
|
||
data-i18n="account.interfaceLanguage">Interface Language</label>
|
||
<select name="ui_language" id="ui_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)]">
|
||
<option value="en" {% if current_user.ui_language=='en' or not
|
||
current_user.ui_language %}selected{% endif %}>English</option>
|
||
<option value="zh" {% if current_user.ui_language=='zh' %}selected{%
|
||
endif %}>中文 (Chinese)</option>
|
||
<option value="de" {% if current_user.ui_language=='de' %}selected{%
|
||
endif %}>Deutsch (German)</option>
|
||
<option value="es" {% if current_user.ui_language=='es' %}selected{%
|
||
endif %}>Español (Spanish)</option>
|
||
<option value="fr" {% if current_user.ui_language=='fr' %}selected{%
|
||
endif %}>Français (French)</option>
|
||
</select>
|
||
<p class="text-xs text-[var(--text-light)] mt-1"
|
||
data-i18n="account.chooseLanguageForInterface">Choose the language
|
||
for the application interface.</p>
|
||
</div>
|
||
<div>
|
||
<label for="transcription_language"
|
||
class="block text-sm font-medium text-[var(--text-muted)]"
|
||
data-i18n="account.transcriptionLanguage">转录语言</label>
|
||
<input type="text" name="transcription_language"
|
||
id="transcription_language"
|
||
value="{{ current_user.transcription_language or '' }}"
|
||
placeholder="e.g., en, es, zh (ISO 639-1 code)"
|
||
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)]">
|
||
<p class="text-xs text-[var(--text-light)] mt-1"
|
||
data-i18n="account.leaveBlankForAutoDetect">Leave blank for
|
||
auto-detection by transcription service.</p>
|
||
</div>
|
||
<div>
|
||
<label for="output_language"
|
||
class="block text-sm font-medium text-[var(--text-muted)]"
|
||
data-i18n="account.preferredOutputLanguage">Preferred Output
|
||
Language</label>
|
||
<input type="text" name="output_language" id="output_language"
|
||
value="{{ current_user.output_language or '' }}"
|
||
placeholder="e.g., English, Spanish, Chinese"
|
||
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)]">
|
||
<p class="text-xs text-[var(--text-light)] mt-1"
|
||
data-i18n="account.languageForSummaries">Language for titles,
|
||
summaries, and chat. Leave blank for default (default behavior of
|
||
your chosen model).</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{% if current_user.is_admin %}
|
||
<div class="mb-6">
|
||
<h3 class="text-lg font-medium text-[var(--text-secondary)] mb-2">热词配置</h3>
|
||
<div
|
||
class="bg-[var(--bg-tertiary)] p-4 rounded-lg border border-[var(--border-primary)] space-y-4">
|
||
|
||
<div>
|
||
<label for="transcription_language"
|
||
class="block text-sm font-medium text-[var(--text-muted)]">热词</label>
|
||
<textarea name="hot-textarea"
|
||
style="height: 120px;margin-top: 10px;border-radius: 0.375rem;"
|
||
class="hot-textarea w-full p-2 border border-[var(--border-primary)] rounded-md bg-[var(--bg-secondary)] text-[var(--text-primary)] resize-none overflow-y-auto"></textarea>
|
||
<!-- <input type="text" name="transcription_language" id="transcription_language" value="{{ current_user.transcription_language or '' }}" placeholder="请输入热词" 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)]"> -->
|
||
<p class="text-xs text-[var(--text-light)] mt-1">一行一个,权重用空格隔开,如 "阿里巴巴 20
|
||
</p>
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
<div class="mb-6">
|
||
<div class="flex items-center justify-between gap-3 mb-2 flex-wrap">
|
||
<div class="flex items-center gap-3 flex-wrap">
|
||
<h3 class="text-lg font-medium text-[var(--text-secondary)]">标准词映射</h3>
|
||
<span id="standardWordMappingsCount"
|
||
class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-[var(--bg-secondary)] text-[var(--text-muted)] border border-[var(--border-primary)]">
|
||
0 条
|
||
</span>
|
||
</div>
|
||
<div class="flex items-center gap-2 flex-wrap">
|
||
<button type="button" id="toggleStandardWordImportPanel"
|
||
class="inline-flex items-center px-3 py-2 text-sm rounded-md border border-[var(--border-primary)] text-[var(--text-secondary)] hover:bg-[var(--bg-secondary)] transition-colors duration-200">
|
||
<i class="fas fa-file-import mr-2"></i>批量导入
|
||
</button>
|
||
<button type="button" id="copyStandardWordMappingsText"
|
||
class="inline-flex items-center px-3 py-2 text-sm rounded-md border border-[var(--border-primary)] text-[var(--text-secondary)] hover:bg-[var(--bg-secondary)] transition-colors duration-200">
|
||
<i class="fas fa-copy mr-2"></i>复制文本
|
||
</button>
|
||
<button type="button" id="clearAllStandardWordMappings"
|
||
class="inline-flex items-center px-3 py-2 text-sm rounded-md border border-[var(--text-danger)] text-[var(--text-danger)] hover:bg-[var(--bg-danger)] hover:text-[var(--text-button)] transition-colors duration-200">
|
||
<i class="fas fa-trash-alt mr-2"></i>批量删除
|
||
</button>
|
||
<button type="button" id="addStandardWordMappingRow"
|
||
class="inline-flex items-center px-3 py-2 text-sm rounded-md bg-[var(--bg-button)] text-[var(--text-button)] hover:bg-[var(--bg-button-hover)] transition-colors duration-200">
|
||
<i class="fas fa-plus mr-2"></i>新增映射
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div
|
||
class="bg-[var(--bg-tertiary)] p-4 rounded-lg border border-[var(--border-primary)] space-y-4">
|
||
<div id="standardWordImportPanel"
|
||
class="hidden rounded-lg border border-dashed border-[var(--border-primary)] bg-[var(--bg-secondary)] p-4 space-y-3">
|
||
<div class="flex items-center justify-between gap-3 flex-wrap">
|
||
<div>
|
||
<p class="text-sm font-medium text-[var(--text-secondary)]">批量导入映射</p>
|
||
<p class="text-xs text-[var(--text-light)] mt-1">支持每行一个映射,格式:标准词:常用词1、常用词2</p>
|
||
</div>
|
||
<button type="button" id="closeStandardWordImportPanel"
|
||
class="inline-flex items-center px-2.5 py-1.5 text-xs rounded-md border border-[var(--border-primary)] text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)] transition-colors duration-200">
|
||
收起
|
||
</button>
|
||
</div>
|
||
<textarea id="standardWordMappingsBulkInput"
|
||
style="height: 120px;border-radius: 0.375rem;"
|
||
class="w-full p-3 border border-[var(--border-primary)] rounded-md bg-[var(--bg-input)] text-[var(--text-primary)] resize-y"
|
||
placeholder="例如: OpenAI:欧喷AI、欧彭AI Whisper:威士博 威士吧"></textarea>
|
||
<div class="flex items-center gap-2 flex-wrap">
|
||
<button type="button" id="replaceStandardWordMappingsFromBulk"
|
||
class="inline-flex items-center px-3 py-2 text-sm rounded-md bg-[var(--bg-button)] text-[var(--text-button)] hover:bg-[var(--bg-button-hover)] transition-colors duration-200">
|
||
覆盖导入
|
||
</button>
|
||
<button type="button" id="appendStandardWordMappingsFromBulk"
|
||
class="inline-flex items-center px-3 py-2 text-sm rounded-md border border-[var(--border-primary)] text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)] transition-colors duration-200">
|
||
追加导入
|
||
</button>
|
||
<button type="button" id="clearStandardWordMappingsBulkInput"
|
||
class="inline-flex items-center px-3 py-2 text-sm rounded-md border border-[var(--border-primary)] text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)] transition-colors duration-200">
|
||
清空
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div
|
||
class="hidden md:grid grid-cols-[110px_minmax(0,1fr)_minmax(0,2fr)_150px] gap-3 px-4 py-2 rounded-md border border-[var(--border-primary)] bg-[var(--bg-secondary)] text-xs font-medium uppercase tracking-wide text-[var(--text-light)]">
|
||
<div>排序</div>
|
||
<div>标准词</div>
|
||
<div>常用词</div>
|
||
<div>操作</div>
|
||
</div>
|
||
<div id="standardWordMappingsList" class="space-y-2"></div>
|
||
<div id="standardWordMappingsEmpty"
|
||
class="text-sm text-[var(--text-light)] bg-[var(--bg-secondary)] border border-dashed border-[var(--border-primary)] rounded-md px-4 py-3">
|
||
暂无映射。点击“新增映射”后,填写标准词和对应的常用词。
|
||
</div>
|
||
<p class="text-xs text-[var(--text-light)]">
|
||
一个标准词可对应多个常用词;常用词之间可使用顿号“、”或空格分隔。例如:OpenAI → 欧喷AI、欧彭AI
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<div class="mb-6">
|
||
<h3 class="text-lg font-medium text-[var(--text-secondary)] mb-2">换行配置</h3>
|
||
<div
|
||
class="bg-[var(--bg-tertiary)] p-4 rounded-lg border border-[var(--border-primary)] space-y-4">
|
||
|
||
<div>
|
||
<label for="transcription_language"
|
||
class="block text-sm font-medium text-[var(--text-muted)]">关键词</label>
|
||
<textarea name="handle-textarea"
|
||
style="height: 120px;margin-top: 10px;border-radius: 0.375rem;"
|
||
class="handle-textarea w-full p-2 border border-[var(--border-primary)] rounded-md bg-[var(--bg-secondary)] text-[var(--text-primary)] resize-none overflow-y-auto"></textarea>
|
||
<!-- <input type="text" name="transcription_language" id="transcription_language" value="{{ current_user.transcription_language or '' }}" placeholder="请输入热词" 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)]"> -->
|
||
<p class="text-xs text-[var(--text-light)] mt-1">
|
||
关键词使用逗号隔开,例如“报告完毕,发言完毕,.*席报告”
|
||
</p>
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
{% endif %}
|
||
<div class="mb-6">
|
||
<h3
|
||
class="text-lg font-medium text-[var(--text-secondary)] mb-2 flex items-center gap-3">
|
||
提示词
|
||
<button id="openPromptModal"
|
||
type="button"
|
||
class="px-2 py-1 text-sm bg-blue-500 text-white rounded">选择提示词</button>
|
||
</h3>
|
||
|
||
|
||
<div
|
||
class="bg-[var(--bg-tertiary)] p-4 rounded-lg border border-[var(--border-primary)] space-y-4">
|
||
<div>
|
||
<label for="transcription_language"
|
||
class="block text-sm font-medium text-[var(--text-muted)]">提示词</label>
|
||
<textarea name="handle-prompt" id="promptTextarea"
|
||
readonly
|
||
style="height: 120px;margin-top: 10px;border-radius: 0.375rem;"
|
||
class="handle-prompt w-full p-2 border border-[var(--border-primary)] rounded-md bg-[var(--bg-secondary)] text-[var(--text-primary)] resize-none overflow-y-auto"></textarea>
|
||
<p class="text-xs text-[var(--text-light)] mt-1">
|
||
点击上方按钮选择提示词,如果不选择使用默认提示词。</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
|
||
<!-- 弹框 Modal -->
|
||
<div id="promptModal"
|
||
class="fixed inset-0 bg-black bg-opacity-50 hidden 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">
|
||
<h3 class="text-xl font-semibold mb-4">选择提示词</h3>
|
||
|
||
<div class="bg-[#111827] p-3 rounded max-h-72 overflow-y-auto text-white">
|
||
|
||
<!-- 表头 -->
|
||
<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>
|
||
</div>
|
||
|
||
<div class="flex justify-end mt-4 gap-3">
|
||
<button id="cancelPrompt" type="button"
|
||
class="px-4 py-2 bg-gray-500 rounded hover:bg-gray-600">取消</button>
|
||
<button id="confirmPrompt" type="button"
|
||
class="px-4 py-2 bg-blue-600 rounded hover:bg-blue-700">确定</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<button type="submit"
|
||
class="w-full inline-flex items-center justify-center px-4 py-2 bg-[var(--bg-button)] text-[var(--text-button)] rounded-md hover:bg-[var(--bg-button-hover)] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[var(--border-focus)] transition-colors duration-200">
|
||
<i class="fas fa-save mr-2"></i> <span
|
||
data-i18n="account.saveAllPreferences">Save All Preferences</span>
|
||
</button>
|
||
</form>
|
||
</div>
|
||
|
||
<!-- Right Column: Stats, User Details, Account Actions -->
|
||
<div class="space-y-6">
|
||
<div>
|
||
<h3 class="text-lg font-medium text-[var(--text-secondary)] mb-2"
|
||
data-i18n="account.statistics">Account Statistics</h3>
|
||
<div
|
||
class="bg-[var(--bg-tertiary)] p-4 rounded-lg border border-[var(--border-primary)]">
|
||
<div class="grid grid-cols-2 gap-4">
|
||
<div
|
||
class="text-center p-4 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-primary)] shadow-sm">
|
||
<span class="block text-3xl font-bold text-[var(--text-accent)]">{{
|
||
current_user.recordings|length }}</span>
|
||
<span class="block text-sm text-[var(--text-muted)]"
|
||
data-i18n="account.totalRecordings">Total Recordings</span>
|
||
</div>
|
||
|
||
<div
|
||
class="text-center p-4 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-primary)] shadow-sm">
|
||
<span class="block text-3xl font-bold text-[var(--text-accent)]">
|
||
{% set completed_count =
|
||
current_user.recordings|selectattr('status', 'equalto',
|
||
'COMPLETED')|list|length %}
|
||
{{ completed_count }}
|
||
</span>
|
||
<span class="block text-sm text-[var(--text-muted)]"
|
||
data-i18n="account.completedRecordings">Completed</span>
|
||
</div>
|
||
|
||
<div
|
||
class="text-center p-4 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-primary)] shadow-sm">
|
||
<span class="block text-3xl font-bold text-[var(--text-warn-strong)]">
|
||
{% set processing_count =
|
||
current_user.recordings|selectattr('status', 'in', ['PENDING',
|
||
'PROCESSING', 'SUMMARIZING'])|list|length %}
|
||
{{ processing_count }}
|
||
</span>
|
||
<span class="block text-sm text-[var(--text-muted)]"
|
||
data-i18n="account.processingRecordings">Processing</span>
|
||
</div>
|
||
|
||
<div
|
||
class="text-center p-4 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-primary)] shadow-sm">
|
||
<span class="block text-3xl font-bold text-[var(--text-danger)]">
|
||
{% set failed_count = current_user.recordings|selectattr('status',
|
||
'equalto', 'FAILED')|list|length %}
|
||
{{ failed_count }}
|
||
</span>
|
||
<span class="block text-sm text-[var(--text-muted)]"
|
||
data-i18n="account.failedRecordings">Failed</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="mt-6"> <!-- Added mt-6 for spacing, was mb-6 on User Details -->
|
||
<h3 class="text-lg font-medium text-[var(--text-secondary)] mb-2"
|
||
data-i18n="account.userDetails">User Details</h3>
|
||
<div
|
||
class="bg-[var(--bg-tertiary)] p-4 rounded-lg border border-[var(--border-primary)]">
|
||
<div class="mb-3">
|
||
<span class="block text-sm font-medium text-[var(--text-muted)]"
|
||
data-i18n="account.username">Username</span>
|
||
<span class="block text-[var(--text-primary)]">{{ current_user.username
|
||
}}</span>
|
||
</div>
|
||
<div class="mb-3">
|
||
<span
|
||
class="block text-sm font-medium text-[var(--text-muted)]">Email</span>
|
||
<span class="block text-[var(--text-primary)]">{{ current_user.email
|
||
}}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="mt-6"> <!-- Added mt-6 for spacing, was mt-8 on Account Actions -->
|
||
<h3 class="text-lg font-medium text-[var(--text-secondary)] mb-2"
|
||
data-i18n="account.accountActions">Account Actions</h3>
|
||
<div class="flex flex-col space-y-3">
|
||
<a href="/tool/speakr"
|
||
class="inline-flex items-center px-4 py-2 bg-[var(--bg-button)] text-[var(--text-button)] rounded-md hover:bg-[var(--bg-button-hover)] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[var(--border-focus)] transition-colors duration-200">
|
||
<i class="fas fa-microphone mr-2"></i> <span
|
||
data-i18n="account.goToRecordings">Go to Recordings</span>
|
||
</a>
|
||
<button id="changePasswordBtn"
|
||
class="inline-flex items-center px-4 py-2 border border-[var(--border-secondary)] text-[var(--text-secondary)] rounded-md hover:bg-[var(--bg-tertiary)] hover:text-[var(--text-accent)] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[var(--border-focus)] transition-colors duration-200">
|
||
<i class="fas fa-key mr-2"></i> <span
|
||
data-i18n="account.changePassword">Change Password</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="content-prompts" class="hidden tab-content pt-6">
|
||
<h2 class="text-2xl font-semibold text-[var(--text-primary)] mb-6"
|
||
data-i18n="accountTabs.promptOptions">Prompt Options</h2>
|
||
|
||
<form id="customPromptsForm" method="POST" action="/tool/speakr/account" class="space-y-6">
|
||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||
|
||
<!-- Event Extraction Settings -->
|
||
<div class="bg-[var(--bg-tertiary)] p-6 rounded-lg border border-[var(--border-primary)]">
|
||
<h3 class="text-lg font-medium text-[var(--text-secondary)] mb-4">
|
||
<i class="fas fa-calendar-alt mr-2"></i>
|
||
<span data-i18n="eventExtraction.title">Event Extraction</span>
|
||
</h3>
|
||
|
||
<div class="mb-4">
|
||
<label class="flex items-start space-x-3">
|
||
<input type="checkbox" name="extract_events" id="extract_events" {% if
|
||
current_user.extract_events %}checked{% endif %}
|
||
class="mt-1 h-4 w-4 text-[var(--border-accent)] focus:ring-[var(--border-focus)] border-[var(--border-secondary)] rounded">
|
||
<div>
|
||
<span class="block text-sm font-medium text-[var(--text-primary)]"
|
||
data-i18n="eventExtraction.enableLabel">
|
||
Enable automatic event extraction from transcripts
|
||
</span>
|
||
<span class="block text-xs text-[var(--text-muted)] mt-1"
|
||
data-i18n="eventExtraction.description">
|
||
When enabled, the AI will identify meetings, appointments, and deadlines
|
||
mentioned in your recordings and create downloadable calendar events.
|
||
</span>
|
||
</div>
|
||
</label>
|
||
</div>
|
||
|
||
<div
|
||
class="mt-4 p-3 bg-[var(--bg-info-light)] text-[var(--text-info-strong)] rounded-lg text-sm">
|
||
<i class="fas fa-info-circle mr-1"></i>
|
||
<span data-i18n="eventExtraction.info">
|
||
Extracted events will appear in a new "Events" tab on recordings where calendar
|
||
items are detected.
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Custom Prompts Section -->
|
||
<div class="bg-[var(--bg-tertiary)] p-6 rounded-lg border border-[var(--border-primary)]">
|
||
<h3 class="text-lg font-medium text-[var(--text-secondary)] mb-4"
|
||
data-i18n="customPrompts.summaryGeneration">Summary Generation Prompt</h3>
|
||
|
||
<div class="mb-4">
|
||
<label for="summary_prompt_custom"
|
||
class="block text-sm font-medium text-[var(--text-muted)] mb-2">
|
||
<span data-i18n="customPrompts.yourCustomPrompt">Your Custom Summary
|
||
Prompt</span>
|
||
</label>
|
||
<textarea name="summary_prompt" id="summary_prompt_custom" rows="8"
|
||
placeholder="Describe how you want your summaries structured. Leave blank to use the admin's default prompt."
|
||
data-i18n-placeholder="customPrompts.promptPlaceholder"
|
||
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)]">{{ current_user.summary_prompt or '' }}</textarea>
|
||
<p class="text-xs text-[var(--text-light)] mt-2">
|
||
<span data-i18n="customPrompts.promptDescription">This prompt will be used to
|
||
generate summaries for your transcriptions. It overrides the admin's default
|
||
prompt.</span>
|
||
</p>
|
||
</div>
|
||
|
||
<div
|
||
class="mt-6 p-4 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-secondary)]">
|
||
<h4 class="text-sm font-semibold text-[var(--text-accent)] mb-2">
|
||
<i class="fas fa-info-circle mr-1"></i>
|
||
<span data-i18n="customPrompts.currentDefaultPrompt">Current Default Prompt
|
||
(Used if you leave the above blank)</span>
|
||
</h4>
|
||
<div
|
||
class="bg-[var(--bg-primary)] p-3 rounded border border-[var(--border-primary)]">
|
||
<pre
|
||
class="text-xs text-[var(--text-muted)] whitespace-pre-wrap">{{ default_summary_prompt_text }}</pre>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="mt-4">
|
||
<details class="cursor-pointer">
|
||
<summary
|
||
class="text-sm text-[var(--text-accent)] hover:text-[var(--text-accent-hover)]">
|
||
<i class="fas fa-question-circle mr-1"></i>
|
||
<span data-i18n="customPrompts.tipsTitle">Tips for Writing Effective
|
||
Prompts</span>
|
||
</summary>
|
||
<div
|
||
class="mt-3 p-3 bg-[var(--bg-info-light)] text-[var(--text-info-strong)] rounded-lg text-sm">
|
||
<ul class="list-disc list-inside space-y-1">
|
||
<li data-i18n="customPrompts.tip1">Be specific about the sections you
|
||
want in your summary</li>
|
||
<li data-i18n="customPrompts.tip2">Use clear formatting instructions
|
||
(e.g., "Use bullet points", "Create numbered lists")</li>
|
||
<li data-i18n="customPrompts.tip3">Specify if you want certain
|
||
information prioritized</li>
|
||
<li data-i18n="customPrompts.tip4">The system will automatically provide
|
||
the transcript content to the AI</li>
|
||
<li data-i18n="customPrompts.tip5">Your output language preference (if
|
||
set) will be applied automatically</li>
|
||
</ul>
|
||
</div>
|
||
</details>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex justify-end">
|
||
<button type="submit"
|
||
class="inline-flex items-center px-4 py-2 bg-[var(--bg-button)] text-[var(--text-button)] rounded-md hover:bg-[var(--bg-button-hover)] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[var(--border-focus)] transition-colors duration-200">
|
||
<i class="fas fa-save mr-2"></i> <span data-i18n="buttons.saveSettings">Save
|
||
Settings</span>
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
|
||
<div id="content-shares" class="hidden tab-content pt-6">
|
||
<h2 class="text-2xl font-semibold text-[var(--text-primary)] mb-6"
|
||
data-i18n="accountTabs.sharedTranscripts">My Shared Transcripts</h2>
|
||
<div id="shares-container" class="space-y-4">
|
||
<!-- Shares will be loaded here by JavaScript -->
|
||
</div>
|
||
</div>
|
||
|
||
<div id="content-speakers" class="hidden tab-content pt-6">
|
||
<div class="flex-shrink-0">
|
||
<h2 class="text-2xl font-semibold text-[var(--text-primary)] mb-4"
|
||
data-i18n="accountTabs.speakersManagement">Speakers Management</h2>
|
||
<p class="text-[var(--text-secondary)] mb-4" data-i18n="speakersManagement.description">
|
||
Manage your saved speakers. These are automatically saved when you use speaker names in
|
||
your recordings.</p>
|
||
</div>
|
||
|
||
<div class="flex-1 overflow-y-auto min-h-0 pr-2"
|
||
style="scrollbar-width: thin; scrollbar-color: var(--border-secondary) transparent;">
|
||
<!-- Loading state -->
|
||
<div id="speakersTabLoading" class="text-center py-8 hidden">
|
||
<i class="fas fa-spinner fa-spin text-2xl text-[var(--text-muted)]"></i>
|
||
<p class="text-[var(--text-muted)] mt-2" data-i18n="speakersManagement.loadingSpeakers">
|
||
Loading speakers...</p>
|
||
</div>
|
||
|
||
<!-- Error state -->
|
||
<div id="speakersTabError"
|
||
class="hidden bg-[var(--bg-danger-light)] text-[var(--text-danger-strong)] p-3 rounded-lg mb-4">
|
||
<i class="fas fa-exclamation-triangle mr-2"></i>
|
||
<span id="speakersTabErrorMessage" data-i18n="speakersManagement.failedToLoad">Failed to
|
||
load speakers</span>
|
||
</div>
|
||
|
||
<!-- Empty state -->
|
||
<div id="speakersTabEmpty" class="text-center py-12 hidden">
|
||
<i class="fas fa-users text-4xl text-[var(--text-light)] mb-4"></i>
|
||
<p class="text-[var(--text-muted)] text-lg mb-2"
|
||
data-i18n="speakersManagement.noSpeakersYet">No speakers saved yet</p>
|
||
<p class="text-sm text-[var(--text-light)]"
|
||
data-i18n="speakersManagement.speakersWillAppear">Speakers will appear here when you
|
||
use speaker names in your recordings</p>
|
||
</div>
|
||
|
||
<!-- Speakers grid with scrollable container -->
|
||
<div id="speakersTabGrid"
|
||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 pb-4">
|
||
<!-- Speakers will be populated here -->
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Fixed footer with count and actions -->
|
||
<div
|
||
class="flex-shrink-0 flex justify-between items-center mt-4 pt-4 border-t border-[var(--border-primary)]">
|
||
<div class="text-sm text-[var(--text-muted)]">
|
||
<span id="speakersTabCount">0</span> <span
|
||
data-i18n="speakersManagement.totalSpeakers">speakers saved</span>
|
||
</div>
|
||
<div class="flex space-x-3">
|
||
<button id="deleteAllSpeakersTabBtn"
|
||
class="px-4 py-2 bg-[var(--bg-danger)] text-[var(--text-button)] rounded-md hover:bg-[var(--bg-danger-hover)] disabled:opacity-50 disabled:cursor-not-allowed"
|
||
disabled>
|
||
<i class="fas fa-trash mr-2"></i> <span data-i18n="buttons.deleteAll">Delete
|
||
All</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="content-tags" class="hidden tab-content pt-6">
|
||
<h2 class="text-2xl font-semibold text-[var(--text-primary)] mb-6"
|
||
data-i18n="accountTabs.tagManagement">Tag Management</h2>
|
||
|
||
<div class="mb-6">
|
||
<div class="flex justify-between items-center mb-4">
|
||
<p class="text-[var(--text-secondary)]" data-i18n="tagManagement.description">Organize
|
||
your recordings with custom tags. Each tag can have its own summary prompt and
|
||
default ASR settings.</p>
|
||
<button id="createTagBtn"
|
||
class="inline-flex items-center px-4 py-2 bg-[var(--bg-button)] text-[var(--text-button)] rounded-md hover:bg-[var(--bg-button-hover)] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[var(--border-focus)] transition-colors duration-200">
|
||
<i class="fas fa-plus mr-2"></i> <span data-i18n="tagManagement.createTag">Create
|
||
Tag</span>
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Loading state -->
|
||
<div id="tagsLoading" class="text-center py-8 hidden">
|
||
<i class="fas fa-spinner fa-spin text-2xl text-[var(--text-muted)]"></i>
|
||
<p class="text-[var(--text-muted)] mt-2">Loading tags...</p>
|
||
</div>
|
||
|
||
<!-- Error state -->
|
||
<div id="tagsError"
|
||
class="hidden bg-[var(--bg-danger-light)] text-[var(--text-danger-strong)] p-3 rounded-lg mb-4">
|
||
<i class="fas fa-exclamation-triangle mr-2"></i>
|
||
<span id="tagsErrorMessage">Failed to load tags</span>
|
||
</div>
|
||
|
||
<!-- Empty state -->
|
||
<div id="tagsEmpty" class="text-center py-12 hidden">
|
||
<i class="fas fa-tags text-4xl text-[var(--text-light)] mb-4"></i>
|
||
<p class="text-[var(--text-muted)] text-lg mb-2">尚未创建任何标签</p>
|
||
<p class="text-sm text-[var(--text-light)]">创建您的第一个标签,以便整理录音文件</p>
|
||
</div>
|
||
|
||
<!-- Tags grid -->
|
||
<div id="tagsGrid" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||
<!-- Tags will be populated here -->
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div id="content-voiceprint" class="hidden tab-content">
|
||
<h2 class="text-2xl font-semibold text-[var(--text-primary)] mb-6"
|
||
style="color: #fff;line-height: 1.5rem;margin: 20px 0 0 0;">声纹管理</h2>
|
||
<div id="content-voiceprint">
|
||
|
||
<div class="top-bar">
|
||
<button class="btn btn-primary" style="margin-right: 10px;" onclick="synchronize()">同步至声纹平台</button>
|
||
<button class="btn btn-primary" onclick="openModal('add')">添加</button>
|
||
</div>
|
||
|
||
<table id="voiceList">
|
||
<thead>
|
||
<tr>
|
||
<th>名称</th>
|
||
<th>声纹编码</th>
|
||
<!-- <th>创建时间</th> -->
|
||
<th>操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<!-- 动态渲染数据 -->
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<div id="message"
|
||
style="z-index:9999;position:fixed;top:20px;right:20px;padding:10px 20px;background:#4caf50;color:white;border-radius:5px;display:none;">
|
||
</div>
|
||
<!-- 添加/编辑弹框 -->
|
||
<div class="modal" id="voiceModal">
|
||
<div class="modal-content">
|
||
<div class="modal-header" id="modalTitle">添加说话人</div>
|
||
<div class="label-tit">
|
||
<span style="color: #dc2626;margin-right: 5px;">*</span>
|
||
声纹向量
|
||
</div>
|
||
<div class="file-box">
|
||
<input type="file" id="voiceFile" accept="audio/*">
|
||
<button type="button" class="btn-clear" onclick="clearUploadFile()"
|
||
style="width: 100px;position: relative;top: -5px;">清空文件</button>
|
||
</div>
|
||
|
||
<div class="record-box">
|
||
<button type="button" onclick="startRecording(this)">🎤 录音</button>
|
||
<button type="button" class="btn-clear" onclick="clearRecording()">清空录音</button>
|
||
</div>
|
||
|
||
<audio id="audioPreview" controls style="display:none;"></audio>
|
||
<p class="tips">请选择一段清晰的独白语音(2秒以上),不要包含背景噪音和其他人的声音</p>
|
||
<div class="label-tit">
|
||
<span style="color: #dc2626;margin-right: 5px;">*</span>
|
||
名称
|
||
</div>
|
||
<input type="text" id="voiceName" placeholder="名称 (必填)" style="background-color: #fff;">
|
||
<!-- <div class="label-tit">描述</div>
|
||
<textarea id="voiceDesc" placeholder="描述"
|
||
style="background-color: #fff;border-radius: 4px !important;"></textarea> -->
|
||
<div class="modal-footer">
|
||
<button class="btn" onclick="closeModal()">取消</button>
|
||
<button id="saveVoiceBtn" class="btn btn-primary" onclick="saveVoice()"
|
||
style="color: #fff;">保存</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 删除确认弹框 -->
|
||
<div class="modal" id="confirmDeleteModal">
|
||
<div class="modal-content">
|
||
<div class="modal-header" style="color: #000;">确认删除?</div>
|
||
<div class="modal-footer">
|
||
<button class="btn" onclick="closeDeleteModal()" style="border-color: #409eff;
|
||
color: #409eff;">取消</button>
|
||
<button class="btn btn-danger" onclick="confirmDelete()"
|
||
style="color: #fff;">删除</button>
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<div id="content-templates" class="hidden tab-content pt-6">
|
||
<div class="space-y-6">
|
||
<!-- Templates Header -->
|
||
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||
<div>
|
||
<h2 class="text-2xl font-semibold text-[var(--text-primary)]"
|
||
data-i18n="transcriptTemplates.title">Transcript Templates</h2>
|
||
<p class="text-[var(--text-secondary)]" data-i18n="transcriptTemplates.description">
|
||
Customize how transcripts are formatted for download and export.</p>
|
||
</div>
|
||
<button id="createTemplateBtn"
|
||
class="inline-flex items-center px-4 py-2 bg-[var(--bg-button)] text-[var(--text-button)] rounded-md hover:bg-[var(--bg-button-hover)] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[var(--border-focus)] transition-colors duration-200">
|
||
<i class="fas fa-plus mr-2"></i>
|
||
<span data-i18n="transcriptTemplates.createNew">Create Template</span>
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Templates Grid -->
|
||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||
<!-- Templates List (Left) -->
|
||
<div class="lg:col-span-1">
|
||
<div
|
||
class="bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-primary)] p-4">
|
||
<h3 class="text-sm font-medium text-[var(--text-secondary)] mb-3"
|
||
data-i18n="transcriptTemplates.availableTemplates">Available Templates</h3>
|
||
<div id="templatesList" class="space-y-2">
|
||
<!-- Templates will be populated here -->
|
||
</div>
|
||
<button id="createDefaultTemplatesBtn"
|
||
class="mt-4 w-full px-3 py-2 text-sm bg-[var(--bg-tertiary)] text-[var(--text-secondary)] rounded-md hover:bg-[var(--bg-accent-light)] transition-colors">
|
||
<i class="fas fa-magic mr-2"></i>
|
||
<span data-i18n="transcriptTemplates.createDefaults">Create Default
|
||
Templates</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Template Editor (Right) -->
|
||
<div class="lg:col-span-2">
|
||
<div id="templateEditor"
|
||
class="bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-primary)] p-6 hidden">
|
||
<form id="templateForm" class="space-y-4">
|
||
<div>
|
||
<label
|
||
class="block text-sm font-medium text-[var(--text-secondary)] mb-1"
|
||
data-i18n="transcriptTemplates.templateName">Template Name</label>
|
||
<input type="text" id="templateName"
|
||
class="w-full px-3 py-2 bg-[var(--bg-primary)] text-[var(--text-primary)] border border-[var(--border-primary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--ring-focus)]"
|
||
required>
|
||
</div>
|
||
|
||
<div>
|
||
<label
|
||
class="block text-sm font-medium text-[var(--text-secondary)] mb-1"
|
||
data-i18n="transcriptTemplates.description">Description</label>
|
||
<input type="text" id="templateDescription"
|
||
class="w-full px-3 py-2 bg-[var(--bg-primary)] text-[var(--text-primary)] border border-[var(--border-primary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--ring-focus)]">
|
||
</div>
|
||
|
||
<div>
|
||
<label
|
||
class="block text-sm font-medium text-[var(--text-secondary)] mb-1"
|
||
data-i18n="transcriptTemplates.template">Template</label>
|
||
<textarea id="templateContent" rows="6"
|
||
class="w-full px-3 py-2 bg-[var(--bg-primary)] text-[var(--text-primary)] border border-[var(--border-primary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--ring-focus)] font-mono text-sm"
|
||
required></textarea>
|
||
|
||
<!-- Available Variables -->
|
||
<div class="mt-2 p-3 bg-[var(--bg-tertiary)] rounded-md">
|
||
<p class="text-xs font-medium text-[var(--text-secondary)] mb-2"
|
||
data-i18n="transcriptTemplates.availableVars">Available
|
||
Variables:</p>
|
||
<div class="flex flex-wrap gap-2">
|
||
<code
|
||
class="text-xs bg-[var(--bg-primary)] px-2 py-1 rounded">{{index}}</code>
|
||
<code
|
||
class="text-xs bg-[var(--bg-primary)] px-2 py-1 rounded">{{speaker}}</code>
|
||
<code
|
||
class="text-xs bg-[var(--bg-primary)] px-2 py-1 rounded">{{text}}</code>
|
||
<code
|
||
class="text-xs bg-[var(--bg-primary)] px-2 py-1 rounded">{{start_time}}</code>
|
||
<code
|
||
class="text-xs bg-[var(--bg-primary)] px-2 py-1 rounded">{{end_time}}</code>
|
||
</div>
|
||
<p class="text-xs text-[var(--text-muted)] mt-2"
|
||
data-i18n="transcriptTemplates.filters">Filters: |upper for
|
||
uppercase, |srt for subtitle time format</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex items-center">
|
||
<input type="checkbox" id="templateIsDefault" class="mr-2">
|
||
<label for="templateIsDefault"
|
||
class="text-sm text-[var(--text-secondary)]"
|
||
data-i18n="transcriptTemplates.setDefault">Set as default
|
||
template</label>
|
||
</div>
|
||
|
||
<div class="flex gap-3">
|
||
<button type="submit"
|
||
class="px-4 py-2 bg-[var(--bg-button)] text-[var(--text-button)] rounded-md hover:bg-[var(--bg-button-hover)] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[var(--border-focus)] transition-colors duration-200">
|
||
<i class="fas fa-save mr-2"></i>
|
||
<span data-i18n="transcriptTemplates.save">保存</span>
|
||
</button>
|
||
<button type="button" id="cancelTemplateBtn"
|
||
class="px-4 py-2 bg-[var(--bg-tertiary)] text-[var(--text-secondary)] rounded-md hover:bg-[var(--bg-secondary)]">
|
||
<span data-i18n="transcriptTemplates.cancel">取消</span>
|
||
</button>
|
||
<button type="button" id="deleteTemplateBtn"
|
||
class="px-4 py-2 bg-[var(--bg-danger)] text-white rounded-md hover:bg-[var(--bg-danger-hover)] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 transition-colors duration-200 hidden">
|
||
<i class="fas fa-trash mr-2"></i>
|
||
<span data-i18n="transcriptTemplates.delete">Delete</span>
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
|
||
<!-- Empty State -->
|
||
<div id="templateEmptyState"
|
||
class="bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-primary)] p-12 text-center">
|
||
<i class="fas fa-file-alt text-4xl text-[var(--text-muted)] mb-4"></i>
|
||
<p class="text-[var(--text-muted)]"
|
||
data-i18n="transcriptTemplates.selectOrCreate">Select a template to edit or
|
||
create a new one</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Documentation Link -->
|
||
<div class="mt-8 text-center">
|
||
<a href="https://murtaza-nasir.github.io/speakr/user-guide/transcript-templates/"
|
||
target="_blank"
|
||
class="text-sm text-[var(--text-muted)] hover:text-[var(--text-secondary)] transition-colors">
|
||
<i class="fas fa-question-circle mr-1"></i>
|
||
<span data-i18n="transcriptTemplates.viewGuide">View Template Guide</span>
|
||
</a>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="content-about" class="hidden tab-content pt-6">
|
||
<div class="space-y-8">
|
||
<!-- Header Section -->
|
||
<div class="text-center">
|
||
<div class="flex items-center justify-center mb-4">
|
||
<img src="/tool/speakr/static/img/icon-192x192.png" alt="{{ html_title }} Logo"
|
||
class="h-16 w-16 mr-4">
|
||
<div>
|
||
<h2 class="text-3xl font-bold text-[var(--text-primary)]">{{ html_title }}</h2>
|
||
<p class="text-[var(--text-secondary)]" data-i18n="aboutPage.tagline">AI-Powered
|
||
Audio Transcription & Note-Taking</p>
|
||
</div>
|
||
</div>
|
||
<div
|
||
class="inline-flex items-center px-3 py-1 bg-[var(--bg-accent)] text-[var(--text-accent)] rounded-full text-sm font-medium">
|
||
<i class="fas fa-tag mr-2"></i>
|
||
Version <span id="system-version" class="ml-1 font-mono">Loading...</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||
<!-- System Information -->
|
||
<div
|
||
class="bg-[var(--bg-secondary)] rounded-xl p-6 border border-[var(--border-primary)] shadow-sm">
|
||
<div class="flex items-center mb-4">
|
||
<div class="p-2 bg-[var(--bg-accent)] rounded-lg mr-3">
|
||
<i class="fas fa-cogs text-[var(--text-accent)] text-lg"></i>
|
||
</div>
|
||
<h3 class="text-xl font-semibold text-[var(--text-primary)]"
|
||
data-i18n="aboutPage.systemConfiguration">System Configuration</h3>
|
||
</div>
|
||
|
||
<div class="space-y-4">
|
||
<div class="border-l-4 border-[var(--border-accent)] pl-4">
|
||
<h4 class="font-medium text-[var(--text-primary)] mb-2"
|
||
data-i18n="aboutPage.largeLanguageModel">Large Language Model</h4>
|
||
<div class="space-y-1 text-sm">
|
||
<p class="text-[var(--text-secondary)]">
|
||
<span class="font-medium" data-i18n="aboutPage.model">Model:</span>
|
||
<span id="llm-model"
|
||
class="font-mono text-[var(--text-primary)] bg-[var(--bg-tertiary)] px-2 py-1 rounded ml-1">Loading...</span>
|
||
</p>
|
||
<p class="text-[var(--text-secondary)]">
|
||
<span class="font-medium"
|
||
data-i18n="aboutPage.endpoint">Endpoint:</span>
|
||
<span id="llm-endpoint"
|
||
class="font-mono text-xs text-[var(--text-muted)] break-all ml-1">Loading...</span>
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="border-l-4 border-green-500 pl-4">
|
||
<h4 class="font-medium text-[var(--text-primary)] mb-2"
|
||
data-i18n="aboutPage.speechRecognition">Speech Recognition</h4>
|
||
<div class="space-y-1 text-sm">
|
||
<p class="text-[var(--text-secondary)]">
|
||
<span class="font-medium" data-i18n="aboutPage.whisperApi">Whisper
|
||
API:</span>
|
||
<span id="whisper-endpoint"
|
||
class="font-mono text-xs text-[var(--text-muted)] ml-1">Loading...</span>
|
||
</p>
|
||
<p class="text-[var(--text-secondary)]">
|
||
<span class="font-medium" data-i18n="aboutPage.asrEnabled">ASR
|
||
Enabled:</span>
|
||
<span id="asr-status" class="ml-1">Loading...</span>
|
||
</p>
|
||
<div id="asr-endpoint-row" style="display: none;">
|
||
<p class="text-[var(--text-secondary)]">
|
||
<span class="font-medium" data-i18n="aboutPage.asrEndpoint">ASR
|
||
Endpoint:</span>
|
||
<span id="asr-endpoint"
|
||
class="font-mono text-xs text-[var(--text-muted)] ml-1">-</span>
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Project Information -->
|
||
<!-- <div class="bg-[var(--bg-secondary)] rounded-xl p-6 border border-[var(--border-primary)] shadow-sm">
|
||
<div class="flex items-center mb-4">
|
||
<div class="p-2 bg-gray-800 rounded-lg mr-3">
|
||
<i class="fab fa-github text-white text-lg"></i>
|
||
</div>
|
||
<h3 class="text-xl font-semibold text-[var(--text-primary)]" data-i18n="aboutPage.projectLinks">Project Links</h3>
|
||
</div>
|
||
|
||
<div class="space-y-3">
|
||
<a href="https://github.com/murtaza-nasir/speakr" target="_blank" rel="noopener noreferrer"
|
||
class="flex items-center p-3 bg-gray-800 hover:bg-gray-700 text-white rounded-lg transition-all duration-200 group transform hover:scale-[1.02]">
|
||
<i class="fab fa-github text-xl mr-3"></i>
|
||
<div class="flex-1">
|
||
<div class="font-medium" data-i18n="aboutPage.githubRepository">GitHub Repository</div>
|
||
<div class="text-sm text-gray-300" data-i18n="aboutPage.githubDescription">Source code, issues, and releases</div>
|
||
</div>
|
||
<i class="fas fa-external-link-alt text-sm opacity-70 group-hover:opacity-100"></i>
|
||
</a>
|
||
|
||
<a href="https://hub.docker.com/r/learnedmachine/speakr" target="_blank" rel="noopener noreferrer"
|
||
class="flex items-center p-3 bg-blue-600 hover:bg-blue-500 text-white rounded-lg transition-all duration-200 group transform hover:scale-[1.02]">
|
||
<i class="fab fa-docker text-xl mr-3"></i>
|
||
<div class="flex-1">
|
||
<div class="font-medium" data-i18n="aboutPage.dockerHub">Docker Hub</div>
|
||
<div class="text-sm text-blue-100" data-i18n="aboutPage.dockerDescription">Official Docker images</div>
|
||
</div>
|
||
<i class="fas fa-external-link-alt text-sm opacity-70 group-hover:opacity-100"></i>
|
||
</a>
|
||
|
||
<a href="https://murtaza-nasir.github.io/speakr" target="_blank" rel="noopener noreferrer"
|
||
class="flex items-center p-3 bg-green-600 hover:bg-green-500 text-white rounded-lg transition-all duration-200 group transform hover:scale-[1.02]">
|
||
<i class="fas fa-book text-xl mr-3"></i>
|
||
<div class="flex-1">
|
||
<div class="font-medium" data-i18n="aboutPage.documentation">Documentation</div>
|
||
<div class="text-sm text-green-100" data-i18n="aboutPage.documentationDescription">User guide and tutorials</div>
|
||
</div>
|
||
<i class="fas fa-external-link-alt text-sm opacity-70 group-hover:opacity-100"></i>
|
||
</a>
|
||
</div>
|
||
|
||
<div class="mt-6 p-3 bg-[var(--bg-tertiary)] rounded-lg border border-[var(--border-secondary)]">
|
||
<p class="text-sm text-[var(--text-secondary)] leading-relaxed">
|
||
<i class="fas fa-info-circle text-[var(--text-accent)] mr-2"></i>
|
||
<span data-i18n="aboutPage.projectDescription">Transform your audio recordings into organized, searchable notes with AI-powered transcription, summarization, and interactive chat features.</span>
|
||
</p>
|
||
</div>
|
||
</div> -->
|
||
</div>
|
||
|
||
<!-- Key Features -->
|
||
<div
|
||
class="bg-[var(--bg-secondary)] rounded-xl p-6 border border-[var(--border-primary)] shadow-sm">
|
||
<div class="flex items-center mb-6">
|
||
<div class="p-2 bg-purple-600 rounded-lg mr-3">
|
||
<i class="fas fa-star text-white text-lg"></i>
|
||
</div>
|
||
<h3 class="text-xl font-semibold text-[var(--text-primary)]"
|
||
data-i18n="aboutPage.keyFeatures">Key Features</h3>
|
||
</div>
|
||
|
||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||
<div
|
||
class="flex items-start p-4 bg-[var(--bg-tertiary)] rounded-lg border border-[var(--border-secondary)] hover:border-[var(--border-accent)] transition-colors">
|
||
<div class="p-2 bg-blue-100 dark:bg-blue-900 rounded-lg mr-3 mt-1">
|
||
<i class="fas fa-microphone text-blue-600 dark:text-blue-400"></i>
|
||
</div>
|
||
<div>
|
||
<h4 class="font-medium text-[var(--text-primary)] mb-1"
|
||
data-i18n="aboutPage.audioTranscription">Audio Transcription</h4>
|
||
<p class="text-sm text-[var(--text-secondary)]"
|
||
data-i18n="aboutPage.audioTranscriptionDesc">Whisper API and custom ASR
|
||
support with high accuracy</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
class="flex items-start p-4 bg-[var(--bg-tertiary)] rounded-lg border border-[var(--border-secondary)] hover:border-[var(--border-accent)] transition-colors">
|
||
<div class="p-2 bg-purple-100 dark:bg-purple-900 rounded-lg mr-3 mt-1">
|
||
<i class="fas fa-robot text-purple-600 dark:text-purple-400"></i>
|
||
</div>
|
||
<div>
|
||
<h4 class="font-medium text-[var(--text-primary)] mb-1"
|
||
data-i18n="aboutPage.aiSummarization">AI Summarization</h4>
|
||
<p class="text-sm text-[var(--text-secondary)]"
|
||
data-i18n="aboutPage.aiSummarizationDesc">OpenRouter and Ollama
|
||
integration with custom prompts</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
class="flex items-start p-4 bg-[var(--bg-tertiary)] rounded-lg border border-[var(--border-secondary)] hover:border-[var(--border-accent)] transition-colors">
|
||
<div class="p-2 bg-green-100 dark:bg-green-900 rounded-lg mr-3 mt-1">
|
||
<i class="fas fa-users text-green-600 dark:text-green-400"></i>
|
||
</div>
|
||
<div>
|
||
<h4 class="font-medium text-[var(--text-primary)] mb-1"
|
||
data-i18n="aboutPage.speakerDiarization">Speaker Diarization</h4>
|
||
<p class="text-sm text-[var(--text-secondary)]"
|
||
data-i18n="aboutPage.speakerDiarizationDesc">Identify and label
|
||
different speakers automatically</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
class="flex items-start p-4 bg-[var(--bg-tertiary)] rounded-lg border border-[var(--border-secondary)] hover:border-[var(--border-accent)] transition-colors">
|
||
<div class="p-2 bg-orange-100 dark:bg-orange-900 rounded-lg mr-3 mt-1">
|
||
<i class="fas fa-comments text-orange-600 dark:text-orange-400"></i>
|
||
</div>
|
||
<div>
|
||
<h4 class="font-medium text-[var(--text-primary)] mb-1"
|
||
data-i18n="aboutPage.interactiveChat">Interactive Chat</h4>
|
||
<p class="text-sm text-[var(--text-secondary)]"
|
||
data-i18n="aboutPage.interactiveChatDesc">Chat with your transcriptions
|
||
using AI</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
class="flex items-start p-4 bg-[var(--bg-tertiary)] rounded-lg border border-[var(--border-secondary)] hover:border-[var(--border-accent)] transition-colors">
|
||
<div class="p-2 bg-indigo-100 dark:bg-indigo-900 rounded-lg mr-3 mt-1">
|
||
<i class="fas fa-search text-indigo-600 dark:text-indigo-400"></i>
|
||
</div>
|
||
<div>
|
||
<h4 class="font-medium text-[var(--text-primary)] mb-1"
|
||
data-i18n="aboutPage.inquireMode">Inquire Mode</h4>
|
||
<p class="text-sm text-[var(--text-secondary)]"
|
||
data-i18n="aboutPage.inquireModeDesc">Semantic search across all your
|
||
recordings</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
class="flex items-start p-4 bg-[var(--bg-tertiary)] rounded-lg border border-[var(--border-secondary)] hover:border-[var(--border-accent)] transition-colors">
|
||
<div class="p-2 bg-teal-100 dark:bg-teal-900 rounded-lg mr-3 mt-1">
|
||
<i class="fas fa-share-alt text-teal-600 dark:text-teal-400"></i>
|
||
</div>
|
||
<div>
|
||
<h4 class="font-medium text-[var(--text-primary)] mb-1"
|
||
data-i18n="aboutPage.sharingExport">Sharing & Export</h4>
|
||
<p class="text-sm text-[var(--text-secondary)]"
|
||
data-i18n="aboutPage.sharingExportDesc">Share recordings and export to
|
||
various formats</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Technology Stack -->
|
||
<div
|
||
class="bg-[var(--bg-secondary)] rounded-xl p-6 border border-[var(--border-primary)] shadow-sm">
|
||
<div class="flex items-center mb-4">
|
||
<div class="p-2 bg-red-500 rounded-lg mr-3">
|
||
<i class="fas fa-code text-white text-lg"></i>
|
||
</div>
|
||
<h3 class="text-xl font-semibold text-[var(--text-primary)]"
|
||
data-i18n="aboutPage.technologyStack">Technology Stack</h3>
|
||
</div>
|
||
|
||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||
<div class="text-center p-3 bg-[var(--bg-tertiary)] rounded-lg">
|
||
<i class="fab fa-python text-3xl text-blue-500 mb-2"></i>
|
||
<p class="text-sm font-medium text-[var(--text-primary)]">Python Flask</p>
|
||
<p class="text-xs text-[var(--text-muted)]" data-i18n="aboutPage.backend">
|
||
Backend</p>
|
||
</div>
|
||
<div class="text-center p-3 bg-[var(--bg-tertiary)] rounded-lg">
|
||
<i class="fab fa-vuejs text-3xl text-green-500 mb-2"></i>
|
||
<p class="text-sm font-medium text-[var(--text-primary)]">Vue.js</p>
|
||
<p class="text-xs text-[var(--text-muted)]" data-i18n="aboutPage.frontend">
|
||
Frontend</p>
|
||
</div>
|
||
<div class="text-center p-3 bg-[var(--bg-tertiary)] rounded-lg">
|
||
<i class="fas fa-database text-3xl text-purple-500 mb-2"></i>
|
||
<p class="text-sm font-medium text-[var(--text-primary)]">SQLite</p>
|
||
<p class="text-xs text-[var(--text-muted)]" data-i18n="aboutPage.database">
|
||
Database</p>
|
||
</div>
|
||
<div class="text-center p-3 bg-[var(--bg-tertiary)] rounded-lg">
|
||
<i class="fab fa-docker text-3xl text-blue-600 mb-2"></i>
|
||
<p class="text-sm font-medium text-[var(--text-primary)]">Docker</p>
|
||
<p class="text-xs text-[var(--text-muted)]" data-i18n="aboutPage.deployment">
|
||
Deployment</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</main>
|
||
|
||
<footer class="text-center py-4 mt-8 text-xs text-[var(--text-light)] border-t border-[var(--border-primary)]">
|
||
<span id="footer-text">{{ html_title }} © {{ now.year }}</span>
|
||
</footer>
|
||
</div>
|
||
|
||
<!-- Change Password Modal -->
|
||
<div id="changePasswordModal"
|
||
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden">
|
||
<div class="bg-[var(--bg-secondary)] p-6 rounded-lg shadow-xl w-full max-w-md">
|
||
<div class="flex justify-between items-center mb-4">
|
||
<h3 class="text-xl font-semibold text-[var(--text-primary)]" data-i18n="account.changePassword">Change
|
||
Password</h3>
|
||
<button id="closeModalBtn"
|
||
class="text-[var(--text-light)] hover:text-[var(--text-muted)] text-2xl leading-none">×</button>
|
||
</div>
|
||
<form id="changePasswordForm" method="POST" action="/tool/speakr/change_password">
|
||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||
<div class="mb-4">
|
||
<label for="current_password" class="block text-sm font-medium text-[var(--text-secondary)] mb-1"
|
||
data-i18n="changePasswordModal.currentPassword">Current Password</label>
|
||
<input type="password" id="current_password" name="current_password" required
|
||
class="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--border-focus)] bg-[var(--bg-input)] text-[var(--text-primary)]">
|
||
</div>
|
||
<div class="mb-4">
|
||
<label for="new_password" class="block text-sm font-medium text-[var(--text-secondary)] mb-1"
|
||
data-i18n="changePasswordModal.newPassword">New Password</label>
|
||
<input type="password" id="new_password" name="new_password" required minlength="8"
|
||
class="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--border-focus)] bg-[var(--bg-input)] text-[var(--text-primary)]">
|
||
<p class="text-xs text-[var(--text-muted)] mt-1"
|
||
data-i18n="changePasswordModal.passwordRequirement">Password must be at least 8 characters long
|
||
</p>
|
||
</div>
|
||
<div class="mb-6">
|
||
<label for="confirm_password" class="block text-sm font-medium text-[var(--text-secondary)] mb-1"
|
||
data-i18n="changePasswordModal.confirmPassword">Confirm New Password</label>
|
||
<input type="password" id="confirm_password" name="confirm_password" required
|
||
class="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--border-focus)] bg-[var(--bg-input)] text-[var(--text-primary)]">
|
||
</div>
|
||
<div class="flex justify-end space-x-3">
|
||
<button type="button" id="cancelBtn"
|
||
class="px-4 py-2 bg-[var(--bg-tertiary)] text-[var(--text-secondary)] rounded-md hover:bg-[var(--border-secondary)]">取消</button>
|
||
<button type="submit"
|
||
class="px-4 py-2 bg-[var(--bg-button)] text-[var(--text-button)] rounded-md hover:bg-[var(--bg-button-hover)]"
|
||
data-i18n="account.changePassword">Change Password</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Manage Speakers Modal -->
|
||
<div id="manageSpeakersModal"
|
||
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden">
|
||
<div class="bg-[var(--bg-secondary)] p-6 rounded-lg shadow-xl w-full max-w-2xl max-h-[80vh] overflow-hidden">
|
||
<div class="flex justify-between items-center mb-4">
|
||
<h3 class="text-xl font-semibold text-[var(--text-primary)]" data-i18n="account.manageSpeakers">Manage
|
||
Speakers</h3>
|
||
<button id="closeSpeakersModalBtn"
|
||
class="text-[var(--text-light)] hover:text-[var(--text-muted)] text-2xl leading-none">×</button>
|
||
</div>
|
||
|
||
<div class="mb-4">
|
||
<p class="text-sm text-[var(--text-muted)] mb-4">
|
||
<span data-i18n="manageSpeakersModal.description">Manage your saved speakers. These are
|
||
automatically saved when you use speaker names in your recordings.</span>
|
||
</p>
|
||
|
||
<!-- Loading state -->
|
||
<div id="speakersLoading" class="text-center py-8 hidden">
|
||
<i class="fas fa-spinner fa-spin text-2xl text-[var(--text-muted)]"></i>
|
||
<p class="text-[var(--text-muted)] mt-2" data-i18n="manageSpeakersModal.loadingSpeakers">Loading
|
||
speakers...</p>
|
||
</div>
|
||
|
||
<!-- Error state -->
|
||
<div id="speakersError"
|
||
class="hidden bg-[var(--bg-danger-light)] text-[var(--text-danger-strong)] p-3 rounded-lg mb-4">
|
||
<i class="fas fa-exclamation-triangle mr-2"></i>
|
||
<span id="speakersErrorMessage" data-i18n="manageSpeakersModal.failedToLoad">Failed to load
|
||
speakers</span>
|
||
</div>
|
||
|
||
<!-- Empty state -->
|
||
<div id="speakersEmpty" class="text-center py-8 hidden">
|
||
<i class="fas fa-users text-4xl text-[var(--text-light)] mb-4"></i>
|
||
<p class="text-[var(--text-muted)]" data-i18n="manageSpeakersModal.noSpeakersYet">No speakers saved
|
||
yet</p>
|
||
<p class="text-sm text-[var(--text-light)]" data-i18n="manageSpeakersModal.speakersWillAppear">
|
||
Speakers will appear here when you use speaker names in your recordings</p>
|
||
</div>
|
||
|
||
<!-- Speakers list -->
|
||
<div id="speakersList" class="space-y-2 max-h-96 overflow-y-auto">
|
||
<!-- Speakers will be populated here -->
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex justify-between items-center pt-4 border-t border-[var(--border-primary)]">
|
||
<div class="text-sm text-[var(--text-muted)]">
|
||
<span><span id="speakersCount">0</span> <span data-i18n="manageSpeakersModal.speakersSaved">speakers
|
||
saved</span></span>
|
||
</div>
|
||
<div class="flex space-x-3">
|
||
<button id="deleteAllSpeakersBtn"
|
||
class="px-4 py-2 bg-[var(--bg-danger)] text-[var(--text-button)] rounded-md hover:bg-[var(--bg-danger-hover)] disabled:opacity-50 disabled:cursor-not-allowed"
|
||
disabled>
|
||
<i class="fas fa-trash mr-2"></i> <span data-i18n="buttons.deleteAll">Delete All</span>
|
||
</button>
|
||
<button id="closeSpeakersBtn"
|
||
class="px-4 py-2 bg-[var(--bg-tertiary)] text-[var(--text-secondary)] rounded-md hover:bg-[var(--border-secondary)]">
|
||
<span data-i18n="buttons.close">Close</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Delete All Speakers Confirmation Modal -->
|
||
<div id="deleteAllSpeakersModal"
|
||
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-60 hidden">
|
||
<div class="bg-[var(--bg-secondary)] p-6 rounded-lg shadow-xl w-full max-w-md">
|
||
<div class="flex items-center mb-4">
|
||
<i class="fas fa-exclamation-triangle text-[var(--text-danger)] text-2xl mr-3"></i>
|
||
<h3 class="text-xl font-semibold text-[var(--text-primary)]" data-i18n="buttons.deleteAll">Delete All
|
||
Speakers</h3>
|
||
</div>
|
||
<p class="text-[var(--text-secondary)] mb-6">
|
||
<span data-i18n="deleteAllSpeakersModal.confirmMessage">Are you sure you want to delete all saved
|
||
speakers? This action cannot be undone.</span>
|
||
</p>
|
||
<div class="flex justify-end space-x-3">
|
||
<button id="cancelDeleteAllBtn"
|
||
class="px-4 py-2 bg-[var(--bg-tertiary)] text-[var(--text-secondary)] rounded-md hover:bg-[var(--border-secondary)]">
|
||
<span data-i18n="buttons.cancel">取消</span>
|
||
</button>
|
||
<button id="confirmDeleteAllBtn"
|
||
class="px-4 py-2 bg-[var(--bg-danger)] text-[var(--text-button)] rounded-md hover:bg-[var(--bg-danger-hover)]">
|
||
<i class="fas fa-trash mr-2"></i> Delete All
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Create/Edit Tag Modal -->
|
||
<div id="tagModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden">
|
||
<div class="bg-[var(--bg-secondary)] p-6 rounded-lg shadow-xl w-full max-w-lg max-h-[90vh] overflow-y-auto">
|
||
<div class="flex justify-between items-center mb-4">
|
||
<h3 id="tagModalTitle" class="text-xl font-semibold text-[var(--text-primary)]"
|
||
data-i18n="editTagModal.createTitle">Create Tag</h3>
|
||
<button id="closeTagModalBtn"
|
||
class="text-[var(--text-light)] hover:text-[var(--text-muted)] text-2xl leading-none">×</button>
|
||
</div>
|
||
|
||
<form id="tagForm" class="space-y-4">
|
||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||
<input type="hidden" id="tagId" name="tag_id">
|
||
|
||
<div>
|
||
<label for="tagName" class="block text-sm font-medium text-[var(--text-secondary)] mb-1"
|
||
data-i18n="editTagModal.tagName">Tag Name *</label>
|
||
<input type="text" id="tagName" name="name" required maxlength="50"
|
||
class="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--border-focus)] bg-[var(--bg-input)] text-[var(--text-primary)]"
|
||
placeholder="e.g., Meetings, Interviews">
|
||
</div>
|
||
|
||
<div>
|
||
<label for="tagColor" class="block text-sm font-medium text-[var(--text-secondary)] mb-1"
|
||
data-i18n="editTagModal.color">Color</label>
|
||
<div class="flex items-center space-x-3">
|
||
<input type="color" id="tagColor" name="color" value="#3B82F6"
|
||
class="w-12 h-10 rounded border border-[var(--border-secondary)]">
|
||
<span class="text-sm text-[var(--text-muted)]" data-i18n="editTagModal.colorDescription">Choose
|
||
a color for easy identification</span>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label for="tagScope"
|
||
class="block text-sm font-medium text-[var(--text-secondary)] mb-1">标签范围{{current_user.is_admin}}</label>
|
||
<select id="tagScope" name="visibility" required
|
||
class="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--border-focus)] bg-[var(--bg-input)] text-[var(--text-primary)]">
|
||
<option value="global">全局</option>
|
||
<option value="private">个人</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label for="tagCustomPrompt" class="block text-sm font-medium text-[var(--text-secondary)] mb-1"
|
||
data-i18n="editTagModal.customPrompt">Custom Summary Prompt</label>
|
||
<textarea id="tagCustomPrompt" name="custom_prompt" rows="4"
|
||
class="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--border-focus)] bg-[var(--bg-input)] text-[var(--text-primary)]"
|
||
placeholder="Optional: Custom prompt for generating summaries for recordings with this tag"
|
||
data-i18n-placeholder="editTagModal.customPromptPlaceholder"></textarea>
|
||
<p class="text-xs text-[var(--text-light)] mt-1" data-i18n="editTagModal.leaveBlankPrompt">Leave
|
||
blank to use your default summary prompt</p>
|
||
</div>
|
||
|
||
{% if use_asr_endpoint %}
|
||
<div class="border-t border-[var(--border-primary)] pt-4">
|
||
<h4 class="text-sm font-medium text-[var(--text-secondary)] mb-3"
|
||
data-i18n="editTagModal.asrDefaultSettings">ASR Default Settings</h4>
|
||
<p class="text-xs text-[var(--text-light)] mb-3" data-i18n="editTagModal.asrSettingsDescription">
|
||
These settings will be applied by default when using this tag with ASR transcription</p>
|
||
|
||
<div class="space-y-3">
|
||
<div>
|
||
<label for="tagLanguage" class="block text-sm font-medium text-[var(--text-muted)] mb-1"
|
||
data-i18n="editTagModal.defaultLanguage">Default Language</label>
|
||
<input type="text" id="tagLanguage" name="default_language" maxlength="10"
|
||
class="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--border-focus)] bg-[var(--bg-input)] text-[var(--text-primary)]"
|
||
placeholder="e.g., en, es, zh">
|
||
</div>
|
||
|
||
<div class="grid grid-cols-2 gap-3">
|
||
<div>
|
||
<label for="tagMinSpeakers"
|
||
class="block text-sm font-medium text-[var(--text-muted)] mb-1"
|
||
data-i18n="editTagModal.minSpeakers">Min Speakers</label>
|
||
<input type="number" id="tagMinSpeakers" name="default_min_speakers" min="1"
|
||
class="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--border-focus)] bg-[var(--bg-input)] text-[var(--text-primary)]"
|
||
</div>
|
||
|
||
<div>
|
||
<label for="tagMaxSpeakers"
|
||
class="block text-sm font-medium text-[var(--text-muted)] mb-1"
|
||
data-i18n="editTagModal.maxSpeakers">Max Speakers</label>
|
||
<input type="number" id="tagMaxSpeakers" name="default_max_speakers" min="1"
|
||
class="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--border-focus)] bg-[var(--bg-input)] text-[var(--text-primary)]">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{% endif %}
|
||
|
||
<div class="flex justify-end space-x-3 pt-4 border-t border-[var(--border-primary)]">
|
||
<button type="button" id="cancelTagBtn"
|
||
class="px-4 py-2 bg-[var(--bg-tertiary)] text-[var(--text-secondary)] rounded-md hover:bg-[var(--border-secondary)]"
|
||
data-i18n="buttons.cancel">取消</button>
|
||
<button type="submit" id="saveTagBtn"
|
||
class="px-4 py-2 bg-[var(--bg-button)] text-[var(--text-button)] rounded-md hover:bg-[var(--bg-button-hover)]">
|
||
<i class="fas fa-save mr-2"></i> <span data-i18n="buttons.createTag">Save Tag</span>
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
|
||
<script>
|
||
let voices = []; // 存储声纹数据
|
||
let editIndex = null;
|
||
let deleteIndex = null;
|
||
let mediaRecorder;
|
||
let audioChunks = [];
|
||
let isRecording = false;
|
||
let isSavingVoice = false;
|
||
let modetypes = "add"
|
||
let modechooseIndex = 0
|
||
// 打开添加/编辑弹框
|
||
async function openModal(mode, index) {
|
||
document.getElementById('voiceModal').style.display = 'flex';
|
||
document.getElementById('audioPreview').style.display = 'none';
|
||
document.getElementById('voiceFile').value = '';
|
||
document.getElementById('voiceName').value = '';
|
||
// document.getElementById('voiceDesc').value = '';
|
||
modetypes = mode
|
||
modechooseIndex = index
|
||
if (mode === 'add') {
|
||
document.getElementById('modalTitle').innerText = '添加说话人';
|
||
editIndex = null;
|
||
} else if (mode === 'edit') {
|
||
document.getElementById('modalTitle').innerText = '编辑说话人';
|
||
editIndex = index;
|
||
const v = voices[index];
|
||
document.getElementById('voiceName').value = v.name;
|
||
|
||
if (v.id) {
|
||
try {
|
||
const response = await fetch(`/tool/speakr/api/voiceprints/${v.id}/audio`);
|
||
if (!response.ok) throw new Error(`获取音频失败:${response.status}`);
|
||
const blob = await response.blob();
|
||
const audioURL = URL.createObjectURL(blob);
|
||
const audio = document.getElementById('audioPreview');
|
||
audio.src = audioURL;
|
||
audio.style.display = 'block';
|
||
v.audio = blob;
|
||
audioChunks = [blob]; // <-- 新增
|
||
console.log("audioChunks 已从远程 Blob 恢复:", audioChunks);
|
||
console.log('声纹音频已加载并回显:', v.name);
|
||
} catch (err) {
|
||
console.error('获取声纹音频失败:', err);
|
||
alert('无法加载该说话人的音频文件。');
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 关闭弹框
|
||
function closeModal() {
|
||
document.getElementById('voiceModal').style.display = 'none';
|
||
}
|
||
async function synchronize() {
|
||
|
||
const response = await fetch(`/tool/speakr/api/voiceprints/sync-local-to-platform`, {
|
||
method: "POST",
|
||
});
|
||
const data = await response.json();
|
||
console.log(data,'data');
|
||
// showMessage(`✅ ${data.message}`, 'success');
|
||
|
||
|
||
}
|
||
// 保存声纹信息
|
||
async function saveVoice() {
|
||
if (isSavingVoice) {
|
||
return;
|
||
}
|
||
const name = document.getElementById('voiceName').value.trim();
|
||
// const desc
|
||
// = document.getElementById('voiceDesc').value.trim();
|
||
const fileInput = document.getElementById('voiceFile').files[0];
|
||
|
||
if (!name) { alert('名称为必填'); return; }
|
||
if (!fileInput && !audioChunks.length && editIndex === null) {
|
||
alert('请上传或录音声纹');
|
||
return;
|
||
}
|
||
|
||
let audioBlob = fileInput ? fileInput : new Blob(audioChunks, { type: 'audio/webm' });
|
||
|
||
// 测试音频文件
|
||
const reader = new FileReader();
|
||
reader.onload = function(e) {
|
||
console.log("audioBlob base64:", e.target.result);
|
||
};
|
||
reader.readAsDataURL(audioBlob);
|
||
console.log("audioBlob type:", audioBlob.type);
|
||
console.log("audioBlob size:", audioBlob.size);
|
||
console.log("audioBlob instanceof File:", audioBlob instanceof File);
|
||
console.log("audioBlob instanceof Blob:", audioBlob instanceof Blob);
|
||
console.log("audioBlob:", audioBlob);
|
||
|
||
// 使用 FormData 发送音频文件
|
||
|
||
const formData = new FormData();
|
||
formData.append("name", name);
|
||
const uploadFileName = fileInput && fileInput.name ? fileInput.name : `voiceprint_${Date.now()}.webm`;
|
||
formData.append("voice_data", audioBlob, uploadFileName);
|
||
isSavingVoice = true;
|
||
const saveBtn = document.getElementById('saveVoiceBtn');
|
||
if (saveBtn) {
|
||
saveBtn.disabled = true;
|
||
saveBtn.dataset.originalText = saveBtn.textContent;
|
||
saveBtn.textContent = '保存中...';
|
||
}
|
||
|
||
if (modetypes == 'add') {
|
||
try {
|
||
const response = await fetch("/tool/speakr/api/voiceprints", {
|
||
method: "POST",
|
||
body: formData,
|
||
});
|
||
const data = await response.json();
|
||
if (!response.ok) {
|
||
showMessage(`${data.error}`, 'error');
|
||
return
|
||
}
|
||
closeModal();
|
||
audioChunks = [];
|
||
renderList()
|
||
showMessage(`✅ ${data.message}`, 'success');
|
||
} catch (err) {
|
||
// console.error("上传出错:", err);
|
||
// alert("上传失败:" + err.message);
|
||
showMessage(`${err.message}`, 'error');
|
||
|
||
} finally {
|
||
isSavingVoice = false;
|
||
if (saveBtn) {
|
||
saveBtn.disabled = false;
|
||
saveBtn.textContent = saveBtn.dataset.originalText || '保存';
|
||
}
|
||
}
|
||
} else {
|
||
try {
|
||
let v = voices[modechooseIndex]
|
||
const response = await fetch(`/tool/speakr/api/voiceprints/${v.id}`, {
|
||
method: "PUT",
|
||
body: formData,
|
||
});
|
||
|
||
|
||
|
||
const data = await response.json();
|
||
if (!response.ok) {
|
||
showMessage(`${data.error}`, 'error');
|
||
return
|
||
}
|
||
closeModal();
|
||
audioChunks = [];
|
||
renderList()
|
||
showMessage(`✅ ${data.message}`, 'success');
|
||
} catch (err) {
|
||
// console.error("上传出错:", err);
|
||
// alert("上传失败:" + err.message);
|
||
showMessage(`${err.message}`, 'error');
|
||
} finally {
|
||
isSavingVoice = false;
|
||
if (saveBtn) {
|
||
saveBtn.disabled = false;
|
||
saveBtn.textContent = saveBtn.dataset.originalText || '保存';
|
||
}
|
||
}
|
||
}
|
||
}
|
||
function showMessage(msg, type, duration = 3000) {
|
||
const messageDiv = document.getElementById('message');
|
||
|
||
if (type === 'error') {
|
||
messageDiv.style.background = '#f44336'; // 红色
|
||
} else if (type === 'warning') {
|
||
messageDiv.style.background = '#ff9800'; // 橙色(可选)
|
||
} else {
|
||
messageDiv.style.background = '#4caf50'; // 默认绿色(成功)
|
||
}
|
||
messageDiv.textContent = msg;
|
||
messageDiv.style.display = 'block';
|
||
|
||
setTimeout(() => {
|
||
messageDiv.style.display = 'none';
|
||
}, duration);
|
||
}
|
||
// 渲染列表
|
||
async function renderList() {
|
||
const response = await fetch(`/tool/speakr/api/voiceprints`, {
|
||
method: "GET",
|
||
});
|
||
const data = await response.json();
|
||
// console.log(data,'data');
|
||
voices = data.voiceprints
|
||
const tbody = document.querySelector('#voiceList tbody');
|
||
tbody.innerHTML = '';
|
||
voices.forEach((v, i) => {
|
||
const tr = document.createElement('tr');
|
||
tr.innerHTML = `
|
||
<td>${v.name}</td>
|
||
<td>${v.id}</td>
|
||
|
||
|
||
<td>
|
||
<button class="btn-icon" onclick="openModal('edit', ${i})">✏️</button>
|
||
<button class="btn-icon" onclick="openDeleteModal(${i})">🗑️</button>
|
||
</td>
|
||
`;
|
||
tbody.appendChild(tr);
|
||
});
|
||
}
|
||
|
||
// 删除逻辑
|
||
function openDeleteModal(index) {
|
||
deleteIndex = index;
|
||
document.getElementById('confirmDeleteModal').style.display = 'flex';
|
||
}
|
||
function closeDeleteModal() {
|
||
document.getElementById('confirmDeleteModal').style.display = 'none';
|
||
deleteIndex = null;
|
||
}
|
||
async function confirmDelete() {
|
||
// if (deleteIndex !== null) voices.splice(deleteIndex, 1);
|
||
console.log(voices[deleteIndex]);
|
||
let v = voices[deleteIndex]
|
||
const response = await fetch(`/tool/speakr/api/voiceprints/${v.id}`, {
|
||
method: "DELETE",
|
||
});
|
||
const data = await response.json();
|
||
if (!response.ok) {
|
||
showMessage(`${data.error}`, 'error');
|
||
return
|
||
}
|
||
console.log(data, 'data');
|
||
renderList();
|
||
closeDeleteModal();
|
||
showMessage(`✅ ${data.message}`, 'success');
|
||
|
||
}
|
||
// 清空上传的文件
|
||
function clearUploadFile() {
|
||
const fileInput = document.getElementById('voiceFile');
|
||
fileInput.value = ''; // 清空文件选择
|
||
console.log('✅ 已清空上传的文件');
|
||
// 如果当前显示的音频是上传的文件,也清除
|
||
const audio = document.getElementById('audioPreview');
|
||
if (audio.src && audio.src.startsWith('blob:')) {
|
||
audio.src = '';
|
||
audio.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
// 清空录音内容
|
||
function clearRecording() {
|
||
audioChunks = [];
|
||
console.log('✅ 已清空录音内容');
|
||
const audio = document.getElementById('audioPreview');
|
||
audio.src = '';
|
||
audio.style.display = 'none';
|
||
}
|
||
function startRecording(btn) {
|
||
if (isRecording) {
|
||
stopRecording(btn);
|
||
return;
|
||
}
|
||
|
||
navigator.mediaDevices.getUserMedia({ audio: true })
|
||
.then(stream => {
|
||
mediaRecorder = new MediaRecorder(stream);
|
||
audioChunks = [];
|
||
mediaRecorder.start();
|
||
isRecording = true;
|
||
|
||
// 按钮切换为录音中样式
|
||
btn.textContent = '停止';
|
||
btn.classList.add('btn-recording');
|
||
|
||
mediaRecorder.ondataavailable = e => audioChunks.push(e.data);
|
||
|
||
mediaRecorder.onstop = () => {
|
||
isRecording = false;
|
||
btn.textContent = '🎤 录音';
|
||
btn.classList.remove('btn-recording');
|
||
|
||
// 生成音频预览
|
||
const audioBlob = new Blob(audioChunks, { type: 'audio/webm' });
|
||
const audioURL = URL.createObjectURL(audioBlob);
|
||
const audio = document.getElementById('audioPreview');
|
||
audio.src = audioURL;
|
||
audio.style.display = 'block';
|
||
|
||
// 释放麦克风资源
|
||
stream.getTracks().forEach(track => track.stop());
|
||
};
|
||
})
|
||
.catch(err => {
|
||
alert('无法访问麦克风:' + err.message);
|
||
});
|
||
}
|
||
|
||
function stopRecording(btn) {
|
||
if (mediaRecorder && isRecording) {
|
||
mediaRecorder.stop();
|
||
}
|
||
}
|
||
document.addEventListener('DOMContentLoaded', async function () {
|
||
// Initialize i18n
|
||
await initializeI18n();
|
||
|
||
// Hide loading overlay after everything is ready
|
||
if (window.AppLoader) {
|
||
AppLoader.waitForReady();
|
||
}
|
||
|
||
// Setup scroll indicators for tabs
|
||
const tabsContainer = document.getElementById('tabs-container');
|
||
const leftIndicator = document.getElementById('left-scroll-indicator');
|
||
const rightIndicator = document.getElementById('right-scroll-indicator');
|
||
|
||
function updateScrollIndicators() {
|
||
if (tabsContainer && leftIndicator && rightIndicator) {
|
||
const scrollLeft = tabsContainer.scrollLeft;
|
||
const scrollWidth = tabsContainer.scrollWidth;
|
||
const clientWidth = tabsContainer.clientWidth;
|
||
|
||
// Show/hide left indicator
|
||
if (scrollLeft > 10) {
|
||
leftIndicator.classList.remove('opacity-0');
|
||
leftIndicator.classList.add('opacity-100');
|
||
} else {
|
||
leftIndicator.classList.add('opacity-0');
|
||
leftIndicator.classList.remove('opacity-100');
|
||
}
|
||
|
||
// Show/hide right indicator
|
||
if (scrollLeft < scrollWidth - clientWidth - 10) {
|
||
rightIndicator.classList.remove('opacity-0');
|
||
rightIndicator.classList.add('opacity-100');
|
||
} else {
|
||
rightIndicator.classList.add('opacity-0');
|
||
rightIndicator.classList.remove('opacity-100');
|
||
}
|
||
}
|
||
}
|
||
|
||
if (tabsContainer) {
|
||
tabsContainer.addEventListener('scroll', updateScrollIndicators);
|
||
window.addEventListener('resize', updateScrollIndicators);
|
||
// Initial check - run immediately and after a delay
|
||
updateScrollIndicators();
|
||
setTimeout(updateScrollIndicators, 100);
|
||
setTimeout(updateScrollIndicators, 500);
|
||
}
|
||
|
||
const tabAccount = document.getElementById('tab-account');
|
||
const tabPrompts = document.getElementById('tab-prompts');
|
||
const tabShares = document.getElementById('tab-shares');
|
||
const tabTags = document.getElementById('tab-tags');
|
||
const voiceprint = document.getElementById('tab-voiceprint');
|
||
const tabTemplates = document.getElementById('tab-templates');
|
||
const tabAbout = document.getElementById('tab-about');
|
||
const contentAccount = document.getElementById('content-account');
|
||
const contentPrompts = document.getElementById('content-prompts');
|
||
const contentShares = document.getElementById('content-shares');
|
||
const contentSpeakers = document.getElementById('content-speakers');
|
||
const contentTags = document.getElementById('content-tags');
|
||
const contentTemplates = document.getElementById('content-templates');
|
||
const contentVoiceprint = document.getElementById('content-voiceprint');
|
||
const contentAbout = document.getElementById('content-about');
|
||
const sharesContainer = document.getElementById('shares-container');
|
||
|
||
|
||
// Simplified tab switching
|
||
const tabSpeakers = document.getElementById('tab-speakers');
|
||
|
||
const tabs = [
|
||
{ tab: tabAccount, content: contentAccount, name: 'account' },
|
||
{ tab: tabPrompts, content: contentPrompts, name: 'prompts' },
|
||
{ tab: tabShares, content: contentShares, name: 'shares' },
|
||
{ tab: tabSpeakers, content: contentSpeakers, name: 'speakers' },
|
||
{ tab: tabTags, content: contentTags, name: 'tags' },
|
||
{ tab: voiceprint, content: contentVoiceprint, name: 'voiceprint' },
|
||
{ tab: tabTemplates, content: contentTemplates, name: 'templates' },
|
||
{ tab: tabAbout, content: contentAbout, name: 'about' }
|
||
];
|
||
|
||
function switchToTab(targetName) {
|
||
tabs.forEach(({ tab, content, name }) => {
|
||
if (name === targetName) {
|
||
// Active tab
|
||
tab.classList.add('border-[var(--border-accent)]', 'text-[var(--text-accent)]');
|
||
tab.classList.remove('border-transparent', 'text-[var(--text-muted)]', 'hover:text-[var(--text-secondary)]', 'hover:border-[var(--border-secondary)]');
|
||
content.classList.remove('hidden');
|
||
} else {
|
||
// Inactive tab
|
||
tab.classList.add('border-transparent', 'text-[var(--text-muted)]', 'hover:text-[var(--text-secondary)]', 'hover:border-[var(--border-secondary)]');
|
||
tab.classList.remove('border-[var(--border-accent)]', 'text-[var(--text-accent)]');
|
||
content.classList.add('hidden');
|
||
}
|
||
});
|
||
|
||
// Load content when switching tabs
|
||
if (targetName === 'speakers') {
|
||
loadSpeakersTab();
|
||
} else if (targetName === 'shares') {
|
||
loadShares();
|
||
} else if (targetName === 'tags') {
|
||
loadTags();
|
||
} else if (targetName === 'templates') {
|
||
loadTemplates();
|
||
} else if (targetName === 'about') {
|
||
loadSystemInfo();
|
||
} else if (targetName === 'voiceprint') {
|
||
loadVoiceprint();
|
||
}
|
||
}
|
||
|
||
function showAccountTab() {
|
||
switchToTab('account');
|
||
}
|
||
|
||
function showPromptsTab() {
|
||
switchToTab('prompts');
|
||
}
|
||
|
||
function showSharesTab() {
|
||
switchToTab('shares');
|
||
}
|
||
|
||
function showSpeakersTab() {
|
||
switchToTab('speakers');
|
||
}
|
||
|
||
function showTagsTab() {
|
||
switchToTab('tags');
|
||
}
|
||
|
||
function showTemplatesTab() {
|
||
switchToTab('templates');
|
||
}
|
||
function showVoiceprint() {
|
||
switchToTab('voiceprint');
|
||
}
|
||
|
||
function showAboutTab() {
|
||
switchToTab('about');
|
||
}
|
||
|
||
tabAccount.addEventListener('click', (e) => {
|
||
e.preventDefault();
|
||
showAccountTab();
|
||
});
|
||
|
||
tabPrompts.addEventListener('click', (e) => {
|
||
e.preventDefault();
|
||
showPromptsTab();
|
||
});
|
||
|
||
tabShares.addEventListener('click', (e) => {
|
||
e.preventDefault();
|
||
showSharesTab();
|
||
});
|
||
|
||
tabSpeakers.addEventListener('click', (e) => {
|
||
e.preventDefault();
|
||
showSpeakersTab();
|
||
});
|
||
|
||
tabTags.addEventListener('click', (e) => {
|
||
e.preventDefault();
|
||
showTagsTab();
|
||
});
|
||
|
||
tabTemplates.addEventListener('click', (e) => {
|
||
e.preventDefault();
|
||
showTemplatesTab();
|
||
});
|
||
|
||
tabAbout.addEventListener('click', (e) => {
|
||
e.preventDefault();
|
||
showAboutTab();
|
||
});
|
||
voiceprint.addEventListener('click', (e) => {
|
||
e.preventDefault();
|
||
showVoiceprint();
|
||
});
|
||
|
||
// Load speakers for the Speakers Management tab
|
||
let speakersTabData = [];
|
||
let speakersTabLoaded = false;
|
||
|
||
async function loadSpeakersTab() {
|
||
// Don't reload if already loaded
|
||
if (speakersTabLoaded) return;
|
||
|
||
const speakersTabLoading = document.getElementById('speakersTabLoading');
|
||
const speakersTabError = document.getElementById('speakersTabError');
|
||
const speakersTabErrorMessage = document.getElementById('speakersTabErrorMessage');
|
||
const speakersTabEmpty = document.getElementById('speakersTabEmpty');
|
||
const speakersTabGrid = document.getElementById('speakersTabGrid');
|
||
const speakersTabCount = document.getElementById('speakersTabCount');
|
||
const deleteAllSpeakersTabBtn = document.getElementById('deleteAllSpeakersTabBtn');
|
||
|
||
// Show loading state
|
||
speakersTabLoading.classList.remove('hidden');
|
||
speakersTabError.classList.add('hidden');
|
||
speakersTabEmpty.classList.add('hidden');
|
||
speakersTabGrid.classList.add('hidden');
|
||
|
||
try {
|
||
const response = await fetch('/tool/speakr/speakers');
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||
}
|
||
|
||
speakersTabData = await response.json();
|
||
speakersTabLoaded = true;
|
||
|
||
// Hide loading state
|
||
speakersTabLoading.classList.add('hidden');
|
||
|
||
if (speakersTabData.length === 0) {
|
||
speakersTabEmpty.classList.remove('hidden');
|
||
speakersTabGrid.classList.add('hidden');
|
||
deleteAllSpeakersTabBtn.disabled = true;
|
||
} else {
|
||
speakersTabEmpty.classList.add('hidden');
|
||
speakersTabGrid.classList.remove('hidden');
|
||
deleteAllSpeakersTabBtn.disabled = false;
|
||
renderSpeakersTabGrid();
|
||
}
|
||
|
||
// Update count
|
||
speakersTabCount.textContent = speakersTabData.length;
|
||
|
||
} catch (error) {
|
||
console.error('Error loading speakers:', error);
|
||
speakersTabLoading.classList.add('hidden');
|
||
speakersTabError.classList.remove('hidden');
|
||
speakersTabEmpty.classList.add('hidden');
|
||
speakersTabGrid.classList.add('hidden');
|
||
speakersTabErrorMessage.textContent = error.message;
|
||
speakersTabCount.textContent = '0';
|
||
}
|
||
}
|
||
|
||
function renderSpeakersTabGrid() {
|
||
const speakersTabGrid = document.getElementById('speakersTabGrid');
|
||
speakersTabGrid.innerHTML = '';
|
||
|
||
speakersTabData.forEach(speaker => {
|
||
const createdDate = new Date(speaker.created_at).toLocaleDateString();
|
||
const lastUsedDate = new Date(speaker.last_used).toLocaleDateString();
|
||
|
||
const speakerCard = document.createElement('div');
|
||
speakerCard.className = 'bg-[var(--bg-tertiary)] rounded-lg border border-[var(--border-primary)] p-4 hover:border-[var(--border-accent)] transition-colors duration-200';
|
||
|
||
speakerCard.innerHTML = `
|
||
<div class="flex flex-col h-full">
|
||
<div class="flex items-start justify-between mb-3">
|
||
<div class="flex items-center flex-1 min-w-0">
|
||
<i class="fas fa-user text-[var(--text-accent)] mr-2 flex-shrink-0"></i>
|
||
<h3 class="font-medium text-base text-[var(--text-primary)] truncate">${escapeHtml(speaker.name)}</h3>
|
||
</div>
|
||
<button class="delete-speaker-tab-btn ml-2 p-1.5 text-[var(--text-danger)] hover:bg-[var(--bg-danger-light)] rounded-md transition-colors duration-200 flex-shrink-0"
|
||
data-speaker-id="${speaker.id}"
|
||
data-speaker-name="${escapeHtml(speaker.name)}"
|
||
data-i18n-title="buttons.deleteSpeaker">
|
||
<i class="fas fa-trash text-sm"></i>
|
||
</button>
|
||
</div>
|
||
<div class="space-y-1 text-xs text-[var(--text-muted)]">
|
||
<p class="flex items-center">
|
||
<i class="fas fa-chart-line mr-1.5 text-xs opacity-60"></i>
|
||
<span>Used ${speaker.use_count} time${speaker.use_count !== 1 ? 's' : ''}</span>
|
||
</p>
|
||
<p class="flex items-center">
|
||
<i class="fas fa-clock mr-1.5 text-xs opacity-60"></i>
|
||
<span>Last: ${lastUsedDate}</span>
|
||
</p>
|
||
<p class="flex items-center">
|
||
<i class="fas fa-calendar-plus mr-1.5 text-xs opacity-60"></i>
|
||
<span>Added: ${createdDate}</span>
|
||
</p>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
// Add delete functionality
|
||
const deleteBtn = speakerCard.querySelector('.delete-speaker-tab-btn');
|
||
deleteBtn.addEventListener('click', () => deleteSpeakerFromTab(speaker.id, speaker.name));
|
||
|
||
speakersTabGrid.appendChild(speakerCard);
|
||
});
|
||
}
|
||
|
||
async function deleteSpeakerFromTab(speakerId, speakerName) {
|
||
if (!confirm(`Are you sure you want to delete "${speakerName}"?`)) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// Get CSRF token
|
||
const csrfToken = document.querySelector('meta[name=csrf-token]')?.getAttribute('content');
|
||
|
||
const headers = {};
|
||
if (csrfToken) {
|
||
headers['X-CSRFToken'] = csrfToken;
|
||
}
|
||
|
||
const response = await fetch(`/tool/speakr/speakers/${speakerId}`, {
|
||
method: 'DELETE',
|
||
headers: headers
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const errorData = await response.json();
|
||
throw new Error(errorData.error || `HTTP ${response.status}: ${response.statusText}`);
|
||
}
|
||
|
||
// Remove from local array and re-render
|
||
speakersTabData = speakersTabData.filter(s => s.id !== speakerId);
|
||
|
||
if (speakersTabData.length === 0) {
|
||
document.getElementById('speakersTabEmpty').classList.remove('hidden');
|
||
document.getElementById('speakersTabGrid').classList.add('hidden');
|
||
document.getElementById('deleteAllSpeakersTabBtn').disabled = true;
|
||
} else {
|
||
renderSpeakersTabGrid();
|
||
}
|
||
|
||
document.getElementById('speakersTabCount').textContent = speakersTabData.length;
|
||
showToast(`Speaker "${speakerName}" deleted successfully`);
|
||
} catch (error) {
|
||
console.error('Error deleting speaker:', error);
|
||
showToast(`Failed to delete speaker: ${error.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
// Delete all speakers from tab
|
||
document.getElementById('deleteAllSpeakersTabBtn')?.addEventListener('click', async () => {
|
||
if (!confirm('Are you sure you want to delete all speakers? This action cannot be undone.')) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// Get CSRF token
|
||
const csrfToken = document.querySelector('meta[name=csrf-token]')?.getAttribute('content');
|
||
|
||
const headers = {};
|
||
if (csrfToken) {
|
||
headers['X-CSRFToken'] = csrfToken;
|
||
}
|
||
|
||
// Delete all speakers one by one
|
||
const deletePromises = speakersTabData.map(speaker =>
|
||
fetch(`/speakers/${speaker.id}`, {
|
||
method: 'DELETE',
|
||
headers: headers
|
||
})
|
||
);
|
||
|
||
const results = await Promise.allSettled(deletePromises);
|
||
|
||
// Check for any failures
|
||
const failures = results.filter(result => result.status === 'rejected');
|
||
if (failures.length > 0) {
|
||
console.error('Some speakers failed to delete:', failures);
|
||
showToast(`${failures.length} speaker(s) failed to delete`, 'error');
|
||
}
|
||
|
||
// Reset the tab state
|
||
speakersTabLoaded = false;
|
||
await loadSpeakersTab();
|
||
|
||
const successCount = results.length - failures.length;
|
||
if (successCount > 0) {
|
||
showToast(`${successCount} speaker(s) deleted successfully`);
|
||
}
|
||
} catch (error) {
|
||
console.error('Error deleting all speakers:', error);
|
||
showToast(`Failed to delete speakers: ${error.message}`, 'error');
|
||
}
|
||
});
|
||
|
||
async function loadSystemInfo() {
|
||
try {
|
||
const response = await fetch('/tool/speakr/api/system/info');
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||
}
|
||
|
||
const data = await response.json();
|
||
|
||
// Update version in multiple places
|
||
document.getElementById('system-version').textContent = data.version;
|
||
|
||
// Update LLM info
|
||
document.getElementById('llm-endpoint').textContent = data.llm_endpoint;
|
||
document.getElementById('llm-model').textContent = data.llm_model;
|
||
|
||
// Update Whisper/ASR info
|
||
document.getElementById('whisper-endpoint').textContent = data.whisper_endpoint;
|
||
document.getElementById('asr-status').textContent = data.asr_enabled ? 'Yes' : 'No';
|
||
|
||
if (data.asr_enabled && data.asr_endpoint) {
|
||
document.getElementById('asr-endpoint').textContent = data.asr_endpoint;
|
||
document.getElementById('asr-endpoint-row').style.display = 'block';
|
||
}
|
||
} catch (error) {
|
||
document.getElementById('system-version').textContent = 'Failed to load';
|
||
document.getElementById('llm-endpoint').textContent = 'Failed to load';
|
||
document.getElementById('llm-model').textContent = 'Failed to load';
|
||
document.getElementById('whisper-endpoint').textContent = 'Failed to load';
|
||
document.getElementById('asr-status').textContent = 'Failed to load';
|
||
}
|
||
}
|
||
|
||
async function loadShares() {
|
||
sharesContainer.innerHTML = '<div class="text-center"><i class="fas fa-spinner fa-spin text-2xl"></i></div>';
|
||
try {
|
||
const response = await fetch('/tool/speakr/api/shares');
|
||
const shares = await response.json();
|
||
if (!response.ok) throw new Error(shares.error || 'Failed to load');
|
||
|
||
if (shares.length === 0) {
|
||
sharesContainer.innerHTML = `<p class="text-center text-[var(--text-muted)]">${t('sharedTranscriptsPage.noSharedTranscripts')}</p>`;
|
||
return;
|
||
}
|
||
|
||
sharesContainer.innerHTML = '';
|
||
shares.forEach(share => {
|
||
const shareEl = document.createElement('div');
|
||
shareEl.className = 'bg-[var(--bg-tertiary)] p-4 rounded-lg border border-[var(--border-primary)]';
|
||
shareEl.innerHTML = `
|
||
<div class="flex justify-between items-start">
|
||
<div>
|
||
<p class="font-semibold">${escapeHtml(share.recording_title)}</p>
|
||
<p class="text-sm text-[var(--text-muted)]">Shared on: ${share.created_at}</p>
|
||
</div>
|
||
<button data-share-id="${share.id}" class="delete-share-btn 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" data-share-id="${share.id}" data-share-prop="share_summary" class="share-checkbox h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" ${share.share_summary ? 'checked' : ''}>
|
||
<span class="ml-2">Share Summary</span>
|
||
</label>
|
||
<label class="flex items-center text-sm">
|
||
<input type="checkbox" data-share-id="${share.id}" data-share-prop="share_notes" class="share-checkbox h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" ${share.share_notes ? 'checked' : ''}>
|
||
<span class="ml-2">Share Notes</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>
|
||
`;
|
||
sharesContainer.appendChild(shareEl);
|
||
});
|
||
|
||
document.querySelectorAll('.delete-share-btn').forEach(btn => {
|
||
btn.addEventListener('click', handleDeleteShare);
|
||
});
|
||
document.querySelectorAll('.share-checkbox').forEach(box => {
|
||
box.addEventListener('change', handleUpdateShare);
|
||
});
|
||
|
||
} catch (error) {
|
||
sharesContainer.innerHTML = `<p class="text-center text-red-500">${t('errors.loadingShares')}: ${error.message}</p>`;
|
||
}
|
||
}
|
||
|
||
async function handleDeleteShare(event) {
|
||
const shareId = event.currentTarget.dataset.shareId;
|
||
if (!confirm('Are you sure you want to delete this share? This will revoke access to the public link.')) return;
|
||
|
||
try {
|
||
// Get CSRF token
|
||
const csrfToken = document.querySelector('meta[name=csrf-token]')?.getAttribute('content');
|
||
|
||
const headers = {};
|
||
if (csrfToken) {
|
||
headers['X-CSRFToken'] = csrfToken;
|
||
}
|
||
|
||
const response = await fetch(`/tool/speakr/api/share/${shareId}`, {
|
||
method: 'DELETE',
|
||
headers: headers
|
||
});
|
||
if (!response.ok) throw new Error('Failed to delete');
|
||
loadShares();
|
||
} catch (error) {
|
||
alert('Error deleting share.');
|
||
}
|
||
}
|
||
|
||
async function handleUpdateShare(event) {
|
||
const shareId = event.currentTarget.dataset.shareId;
|
||
const prop = event.currentTarget.dataset.shareProp;
|
||
const value = event.currentTarget.checked;
|
||
|
||
try {
|
||
// Get CSRF token
|
||
const csrfToken = document.querySelector('meta[name=csrf-token]')?.getAttribute('content');
|
||
|
||
const headers = { 'Content-Type': 'application/json' };
|
||
if (csrfToken) {
|
||
headers['X-CSRFToken'] = csrfToken;
|
||
}
|
||
|
||
const response = await fetch(`/tool/speakr/api/share/${shareId}`, {
|
||
method: 'PUT',
|
||
headers: headers,
|
||
body: JSON.stringify({ [prop]: value })
|
||
});
|
||
if (!response.ok) throw new Error('Failed to update');
|
||
} catch (error) {
|
||
alert('Error updating share.');
|
||
event.currentTarget.checked = !value; // Revert on failure
|
||
}
|
||
}
|
||
|
||
function escapeHtml(text) {
|
||
const div = document.createElement('div');
|
||
div.textContent = text;
|
||
return div.innerHTML;
|
||
}
|
||
|
||
// Update footer with version
|
||
async function updateFooter() {
|
||
try {
|
||
const response = await fetch('/tool/speakr/api/system/info');
|
||
if (response.ok) {
|
||
const data = await response.json();
|
||
const footerText = document.getElementById('footer-text');
|
||
if (footerText && data.version) {
|
||
footerText.textContent = `{{ html_title }} ${data.version} © {{ now.year }}`;
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.log('Could not fetch version for footer');
|
||
}
|
||
}
|
||
|
||
// Update footer on page load
|
||
updateFooter();
|
||
|
||
// Dark mode toggle functionality
|
||
const darkModeToggle = document.getElementById('darkModeToggle');
|
||
const darkModeIcon = document.getElementById('darkModeIcon');
|
||
|
||
// Initialize dark mode based on localStorage or system preference
|
||
function initializeDarkMode() {
|
||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||
const savedMode = localStorage.getItem('darkMode');
|
||
|
||
if (savedMode === 'true' || (savedMode === null && prefersDark)) {
|
||
document.documentElement.classList.add('dark');
|
||
darkModeIcon.classList.remove('fa-moon');
|
||
darkModeIcon.classList.add('fa-sun');
|
||
} else {
|
||
document.documentElement.classList.remove('dark');
|
||
darkModeIcon.classList.remove('fa-sun');
|
||
darkModeIcon.classList.add('fa-moon');
|
||
}
|
||
}
|
||
|
||
// Toggle dark mode
|
||
darkModeToggle.addEventListener('click', () => {
|
||
const isDarkMode = document.documentElement.classList.toggle('dark');
|
||
localStorage.setItem('darkMode', isDarkMode);
|
||
|
||
if (isDarkMode) {
|
||
darkModeIcon.classList.remove('fa-moon');
|
||
darkModeIcon.classList.add('fa-sun');
|
||
} else {
|
||
darkModeIcon.classList.remove('fa-sun');
|
||
darkModeIcon.classList.add('fa-moon');
|
||
}
|
||
});
|
||
|
||
// User dropdown functionality
|
||
const userDropdownButton = document.getElementById('userDropdownButton');
|
||
const userDropdownMenu = document.getElementById('userDropdownMenu');
|
||
|
||
userDropdownButton.addEventListener('click', () => {
|
||
userDropdownMenu.classList.toggle('hidden');
|
||
});
|
||
|
||
// Close dropdown when clicking outside
|
||
document.addEventListener('click', (event) => {
|
||
if (!userDropdownButton.contains(event.target) && !userDropdownMenu.contains(event.target)) {
|
||
userDropdownMenu.classList.add('hidden');
|
||
}
|
||
});
|
||
|
||
// Initialize dark mode on page load
|
||
initializeDarkMode();
|
||
|
||
// Change Password Modal functionality
|
||
const changePasswordBtn = document.getElementById('changePasswordBtn');
|
||
const changePasswordModal = document.getElementById('changePasswordModal');
|
||
const closeModalBtn = document.getElementById('closeModalBtn');
|
||
const cancelBtn = document.getElementById('cancelBtn');
|
||
const changePasswordForm = document.getElementById('changePasswordForm');
|
||
const newPasswordInput = document.getElementById('new_password');
|
||
const confirmPasswordInput = document.getElementById('confirm_password');
|
||
|
||
// Open modal
|
||
changePasswordBtn.addEventListener('click', () => {
|
||
changePasswordModal.classList.remove('hidden');
|
||
// Reset form
|
||
changePasswordForm.reset();
|
||
});
|
||
|
||
// Close modal functions
|
||
function closeModal() {
|
||
changePasswordModal.classList.add('hidden');
|
||
}
|
||
|
||
closeModalBtn.addEventListener('click', closeModal);
|
||
cancelBtn.addEventListener('click', closeModal);
|
||
|
||
// Close modal when clicking outside
|
||
changePasswordModal.addEventListener('click', (event) => {
|
||
if (event.target === changePasswordModal) {
|
||
closeModal();
|
||
}
|
||
});
|
||
|
||
// Form validation
|
||
changePasswordForm.addEventListener('submit', (event) => {
|
||
if (newPasswordInput.value !== confirmPasswordInput.value) {
|
||
event.preventDefault();
|
||
alert('New password and confirmation do not match.');
|
||
}
|
||
});
|
||
|
||
// Keep the modal functionality for backward compatibility if button still exists
|
||
const manageSpeakersBtn = document.getElementById('manageSpeakersBtn');
|
||
const manageSpeakersModal = document.getElementById('manageSpeakersModal');
|
||
const closeSpeakersModalBtn = document.getElementById('closeSpeakersModalBtn');
|
||
const closeSpeakersBtn = document.getElementById('closeSpeakersBtn');
|
||
const deleteAllSpeakersBtn = document.getElementById('deleteAllSpeakersBtn');
|
||
const deleteAllSpeakersModal = document.getElementById('deleteAllSpeakersModal');
|
||
const cancelDeleteAllBtn = document.getElementById('cancelDeleteAllBtn');
|
||
const confirmDeleteAllBtn = document.getElementById('confirmDeleteAllBtn');
|
||
|
||
const speakersLoading = document.getElementById('speakersLoading');
|
||
const speakersError = document.getElementById('speakersError');
|
||
const speakersErrorMessage = document.getElementById('speakersErrorMessage');
|
||
const speakersEmpty = document.getElementById('speakersEmpty');
|
||
const speakersList = document.getElementById('speakersList');
|
||
const speakersCount = document.getElementById('speakersCount');
|
||
|
||
let speakers = [];
|
||
|
||
// Open speakers modal only if button exists
|
||
if (manageSpeakersBtn) {
|
||
manageSpeakersBtn.addEventListener('click', () => {
|
||
manageSpeakersModal.classList.remove('hidden');
|
||
loadSpeakers();
|
||
});
|
||
}
|
||
|
||
// Close speakers modal functions
|
||
function closeSpeakersModal() {
|
||
manageSpeakersModal.classList.add('hidden');
|
||
}
|
||
|
||
closeSpeakersModalBtn.addEventListener('click', closeSpeakersModal);
|
||
closeSpeakersBtn.addEventListener('click', closeSpeakersModal);
|
||
|
||
// Close modal when clicking outside
|
||
manageSpeakersModal.addEventListener('click', (event) => {
|
||
if (event.target === manageSpeakersModal) {
|
||
closeSpeakersModal();
|
||
}
|
||
});
|
||
|
||
// Load speakers from API
|
||
async function loadSpeakers() {
|
||
showLoadingState();
|
||
|
||
try {
|
||
const response = await fetch('/tool/speakr/speakers');
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||
}
|
||
|
||
speakers = await response.json();
|
||
displaySpeakers();
|
||
} catch (error) {
|
||
console.error('Error loading speakers:', error);
|
||
showErrorState(error.message);
|
||
}
|
||
}
|
||
|
||
// Show loading state
|
||
function showLoadingState() {
|
||
speakersLoading.classList.remove('hidden');
|
||
speakersError.classList.add('hidden');
|
||
speakersEmpty.classList.add('hidden');
|
||
speakersList.classList.add('hidden');
|
||
}
|
||
|
||
// Show error state
|
||
function showErrorState(message) {
|
||
speakersLoading.classList.add('hidden');
|
||
speakersError.classList.remove('hidden');
|
||
speakersEmpty.classList.add('hidden');
|
||
speakersList.classList.add('hidden');
|
||
speakersErrorMessage.textContent = message;
|
||
updateSpeakersCount(0);
|
||
}
|
||
|
||
// Display speakers
|
||
function displaySpeakers() {
|
||
speakersLoading.classList.add('hidden');
|
||
speakersError.classList.add('hidden');
|
||
|
||
if (speakers.length === 0) {
|
||
speakersEmpty.classList.remove('hidden');
|
||
speakersList.classList.add('hidden');
|
||
deleteAllSpeakersBtn.disabled = true;
|
||
} else {
|
||
speakersEmpty.classList.add('hidden');
|
||
speakersList.classList.remove('hidden');
|
||
deleteAllSpeakersBtn.disabled = false;
|
||
renderSpeakersList();
|
||
}
|
||
|
||
updateSpeakersCount(speakers.length);
|
||
}
|
||
|
||
// Render speakers list
|
||
function renderSpeakersList() {
|
||
speakersList.innerHTML = '';
|
||
|
||
speakers.forEach(speaker => {
|
||
const speakerItem = createSpeakerItem(speaker);
|
||
speakersList.appendChild(speakerItem);
|
||
});
|
||
}
|
||
|
||
// Create speaker item element
|
||
function createSpeakerItem(speaker) {
|
||
const div = document.createElement('div');
|
||
div.className = 'flex items-center justify-between p-3 bg-[var(--bg-tertiary)] rounded-lg border border-[var(--border-primary)] hover:bg-[var(--bg-accent)] transition-colors duration-200';
|
||
|
||
const createdDate = new Date(speaker.created_at).toLocaleDateString();
|
||
const lastUsedDate = new Date(speaker.last_used).toLocaleDateString();
|
||
|
||
div.innerHTML = `
|
||
<div class="flex-1">
|
||
<div class="flex items-center">
|
||
<i class="fas fa-user text-[var(--text-muted)] mr-2"></i>
|
||
<span class="font-medium text-[var(--text-primary)]">${escapeHtml(speaker.name)}</span>
|
||
</div>
|
||
<div class="text-xs text-[var(--text-muted)] mt-1">
|
||
Used ${speaker.use_count} time${speaker.use_count !== 1 ? 's' : ''} •
|
||
Last used: ${lastUsedDate} •
|
||
Created: ${createdDate}
|
||
</div>
|
||
</div>
|
||
<button class="delete-speaker-btn ml-3 p-2 text-[var(--text-danger)] hover:bg-[var(--bg-danger-light)] rounded-md transition-colors duration-200"
|
||
data-speaker-id="${speaker.id}"
|
||
data-i18n-title="buttons.deleteSpeaker">
|
||
<i class="fas fa-trash text-sm"></i>
|
||
</button>
|
||
`;
|
||
|
||
// Add delete functionality
|
||
const deleteBtn = div.querySelector('.delete-speaker-btn');
|
||
deleteBtn.addEventListener('click', () => deleteSpeaker(speaker.id, speaker.name));
|
||
|
||
return div;
|
||
}
|
||
|
||
// Delete individual speaker
|
||
async function deleteSpeaker(speakerId, speakerName) {
|
||
if (!confirm(`Are you sure you want to delete "${speakerName}"?`)) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// Get CSRF token
|
||
const csrfToken = document.querySelector('meta[name=csrf-token]')?.getAttribute('content');
|
||
|
||
const headers = {};
|
||
if (csrfToken) {
|
||
headers['X-CSRFToken'] = csrfToken;
|
||
}
|
||
|
||
const response = await fetch(`/tool/speakr/speakers/${speakerId}`, {
|
||
method: 'DELETE',
|
||
headers: headers
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const errorData = await response.json();
|
||
throw new Error(errorData.error || `HTTP ${response.status}: ${response.statusText}`);
|
||
}
|
||
|
||
// Remove from local array
|
||
speakers = speakers.filter(s => s.id !== speakerId);
|
||
displaySpeakers();
|
||
showToast(`Speaker "${speakerName}" deleted successfully`);
|
||
} catch (error) {
|
||
console.error('Error deleting speaker:', error);
|
||
showToast(`Failed to delete speaker: ${error.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
// Delete all speakers
|
||
deleteAllSpeakersBtn.addEventListener('click', () => {
|
||
deleteAllSpeakersModal.classList.remove('hidden');
|
||
});
|
||
|
||
// Cancel delete all
|
||
cancelDeleteAllBtn.addEventListener('click', () => {
|
||
deleteAllSpeakersModal.classList.add('hidden');
|
||
});
|
||
|
||
// Close delete all modal when clicking outside
|
||
deleteAllSpeakersModal.addEventListener('click', (event) => {
|
||
if (event.target === deleteAllSpeakersModal) {
|
||
deleteAllSpeakersModal.classList.add('hidden');
|
||
}
|
||
});
|
||
|
||
// Confirm delete all speakers
|
||
confirmDeleteAllBtn.addEventListener('click', async () => {
|
||
deleteAllSpeakersModal.classList.add('hidden');
|
||
|
||
try {
|
||
// Get CSRF token
|
||
const csrfToken = document.querySelector('meta[name=csrf-token]')?.getAttribute('content');
|
||
|
||
const headers = {};
|
||
if (csrfToken) {
|
||
headers['X-CSRFToken'] = csrfToken;
|
||
}
|
||
|
||
// Delete all speakers one by one
|
||
const deletePromises = speakers.map(speaker =>
|
||
fetch(`/speakers/${speaker.id}`, {
|
||
method: 'DELETE',
|
||
headers: headers
|
||
})
|
||
);
|
||
|
||
const results = await Promise.allSettled(deletePromises);
|
||
|
||
// Check for any failures
|
||
const failures = results.filter(result => result.status === 'rejected');
|
||
if (failures.length > 0) {
|
||
console.error('Some speakers failed to delete:', failures);
|
||
showToast(`${failures.length} speaker(s) failed to delete`, 'error');
|
||
}
|
||
|
||
// Reload speakers list
|
||
await loadSpeakers();
|
||
showToast(`${speakers.length === 0 ? 'All speakers' : (results.length - failures.length) + ' speakers'} deleted successfully`);
|
||
} catch (error) {
|
||
console.error('Error deleting all speakers:', error);
|
||
showToast(`Failed to delete speakers: ${error.message}`, 'error');
|
||
}
|
||
});
|
||
|
||
// Update speakers count
|
||
function updateSpeakersCount(count) {
|
||
speakersCount.textContent = count;
|
||
}
|
||
|
||
// Utility function to escape HTML
|
||
function escapeHtml(text) {
|
||
const div = document.createElement('div');
|
||
div.textContent = text;
|
||
return div.innerHTML;
|
||
}
|
||
|
||
// Toast notification function
|
||
function showToast(message, type = 'success') {
|
||
// Create toast element
|
||
const toast = document.createElement('div');
|
||
toast.className = `fixed top-4 right-4 z-50 px-4 py-2 rounded-lg shadow-lg transition-all duration-300 transform translate-x-full ${type === 'error'
|
||
? 'bg-[var(--bg-danger)] text-[var(--text-button)]'
|
||
: 'bg-[var(--bg-success-light)] text-[var(--text-success-strong)]'
|
||
}`;
|
||
toast.innerHTML = `
|
||
<div class="flex items-center">
|
||
<i class="fas ${type === 'error' ? 'fa-exclamation-circle' : 'fa-check-circle'} mr-2"></i>
|
||
<span>${escapeHtml(message)}</span>
|
||
</div>
|
||
`;
|
||
|
||
document.body.appendChild(toast);
|
||
|
||
// Animate in
|
||
setTimeout(() => {
|
||
toast.classList.remove('translate-x-full');
|
||
}, 10);
|
||
|
||
// Animate out and remove
|
||
setTimeout(() => {
|
||
toast.classList.add('translate-x-full');
|
||
setTimeout(() => {
|
||
document.body.removeChild(toast);
|
||
}, 300);
|
||
}, 3000);
|
||
}
|
||
|
||
// ===== TAG MANAGEMENT FUNCTIONALITY =====
|
||
|
||
// Tag management elements
|
||
const createTagBtn = document.getElementById('createTagBtn');
|
||
const tagModal = document.getElementById('tagModal');
|
||
const closeTagModalBtn = document.getElementById('closeTagModalBtn');
|
||
const cancelTagBtn = document.getElementById('cancelTagBtn');
|
||
const tagForm = document.getElementById('tagForm');
|
||
const tagModalTitle = document.getElementById('tagModalTitle');
|
||
const saveTagBtn = document.getElementById('saveTagBtn');
|
||
|
||
const tagsLoading = document.getElementById('tagsLoading');
|
||
const tagsError = document.getElementById('tagsError');
|
||
const tagsErrorMessage = document.getElementById('tagsErrorMessage');
|
||
const tagsEmpty = document.getElementById('tagsEmpty');
|
||
const tagsGrid = document.getElementById('tagsGrid');
|
||
|
||
let tags = [];
|
||
let isEditingTag = false;
|
||
|
||
// Load tags from API
|
||
async function loadTags() {
|
||
showTagsLoadingState();
|
||
|
||
try {
|
||
const response = await fetch('/tool/speakr/api/tags');
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||
}
|
||
|
||
tags = await response.json();
|
||
displayTags();
|
||
} catch (error) {
|
||
console.error('Error loading tags:', error);
|
||
showTagsErrorState(error.message);
|
||
}
|
||
}
|
||
|
||
|
||
async function loadVoiceprint() {
|
||
showTagsLoadingState();
|
||
renderList()
|
||
|
||
|
||
// try {
|
||
// const response = await fetch('/tool/speakr/api/tags');
|
||
// if (!response.ok) {
|
||
// throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||
// }
|
||
|
||
// tags = await response.json();
|
||
// displayTags();
|
||
// } catch (error) {
|
||
// console.error('Error loading tags:', error);
|
||
// showTagsErrorState(error.message);
|
||
// }
|
||
}
|
||
|
||
// Show loading state
|
||
function showTagsLoadingState() {
|
||
tagsLoading.classList.remove('hidden');
|
||
tagsError.classList.add('hidden');
|
||
tagsEmpty.classList.add('hidden');
|
||
tagsGrid.classList.add('hidden');
|
||
}
|
||
|
||
// Show error state
|
||
function showTagsErrorState(message) {
|
||
tagsLoading.classList.add('hidden');
|
||
tagsError.classList.remove('hidden');
|
||
tagsEmpty.classList.add('hidden');
|
||
tagsGrid.classList.add('hidden');
|
||
tagsErrorMessage.textContent = message;
|
||
}
|
||
|
||
// Display tags
|
||
function displayTags() {
|
||
tagsLoading.classList.add('hidden');
|
||
tagsError.classList.add('hidden');
|
||
|
||
if (tags.length === 0) {
|
||
tagsEmpty.classList.remove('hidden');
|
||
tagsGrid.classList.add('hidden');
|
||
} else {
|
||
tagsEmpty.classList.add('hidden');
|
||
tagsGrid.classList.remove('hidden');
|
||
renderTagsGrid();
|
||
}
|
||
}
|
||
|
||
// Render tags grid
|
||
function renderTagsGrid() {
|
||
tagsGrid.innerHTML = '';
|
||
|
||
tags.forEach(tag => {
|
||
const tagCard = createTagCard(tag);
|
||
tagsGrid.appendChild(tagCard);
|
||
});
|
||
}
|
||
let frontendCurrentUser = {
|
||
isAdmin: {{ current_user.is_admin| tojson
|
||
}}
|
||
}
|
||
// Create tag card element
|
||
function createTagCard(tag) {
|
||
const div = document.createElement('div');
|
||
div.className = 'bg-[var(--bg-tertiary)] p-4 rounded-lg border border-[var(--border-primary)] hover:shadow-md transition-shadow duration-200';
|
||
console.log(frontendCurrentUser, 'current_user', tag.visibility);
|
||
|
||
div.innerHTML = `
|
||
<div class="flex items-start justify-between mb-3">
|
||
<div class="flex items-center">
|
||
<div class="w-4 h-4 rounded-full mr-3" style="background-color: ${escapeHtml(tag.color)}"></div>
|
||
<h3 class="font-semibold text-[var(--text-primary)]">${escapeHtml(tag.name)}${tag.visibility == "global" ? "(全局)" : "(个人)"}</h3>
|
||
</div>
|
||
${(frontendCurrentUser.isAdmin || tag.visibility != "global") ? `
|
||
<div class="flex space-x-1">
|
||
<button class="edit-tag-btn p-1 text-[var(--text-muted)] hover:text-[var(--text-accent)] rounded"
|
||
data-tag-id="${tag.id}" data-i18n-title="buttons.editTag">
|
||
<i class="fas fa-edit text-sm"></i>
|
||
</button>
|
||
<button class="delete-tag-btn p-1 text-[var(--text-muted)] hover:text-[var(--text-danger)] rounded"
|
||
data-tag-id="${tag.id}" data-i18n-title="buttons.deleteTag">
|
||
<i class="fas fa-trash text-sm"></i>
|
||
</button>
|
||
</div>
|
||
` : ''}
|
||
</div>
|
||
|
||
<div class="space-y-2 text-sm text-[var(--text-muted)]">
|
||
${tag.custom_prompt ? `
|
||
<div>
|
||
<span class="font-medium" data-i18n="tagManagement.customPrompt">Custom Prompt:</span>
|
||
<span class="text-xs">${escapeHtml(tag.custom_prompt.substring(0, 60))}${tag.custom_prompt.length > 60 ? '...' : ''}</span>
|
||
</div>
|
||
` : ''}
|
||
|
||
${tag.default_language || tag.default_min_speakers || tag.default_max_speakers ? `
|
||
<div class="pt-2 border-t border-[var(--border-secondary)]">
|
||
<span class="font-medium text-xs" data-i18n="tagManagement.asrDefaults">ASR Defaults:</span>
|
||
<div class="flex flex-wrap gap-2 mt-1">
|
||
${tag.default_language ? `<span class="inline-flex items-center px-2 py-1 rounded-full text-xs bg-[var(--bg-accent)] text-[var(--text-accent)]">Lang: ${escapeHtml(tag.default_language)}</span>` : ''}
|
||
${tag.default_min_speakers ? `<span class="inline-flex items-center px-2 py-1 rounded-full text-xs bg-[var(--bg-accent)] text-[var(--text-accent)]">Min: ${tag.default_min_speakers}</span>` : ''}
|
||
${tag.default_max_speakers ? `<span class="inline-flex items-center px-2 py-1 rounded-full text-xs bg-[var(--bg-accent)] text-[var(--text-accent)]">Max: ${tag.default_max_speakers}</span>` : ''}
|
||
</div>
|
||
</div>
|
||
` : ''}
|
||
</div>
|
||
`;
|
||
|
||
// Add event listeners
|
||
const editBtn = div.querySelector('.edit-tag-btn');
|
||
const deleteBtn = div.querySelector('.delete-tag-btn');
|
||
if (frontendCurrentUser.isAdmin || tag.visibility != "global") {
|
||
editBtn.addEventListener('click', () => editTag(tag));
|
||
deleteBtn.addEventListener('click', () => deleteTag(tag.id, tag.name));
|
||
}
|
||
|
||
|
||
|
||
return div;
|
||
}
|
||
|
||
// Create tag modal functionality
|
||
createTagBtn.addEventListener('click', () => {
|
||
openTagModal();
|
||
});
|
||
|
||
// Close tag modal functions
|
||
function closeTagModal() {
|
||
tagModal.classList.add('hidden');
|
||
tagForm.reset();
|
||
isEditingTag = false;
|
||
document.getElementById('tagId').value = '';
|
||
}
|
||
|
||
closeTagModalBtn.addEventListener('click', closeTagModal);
|
||
cancelTagBtn.addEventListener('click', closeTagModal);
|
||
|
||
// Close modal when clicking outside
|
||
tagModal.addEventListener('click', (event) => {
|
||
if (event.target === tagModal) {
|
||
closeTagModal();
|
||
}
|
||
});
|
||
|
||
// Open tag modal for create
|
||
function openTagModal(tag = null) {
|
||
if (tag) {
|
||
// Edit mode
|
||
isEditingTag = true;
|
||
tagModalTitle.textContent = 'Edit Tag';
|
||
saveTagBtn.innerHTML = '<i class="fas fa-save mr-2"></i> Update Tag';
|
||
|
||
// Populate form
|
||
document.getElementById('tagId').value = tag.id;
|
||
document.getElementById('tagName').value = tag.name;
|
||
document.getElementById('tagColor').value = tag.color;
|
||
document.getElementById('tagCustomPrompt').value = tag.custom_prompt || '';
|
||
document.getElementById('tagScope').value = tag.visibility || '';
|
||
|
||
if (document.getElementById('tagLanguage')) {
|
||
document.getElementById('tagLanguage').value = tag.default_language || '';
|
||
}
|
||
if (document.getElementById('tagMinSpeakers')) {
|
||
document.getElementById('tagMinSpeakers').value = tag.default_min_speakers || '';
|
||
}
|
||
if (document.getElementById('tagMaxSpeakers')) {
|
||
document.getElementById('tagMaxSpeakers').value = tag.default_max_speakers || '';
|
||
}
|
||
} else {
|
||
// Create mode
|
||
isEditingTag = false;
|
||
tagModalTitle.textContent = 'Create Tag';
|
||
saveTagBtn.innerHTML = '<i class="fas fa-save mr-2"></i> Save Tag';
|
||
tagForm.reset();
|
||
document.getElementById('tagColor').value = '#3B82F6';
|
||
}
|
||
|
||
tagModal.classList.remove('hidden');
|
||
}
|
||
|
||
// Edit tag
|
||
function editTag(tag) {
|
||
openTagModal(tag);
|
||
}
|
||
|
||
// Delete tag
|
||
async function deleteTag(tagId, tagName) {
|
||
if (!confirm(`Are you sure you want to delete "${tagName}"?`)) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const csrfToken = document.querySelector('meta[name=csrf-token]')?.getAttribute('content');
|
||
|
||
const headers = {};
|
||
if (csrfToken) {
|
||
headers['X-CSRFToken'] = csrfToken;
|
||
}
|
||
|
||
const response = await fetch(`/tool/speakr/api/tags/${tagId}`, {
|
||
method: 'DELETE',
|
||
headers: headers
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const errorData = await response.json();
|
||
throw new Error(errorData.error || `HTTP ${response.status}: ${response.statusText}`);
|
||
}
|
||
|
||
// Remove from local array
|
||
tags = tags.filter(t => t.id !== tagId);
|
||
displayTags();
|
||
showToast(`Tag "${tagName}" deleted successfully`);
|
||
} catch (error) {
|
||
console.error('Error deleting tag:', error);
|
||
showToast(`Failed to delete tag: ${error.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
// Save tag form submission
|
||
tagForm.addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
|
||
const formData = new FormData(tagForm);
|
||
const tagData = {
|
||
name: formData.get('name'),
|
||
color: formData.get('color'),
|
||
custom_prompt: formData.get('custom_prompt') || null,
|
||
default_language: formData.get('default_language') || null,
|
||
visibility: formData.get('visibility') || null,
|
||
|
||
default_min_speakers: formData.get('default_min_speakers') ? parseInt(formData.get('default_min_speakers')) : null,
|
||
default_max_speakers: formData.get('default_max_speakers') ? parseInt(formData.get('default_max_speakers')) : null
|
||
};
|
||
|
||
try {
|
||
const csrfToken = document.querySelector('meta[name=csrf-token]')?.getAttribute('content');
|
||
|
||
const headers = { 'Content-Type': 'application/json' };
|
||
if (csrfToken) {
|
||
headers['X-CSRFToken'] = csrfToken;
|
||
}
|
||
|
||
let url = '/tool/speakr/api/tags';
|
||
let method = 'POST';
|
||
|
||
if (isEditingTag) {
|
||
url += `/${formData.get('tag_id')}`;
|
||
method = 'PUT';
|
||
}
|
||
|
||
const response = await fetch(url, {
|
||
method: method,
|
||
headers: headers,
|
||
body: JSON.stringify(tagData)
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const errorData = await response.json();
|
||
throw new Error(errorData.error || `HTTP ${response.status}: ${response.statusText}`);
|
||
}
|
||
|
||
const savedTag = await response.json();
|
||
|
||
if (isEditingTag) {
|
||
// Update existing tag in local array
|
||
const index = tags.findIndex(t => t.id === savedTag.id);
|
||
if (index !== -1) {
|
||
tags[index] = savedTag;
|
||
}
|
||
showToast(`Tag "${savedTag.name}" updated successfully`);
|
||
} else {
|
||
// Add new tag to local array
|
||
tags.push(savedTag);
|
||
showToast(`Tag "${savedTag.name}" created successfully`);
|
||
}
|
||
|
||
displayTags();
|
||
closeTagModal();
|
||
} catch (error) {
|
||
console.error('Error saving tag:', error);
|
||
showToast(`Failed to save tag: ${error.message}`, 'error');
|
||
}
|
||
});
|
||
|
||
// AJAX form submission for Account Information
|
||
const accountInfoForm = document.getElementById('accountInfoForm');
|
||
const canManageSystemConfigs = {{ current_user.is_admin | tojson }};
|
||
let currentConfigScene = String(localStorage.getItem('selectedScene') || 'cg').trim().toLowerCase() === 'yl' ? 'yl' : 'cg';
|
||
const accountSceneCgButton = document.getElementById('accountSceneCg');
|
||
const accountSceneYlButton = document.getElementById('accountSceneYl');
|
||
let textareacot = ''
|
||
const textarea = document.querySelector('textarea.hot-textarea');
|
||
if (textarea) {
|
||
textarea.addEventListener('input', function (e) {
|
||
const currentValue = e.target.value;
|
||
textareacot = currentValue
|
||
console.log('textarea值已更新:', currentValue);
|
||
});
|
||
}
|
||
async function updataBaseHot(params) {
|
||
if (!textarea || !textareacot.trim()) {
|
||
return;
|
||
}
|
||
const response = await fetch(`/tool/speakr/api/hotwords`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
hotwords: textareacot,
|
||
scene: currentConfigScene,
|
||
}),
|
||
});
|
||
const data = await response.json();
|
||
if (!response.ok)
|
||
throw new Error(data.error || "Failed to create share link");
|
||
// console.log(data.summary, "接收到回参");
|
||
|
||
}
|
||
async function getBaseHot(params) {
|
||
const response = await fetch(
|
||
`/tool/speakr/api/hotwords?scene=${currentConfigScene}`,
|
||
{
|
||
method: "GET",
|
||
}
|
||
);
|
||
const data = await response.json();
|
||
if (response.ok) {
|
||
console.log(data, 'data');
|
||
updateTextareaValue(data.text_format)
|
||
}
|
||
|
||
}
|
||
|
||
let textareahandlecot = ''
|
||
const textareahandle = document.querySelector('textarea.handle-textarea');
|
||
if (textareahandle) {
|
||
textareahandle.addEventListener('input', function (e) {
|
||
const currentValue = e.target.value;
|
||
textareahandlecot = currentValue
|
||
});
|
||
}
|
||
|
||
function normalizeAccountScene(scene) {
|
||
return String(scene || 'cg').trim().toLowerCase() === 'yl' ? 'yl' : 'cg';
|
||
}
|
||
|
||
function getStandardWordMappingsConfigKey() {
|
||
return currentConfigScene === 'yl' ? 'standard_word_mappings_yl' : 'standard_word_mappings';
|
||
}
|
||
|
||
function refreshAccountSceneButtons() {
|
||
if (accountSceneCgButton) {
|
||
accountSceneCgButton.classList.toggle('bg-[var(--bg-button)]', currentConfigScene === 'cg');
|
||
accountSceneCgButton.classList.toggle('text-[var(--text-button)]', currentConfigScene === 'cg');
|
||
accountSceneCgButton.classList.toggle('border-[var(--bg-button)]', currentConfigScene === 'cg');
|
||
}
|
||
if (accountSceneYlButton) {
|
||
accountSceneYlButton.classList.toggle('bg-[var(--bg-button)]', currentConfigScene === 'yl');
|
||
accountSceneYlButton.classList.toggle('text-[var(--text-button)]', currentConfigScene === 'yl');
|
||
accountSceneYlButton.classList.toggle('border-[var(--bg-button)]', currentConfigScene === 'yl');
|
||
}
|
||
}
|
||
|
||
function setAccountConfigScene(scene) {
|
||
const nextScene = normalizeAccountScene(scene);
|
||
if (currentConfigScene === nextScene) {
|
||
refreshAccountSceneButtons();
|
||
return;
|
||
}
|
||
currentConfigScene = nextScene;
|
||
localStorage.setItem('account_config_scene', currentConfigScene);
|
||
refreshAccountSceneButtons();
|
||
if (canManageSystemConfigs) {
|
||
getBaseHot();
|
||
getStandardWordMappingsConfig();
|
||
}
|
||
}
|
||
|
||
async function updataBasehandle(params) {
|
||
if (!textareahandle) {
|
||
return;
|
||
}
|
||
const response = await fetch(`/tool/speakr/api/system-configs/end_phrase_patterns`, {
|
||
method: "PUT",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
config_value: textareahandlecot,
|
||
}),
|
||
});
|
||
const data = await response.json();
|
||
if (!response.ok)
|
||
throw new Error(data.error || "Failed to create share link");
|
||
// console.log(data.summary, "接收到回参");
|
||
|
||
}
|
||
async function getBasehandle(params) {
|
||
const response = await fetch(
|
||
`/tool/speakr/api/system-configs/end_phrase_patterns`,
|
||
{
|
||
method: "GET",
|
||
}
|
||
);
|
||
const data = await response.json();
|
||
if (response.ok) {
|
||
let res = data?.config?.config_value || ''
|
||
updateTextareahandleValue(res)
|
||
}
|
||
|
||
}
|
||
const standardWordMappingsList = document.getElementById('standardWordMappingsList');
|
||
const standardWordMappingsEmpty = document.getElementById('standardWordMappingsEmpty');
|
||
const standardWordMappingsCount = document.getElementById('standardWordMappingsCount');
|
||
const standardWordImportPanel = document.getElementById('standardWordImportPanel');
|
||
const standardWordMappingsBulkInput = document.getElementById('standardWordMappingsBulkInput');
|
||
const addStandardWordMappingRowButton = document.getElementById('addStandardWordMappingRow');
|
||
const toggleStandardWordImportPanelButton = document.getElementById('toggleStandardWordImportPanel');
|
||
const closeStandardWordImportPanelButton = document.getElementById('closeStandardWordImportPanel');
|
||
const appendStandardWordMappingsFromBulkButton = document.getElementById('appendStandardWordMappingsFromBulk');
|
||
const replaceStandardWordMappingsFromBulkButton = document.getElementById('replaceStandardWordMappingsFromBulk');
|
||
const clearStandardWordMappingsBulkInputButton = document.getElementById('clearStandardWordMappingsBulkInput');
|
||
const copyStandardWordMappingsTextButton = document.getElementById('copyStandardWordMappingsText');
|
||
const clearAllStandardWordMappingsButton = document.getElementById('clearAllStandardWordMappings');
|
||
let draggedStandardWordMappingRow = null;
|
||
|
||
function splitStandardWordVariants(rawValue) {
|
||
return String(rawValue || '')
|
||
.split(/[\s、,,]+/)
|
||
.map(item => item.trim())
|
||
.filter(Boolean);
|
||
}
|
||
|
||
function normalizeStandardWordMappings(mappings) {
|
||
if (!Array.isArray(mappings)) {
|
||
return [];
|
||
}
|
||
|
||
return mappings
|
||
.map((mapping) => {
|
||
const standard = String(
|
||
mapping?.standard ?? mapping?.standard_word ?? ''
|
||
).trim();
|
||
const variants = Array.isArray(mapping?.variants)
|
||
? mapping.variants
|
||
: splitStandardWordVariants(
|
||
mapping?.variants ?? mapping?.common_words ?? ''
|
||
);
|
||
|
||
const deduplicatedVariants = [...new Set(
|
||
variants
|
||
.map((variant) => String(variant || '').trim())
|
||
.filter((variant) => variant && variant !== standard)
|
||
)];
|
||
|
||
if (!standard || !deduplicatedVariants.length) {
|
||
return null;
|
||
}
|
||
|
||
return {
|
||
standard,
|
||
variants: deduplicatedVariants
|
||
};
|
||
})
|
||
.filter(Boolean);
|
||
}
|
||
|
||
function parseStandardWordMappingsConfig(rawValue) {
|
||
if (!rawValue || typeof rawValue !== 'string') {
|
||
return [];
|
||
}
|
||
|
||
const trimmedValue = rawValue.trim();
|
||
if (!trimmedValue) {
|
||
return [];
|
||
}
|
||
|
||
if (trimmedValue.startsWith('[') || trimmedValue.startsWith('{')) {
|
||
try {
|
||
const parsed = JSON.parse(trimmedValue);
|
||
if (Array.isArray(parsed)) {
|
||
return normalizeStandardWordMappings(parsed);
|
||
}
|
||
if (parsed && typeof parsed === 'object') {
|
||
return normalizeStandardWordMappings([parsed]);
|
||
}
|
||
} catch (error) {
|
||
console.warn('解析标准词映射 JSON 失败,将尝试按文本格式解析', error);
|
||
}
|
||
}
|
||
|
||
const mappings = trimmedValue
|
||
.split('\n')
|
||
.map((line) => {
|
||
const normalizedLine = line.trim();
|
||
if (!normalizedLine) {
|
||
return null;
|
||
}
|
||
|
||
const separator = normalizedLine.includes(':')
|
||
? ':'
|
||
: normalizedLine.includes(':')
|
||
? ':'
|
||
: null;
|
||
|
||
if (!separator) {
|
||
return null;
|
||
}
|
||
|
||
const separatorIndex = normalizedLine.indexOf(separator);
|
||
const standard = normalizedLine.slice(0, separatorIndex).trim();
|
||
const variantsValue = normalizedLine.slice(separatorIndex + 1).trim();
|
||
return {
|
||
standard,
|
||
variants: splitStandardWordVariants(variantsValue)
|
||
};
|
||
})
|
||
.filter(Boolean);
|
||
|
||
return normalizeStandardWordMappings(mappings);
|
||
}
|
||
|
||
function setStandardWordImportPanelVisible(visible) {
|
||
if (!standardWordImportPanel) {
|
||
return;
|
||
}
|
||
|
||
standardWordImportPanel.classList.toggle('hidden', !visible);
|
||
}
|
||
|
||
function updateStandardWordMappingsEmptyState() {
|
||
if (!standardWordMappingsList || !standardWordMappingsEmpty || !standardWordMappingsCount) {
|
||
return;
|
||
}
|
||
|
||
const rows = standardWordMappingsList.querySelectorAll('.standard-word-mapping-row');
|
||
const hasRows = rows.length > 0;
|
||
standardWordMappingsEmpty.classList.toggle('hidden', hasRows);
|
||
standardWordMappingsCount.textContent = `${rows.length} 条`;
|
||
}
|
||
|
||
function refreshStandardWordMappingRowState() {
|
||
if (!standardWordMappingsList) {
|
||
return;
|
||
}
|
||
|
||
const rows = Array.from(
|
||
standardWordMappingsList.querySelectorAll('.standard-word-mapping-row')
|
||
);
|
||
|
||
rows.forEach((row, index) => {
|
||
const orderBadge = row.querySelector('.standard-word-row-order');
|
||
const moveUpButton = row.querySelector('.move-standard-word-mapping-up');
|
||
const moveDownButton = row.querySelector('.move-standard-word-mapping-down');
|
||
|
||
if (orderBadge) {
|
||
orderBadge.textContent = `#${index + 1}`;
|
||
}
|
||
|
||
if (moveUpButton) {
|
||
moveUpButton.disabled = index === 0;
|
||
moveUpButton.classList.toggle('opacity-50', index === 0);
|
||
moveUpButton.classList.toggle('cursor-not-allowed', index === 0);
|
||
}
|
||
|
||
if (moveDownButton) {
|
||
const isLast = index === rows.length - 1;
|
||
moveDownButton.disabled = isLast;
|
||
moveDownButton.classList.toggle('opacity-50', isLast);
|
||
moveDownButton.classList.toggle('cursor-not-allowed', isLast);
|
||
}
|
||
});
|
||
|
||
updateStandardWordMappingsEmptyState();
|
||
}
|
||
|
||
function moveStandardWordMappingRow(row, direction) {
|
||
if (!row || !standardWordMappingsList) {
|
||
return;
|
||
}
|
||
|
||
if (direction < 0 && row.previousElementSibling) {
|
||
standardWordMappingsList.insertBefore(row, row.previousElementSibling);
|
||
} else if (direction > 0 && row.nextElementSibling) {
|
||
standardWordMappingsList.insertBefore(row.nextElementSibling, row);
|
||
}
|
||
|
||
refreshStandardWordMappingRowState();
|
||
}
|
||
|
||
function serializeStandardWordMappingsToText(mappings) {
|
||
return normalizeStandardWordMappings(mappings)
|
||
.map((mapping) => `${mapping.standard}:${mapping.variants.join('、')}`)
|
||
.join('\n');
|
||
}
|
||
|
||
async function copyStandardWordMappingsText() {
|
||
const textValue = serializeStandardWordMappingsToText(collectStandardWordMappings());
|
||
|
||
if (!textValue) {
|
||
showToast('暂无可复制的标准词映射', 'error');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
if (navigator?.clipboard?.writeText) {
|
||
await navigator.clipboard.writeText(textValue);
|
||
} else {
|
||
const tempTextarea = document.createElement('textarea');
|
||
tempTextarea.value = textValue;
|
||
document.body.appendChild(tempTextarea);
|
||
tempTextarea.select();
|
||
document.execCommand('copy');
|
||
tempTextarea.remove();
|
||
}
|
||
showToast('标准词映射文本已复制', 'success');
|
||
} catch (error) {
|
||
console.error('复制标准词映射失败', error);
|
||
showToast('复制失败,请手动复制', 'error');
|
||
}
|
||
}
|
||
|
||
function clearAllStandardWordMappings() {
|
||
if (!standardWordMappingsList) {
|
||
return;
|
||
}
|
||
|
||
const rows = standardWordMappingsList.querySelectorAll('.standard-word-mapping-row');
|
||
if (!rows.length) {
|
||
showToast('暂无可删除的标准词映射', 'error');
|
||
return;
|
||
}
|
||
|
||
const confirmed = window.confirm(
|
||
`确定要批量删除全部 ${rows.length} 条标准词映射吗?保存设置后将生效。`
|
||
);
|
||
if (!confirmed) {
|
||
return;
|
||
}
|
||
|
||
renderStandardWordMappings([]);
|
||
showToast('已清空全部标准词映射,请保存设置以生效', 'success');
|
||
}
|
||
|
||
function importStandardWordMappings(mode = 'append') {
|
||
const importedMappings = parseStandardWordMappingsConfig(
|
||
standardWordMappingsBulkInput?.value || ''
|
||
);
|
||
|
||
if (!importedMappings.length) {
|
||
showToast('未解析到有效映射,请检查导入格式', 'error');
|
||
return;
|
||
}
|
||
|
||
const currentMappings = mode === 'replace'
|
||
? []
|
||
: collectStandardWordMappings();
|
||
|
||
renderStandardWordMappings([...currentMappings, ...importedMappings]);
|
||
setStandardWordImportPanelVisible(false);
|
||
showToast(
|
||
mode === 'replace'
|
||
? `已覆盖导入 ${importedMappings.length} 条映射`
|
||
: `已追加导入 ${importedMappings.length} 条映射`,
|
||
'success'
|
||
);
|
||
}
|
||
|
||
function createStandardWordMappingRow(mapping = {}) {
|
||
const row = document.createElement('div');
|
||
row.className =
|
||
'standard-word-mapping-row grid grid-cols-1 md:grid-cols-[110px_minmax(0,1fr)_minmax(0,2fr)_150px] gap-3 items-start rounded-lg border border-[var(--border-primary)] bg-[var(--bg-secondary)] p-4';
|
||
|
||
row.innerHTML = `
|
||
<div>
|
||
<label class="block text-sm font-medium text-[var(--text-muted)] mb-2 md:hidden">排序</label>
|
||
<div class="flex items-center gap-2 md:flex-col md:items-start">
|
||
<button
|
||
type="button"
|
||
class="drag-standard-word-mapping inline-flex items-center justify-center w-9 h-9 rounded-md border border-[var(--border-primary)] text-[var(--text-muted)] cursor-grab active:cursor-grabbing"
|
||
title="拖拽排序"
|
||
>
|
||
<i class="fas fa-grip-vertical"></i>
|
||
</button>
|
||
<span
|
||
class="standard-word-row-order inline-flex items-center px-2 py-1 rounded-md text-xs font-medium bg-[var(--bg-tertiary)] text-[var(--text-secondary)] border border-[var(--border-primary)]"
|
||
>
|
||
#1
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label class="block text-sm font-medium text-[var(--text-muted)] mb-2">标准词</label>
|
||
<input
|
||
type="text"
|
||
class="standard-word-standard-input 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="例如:OpenAI"
|
||
>
|
||
</div>
|
||
<div>
|
||
<label class="block text-sm font-medium text-[var(--text-muted)] mb-2">常用词</label>
|
||
<input
|
||
type="text"
|
||
class="standard-word-variants-input 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="例如:欧喷AI、欧彭AI 或 欧喷AI 欧彭AI"
|
||
>
|
||
</div>
|
||
<div>
|
||
<label class="block text-sm font-medium text-[var(--text-muted)] mb-2 md:hidden">操作</label>
|
||
<div class="flex items-center gap-2 md:flex-col md:items-stretch">
|
||
<button
|
||
type="button"
|
||
class="move-standard-word-mapping-up inline-flex items-center justify-center px-3 py-2 text-sm rounded-md border border-[var(--border-primary)] text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)] transition-colors duration-200"
|
||
title="上移"
|
||
>
|
||
<i class="fas fa-arrow-up"></i>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="move-standard-word-mapping-down inline-flex items-center justify-center px-3 py-2 text-sm rounded-md border border-[var(--border-primary)] text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)] transition-colors duration-200"
|
||
title="下移"
|
||
>
|
||
<i class="fas fa-arrow-down"></i>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="remove-standard-word-mapping inline-flex items-center justify-center px-3 py-2 text-sm rounded-md border border-[var(--border-primary)] text-[var(--text-secondary)] hover:text-[var(--text-danger)] hover:border-[var(--text-danger)] transition-colors duration-200"
|
||
>
|
||
<i class="fas fa-trash-alt md:mr-0 mr-2"></i><span class="md:hidden">删除</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
row.querySelector('.standard-word-standard-input').value = mapping.standard || '';
|
||
row.querySelector('.standard-word-variants-input').value = Array.isArray(mapping.variants)
|
||
? mapping.variants.join('、')
|
||
: '';
|
||
|
||
row.querySelector('.remove-standard-word-mapping').addEventListener('click', () => {
|
||
row.remove();
|
||
refreshStandardWordMappingRowState();
|
||
});
|
||
row.querySelector('.move-standard-word-mapping-up').addEventListener('click', () => {
|
||
moveStandardWordMappingRow(row, -1);
|
||
});
|
||
row.querySelector('.move-standard-word-mapping-down').addEventListener('click', () => {
|
||
moveStandardWordMappingRow(row, 1);
|
||
});
|
||
|
||
const dragHandle = row.querySelector('.drag-standard-word-mapping');
|
||
dragHandle.draggable = true;
|
||
|
||
dragHandle.addEventListener('dragstart', (event) => {
|
||
draggedStandardWordMappingRow = row;
|
||
row.classList.add('opacity-60', 'ring-2', 'ring-[var(--border-focus)]');
|
||
if (event.dataTransfer) {
|
||
event.dataTransfer.effectAllowed = 'move';
|
||
event.dataTransfer.setData('text/plain', mapping.standard || '');
|
||
}
|
||
});
|
||
|
||
dragHandle.addEventListener('dragend', () => {
|
||
draggedStandardWordMappingRow = null;
|
||
row.classList.remove('opacity-60', 'ring-2', 'ring-[var(--border-focus)]');
|
||
refreshStandardWordMappingRowState();
|
||
});
|
||
|
||
row.addEventListener('dragover', (event) => {
|
||
if (!draggedStandardWordMappingRow || draggedStandardWordMappingRow === row) {
|
||
return;
|
||
}
|
||
|
||
event.preventDefault();
|
||
const rect = row.getBoundingClientRect();
|
||
const shouldInsertBefore = event.clientY < rect.top + rect.height / 2;
|
||
if (shouldInsertBefore) {
|
||
standardWordMappingsList.insertBefore(draggedStandardWordMappingRow, row);
|
||
} else {
|
||
standardWordMappingsList.insertBefore(
|
||
draggedStandardWordMappingRow,
|
||
row.nextElementSibling
|
||
);
|
||
}
|
||
});
|
||
|
||
return row;
|
||
}
|
||
|
||
function renderStandardWordMappings(mappings = []) {
|
||
if (!standardWordMappingsList) {
|
||
return;
|
||
}
|
||
|
||
standardWordMappingsList.innerHTML = '';
|
||
|
||
const normalizedMappings = normalizeStandardWordMappings(mappings);
|
||
normalizedMappings.forEach((mapping) => {
|
||
standardWordMappingsList.appendChild(createStandardWordMappingRow(mapping));
|
||
});
|
||
|
||
refreshStandardWordMappingRowState();
|
||
}
|
||
|
||
function collectStandardWordMappings() {
|
||
if (!standardWordMappingsList) {
|
||
return [];
|
||
}
|
||
|
||
const mappings = Array.from(
|
||
standardWordMappingsList.querySelectorAll('.standard-word-mapping-row')
|
||
).map((row) => ({
|
||
standard: row.querySelector('.standard-word-standard-input')?.value || '',
|
||
variants: splitStandardWordVariants(
|
||
row.querySelector('.standard-word-variants-input')?.value || ''
|
||
)
|
||
}));
|
||
|
||
return normalizeStandardWordMappings(mappings);
|
||
}
|
||
|
||
async function updateStandardWordMappingsConfig() {
|
||
if (!standardWordMappingsList) {
|
||
return;
|
||
}
|
||
|
||
const mappings = collectStandardWordMappings();
|
||
const response = await fetch(`/tool/speakr/api/system-configs/${getStandardWordMappingsConfigKey()}`, {
|
||
method: "PUT",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
config_value: JSON.stringify(mappings),
|
||
description: "实时转录后处理中用于纠错后标准化替换的标准词映射"
|
||
}),
|
||
});
|
||
const data = await response.json();
|
||
if (!response.ok) {
|
||
throw new Error(data.error || "Failed to update standard word mappings");
|
||
}
|
||
}
|
||
|
||
async function getStandardWordMappingsConfig() {
|
||
if (!standardWordMappingsList) {
|
||
return;
|
||
}
|
||
|
||
const response = await fetch(`/tool/speakr/api/system-configs/${getStandardWordMappingsConfigKey()}`, {
|
||
method: "GET",
|
||
});
|
||
const data = await response.json();
|
||
if (response.ok) {
|
||
renderStandardWordMappings(
|
||
parseStandardWordMappingsConfig(data?.config?.config_value || '')
|
||
);
|
||
return;
|
||
}
|
||
|
||
renderStandardWordMappings([]);
|
||
}
|
||
|
||
if (toggleStandardWordImportPanelButton) {
|
||
toggleStandardWordImportPanelButton.addEventListener('click', () => {
|
||
const willShow = standardWordImportPanel?.classList.contains('hidden');
|
||
setStandardWordImportPanelVisible(willShow);
|
||
if (willShow) {
|
||
standardWordMappingsBulkInput?.focus();
|
||
}
|
||
});
|
||
}
|
||
|
||
if (closeStandardWordImportPanelButton) {
|
||
closeStandardWordImportPanelButton.addEventListener('click', () => {
|
||
setStandardWordImportPanelVisible(false);
|
||
});
|
||
}
|
||
|
||
if (appendStandardWordMappingsFromBulkButton) {
|
||
appendStandardWordMappingsFromBulkButton.addEventListener('click', () => {
|
||
importStandardWordMappings('append');
|
||
});
|
||
}
|
||
|
||
if (replaceStandardWordMappingsFromBulkButton) {
|
||
replaceStandardWordMappingsFromBulkButton.addEventListener('click', () => {
|
||
importStandardWordMappings('replace');
|
||
});
|
||
}
|
||
|
||
if (clearStandardWordMappingsBulkInputButton) {
|
||
clearStandardWordMappingsBulkInputButton.addEventListener('click', () => {
|
||
if (standardWordMappingsBulkInput) {
|
||
standardWordMappingsBulkInput.value = '';
|
||
standardWordMappingsBulkInput.focus();
|
||
}
|
||
});
|
||
}
|
||
|
||
if (copyStandardWordMappingsTextButton) {
|
||
copyStandardWordMappingsTextButton.addEventListener('click', () => {
|
||
copyStandardWordMappingsText();
|
||
});
|
||
}
|
||
|
||
if (clearAllStandardWordMappingsButton) {
|
||
clearAllStandardWordMappingsButton.addEventListener('click', () => {
|
||
clearAllStandardWordMappings();
|
||
});
|
||
}
|
||
|
||
if (addStandardWordMappingRowButton) {
|
||
addStandardWordMappingRowButton.addEventListener('click', () => {
|
||
standardWordMappingsList?.appendChild(createStandardWordMappingRow());
|
||
refreshStandardWordMappingRowState();
|
||
});
|
||
}
|
||
const openBtn = document.getElementById('openPromptModal');
|
||
const modal = document.getElementById('promptModal');
|
||
const cancelBtnprompt = document.getElementById('cancelPrompt');
|
||
const confirmBtn = document.getElementById('confirmPrompt');
|
||
const promptListEl = document.getElementById('promptList');
|
||
|
||
|
||
// 保存当前选中的 prompt id
|
||
let selectedPromptId = localStorage.getItem('selected_prompt_id') || null;
|
||
|
||
|
||
// 打开弹框
|
||
openBtn.onclick = async () => {
|
||
modal.classList.remove('hidden');
|
||
await loadPromptList();
|
||
};
|
||
|
||
|
||
// 关闭弹框
|
||
cancelBtnprompt.onclick = () => modal.classList.add('hidden');
|
||
|
||
|
||
// 确认选择
|
||
confirmBtn.onclick = () => {
|
||
const checkedRadio = document.querySelector('input[name="prompt_radio"]:checked');
|
||
if (checkedRadio) {
|
||
selectedPromptId = checkedRadio.value;
|
||
localStorage.setItem('selected_prompt_id', selectedPromptId);
|
||
|
||
|
||
// 显示选中的名称到 textarea
|
||
const selectedName = checkedRadio.getAttribute('data-name');
|
||
document.getElementById('promptTextarea').value = selectedName;
|
||
}
|
||
modal.classList.add('hidden');
|
||
};
|
||
|
||
|
||
// 加载 Prompt 列表
|
||
async function loadPromptList() {
|
||
promptListEl.innerHTML = '<p class="text-sm text-gray-500">加载中...</p>';
|
||
|
||
|
||
try {
|
||
const response = await fetch(`/tool/speakr/api/external/prompts/list`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ skip: 0, limit: 999 })
|
||
});
|
||
|
||
|
||
const data = await response.json();
|
||
|
||
|
||
// 渲染列表
|
||
promptListEl.innerHTML = '';
|
||
|
||
|
||
data.prompt_list.forEach(item => {
|
||
const row = document.createElement('div');
|
||
|
||
row.className = "grid grid-cols-[50px_350px] px-2 py-2 items-start gap-2 bg-[#1f2937] rounded";
|
||
|
||
row.innerHTML = `
|
||
<div class="flex justify-center items-start pt-1">
|
||
<input type="radio"
|
||
name="prompt_radio"
|
||
value="${item.id}"
|
||
data-name="${item.name}"
|
||
${selectedPromptId == item.id ? 'checked' : ''}>
|
||
</div>
|
||
|
||
<div class="whitespace-normal break-words leading-5">
|
||
${item.name}
|
||
</div>
|
||
`;
|
||
|
||
promptList.appendChild(row);
|
||
});
|
||
} catch (err) {
|
||
promptListEl.innerHTML = '<p class="text-red-500 text-sm">加载失败</p>';
|
||
}
|
||
}
|
||
if (accountSceneCgButton) {
|
||
accountSceneCgButton.addEventListener('click', () => setAccountConfigScene('cg'));
|
||
}
|
||
if (accountSceneYlButton) {
|
||
accountSceneYlButton.addEventListener('click', () => setAccountConfigScene('yl'));
|
||
}
|
||
refreshAccountSceneButtons();
|
||
if (canManageSystemConfigs) {
|
||
getBaseHot()
|
||
getBasehandle()
|
||
getStandardWordMappingsConfig()
|
||
}
|
||
// getBasePrompt()
|
||
function updateTextareaValue(newValue) {
|
||
if (!textarea) {
|
||
return;
|
||
}
|
||
// 设置新值
|
||
textarea.value = newValue;
|
||
// 手动触发input事件(让监听器感知到值变化)
|
||
textarea.dispatchEvent(new Event('input'));
|
||
}
|
||
function updateTextareahandleValue(newValue) {
|
||
if (!textareahandle) {
|
||
return;
|
||
}
|
||
// 设置新值
|
||
textareahandle.value = newValue;
|
||
// 手动触发input事件(让监听器感知到值变化)
|
||
textareahandle.dispatchEvent(new Event('input'));
|
||
}
|
||
function updatePromptValue(newValue) {
|
||
// 设置新值
|
||
textareaHandelPrompt.value = newValue;
|
||
// 手动触发input事件(让监听器感知到值变化)
|
||
textareaHandelPrompt.dispatchEvent(new Event('input'));
|
||
}
|
||
if (accountInfoForm) {
|
||
accountInfoForm.addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
const formData = new FormData(accountInfoForm);
|
||
console.log(formData, 'formData');
|
||
|
||
try {
|
||
if (canManageSystemConfigs) {
|
||
await Promise.all([
|
||
updataBaseHot(),
|
||
updataBasehandle(),
|
||
updateStandardWordMappingsConfig()
|
||
]);
|
||
}
|
||
|
||
const response = await fetch('/tool/speakr/account', {
|
||
method: 'POST',
|
||
body: formData
|
||
});
|
||
|
||
if (response.ok) {
|
||
if (canManageSystemConfigs) {
|
||
getBaseHot()
|
||
getBasehandle()
|
||
getStandardWordMappingsConfig()
|
||
}
|
||
|
||
showToast('Account information updated successfully', 'success');
|
||
} else {
|
||
const text = await response.text();
|
||
showToast('Failed to update account information', 'error');
|
||
}
|
||
} catch (error) {
|
||
showToast('Network error: ' + error.message, 'error');
|
||
}
|
||
});
|
||
}
|
||
|
||
// AJAX form submission for Custom Prompts
|
||
const customPromptsForm = document.getElementById('customPromptsForm');
|
||
if (customPromptsForm) {
|
||
customPromptsForm.addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
|
||
const formData = new FormData(customPromptsForm);
|
||
|
||
try {
|
||
const response = await fetch('/tool/speakr/account', {
|
||
method: 'POST',
|
||
body: formData
|
||
});
|
||
|
||
if (response.ok) {
|
||
showToast('Custom prompt saved successfully', 'success');
|
||
} else {
|
||
const text = await response.text();
|
||
showToast('Failed to save custom prompt', 'error');
|
||
}
|
||
} catch (error) {
|
||
showToast('Network error: ' + error.message, 'error');
|
||
}
|
||
});
|
||
}
|
||
|
||
// Check for stored tab preference (from language change reload)
|
||
const storedTab = localStorage.getItem('activeAccountTab');
|
||
if (storedTab) {
|
||
localStorage.removeItem('activeAccountTab'); // Clean up after use
|
||
}
|
||
|
||
// Check for URL hash and show appropriate tab
|
||
const urlHash = window.location.hash.substring(1); // Remove the '#' symbol
|
||
if (urlHash === 'prompts') {
|
||
showPromptsTab();
|
||
} else if (urlHash === 'tags') {
|
||
showTagsTab();
|
||
} else if (urlHash === 'shares') {
|
||
showSharesTab();
|
||
} else if (urlHash === 'shares') {
|
||
showSharesTab();
|
||
} else if (urlHash === 'voiceprint') {
|
||
showVoiceprint();
|
||
} else if (storedTab) {
|
||
// Restore previously active tab after language change
|
||
if (storedTab === 'prompts') {
|
||
showPromptsTab();
|
||
} else if (storedTab === 'tags') {
|
||
showTagsTab();
|
||
} else if (storedTab === 'shares') {
|
||
showSharesTab();
|
||
} else if (storedTab === 'about') {
|
||
showAboutTab();
|
||
} else if (storedTab === 'voiceprint') {
|
||
showVoiceprint();
|
||
} else {
|
||
showAccountTab();
|
||
}
|
||
} else {
|
||
// Default to account tab
|
||
showAccountTab();
|
||
}
|
||
|
||
// Track original UI language to detect changes
|
||
let currentUILanguage = document.getElementById('ui_language').value;
|
||
const originalUILanguage = currentUILanguage;
|
||
|
||
// Add immediate language change listener to the UI language dropdown
|
||
const uiLanguageSelect = document.getElementById('ui_language');
|
||
if (uiLanguageSelect) {
|
||
uiLanguageSelect.addEventListener('change', async function (e) {
|
||
const newLanguage = e.target.value;
|
||
if (newLanguage !== currentUILanguage) {
|
||
await changeLanguageImmediately(newLanguage);
|
||
// Update the current language tracker
|
||
currentUILanguage = newLanguage;
|
||
}
|
||
});
|
||
}
|
||
|
||
// Add event listener to the account info form
|
||
document.getElementById('accountInfoForm').addEventListener('submit', function (e) {
|
||
const newUILanguage = document.getElementById('ui_language').value;
|
||
if (newUILanguage !== originalUILanguage) {
|
||
// Language has changed, store flag to reload main app
|
||
localStorage.setItem('ui_language_changed', 'true');
|
||
localStorage.setItem('preferredLanguage', newUILanguage);
|
||
}
|
||
});
|
||
|
||
// Template Management Functions
|
||
let templates = [];
|
||
let currentTemplate = null;
|
||
let isEditingTemplate = false;
|
||
|
||
async function loadTemplates() {
|
||
try {
|
||
const response = await fetch('/tool/speakr/api/transcript-templates');
|
||
if (!response.ok) {
|
||
throw new Error('Failed to load templates');
|
||
}
|
||
templates = await response.json();
|
||
|
||
// Create default templates if none exist
|
||
if (templates.length === 0) {
|
||
document.getElementById('createDefaultTemplatesBtn').style.display = 'block';
|
||
} else {
|
||
document.getElementById('createDefaultTemplatesBtn').style.display = 'none';
|
||
}
|
||
|
||
renderTemplatesList();
|
||
} catch (error) {
|
||
console.error('Error loading templates:', error);
|
||
showToast('Failed to load templates', 'error');
|
||
}
|
||
}
|
||
|
||
function renderTemplatesList() {
|
||
const templatesList = document.getElementById('templatesList');
|
||
templatesList.innerHTML = '';
|
||
|
||
if (templates.length === 0) {
|
||
templatesList.innerHTML = '<p class="text-sm text-[var(--text-muted)] text-center py-4">暂无模板</p>';
|
||
return;
|
||
}
|
||
|
||
templates.forEach(template => {
|
||
const templateItem = document.createElement('div');
|
||
templateItem.className = 'p-3 bg-[var(--bg-primary)] rounded-md hover:bg-[var(--bg-tertiary)] cursor-pointer transition-colors border border-[var(--border-primary)]';
|
||
if (template.is_default) {
|
||
templateItem.className += ' ring-2 ring-[var(--ring-focus)]';
|
||
}
|
||
|
||
templateItem.innerHTML = `
|
||
<div class="flex items-start justify-between">
|
||
<div class="flex-1">
|
||
<h4 class="text-sm font-medium text-[var(--text-primary)]">${template.name}</h4>
|
||
${template.description ? `<p class="text-xs text-[var(--text-muted)] mt-1">${template.description}</p>` : ''}
|
||
${template.is_default ? '<span class="text-xs text-[var(--text-accent)] mt-1"><i class="fas fa-star mr-1"></i>Default</span>' : ''}
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
templateItem.addEventListener('click', () => selectTemplate(template));
|
||
templatesList.appendChild(templateItem);
|
||
});
|
||
}
|
||
|
||
function selectTemplate(template) {
|
||
currentTemplate = template;
|
||
isEditingTemplate = true;
|
||
|
||
// Show editor, hide empty state
|
||
document.getElementById('templateEditor').classList.remove('hidden');
|
||
document.getElementById('templateEmptyState').classList.add('hidden');
|
||
|
||
// Populate form
|
||
document.getElementById('templateName').value = template.name;
|
||
document.getElementById('templateDescription').value = template.description || '';
|
||
document.getElementById('templateContent').value = template.template;
|
||
document.getElementById('templateIsDefault').checked = template.is_default;
|
||
|
||
// Show delete button
|
||
document.getElementById('deleteTemplateBtn').classList.remove('hidden');
|
||
}
|
||
|
||
function createNewTemplate() {
|
||
|
||
currentTemplate = null;
|
||
isEditingTemplate = false;
|
||
|
||
// Show editor, hide empty state
|
||
document.getElementById('templateEditor').classList.remove('hidden');
|
||
document.getElementById('templateEmptyState').classList.add('hidden');
|
||
|
||
// Clear form
|
||
document.getElementById('templateForm').reset();
|
||
|
||
// Hide delete button
|
||
document.getElementById('deleteTemplateBtn').classList.add('hidden');
|
||
}
|
||
|
||
document.getElementById('createTemplateBtn')?.addEventListener('click', createNewTemplate);
|
||
|
||
document.getElementById('cancelTemplateBtn')?.addEventListener('click', () => {
|
||
// Hide editor, show empty state
|
||
document.getElementById('templateEditor').classList.add('hidden');
|
||
document.getElementById('templateEmptyState').classList.remove('hidden');
|
||
currentTemplate = null;
|
||
isEditingTemplate = false;
|
||
});
|
||
|
||
document.getElementById('templateForm')?.addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
|
||
const templateData = {
|
||
name: document.getElementById('templateName').value,
|
||
description: document.getElementById('templateDescription').value,
|
||
template: document.getElementById('templateContent').value,
|
||
is_default: document.getElementById('templateIsDefault').checked
|
||
};
|
||
|
||
try {
|
||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
|
||
const url = isEditingTemplate ? `/tool/speakr/api/transcript-templates/${currentTemplate.id}` : '/tool/speakr/api/transcript-templates';
|
||
const method = isEditingTemplate ? 'PUT' : 'POST';
|
||
|
||
const response = await fetch(url, {
|
||
method: method,
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-CSRFToken': csrfToken
|
||
},
|
||
body: JSON.stringify(templateData)
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const errorData = await response.json();
|
||
throw new Error(errorData.error || 'Failed to save template');
|
||
}
|
||
|
||
const savedTemplate = await response.json();
|
||
|
||
if (isEditingTemplate) {
|
||
// Update existing template
|
||
const index = templates.findIndex(t => t.id === savedTemplate.id);
|
||
if (index !== -1) {
|
||
templates[index] = savedTemplate;
|
||
}
|
||
} else {
|
||
// Add new template
|
||
templates.push(savedTemplate);
|
||
}
|
||
|
||
// Update default status for other templates if needed
|
||
if (savedTemplate.is_default) {
|
||
templates.forEach(t => {
|
||
if (t.id !== savedTemplate.id) {
|
||
t.is_default = false;
|
||
}
|
||
});
|
||
}
|
||
|
||
renderTemplatesList();
|
||
showToast(isEditingTemplate ? 'Template updated successfully' : 'Template created successfully', 'success');
|
||
|
||
// Hide editor
|
||
document.getElementById('templateEditor').classList.add('hidden');
|
||
document.getElementById('templateEmptyState').classList.remove('hidden');
|
||
currentTemplate = null;
|
||
isEditingTemplate = false;
|
||
} catch (error) {
|
||
console.error('Error saving template:', error);
|
||
showToast(error.message || 'Failed to save template', 'error');
|
||
}
|
||
});
|
||
|
||
document.getElementById('deleteTemplateBtn')?.addEventListener('click', async () => {
|
||
if (!currentTemplate || !confirm('Are you sure you want to delete this template?')) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
|
||
const response = await fetch(`/tool/speakr/api/transcript-templates/${currentTemplate.id}`, {
|
||
method: 'DELETE',
|
||
headers: {
|
||
'X-CSRFToken': csrfToken
|
||
}
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error('Failed to delete template');
|
||
}
|
||
|
||
// Remove from local array
|
||
templates = templates.filter(t => t.id !== currentTemplate.id);
|
||
renderTemplatesList();
|
||
|
||
// Hide editor
|
||
document.getElementById('templateEditor').classList.add('hidden');
|
||
document.getElementById('templateEmptyState').classList.remove('hidden');
|
||
currentTemplate = null;
|
||
isEditingTemplate = false;
|
||
|
||
showToast('Template deleted successfully', 'success');
|
||
} catch (error) {
|
||
console.error('Error deleting template:', error);
|
||
showToast('Failed to delete template', 'error');
|
||
}
|
||
});
|
||
|
||
document.getElementById('createDefaultTemplatesBtn')?.addEventListener('click', async () => {
|
||
try {
|
||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
|
||
const response = await fetch('/tool/speakr/api/transcript-templates/create-defaults', {
|
||
method: 'POST',
|
||
headers: {
|
||
'X-CSRFToken': csrfToken
|
||
}
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error('Failed to create default templates');
|
||
}
|
||
|
||
const result = await response.json();
|
||
templates = result.templates;
|
||
renderTemplatesList();
|
||
showToast('Default templates created successfully', 'success');
|
||
} catch (error) {
|
||
console.error('Error creating default templates:', error);
|
||
showToast('Failed to create default templates', 'error');
|
||
}
|
||
});
|
||
|
||
function showToast(message, type = 'info') {
|
||
// Simple toast notification
|
||
const toast = document.createElement('div');
|
||
toast.className = `fixed bottom-4 right-4 px-4 py-2 rounded-md text-white z-50 ${type === 'success' ? 'bg-green-600' :
|
||
type === 'error' ? 'bg-red-600' :
|
||
'bg-blue-600'
|
||
}`;
|
||
toast.textContent = message;
|
||
document.body.appendChild(toast);
|
||
|
||
setTimeout(() => {
|
||
toast.remove();
|
||
}, 3000);
|
||
}
|
||
});
|
||
</script>
|
||
</body>
|
||
<style scoped>
|
||
:root {
|
||
--text-primarys: #333;
|
||
--btn-primarys: #4f46e5;
|
||
--btn-dangers: #dc2626;
|
||
--bg-modals: rgba(0, 0, 0, 0.5);
|
||
/* --border-color: #e5e7eb; */
|
||
}
|
||
|
||
body {
|
||
font-family: Arial, sans-serif;
|
||
/* margin: 20px; */
|
||
/* background: #f9f9f9; */
|
||
}
|
||
|
||
.btn {
|
||
padding: 6px 12px;
|
||
border: none;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
color: white;
|
||
}
|
||
|
||
.btn-primary {
|
||
background: var(--btn-primarys);
|
||
}
|
||
|
||
.btn-danger {
|
||
background: var(--btn-dangers);
|
||
}
|
||
|
||
.btn-icon {
|
||
background: none;
|
||
padding: 4px;
|
||
cursor: pointer;
|
||
}
|
||
|
||
table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
background: white;
|
||
border: 1px solid var(--border-colors);
|
||
border-radius: 6px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
th,
|
||
td {
|
||
text-align: left;
|
||
padding: 12px;
|
||
border-bottom: 1px solid var(--border-colors);
|
||
color: #000;
|
||
}
|
||
|
||
th {
|
||
background: #f3f4f6;
|
||
color: #000;
|
||
|
||
}
|
||
|
||
.top-bar {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.modal {
|
||
display: none;
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
background: var(--bg-modals);
|
||
justify-content: center;
|
||
align-items: center;
|
||
z-index: 1000;
|
||
}
|
||
|
||
.modal-content {
|
||
background: white;
|
||
padding: 20px;
|
||
border-radius: 8px;
|
||
width: 400px;
|
||
}
|
||
|
||
.modal-header {
|
||
font-size: 18px;
|
||
font-weight: bold;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.modal-footer {
|
||
text-align: right;
|
||
margin-top: 16px;
|
||
}
|
||
|
||
input,
|
||
textarea {
|
||
width: 100%;
|
||
padding: 6px;
|
||
margin-bottom: 12px;
|
||
border: 1px solid #d1d5db;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
audio {
|
||
width: 100%;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
/* ===== 弹框整体样式优化 ===== */
|
||
#voiceModal {
|
||
display: none;
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
background: rgba(0, 0, 0, 0.45);
|
||
justify-content: center;
|
||
align-items: center;
|
||
z-index: 1000;
|
||
font-family: "Microsoft YaHei", sans-serif;
|
||
}
|
||
|
||
#voiceModal .modal-content {
|
||
background: #fff;
|
||
width: 420px;
|
||
border-radius: 8px;
|
||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||
padding: 20px 24px;
|
||
color: #000;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
/* ===== 标题样式 ===== */
|
||
#voiceModal .modal-header {
|
||
font-size: 18px;
|
||
font-weight: 600;
|
||
color: #000;
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
/* ===== 标签标题 ===== */
|
||
#voiceModal .label-tit {
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
color: #000;
|
||
margin-bottom: 6px;
|
||
}
|
||
|
||
/* ===== 输入框、下拉框、文本域 ===== */
|
||
#voiceModal input[type="file"],
|
||
#voiceModal input[type="text"],
|
||
#voiceModal textarea {
|
||
width: 100%;
|
||
padding: 8px 10px;
|
||
border: 1px solid #d9d9d9;
|
||
border-radius: 4px;
|
||
box-sizing: border-box;
|
||
font-size: 14px;
|
||
color: #000;
|
||
outline: none;
|
||
transition: border-color 0.2s;
|
||
}
|
||
|
||
#voiceModal input[type="file"]:hover,
|
||
#voiceModal input[type="text"]:hover,
|
||
#voiceModal textarea:hover {
|
||
border-color: #409eff;
|
||
}
|
||
|
||
#voiceModal input[type="file"]:focus,
|
||
#voiceModal input[type="text"]:focus,
|
||
#voiceModal textarea:focus {
|
||
border-color: #409eff;
|
||
box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.1);
|
||
}
|
||
|
||
/* ===== 提示文字 ===== */
|
||
#voiceModal .tips {
|
||
font-size: 12px;
|
||
color: #666;
|
||
margin-top: 4px;
|
||
margin-bottom: 16px;
|
||
line-height: 1.4;
|
||
}
|
||
|
||
/* ===== 按钮样式 ===== */
|
||
#voiceModal .modal-footer {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
gap: 8px;
|
||
margin-top: 16px;
|
||
}
|
||
|
||
#voiceModal .btn {
|
||
padding: 6px 14px;
|
||
border: 1px solid #d9d9d9;
|
||
border-radius: 4px;
|
||
background: #fff;
|
||
cursor: pointer;
|
||
font-size: 14px;
|
||
transition: all 0.2s;
|
||
color: #000;
|
||
}
|
||
|
||
#voiceModal .btn:hover {
|
||
border-color: #bfbfbf;
|
||
background: #f5f5f5;
|
||
}
|
||
|
||
#voiceModal .btn-primary {
|
||
background: #1677ff;
|
||
color: #fff !important;
|
||
border-color: #1677ff;
|
||
}
|
||
|
||
#voiceModal .btn-primary:hover {
|
||
background: #409eff;
|
||
}
|
||
|
||
/* ===== 录音按钮样式(普通与录音中状态) ===== */
|
||
#voiceModal button[type="button"][onclick^="startRecording"] {
|
||
margin-top: 6px;
|
||
margin-bottom: 10px;
|
||
padding: 6px 12px;
|
||
font-size: 14px;
|
||
border-radius: 4px;
|
||
border: 1px solid #d9d9d9;
|
||
background: #fff;
|
||
color: #000;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
#voiceModal button[type="button"][onclick^="startRecording"]:hover {
|
||
border-color: #409eff;
|
||
color: #409eff;
|
||
}
|
||
|
||
/* 录音中状态 */
|
||
#voiceModal .btn-recording {
|
||
background: #fff !important;
|
||
color: #dc2626 !important;
|
||
border: 1px solid #dc2626 !important;
|
||
font-weight: 600;
|
||
}
|
||
|
||
#voiceModal .btn-recording::before {
|
||
content: "⏹ ";
|
||
color: #dc2626;
|
||
}
|
||
|
||
/* ===== 音频播放控件 ===== */
|
||
#voiceModal audio {
|
||
width: 100%;
|
||
margin-top: 10px;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
/* ===== 输入框间距 ===== */
|
||
#voiceModal input[type="text"],
|
||
#voiceModal textarea,
|
||
#voiceModal input[type="file"] {
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
/* 录音中状态 */
|
||
.btn-recording {
|
||
background: #fff !important;
|
||
color: #dc2626 !important;
|
||
border: 1px solid #dc2626;
|
||
font-weight: bold;
|
||
}
|
||
|
||
/* 录音时的红色方块动画效果(可选) */
|
||
.btn-recording::before {
|
||
content: "⏹ ";
|
||
color: #dc2626;
|
||
}
|
||
|
||
/* 普通状态下的麦克风图标 */
|
||
button#recordBtn::before {
|
||
content: "🎤 ";
|
||
}
|
||
|
||
.file-box,
|
||
.record-box {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
/* 清空按钮样式 */
|
||
.btn-clear {
|
||
padding: 4px 10px;
|
||
border: 1px solid #d9d9d9;
|
||
border-radius: 4px;
|
||
background: #fff;
|
||
color: #666;
|
||
font-size: 13px;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.btn-clear:hover {
|
||
border-color: #ff4d4f;
|
||
color: #ff4d4f;
|
||
}
|
||
</style>
|
||
|
||
</html>
|