407 lines
20 KiB
JavaScript
407 lines
20 KiB
JavaScript
import { WORD_LIBRARIES } from '../constants.js';
|
||
import { COMMON_NAMES, STOP_WORDS } from '../data/stopwords.js';
|
||
import { state } from '../core/state.js';
|
||
import { saveJsonSetting, saveSettings, STORAGE_KEYS } from '../core/storage.js';
|
||
import { renderPage } from '../core/router.js';
|
||
import { showToast } from '../ui/toast.js';
|
||
import { clearCloudTtsCache, getTtsSettings, resetCloudTtsAuthBlock, sanitizeAzureSpeechKey, testCloudTtsConnection as testAzureTTSConnection } from '../services/tts.js';
|
||
import { callAI } from '../services/ai.js';
|
||
import { escapeHtml } from '../ui/dom.js';
|
||
export { toggleBatchExtract } from './extract.js';
|
||
export { showFilterWordsModal } from './filter-words.js';
|
||
export { manualLoadWords, switchWordLibrary, toggleAutoLoad } from '../services/library.js';
|
||
export { clearAllData, clearFavorites, doImportBackup, exportAllData, handleImportBackup, showImportAllModal } from './settings-data.js';
|
||
// ==================== Page: Settings ====================
|
||
export function renderSettings(el) {
|
||
const s = state.settings;
|
||
|
||
el.innerHTML = `
|
||
<div class="page-header">
|
||
<div>
|
||
<h1>设置</h1>
|
||
<p class="page-desc">配置 AI 模型接口与管理学习数据</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="settings-layout">
|
||
<div class="card settings-card">
|
||
<h3><span class="icon-badge" style="color:#a855f7;background:rgba(168,85,247,0.1)"><i class="fas fa-robot"></i></span> AI 模型配置</h3>
|
||
<div class="form-group">
|
||
<label>API Key</label>
|
||
<input type="password" id="set-api-key" value="${escapeHtml(s.apiKey || '')}" placeholder="输入你的 API Key">
|
||
</div>
|
||
<div class="form-group">
|
||
<label>API URL</label>
|
||
<input type="text" id="set-api-url" value="${escapeHtml(s.apiUrl || 'https://tohub.com/v1')}" placeholder="https://tohub.com/v1">
|
||
<p class="hint">格式:https://your-api.com/v1</p>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Model</label>
|
||
<input type="text" id="set-model" value="${escapeHtml(s.model || 'gemini-3-flash')}" placeholder="gemini-3-flash">
|
||
</div>
|
||
<div class="btn-group settings-card-actions">
|
||
<button class="btn btn-primary" data-action="settings.doSaveSettings"><i class="fas fa-save"></i> 保存设置</button>
|
||
<button class="btn btn-secondary" data-action="settings.testAIConnection"><i class="fas fa-plug"></i> 测试连接</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card settings-card">
|
||
<h3><span class="icon-badge" style="color:#06b6d4;background:rgba(6,182,212,0.1)"><i class="fas fa-headphones"></i></span> TTS 语音配置</h3>
|
||
<p class="settings-card-desc">优先使用云端 TTS 朗读,失败后自动回退到有道发音。</p>
|
||
<div class="settings-row" style="margin-bottom:20px">
|
||
<div class="settings-row-main">
|
||
<div>
|
||
<div class="settings-row-title">启用云端 TTS</div>
|
||
<div class="settings-row-desc">开启后朗读会优先调用下面配置的 TTS 模型</div>
|
||
</div>
|
||
</div>
|
||
<div class="toggle-switch ${s.ttsEnabled !== false ? 'active' : ''}" data-action="settings.toggleTtsEnabled"><div class="toggle-knob"></div></div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>API Key</label>
|
||
<input type="password" id="set-tts-api-key" value="${escapeHtml(s.ttsApiKey || '')}" placeholder="输入 Azure Speech API 密钥">
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Region</label>
|
||
<input type="text" id="set-tts-region" value="${escapeHtml(s.ttsRegion || 'eastus')}" placeholder="eastus">
|
||
<p class="hint">例如 eastus,需与 API Key 所属区域一致。</p>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Voice</label>
|
||
<select id="set-tts-voice">
|
||
${['en-US-JennyNeural', 'en-US-AvaNeural', 'en-US-GuyNeural', 'en-US-AriaNeural', 'en-GB-SoniaNeural', 'en-GB-RyanNeural'].map(v => `<option value="${v}" ${(s.ttsVoice || 'en-US-JennyNeural') === v ? 'selected' : ''}>${v}</option>`).join('')}
|
||
</select>
|
||
</div>
|
||
<div class="settings-row" style="margin-bottom:20px">
|
||
<div class="settings-row-main">
|
||
<div>
|
||
<div class="settings-row-title">预取 Azure TTS 音频</div>
|
||
<div class="settings-row-desc">提前生成 Azure 音频,减少播放等待,但会消耗更多请求额度</div>
|
||
</div>
|
||
</div>
|
||
<div class="toggle-switch ${s.ttsPrefetchEnabled === true ? 'active' : ''}" data-action="settings.toggleTtsPrefetchEnabled"><div class="toggle-knob"></div></div>
|
||
</div>
|
||
<div class="btn-group settings-card-actions">
|
||
<button class="btn btn-primary" data-action="settings.doSaveSettings"><i class="fas fa-save"></i> 保存 TTS 设置</button>
|
||
<button class="btn btn-secondary" data-action="settings.testTTSConnection"><i class="fas fa-volume-up"></i> 测试 TTS</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card settings-card full-width">
|
||
<h3><span class="icon-badge" style="color:#3b82f6;background:rgba(59,130,246,0.1)"><i class="fas fa-database"></i></span> 数据管理</h3>
|
||
<p class="settings-card-desc" style="margin-bottom:0">所有数据存储在浏览器本地,清除浏览器数据会丢失。建议定期导出备份。</p>
|
||
<div class="settings-stat-grid">
|
||
<div class="settings-stat-item">
|
||
<div class="settings-stat-value" style="color:var(--primary)">${state.words.length}</div>
|
||
<div class="settings-stat-label">单词</div>
|
||
</div>
|
||
<div class="settings-stat-item">
|
||
<div class="settings-stat-value" style="color:var(--success)">${state.records.length}</div>
|
||
<div class="settings-stat-label">学习记录</div>
|
||
</div>
|
||
<div class="settings-stat-item">
|
||
<div class="settings-stat-value" style="color:var(--warning)">${Object.keys(state.schedule).length}</div>
|
||
<div class="settings-stat-label">复习计划</div>
|
||
</div>
|
||
<div class="settings-stat-item">
|
||
<div class="settings-stat-value" style="color:var(--warning)">${state.favorites.length}</div>
|
||
<div class="settings-stat-label">收藏单词</div>
|
||
</div>
|
||
<div class="settings-stat-item">
|
||
<div class="settings-stat-value" style="color:var(--primary)">${state.mailEmails.length}</div>
|
||
<div class="settings-stat-label">保存邮件</div>
|
||
</div>
|
||
</div>
|
||
<div class="btn-group settings-card-actions">
|
||
<button class="btn btn-secondary" data-action="settings.exportAllData"><i class="fas fa-download"></i> 导出全部数据</button>
|
||
<button class="btn btn-secondary" data-action="settings.showImportAllModal"><i class="fas fa-upload"></i> 导入数据备份</button>
|
||
</div>
|
||
<div class="settings-divider">
|
||
<div class="btn-group">
|
||
<button class="btn btn-error" data-action="settings.clearAllData"><i class="fas fa-trash-alt"></i> 清空所有数据</button>
|
||
<button class="btn btn-secondary" data-action="settings.clearFavorites" style="color:var(--warning);border-color:var(--warning)"><i class="fas fa-star"></i> 清除收藏单词</button>
|
||
</div>
|
||
<p class="hint" style="margin-top:8px"><i class="fas fa-exclamation-triangle" style="color:var(--warning)"></i> 清空数据不可恢复,AI 配置和收藏单词将保留</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="settings-layout" style="margin-top:24px">
|
||
<div class="card settings-card">
|
||
<h3><span class="icon-badge" style="color:#22c55e;background:rgba(34,197,94,0.1)"><i class="fas fa-sliders-h"></i></span> 词库与提词</h3>
|
||
|
||
<div class="settings-row" style="display:block">
|
||
<div class="settings-row-title" style="margin-bottom:6px">当前单词库</div>
|
||
<div class="settings-row-desc" style="margin-bottom:10px">切换后会使用所选词库,并为每个词库分别保留学习进度和收藏。</div>
|
||
<div class="word-library-picker">
|
||
<select id="word-library-select">
|
||
${WORD_LIBRARIES.map(library => `<option value="${library.id}" ${state.activeWordLibrary === library.id ? 'selected' : ''}>${escapeHtml(library.name)}</option>`).join('')}
|
||
</select>
|
||
<button class="btn btn-primary" data-action="settings.switchWordLibrary"><i class="fas fa-exchange-alt"></i> 切换词库</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="settings-row">
|
||
<div class="settings-row-main">
|
||
<div class="toggle-switch ${state.autoLoadEnabled ? 'active' : ''}" data-action="settings.toggleAutoLoad" data-arg0="${!state.autoLoadEnabled}">
|
||
<div class="toggle-knob"></div>
|
||
</div>
|
||
<div>
|
||
<div class="settings-row-title">启动时自动加载内置词库</div>
|
||
<div class="settings-row-desc">若词库为空,自动加载 data/ 目录下的单词文件</div>
|
||
</div>
|
||
</div>
|
||
<button class="btn btn-sm btn-secondary" data-action="settings.manualLoadWords" title="手动加载"><i class="fas fa-sync-alt"></i></button>
|
||
</div>
|
||
|
||
<div class="settings-row">
|
||
<div class="settings-row-main">
|
||
<div class="toggle-switch ${state.batchExtractEnabled ? 'active' : ''}" data-action="settings.toggleBatchExtract" data-arg0="${!state.batchExtractEnabled}">
|
||
<div class="toggle-knob"></div>
|
||
</div>
|
||
<div>
|
||
<div class="settings-row-title">邮件批量提词</div>
|
||
<div class="settings-row-desc">在提词页面显示批量提取功能,支持上传文件并统计词频</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="settings-divider">
|
||
<div class="settings-row-title" style="margin-bottom:8px"><i class="fas fa-filter" style="color:var(--error);margin-right:4px"></i> 过滤词管理</div>
|
||
<p class="settings-row-desc" style="margin-bottom:10px">管理提词时自动排除的停用词和常见人名</p>
|
||
<div class="btn-group">
|
||
<button class="btn btn-sm btn-secondary" data-action="settings.showFilterWordsModal" data-arg0="stop"><i class="fas fa-ban"></i> 停用词 (${STOP_WORDS.size})</button>
|
||
<button class="btn btn-sm btn-secondary" data-action="settings.showFilterWordsModal" data-arg0="names"><i class="fas fa-user-slash"></i> 常见人名 (${COMMON_NAMES.size})</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card settings-card">
|
||
<h3><span class="icon-badge" style="color:#f59e0b;background:rgba(245,158,11,0.1)"><i class="fas fa-volume-up"></i></span> 自动播放设置</h3>
|
||
<p class="settings-card-desc">配置单词库自动播放时的朗读次数和间隔时间</p>
|
||
|
||
<div class="settings-row" style="display:block">
|
||
<div class="settings-row-title" style="margin-bottom:10px">每个单词朗读次数</div>
|
||
<div class="btn-group" style="margin-top:0">
|
||
<button class="btn btn-sm ${state.autoPlayRepeat === 1 ? 'btn-primary' : 'btn-secondary'}" data-action="settings.setAutoPlayRepeat" data-arg0="1">1 次</button>
|
||
<button class="btn btn-sm ${state.autoPlayRepeat === 2 ? 'btn-primary' : 'btn-secondary'}" data-action="settings.setAutoPlayRepeat" data-arg0="2">2 次</button>
|
||
<button class="btn btn-sm ${state.autoPlayRepeat === 3 ? 'btn-primary' : 'btn-secondary'}" data-action="settings.setAutoPlayRepeat" data-arg0="3">3 次</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="repeat-delay-setting" class="settings-row" style="display:${state.autoPlayRepeat <= 1 ? 'none' : 'block'};margin-top:12px">
|
||
<div class="settings-row-title" style="margin-bottom:6px">重复朗读间隔</div>
|
||
<div class="settings-row-desc" style="margin-bottom:8px">同一单词多次朗读之间的间隔时间</div>
|
||
<div class="settings-input-row">
|
||
<input type="number" id="set-repeat-delay" value="${state.autoPlayRepeatDelay || ''}" placeholder="默认 1000" min="300" max="5000" step="100">
|
||
<span class="unit">毫秒(留空使用默认 1 秒)</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="settings-row" style="display:block;margin-top:12px">
|
||
<div class="settings-row-title" style="margin-bottom:6px">单词切换间隔</div>
|
||
<div class="settings-row-desc" style="margin-bottom:8px">自动播放时切换到下一个单词的间隔时间</div>
|
||
<div class="settings-input-row">
|
||
<input type="number" id="set-word-interval" value="${state.autoPlayInterval || ''}" placeholder="默认 2500" min="500" max="10000" step="100">
|
||
<span class="unit">毫秒(留空使用默认 2.5 秒)</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="settings-row" style="display:block;margin-top:12px">
|
||
<div class="settings-row-title" style="margin-bottom:6px">播放时滚动方式</div>
|
||
<div class="settings-row-desc" style="margin-bottom:8px">自动播放切换单词时,页面滚动行为</div>
|
||
<div class="btn-group" style="margin-top:0">
|
||
<button class="btn btn-sm ${state.autoPlayScrollMode === 'center' ? 'btn-primary' : 'btn-secondary'}" data-action="settings.setAutoPlayScrollMode" data-arg0="center">始终居中</button>
|
||
<button class="btn btn-sm ${state.autoPlayScrollMode === 'visible' ? 'btn-primary' : 'btn-secondary'}" data-action="settings.setAutoPlayScrollMode" data-arg0="visible">不在视口内时才滚动</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="btn-group settings-card-actions">
|
||
<button class="btn btn-sm btn-primary" data-action="settings.saveAutoPlaySettings"><i class="fas fa-save"></i> 保存播放设置</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card settings-card full-width">
|
||
<h3><span class="icon-badge" style="color:#ef4444;background:rgba(239,68,68,0.1)"><i class="fas fa-cloud-download-alt"></i></span> 应用更新</h3>
|
||
<p class="settings-card-desc">当前版本:${escapeHtml(window.APP_VERSION || '未知')}</p>
|
||
<p class="settings-card-desc">如果 iPhone Safari 仍显示旧版本,可重新获取最新页面和程序文件。单词、学习记录、收藏及设置不会被清除。</p>
|
||
<div class="btn-group settings-card-actions">
|
||
<button class="btn btn-secondary" id="force-refresh-btn" data-action="settings.forceRefreshBrowserCache"><i class="fas fa-sync-alt"></i> 获取最新版本</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
export async function forceRefreshBrowserCache() {
|
||
if (!confirm('确定获取最新版本并重新加载吗?本地学习数据和设置不会被删除。')) return;
|
||
|
||
const button = document.getElementById('force-refresh-btn');
|
||
if (button) {
|
||
button.disabled = true;
|
||
button.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 正在获取最新版本...';
|
||
}
|
||
|
||
try {
|
||
// 当前无 Service Worker,Cache Storage 通常为空;此处为将来引入 SW 时的前瞻兼容。
|
||
// 真正的缓存绕过依赖下方的时间戳 URL + 静态资源 ?v= 版本号。
|
||
if ('caches' in window) {
|
||
const cacheNames = await caches.keys();
|
||
await Promise.all(cacheNames.map(cacheName => caches.delete(cacheName)));
|
||
}
|
||
} catch (error) {
|
||
console.warn('清理应用缓存失败,将继续重新加载:', error);
|
||
}
|
||
|
||
const url = new URL(window.location.href);
|
||
url.searchParams.set('_app_update', Date.now().toString());
|
||
window.location.replace(url.toString());
|
||
}
|
||
|
||
export function setAutoPlayRepeat(count) {
|
||
const previousValue = state.autoPlayRepeat;
|
||
state.autoPlayRepeat = count;
|
||
if (!saveJsonSetting(STORAGE_KEYS.autoPlayRepeat, count)) {
|
||
state.autoPlayRepeat = previousValue;
|
||
return false;
|
||
}
|
||
const delayEl = document.getElementById('repeat-delay-setting');
|
||
if (delayEl) delayEl.style.display = count <= 1 ? 'none' : '';
|
||
if (state.currentPage === 'settings') renderPage('settings');
|
||
return true;
|
||
}
|
||
|
||
export function setAutoPlayScrollMode(mode) {
|
||
const previousValue = state.autoPlayScrollMode;
|
||
state.autoPlayScrollMode = mode === 'visible' ? 'visible' : 'center';
|
||
if (!saveJsonSetting(STORAGE_KEYS.autoPlayScrollMode, state.autoPlayScrollMode)) {
|
||
state.autoPlayScrollMode = previousValue;
|
||
return false;
|
||
}
|
||
if (state.currentPage === 'settings') renderPage('settings');
|
||
return true;
|
||
}
|
||
|
||
export function saveAutoPlaySettings() {
|
||
const intervalVal = parseInt(document.getElementById('set-word-interval').value) || 0;
|
||
const repeatDelayVal = parseInt(document.getElementById('set-repeat-delay').value) || 0;
|
||
const previousInterval = state.autoPlayInterval;
|
||
const previousRepeatDelay = state.autoPlayRepeatDelay;
|
||
state.autoPlayInterval = intervalVal;
|
||
state.autoPlayRepeatDelay = repeatDelayVal;
|
||
const saved = saveJsonSetting(STORAGE_KEYS.autoPlayInterval, intervalVal)
|
||
&& saveJsonSetting(STORAGE_KEYS.autoPlayRepeatDelay, repeatDelayVal);
|
||
if (!saved) {
|
||
state.autoPlayInterval = previousInterval;
|
||
state.autoPlayRepeatDelay = previousRepeatDelay;
|
||
saveJsonSetting(STORAGE_KEYS.autoPlayInterval, previousInterval);
|
||
saveJsonSetting(STORAGE_KEYS.autoPlayRepeatDelay, previousRepeatDelay);
|
||
return false;
|
||
}
|
||
showToast('播放设置已保存', 'success');
|
||
return true;
|
||
}
|
||
|
||
function valueById(id, fallback = '') {
|
||
const el = document.getElementById(id);
|
||
return el ? el.value.trim() : fallback;
|
||
}
|
||
|
||
function syncSettingsFromForm() {
|
||
state.settings = {
|
||
...state.settings,
|
||
apiKey: valueById('set-api-key', state.settings.apiKey || ''),
|
||
apiUrl: valueById('set-api-url', state.settings.apiUrl || '').replace(/\/$/, ''),
|
||
model: valueById('set-model', state.settings.model || ''),
|
||
ttsProvider: 'azure',
|
||
ttsApiKey: sanitizeAzureSpeechKey(valueById('set-tts-api-key', state.settings.ttsApiKey || '')),
|
||
ttsEndpoint: '',
|
||
ttsRegion: valueById('set-tts-region', state.settings.ttsRegion || 'eastus').toLowerCase(),
|
||
ttsVoice: valueById('set-tts-voice', state.settings.ttsVoice || 'en-US-JennyNeural'),
|
||
ttsOutputFormat: 'audio-24khz-48kbitrate-mono-mp3',
|
||
ttsPrefetchEnabled: state.settings.ttsPrefetchEnabled === true
|
||
};
|
||
}
|
||
|
||
export function toggleTtsEnabled() {
|
||
const previousSettings = state.settings;
|
||
syncSettingsFromForm();
|
||
state.settings.ttsEnabled = state.settings.ttsEnabled === false;
|
||
resetCloudTtsAuthBlock();
|
||
clearCloudTtsCache();
|
||
if (!saveSettings()) {
|
||
state.settings = previousSettings;
|
||
return false;
|
||
}
|
||
renderPage('settings');
|
||
return true;
|
||
}
|
||
|
||
export function toggleTtsPrefetchEnabled() {
|
||
const previousSettings = state.settings;
|
||
syncSettingsFromForm();
|
||
state.settings.ttsPrefetchEnabled = state.settings.ttsPrefetchEnabled !== true;
|
||
if (!saveSettings()) {
|
||
state.settings = previousSettings;
|
||
return false;
|
||
}
|
||
renderPage('settings');
|
||
return true;
|
||
}
|
||
|
||
function persistSettings() {
|
||
const previousSettings = state.settings;
|
||
syncSettingsFromForm();
|
||
resetCloudTtsAuthBlock();
|
||
clearCloudTtsCache();
|
||
if (saveSettings()) return true;
|
||
state.settings = previousSettings;
|
||
return false;
|
||
}
|
||
|
||
export function doSaveSettings() {
|
||
if (!persistSettings()) return false;
|
||
showToast('设置已保存', 'success');
|
||
return true;
|
||
}
|
||
|
||
export async function testTTSConnection() {
|
||
if (!persistSettings()) return;
|
||
const cfg = getTtsSettings();
|
||
if (!cfg.enabled) {
|
||
showToast('请先启用云端 TTS', 'warning');
|
||
return;
|
||
}
|
||
if (!cfg.apiKey || !cfg.region || !cfg.voice) {
|
||
showToast('请填写 TTS API Key、Region 和 Voice', 'warning');
|
||
return;
|
||
}
|
||
showToast('正在测试 TTS...', 'info');
|
||
try {
|
||
const played = await testAzureTTSConnection();
|
||
showToast(played ? 'TTS 测试成功' : 'TTS 未播放:请检查配置或稍后重试', played ? 'success' : 'warning');
|
||
} catch (e) {
|
||
showToast('TTS 测试失败:' + e.message, 'error');
|
||
}
|
||
}
|
||
|
||
export async function testAIConnection() {
|
||
if (!doSaveSettings()) return;
|
||
|
||
if (!state.settings.apiKey || !state.settings.apiUrl || !state.settings.model) {
|
||
showToast('请填写所有配置项', 'warning');
|
||
return;
|
||
}
|
||
|
||
showToast('正在测试连接...', 'info');
|
||
|
||
try {
|
||
const reply = await callAI([
|
||
{ role: 'user', content: '请回复"连接成功"四个字' }
|
||
]);
|
||
showToast('连接成功!AI 回复:' + reply.slice(0, 50), 'success');
|
||
} catch (e) {
|
||
showToast('连接失败:' + e.message, 'error');
|
||
}
|
||
}
|