Files
AI_English/src/js/pages/settings.js
T
eddy a878899512 feat: 在设置中显示当前应用版本
- 通过全局应用版本变量公开资源版本号
- 在应用更新区域显示当前版本
- 更新资源版本号以刷新缓存文件
2026-07-20 00:08:29 +08:00

407 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 WorkerCache 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');
}
}