feat(personal_collect,motion_reminder): 新增回收站与云端TTS语音功能
- 新增小程序个人收藏回收站页面,支持查看已删除收藏、恢复与彻底删除操作 - 修复个人收藏文件上传时的中文名乱码问题,新增原文件名透传参数 - 接入阿里云百炼TTS服务,实现运动提醒模块的云端语音合成与本地缓存,支持系统TTS与云端MP3双降级播放 - 新增语音引擎探测与异常预警UI,覆盖web端与移动端 - 优化web端订单盒页面布局,修复卡片操作按钮换行问题 - 新增调试探针脚本、ESLint配置与相关lint脚本 - 完善后端接口与路由,更新项目依赖与配置文件
This commit is contained in:
+270
-1
@@ -692,13 +692,282 @@ body {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
z-index: 2000;
|
||||
z-index: 2200;
|
||||
}
|
||||
|
||||
.icon-picker.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* ===== 书签后台维护:tab + 表格 + 分页 + 弹窗 ===== */
|
||||
.admin-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.admin-tab-btn {
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.admin-tab-btn.active {
|
||||
opacity: 1;
|
||||
background: rgba(59, 130, 246, 0.4);
|
||||
border-color: rgba(99, 102, 241, 0.8);
|
||||
}
|
||||
|
||||
.admin-tab-page {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.admin-tab-page.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.admin-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.admin-toolbar .bookmark-search {
|
||||
flex: 1 1 200px;
|
||||
min-width: 160px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.bookmark-filter-select {
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
color: #ffffff;
|
||||
padding: 0.5rem 0.6rem;
|
||||
outline: none;
|
||||
max-width: 220px;
|
||||
}
|
||||
|
||||
.admin-count {
|
||||
margin-left: auto;
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.8;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-table-wrap {
|
||||
overflow-x: auto;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.admin-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.92rem;
|
||||
min-width: 660px;
|
||||
}
|
||||
|
||||
.admin-table th,
|
||||
.admin-table td {
|
||||
padding: 8px 10px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.admin-table th {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.admin-table tbody tr:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.admin-table .th-actions,
|
||||
.admin-table .td-actions {
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-table .td-actions .admin-row-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.admin-table .td-icon {
|
||||
width: 34px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.admin-table .td-icon img {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
object-fit: contain;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.admin-table .td-main .td-sub {
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.75;
|
||||
max-width: 220px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-table .td-remark-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-table .td-remark-wrap.expanded {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.admin-table .td-remark-wrap.expanded .td-sub {
|
||||
white-space: normal;
|
||||
max-width: 420px;
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
}
|
||||
|
||||
.remark-toggle {
|
||||
flex: none;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #93c5fd;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1;
|
||||
padding: 0 2px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.remark-toggle:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.admin-table .td-url {
|
||||
max-width: 240px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-table .td-url .url-type-icon {
|
||||
margin-right: 4px;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.admin-table .td-url a {
|
||||
color: #93c5fd;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.admin-table .td-url a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.admin-table .empty-row {
|
||||
text-align: center;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
padding: 18px 10px;
|
||||
}
|
||||
|
||||
.admin-pagination {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.pagination-btn {
|
||||
padding: 0.32rem 0.7rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.pagination-btn.active {
|
||||
background: rgba(59, 130, 246, 0.5);
|
||||
border-color: rgba(99, 102, 241, 0.9);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.pagination-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.pagination-dots {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
/* 后台维护弹窗 */
|
||||
.nav-modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
z-index: 2100;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.nav-modal-overlay.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.nav-modal {
|
||||
width: 100%;
|
||||
max-width: 460px;
|
||||
max-height: 86vh;
|
||||
overflow-y: auto;
|
||||
border-radius: 12px;
|
||||
background: rgba(15, 20, 35, 0.98);
|
||||
border: 1px solid rgba(255, 255, 255, 0.22);
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.nav-modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.nav-modal-title {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.nav-modal-close {
|
||||
padding: 4px 10px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.nav-form .form-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.nav-form button[type="submit"]:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.fitness-diary-reminder,
|
||||
.weekly-report-reminder {
|
||||
position: fixed;
|
||||
|
||||
+460
-78
@@ -85,6 +85,19 @@ class ToolboxHome {
|
||||
this.editingCategoryCode = '';
|
||||
this.editingBookmarkId = '';
|
||||
this.iconPickerTarget = null;
|
||||
// 后台维护状态(tab + 分页 + 筛选)
|
||||
this.adminTab = 'bookmarks';
|
||||
this.adminCatPage = 1;
|
||||
this.adminCatPageSize = 10;
|
||||
this.adminBookPage = 1;
|
||||
this.adminBookPageSize = 50;
|
||||
this.adminBookCatFilter = '';
|
||||
this.adminBookQuery = '';
|
||||
this.adminCategories = [];
|
||||
this.adminBookmarks = [];
|
||||
this.adminCatTotal = 0;
|
||||
this.adminBookTotal = 0;
|
||||
this.adminSearchTimer = null;
|
||||
this.activeReminderKey = '';
|
||||
this.reminderQueue = [];
|
||||
|
||||
@@ -205,17 +218,78 @@ class ToolboxHome {
|
||||
<div class="admin-title">书签后台维护</div>
|
||||
<button class="bookmark-btn" id="bookmark-admin-close" type="button">返回书签</button>
|
||||
</div>
|
||||
<div class="admin-columns">
|
||||
<div class="admin-panel">
|
||||
<div class="admin-panel-title">分类维护</div>
|
||||
<form class="admin-form" id="category-form">
|
||||
<div class="admin-tabs" id="admin-tabs">
|
||||
<button class="bookmark-btn admin-tab-btn active" data-adm-tab="bookmarks" type="button">🔖 书签管理</button>
|
||||
<button class="bookmark-btn admin-tab-btn" data-adm-tab="categories" type="button">📂 分类管理</button>
|
||||
</div>
|
||||
<div class="admin-tab-page active" data-adm-page="bookmarks">
|
||||
<div class="admin-toolbar">
|
||||
<button class="bookmark-btn primary" id="bookmark-add-btn" type="button">新增书签</button>
|
||||
<select id="bookmark-admin-category-filter" class="bookmark-filter-select" title="按分类筛选"></select>
|
||||
<input id="bookmark-admin-search" class="bookmark-search admin-search" placeholder="搜索名称/网址/备注..." autocomplete="off" />
|
||||
<button class="bookmark-btn" id="bookmark-admin-search-clear" type="button">清空</button>
|
||||
<div class="admin-count" id="bookmark-count"></div>
|
||||
</div>
|
||||
<div class="admin-table-wrap">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="th-icon"></th>
|
||||
<th>名称</th>
|
||||
<th>网址</th>
|
||||
<th>分类</th>
|
||||
<th>排序</th>
|
||||
<th>创建时间</th>
|
||||
<th class="th-actions">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="bookmark-table-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="admin-pagination" id="bookmark-pagination"></div>
|
||||
</div>
|
||||
<div class="admin-tab-page" data-adm-page="categories">
|
||||
<div class="admin-toolbar">
|
||||
<button class="bookmark-btn primary" id="category-add-btn" type="button">新增分类</button>
|
||||
<div class="admin-count" id="category-count"></div>
|
||||
</div>
|
||||
<div class="admin-table-wrap">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>编码</th>
|
||||
<th>名称</th>
|
||||
<th>排序</th>
|
||||
<th>书签数</th>
|
||||
<th class="th-actions">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="category-table-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="admin-pagination" id="category-pagination"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 后台维护弹窗(新增/编辑分类、书签共用)
|
||||
const adminModal = document.createElement('div');
|
||||
adminModal.className = 'nav-modal-overlay';
|
||||
adminModal.id = 'nav-admin-modal';
|
||||
adminModal.innerHTML = `
|
||||
<div class="nav-modal" role="dialog" aria-modal="true" aria-labelledby="nav-admin-modal-title">
|
||||
<div class="nav-modal-header">
|
||||
<div class="nav-modal-title" id="nav-admin-modal-title">新增分类</div>
|
||||
<button class="bookmark-btn nav-modal-close" id="nav-admin-modal-close" type="button">✕</button>
|
||||
</div>
|
||||
<div class="nav-modal-body">
|
||||
<form class="admin-form nav-form" id="admin-category-form">
|
||||
<div class="form-row">
|
||||
<label>编码</label>
|
||||
<input name="code" required />
|
||||
<input name="code" required placeholder="如 12 / dev / life" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>名称</label>
|
||||
<input name="name" required />
|
||||
<input name="name" required placeholder="分类名称" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>排序</label>
|
||||
@@ -226,18 +300,14 @@ class ToolboxHome {
|
||||
<button class="bookmark-btn" type="button" data-action="cancel-category">取消</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="admin-list" id="category-list"></div>
|
||||
</div>
|
||||
<div class="admin-panel">
|
||||
<div class="admin-panel-title">书签维护</div>
|
||||
<form class="admin-form" id="bookmark-form">
|
||||
<form class="admin-form nav-form" id="admin-bookmark-form" style="display:none">
|
||||
<div class="form-row">
|
||||
<label>名称</label>
|
||||
<input name="name" required />
|
||||
<input name="name" required placeholder="书签名称" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>网址</label>
|
||||
<input name="url" required />
|
||||
<textarea name="url" rows="3" required placeholder="https://..."></textarea>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>分类</label>
|
||||
@@ -269,7 +339,6 @@ class ToolboxHome {
|
||||
<button class="bookmark-btn" type="button" data-action="cancel-bookmark">取消</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="admin-list" id="bookmark-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -334,6 +403,7 @@ class ToolboxHome {
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(iconPicker);
|
||||
document.body.appendChild(adminModal);
|
||||
|
||||
const fitnessDiaryReminder = document.createElement('div');
|
||||
fitnessDiaryReminder.className = 'fitness-diary-reminder';
|
||||
@@ -383,13 +453,22 @@ class ToolboxHome {
|
||||
this.bookmarkContainer = bookmarkSections;
|
||||
this.bookmarkEmpty = bookmarkEmpty;
|
||||
this.bookmarkAdmin = bookmarkAdmin;
|
||||
this.categoryForm = bookmarkAdmin.querySelector('#category-form');
|
||||
this.bookmarkForm = bookmarkAdmin.querySelector('#bookmark-form');
|
||||
this.categoryListEl = bookmarkAdmin.querySelector('#category-list');
|
||||
this.bookmarkListEl = bookmarkAdmin.querySelector('#bookmark-list');
|
||||
this.categoryForm = adminModal.querySelector('#admin-category-form');
|
||||
this.bookmarkForm = adminModal.querySelector('#admin-bookmark-form');
|
||||
this.categoryListEl = bookmarkAdmin.querySelector('#category-table-body');
|
||||
this.bookmarkListEl = bookmarkAdmin.querySelector('#bookmark-table-body');
|
||||
this.bookmarkSearchInput = bookmarkToolbar.querySelector('#bookmark-search');
|
||||
this.bookmarkCreatedAtEl = bookmarkAdmin.querySelector('[data-field="created_at"]');
|
||||
this.iconPreviewEl = bookmarkAdmin.querySelector('[data-field="icon-preview"]');
|
||||
this.bookmarkCreatedAtEl = adminModal.querySelector('[data-field="created_at"]');
|
||||
this.iconPreviewEl = adminModal.querySelector('[data-field="icon-preview"]');
|
||||
this.navAdminModal = adminModal;
|
||||
this.navAdminModalTitle = adminModal.querySelector('#nav-admin-modal-title');
|
||||
this.adminTabsEl = bookmarkAdmin.querySelector('#admin-tabs');
|
||||
this.categoryPaginationEl = bookmarkAdmin.querySelector('#category-pagination');
|
||||
this.bookmarkPaginationEl = bookmarkAdmin.querySelector('#bookmark-pagination');
|
||||
this.bookmarkAdminCatFilter = bookmarkAdmin.querySelector('#bookmark-admin-category-filter');
|
||||
this.bookmarkAdminSearchInput = bookmarkAdmin.querySelector('#bookmark-admin-search');
|
||||
this.categoryCountEl = bookmarkAdmin.querySelector('#category-count');
|
||||
this.bookmarkCountEl = bookmarkAdmin.querySelector('#bookmark-count');
|
||||
this.iconPicker = iconPicker;
|
||||
this.iconPickerGrid = iconPicker.querySelector('.icon-picker-grid');
|
||||
this.fitnessDiaryReminder = fitnessDiaryReminder;
|
||||
@@ -525,6 +604,7 @@ class ToolboxHome {
|
||||
this.adminVisible = false;
|
||||
if (this.bookmarkAdmin) this.bookmarkAdmin.classList.remove('active');
|
||||
if (this.bookmarksPage) this.bookmarksPage.classList.remove('admin-visible');
|
||||
this.closeAdminModal();
|
||||
}
|
||||
|
||||
getIconUrl(icon) {
|
||||
@@ -631,58 +711,269 @@ class ToolboxHome {
|
||||
|
||||
renderAdmin() {
|
||||
if (!this.bookmarkAdmin) return;
|
||||
const categorySelect = this.bookmarkForm ? this.bookmarkForm.querySelector('select[name="category_code"]') : null;
|
||||
if (categorySelect) {
|
||||
categorySelect.innerHTML = '';
|
||||
const emptyOpt = document.createElement('option');
|
||||
emptyOpt.value = '';
|
||||
emptyOpt.textContent = '未分类';
|
||||
categorySelect.appendChild(emptyOpt);
|
||||
this.categories.forEach(cat => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = cat.code;
|
||||
opt.textContent = `${cat.code} - ${cat.name}`;
|
||||
categorySelect.appendChild(opt);
|
||||
});
|
||||
if (!this.adminVisible) return;
|
||||
this.switchAdminTab(this.adminTab);
|
||||
}
|
||||
|
||||
// 后台维护 tab 切换(分类 / 书签)
|
||||
switchAdminTab(tab) {
|
||||
if (!this.bookmarkAdmin) return;
|
||||
this.adminTab = tab;
|
||||
this.bookmarkAdmin.querySelectorAll('.admin-tab-btn').forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.admTab === tab);
|
||||
});
|
||||
this.bookmarkAdmin.querySelectorAll('.admin-tab-page').forEach(p => {
|
||||
p.classList.toggle('active', p.dataset.admPage === tab);
|
||||
});
|
||||
if (tab === 'bookmarks') {
|
||||
this.renderAdminCategoryFilter();
|
||||
this.loadAdminBookmarks();
|
||||
} else {
|
||||
this.loadAdminCategories();
|
||||
}
|
||||
if (this.categoryListEl) {
|
||||
this.categoryListEl.innerHTML = '';
|
||||
this.categories.forEach(cat => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'admin-row';
|
||||
row.innerHTML = `
|
||||
<div class="admin-row-main">
|
||||
<div class="admin-row-title">${cat.code} - ${cat.name}</div>
|
||||
<div class="admin-row-meta">排序 ${cat.sort_order || 0}</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
esc(s) {
|
||||
return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
}
|
||||
|
||||
// 判断网址是否为文件下载链接(按扩展名 + 下载关键字)
|
||||
isFileUrl(url) {
|
||||
const raw = String(url || '');
|
||||
const path = raw.split(/[?#]/)[0] || '';
|
||||
if (/\.(exe|msi|zip|rar|7z|tar|gz|bz2|pdf|doc|docx|xls|xlsx|ppt|pptx|csv|json|apk|ipa|dmg|pkg|iso|jar|torrent|xmind|dwg)(\/|$)/i.test(path)) return true;
|
||||
if (/\.(png|jpe?g|gif|webp|svg|bmp|ico|avif|mp3|wav|flac|ogg|aac|mp4|mkv|mov|avi|m4a|wmv|flv|torrent)(\/|$)/i.test(path)) return true;
|
||||
if (/\/(download|downloads|dl|get|file|files)\//i.test(path)) return true;
|
||||
if (/[?&](download|dl|file|action)=/i.test(raw)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// 统一按北京时间展示创建时间
|
||||
fmtNavTime(iso) {
|
||||
if (!iso) return '-';
|
||||
try {
|
||||
const parts = new Intl.DateTimeFormat('zh-CN', {
|
||||
timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', hour12: false
|
||||
}).formatToParts(new Date(iso));
|
||||
const get = t => { const p = parts.find(x => x.type === t); return p ? p.value : ''; };
|
||||
return `${get('year')}-${get('month')}-${get('day')} ${get('hour')}:${get('minute')}`;
|
||||
} catch {
|
||||
return String(iso || '').slice(0, 16).replace('T', ' ');
|
||||
}
|
||||
}
|
||||
|
||||
getCategoryName(code) {
|
||||
const cat = (this.categories || []).find(c => String(c.code) === String(code == null ? '' : code));
|
||||
return cat ? cat.name : '未分类';
|
||||
}
|
||||
|
||||
async loadAdminCategories() {
|
||||
if (!this.bookmarkAdmin || !this.categoryListEl) return;
|
||||
try {
|
||||
const res = await fetch(`/api/nav_bookmarks/categories?page=${this.adminCatPage}&pageSize=${this.adminCatPageSize}&t=${Date.now()}`);
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
if (!data || !data.ok) return;
|
||||
this.adminCategories = Array.isArray(data.rows) ? data.rows : [];
|
||||
this.adminCatTotal = Number(data.total || 0);
|
||||
const totalPages = Math.max(1, Number(data.total_pages || 1));
|
||||
if (this.adminCatPage > totalPages) {
|
||||
this.adminCatPage = totalPages;
|
||||
this.loadAdminCategories();
|
||||
return;
|
||||
}
|
||||
this.renderAdminCategoryTable();
|
||||
if (this.categoryCountEl) this.categoryCountEl.textContent = `共 ${this.adminCatTotal} 个分类`;
|
||||
this.renderAdminPagination(this.categoryPaginationEl, this.adminCatPage, totalPages, p => { this.adminCatPage = p; this.loadAdminCategories(); });
|
||||
} catch {}
|
||||
}
|
||||
|
||||
renderAdminCategoryTable() {
|
||||
if (!this.categoryListEl) return;
|
||||
if (!this.adminCategories.length) {
|
||||
this.categoryListEl.innerHTML = '<tr><td colspan="5" class="empty-row">暂无分类</td></tr>';
|
||||
return;
|
||||
}
|
||||
this.categoryListEl.innerHTML = this.adminCategories.map(cat => `
|
||||
<tr>
|
||||
<td>${this.esc(cat.code)}</td>
|
||||
<td>${this.esc(cat.name)}</td>
|
||||
<td>${Number(cat.sort_order || 0)}</td>
|
||||
<td>${Number(cat.bookmark_count || 0)}</td>
|
||||
<td class="td-actions">
|
||||
<div class="admin-row-actions">
|
||||
<button class="bookmark-btn" type="button" data-action="edit-category" data-code="${cat.code}">编辑</button>
|
||||
<button class="bookmark-btn danger" type="button" data-action="delete-category" data-code="${cat.code}">删除</button>
|
||||
<button class="bookmark-btn" type="button" data-action="edit-category" data-code="${this.esc(cat.code)}">编辑</button>
|
||||
<button class="bookmark-btn danger" type="button" data-action="delete-category" data-code="${this.esc(cat.code)}">删除</button>
|
||||
</div>
|
||||
`;
|
||||
this.categoryListEl.appendChild(row);
|
||||
});
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
async loadAdminBookmarks() {
|
||||
if (!this.bookmarkAdmin || !this.bookmarkListEl) return;
|
||||
try {
|
||||
const p = new URLSearchParams({ page: String(this.adminBookPage), pageSize: String(this.adminBookPageSize), t: String(Date.now()) });
|
||||
if (this.adminBookCatFilter) p.set('category_code', this.adminBookCatFilter);
|
||||
if (this.adminBookQuery) p.set('q', this.adminBookQuery);
|
||||
const res = await fetch(`/api/nav_bookmarks/bookmarks?${p.toString()}`);
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
if (!data || !data.ok) return;
|
||||
this.adminBookmarks = Array.isArray(data.rows) ? data.rows : [];
|
||||
this.adminBookTotal = Number(data.total || 0);
|
||||
const totalPages = Math.max(1, Number(data.total_pages || 1));
|
||||
if (this.adminBookPage > totalPages) {
|
||||
this.adminBookPage = totalPages;
|
||||
this.loadAdminBookmarks();
|
||||
return;
|
||||
}
|
||||
this.renderAdminBookmarkTable();
|
||||
if (this.bookmarkCountEl) this.bookmarkCountEl.textContent = `共 ${this.adminBookTotal} 条`;
|
||||
this.renderAdminPagination(this.bookmarkPaginationEl, this.adminBookPage, totalPages, p => { this.adminBookPage = p; this.loadAdminBookmarks(); });
|
||||
} catch {}
|
||||
}
|
||||
|
||||
renderAdminBookmarkTable() {
|
||||
if (!this.bookmarkListEl) return;
|
||||
if (!this.adminBookmarks.length) {
|
||||
const hasFilter = this.adminBookCatFilter || String(this.adminBookQuery || '').trim();
|
||||
this.bookmarkListEl.innerHTML = `<tr><td colspan="7" class="empty-row">暂无书签${hasFilter ? '(筛选条件无结果)' : ''}</td></tr>`;
|
||||
return;
|
||||
}
|
||||
if (this.bookmarkListEl) {
|
||||
this.bookmarkListEl.innerHTML = '';
|
||||
this.bookmarks.forEach(item => {
|
||||
const cat = this.categories.find(c => String(c.code) === String(item.category_code)) || { name: '未分类' };
|
||||
const row = document.createElement('div');
|
||||
row.className = 'admin-row';
|
||||
row.innerHTML = `
|
||||
<div class="admin-row-main">
|
||||
<div class="admin-row-title">${item.name || '未命名'}</div>
|
||||
<div class="admin-row-meta">${cat.name} · 排序 ${item.sort_order || 0}</div>
|
||||
</div>
|
||||
<div class="admin-row-actions">
|
||||
<button class="bookmark-btn" type="button" data-action="edit-bookmark" data-id="${item.id}">编辑</button>
|
||||
<button class="bookmark-btn danger" type="button" data-action="delete-bookmark" data-id="${item.id}">删除</button>
|
||||
</div>
|
||||
`;
|
||||
this.bookmarkListEl.appendChild(row);
|
||||
});
|
||||
this.bookmarkListEl.innerHTML = this.adminBookmarks.map(item => {
|
||||
const iconUrl = this.getIconUrl(item.icon);
|
||||
const iconHtml = iconUrl ? `<img src="${this.esc(iconUrl)}" alt="" loading="lazy" />` : '';
|
||||
const isFile = this.isFileUrl(item.url);
|
||||
const urlIcon = `<span class="url-type-icon" title="${isFile ? '文件下载' : '网页链接'}">${isFile ? '📄' : '🔗'}</span>`;
|
||||
const remarkHtml = item.remark ? `
|
||||
<div class="td-remark-wrap">
|
||||
<span class="td-sub">${this.esc(item.remark)}</span>
|
||||
<button class="remark-toggle" type="button" data-action="toggle-remark" title="展开/收起备注">👇</button>
|
||||
</div>` : '';
|
||||
return `
|
||||
<tr>
|
||||
<td class="td-icon">${iconHtml}</td>
|
||||
<td class="td-main">
|
||||
<div>${this.esc(item.name || '未命名')}</div>
|
||||
${remarkHtml}
|
||||
</td>
|
||||
<td class="td-url">${urlIcon}<a href="${this.esc(item.url || '#')}" target="_blank" rel="noopener">${this.esc(item.url || '-')}</a></td>
|
||||
<td>${this.esc(this.getCategoryName(item.category_code))}</td>
|
||||
<td>${Number(item.sort_order || 0)}</td>
|
||||
<td>${this.fmtNavTime(item.created_at)}</td>
|
||||
<td class="td-actions">
|
||||
<div class="admin-row-actions">
|
||||
<button class="bookmark-btn" type="button" data-action="edit-bookmark" data-id="${this.esc(item.id)}">编辑</button>
|
||||
<button class="bookmark-btn danger" type="button" data-action="delete-bookmark" data-id="${this.esc(item.id)}">删除</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
// 备注未超长时隐藏展开按钮,保证行高低稳定
|
||||
this.bookmarkListEl.querySelectorAll('.td-remark-wrap').forEach(wrap => {
|
||||
const sub = wrap.querySelector('.td-sub');
|
||||
const btn = wrap.querySelector('.remark-toggle');
|
||||
if (sub && btn && sub.scrollWidth <= sub.clientWidth + 1) btn.style.visibility = 'hidden';
|
||||
});
|
||||
}
|
||||
|
||||
// 书签管理 tab 顶部的分类筛选下拉(用全量分类填充)
|
||||
renderAdminCategoryFilter() {
|
||||
if (!this.bookmarkAdminCatFilter) return;
|
||||
const current = this.bookmarkAdminCatFilter.value || this.adminBookCatFilter;
|
||||
this.bookmarkAdminCatFilter.innerHTML = '';
|
||||
const all = document.createElement('option');
|
||||
all.value = '';
|
||||
all.textContent = '全部分类';
|
||||
this.bookmarkAdminCatFilter.appendChild(all);
|
||||
(this.categories || []).forEach(cat => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = cat.code;
|
||||
opt.textContent = `${cat.code} - ${cat.name}`;
|
||||
this.bookmarkAdminCatFilter.appendChild(opt);
|
||||
});
|
||||
if (current) this.bookmarkAdminCatFilter.value = current;
|
||||
}
|
||||
|
||||
renderAdminPagination(container, page, totalPages, onChange) {
|
||||
if (!container) return;
|
||||
container.innerHTML = '';
|
||||
if (totalPages <= 1) return;
|
||||
const mkBtn = (text, p, disabled, active) => {
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.className = 'bookmark-btn pagination-btn' + (active ? ' active' : '');
|
||||
b.textContent = text;
|
||||
b.disabled = !!disabled;
|
||||
if (!disabled) b.addEventListener('click', () => onChange(p));
|
||||
return b;
|
||||
};
|
||||
const mkDots = () => {
|
||||
const s = document.createElement('span');
|
||||
s.className = 'pagination-dots';
|
||||
s.textContent = '…';
|
||||
return s;
|
||||
};
|
||||
container.appendChild(mkBtn('上一页', page - 1, page <= 1, false));
|
||||
const start = Math.max(1, page - 2);
|
||||
const end = Math.min(totalPages, page + 2);
|
||||
if (start > 1) {
|
||||
container.appendChild(mkBtn('1', 1, false, false));
|
||||
if (start > 2) container.appendChild(mkDots());
|
||||
}
|
||||
this.updateIconPreview();
|
||||
for (let p = start; p <= end; p++) container.appendChild(mkBtn(String(p), p, false, p === page));
|
||||
if (end < totalPages) {
|
||||
if (end < totalPages - 1) container.appendChild(mkDots());
|
||||
container.appendChild(mkBtn(String(totalPages), totalPages, false, false));
|
||||
}
|
||||
container.appendChild(mkBtn('下一页', page + 1, page >= totalPages, false));
|
||||
}
|
||||
|
||||
openAdminModal(type, title) {
|
||||
if (!this.navAdminModal) return;
|
||||
const isCat = type === 'category';
|
||||
if (this.navAdminModalTitle) this.navAdminModalTitle.textContent = title || (isCat ? '新增分类' : '新增书签');
|
||||
if (this.categoryForm) this.categoryForm.style.display = isCat ? '' : 'none';
|
||||
if (this.bookmarkForm) this.bookmarkForm.style.display = isCat ? 'none' : '';
|
||||
if (!isCat) this.prefillBookmarkSelect();
|
||||
if (this.iconPicker) this.iconPicker.classList.remove('active');
|
||||
this.navAdminModal.classList.add('active');
|
||||
}
|
||||
|
||||
closeAdminModal() {
|
||||
if (this.navAdminModal) this.navAdminModal.classList.remove('active');
|
||||
}
|
||||
|
||||
openCategoryModal(data) {
|
||||
this.openAdminModal('category', data ? '编辑分类' : '新增分类');
|
||||
if (data) this.fillCategoryForm(data); else this.resetCategoryForm();
|
||||
}
|
||||
|
||||
openBookmarkModal(data) {
|
||||
this.openAdminModal('bookmark', data ? '编辑书签' : '新增书签');
|
||||
if (data) this.fillBookmarkForm(data); else this.resetBookmarkForm();
|
||||
}
|
||||
|
||||
// 书签表单里的分类下拉(用全量分类填充)
|
||||
prefillBookmarkSelect() {
|
||||
const sel = this.bookmarkForm ? this.bookmarkForm.querySelector('select[name="category_code"]') : null;
|
||||
if (!sel) return;
|
||||
const current = sel.value;
|
||||
sel.innerHTML = '';
|
||||
const emptyOpt = document.createElement('option');
|
||||
emptyOpt.value = '';
|
||||
emptyOpt.textContent = '未分类';
|
||||
sel.appendChild(emptyOpt);
|
||||
(this.categories || []).forEach(cat => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = cat.code;
|
||||
opt.textContent = `${cat.code} - ${cat.name}`;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
if (current) sel.value = current;
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
@@ -774,6 +1065,70 @@ class ToolboxHome {
|
||||
});
|
||||
}
|
||||
|
||||
// 后台维护:管理员 tab 切换(分类 / 书签)
|
||||
if (this.adminTabsEl) {
|
||||
this.adminTabsEl.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('.admin-tab-btn');
|
||||
if (!btn) return;
|
||||
this.switchAdminTab(btn.dataset.admTab);
|
||||
});
|
||||
}
|
||||
|
||||
// 新增分类 / 新增书签
|
||||
const categoryAddBtn = document.getElementById('category-add-btn');
|
||||
if (categoryAddBtn) {
|
||||
categoryAddBtn.addEventListener('click', () => {
|
||||
this.openCategoryModal(null);
|
||||
});
|
||||
}
|
||||
const bookmarkAddBtn = document.getElementById('bookmark-add-btn');
|
||||
if (bookmarkAddBtn) {
|
||||
bookmarkAddBtn.addEventListener('click', () => {
|
||||
this.openBookmarkModal(null);
|
||||
});
|
||||
}
|
||||
|
||||
// 书签管理:分类筛选 + 关键词搜索(300ms 防抖)
|
||||
if (this.bookmarkAdminCatFilter) {
|
||||
this.bookmarkAdminCatFilter.addEventListener('change', () => {
|
||||
this.adminBookCatFilter = this.bookmarkAdminCatFilter.value;
|
||||
this.adminBookPage = 1;
|
||||
this.loadAdminBookmarks();
|
||||
});
|
||||
}
|
||||
if (this.bookmarkAdminSearchInput) {
|
||||
this.bookmarkAdminSearchInput.addEventListener('input', () => {
|
||||
clearTimeout(this.adminSearchTimer);
|
||||
this.adminSearchTimer = setTimeout(() => {
|
||||
this.adminBookQuery = this.bookmarkAdminSearchInput.value.trim();
|
||||
this.adminBookPage = 1;
|
||||
this.loadAdminBookmarks();
|
||||
}, 300);
|
||||
});
|
||||
}
|
||||
const adminSearchClear = document.getElementById('bookmark-admin-search-clear');
|
||||
if (adminSearchClear) {
|
||||
adminSearchClear.addEventListener('click', () => {
|
||||
if (this.bookmarkAdminSearchInput) this.bookmarkAdminSearchInput.value = '';
|
||||
this.adminBookQuery = '';
|
||||
this.adminBookPage = 1;
|
||||
this.loadAdminBookmarks();
|
||||
});
|
||||
}
|
||||
|
||||
// 后台维护弹窗:关闭按钮 / 点击遮罩关闭
|
||||
const navModalClose = document.getElementById('nav-admin-modal-close');
|
||||
if (navModalClose) {
|
||||
navModalClose.addEventListener('click', () => {
|
||||
this.closeAdminModal();
|
||||
});
|
||||
}
|
||||
if (this.navAdminModal) {
|
||||
this.navAdminModal.addEventListener('click', (e) => {
|
||||
if (e.target === this.navAdminModal) this.closeAdminModal();
|
||||
});
|
||||
}
|
||||
|
||||
if (this.bookmarkContainer) {
|
||||
this.bookmarkContainer.addEventListener('click', (e) => {
|
||||
const toggle = e.target.closest('.category-toggle');
|
||||
@@ -785,6 +1140,7 @@ class ToolboxHome {
|
||||
}
|
||||
|
||||
if (this.categoryForm) {
|
||||
const categorySubmitBtn = this.categoryForm.querySelector('button[type="submit"]');
|
||||
this.categoryForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const data = new FormData(this.categoryForm);
|
||||
@@ -793,6 +1149,8 @@ class ToolboxHome {
|
||||
name: String(data.get('name') || '').trim(),
|
||||
sort_order: Number(data.get('sort_order') || 0)
|
||||
};
|
||||
const wasEditing = !!this.editingCategoryCode;
|
||||
if (categorySubmitBtn) { categorySubmitBtn.disabled = true; categorySubmitBtn.textContent = '保存中…'; }
|
||||
try {
|
||||
if (this.editingCategoryCode) {
|
||||
await this.apiRequest(`/api/nav_bookmarks/categories/${encodeURIComponent(this.editingCategoryCode)}`, {
|
||||
@@ -808,20 +1166,28 @@ class ToolboxHome {
|
||||
});
|
||||
}
|
||||
this.resetCategoryForm();
|
||||
this.closeAdminModal();
|
||||
if (!wasEditing) this.adminCatPage = 1;
|
||||
await this.loadBookmarks();
|
||||
await this.loadAdminCategories();
|
||||
this.renderAdminCategoryFilter();
|
||||
} catch (err) {
|
||||
alert(err.message || '保存失败');
|
||||
} finally {
|
||||
if (categorySubmitBtn) { categorySubmitBtn.disabled = false; categorySubmitBtn.textContent = '保存'; }
|
||||
}
|
||||
});
|
||||
const cancelBtn = this.categoryForm.querySelector('[data-action="cancel-category"]');
|
||||
if (cancelBtn) {
|
||||
cancelBtn.addEventListener('click', () => {
|
||||
this.resetCategoryForm();
|
||||
this.closeAdminModal();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (this.bookmarkForm) {
|
||||
const bookmarkSubmitBtn = this.bookmarkForm.querySelector('button[type="submit"]');
|
||||
this.bookmarkForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const data = new FormData(this.bookmarkForm);
|
||||
@@ -833,6 +1199,8 @@ class ToolboxHome {
|
||||
remark: String(data.get('remark') || '').trim(),
|
||||
sort_order: Number(data.get('sort_order') || 0)
|
||||
};
|
||||
const wasEditing = !!this.editingBookmarkId;
|
||||
if (bookmarkSubmitBtn) { bookmarkSubmitBtn.disabled = true; bookmarkSubmitBtn.textContent = '保存中…'; }
|
||||
try {
|
||||
if (this.editingBookmarkId) {
|
||||
await this.apiRequest(`/api/nav_bookmarks/bookmarks/${encodeURIComponent(this.editingBookmarkId)}`, {
|
||||
@@ -848,15 +1216,21 @@ class ToolboxHome {
|
||||
});
|
||||
}
|
||||
this.resetBookmarkForm();
|
||||
this.closeAdminModal();
|
||||
if (!wasEditing) this.adminBookPage = 1;
|
||||
await this.loadBookmarks();
|
||||
await this.loadAdminBookmarks();
|
||||
} catch (err) {
|
||||
alert(err.message || '保存失败');
|
||||
} finally {
|
||||
if (bookmarkSubmitBtn) { bookmarkSubmitBtn.disabled = false; bookmarkSubmitBtn.textContent = '保存'; }
|
||||
}
|
||||
});
|
||||
const cancelBtn = this.bookmarkForm.querySelector('[data-action="cancel-bookmark"]');
|
||||
if (cancelBtn) {
|
||||
cancelBtn.addEventListener('click', () => {
|
||||
this.resetBookmarkForm();
|
||||
this.closeAdminModal();
|
||||
});
|
||||
}
|
||||
this.bookmarkForm.addEventListener('input', (e) => {
|
||||
@@ -886,14 +1260,16 @@ class ToolboxHome {
|
||||
const action = btn.dataset.action;
|
||||
const code = btn.dataset.code;
|
||||
if (action === 'edit-category') {
|
||||
const cat = this.categories.find(c => String(c.code) === String(code));
|
||||
if (cat) this.fillCategoryForm(cat);
|
||||
const cat = this.adminCategories.find(c => String(c.code) === String(code)) || this.categories.find(c => String(c.code) === String(code));
|
||||
if (cat) this.openCategoryModal(cat);
|
||||
}
|
||||
if (action === 'delete-category') {
|
||||
if (!confirm('确定删除该分类吗?')) return;
|
||||
try {
|
||||
await this.apiRequest(`/api/nav_bookmarks/categories/${encodeURIComponent(code)}`, { method: 'DELETE' });
|
||||
await this.loadBookmarks();
|
||||
await this.loadAdminCategories();
|
||||
this.renderAdminCategoryFilter();
|
||||
} catch (err) {
|
||||
alert(err.message || '删除失败');
|
||||
}
|
||||
@@ -902,23 +1278,29 @@ class ToolboxHome {
|
||||
}
|
||||
|
||||
if (this.bookmarkListEl) {
|
||||
this.bookmarkListEl.addEventListener('click', async (e) => {
|
||||
this.bookmarkListEl.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('button[data-action]');
|
||||
if (!btn) return;
|
||||
const action = btn.dataset.action;
|
||||
const id = btn.dataset.id;
|
||||
if (action === 'toggle-remark') {
|
||||
const wrap = btn.closest('.td-remark-wrap');
|
||||
if (!wrap) return;
|
||||
const expanded = wrap.classList.toggle('expanded');
|
||||
btn.textContent = expanded ? '👆' : '👇';
|
||||
btn.title = expanded ? '收起备注' : '展开/收起备注';
|
||||
return;
|
||||
}
|
||||
if (action === 'edit-bookmark') {
|
||||
const item = this.bookmarks.find(b => String(b.id) === String(id));
|
||||
if (item) this.fillBookmarkForm(item);
|
||||
const item = this.adminBookmarks.find(b => String(b.id) === String(id)) || this.bookmarks.find(b => String(b.id) === String(id));
|
||||
if (item) this.openBookmarkModal(item);
|
||||
}
|
||||
if (action === 'delete-bookmark') {
|
||||
if (!confirm('确定删除该书签吗?')) return;
|
||||
try {
|
||||
await this.apiRequest(`/api/nav_bookmarks/bookmarks/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||||
this.apiRequest(`/api/nav_bookmarks/bookmarks/${encodeURIComponent(id)}`, { method: 'DELETE' }).then(async () => {
|
||||
await this.loadBookmarks();
|
||||
} catch (err) {
|
||||
alert(err.message || '删除失败');
|
||||
}
|
||||
await this.loadAdminBookmarks();
|
||||
}).catch(err => alert(err.message || '删除失败'));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1165,7 +1547,7 @@ class ToolboxHome {
|
||||
if (!this.bookmarkForm) return;
|
||||
this.editingBookmarkId = String(item.id);
|
||||
const nameInput = this.bookmarkForm.querySelector('input[name="name"]');
|
||||
const urlInput = this.bookmarkForm.querySelector('input[name="url"]');
|
||||
const urlInput = this.bookmarkForm.querySelector('textarea[name="url"]');
|
||||
const catSelect = this.bookmarkForm.querySelector('select[name="category_code"]');
|
||||
const iconInput = this.bookmarkForm.querySelector('input[name="icon"]');
|
||||
const remarkInput = this.bookmarkForm.querySelector('textarea[name="remark"]');
|
||||
|
||||
@@ -51,6 +51,12 @@
|
||||
<div class="stale-desc">如果这是上次异常退出遗留,可以先清理占用再重新启动。</div>
|
||||
<button class="btn-stale-clean" id="btnCleanStale">清理占用</button>
|
||||
</div>
|
||||
<!-- 语音不可用预警 -->
|
||||
<div id="voiceWarning" class="stale-warning voice-warning" style="display:none">
|
||||
<div class="stale-title">语音播报可能不可用</div>
|
||||
<div class="stale-desc" id="voiceWarningDesc">本机系统语音未启动,已尝试云端语音兜底。</div>
|
||||
<button class="btn-stale-clean" id="btnRetryVoice">重新检测</button>
|
||||
</div>
|
||||
<!-- 新建按钮 -->
|
||||
<div class="cards-toolbar">
|
||||
<button class="btn-primary" id="btnNewCard">
|
||||
|
||||
@@ -120,6 +120,12 @@ input[type='number'] { -moz-appearance: textfield; appearance: textfield; }
|
||||
.btn-stale-clean:hover { filter: brightness(1.08); }
|
||||
.btn-stale-clean:active { transform: scale(0.96); }
|
||||
|
||||
/* 语音不可用预警:沿用 stale-warning 结构,改用错误色以区别于残留 session 警告 */
|
||||
.voice-warning { border-color: var(--error); background: rgba(239,68,68,0.07); }
|
||||
.voice-warning .stale-title { color: var(--error); }
|
||||
.voice-warning .btn-stale-clean { background: var(--error); color: #fff; }
|
||||
.voice-probe-text { color: var(--primary); font-weight: 600; }
|
||||
|
||||
/* ============================================================
|
||||
卡片页
|
||||
============================================================ */
|
||||
@@ -154,7 +160,7 @@ input[type='number'] { -moz-appearance: textfield; appearance: textfield; }
|
||||
.step-preview-line { font-size: 13px; color: var(--text-secondary); line-height: 1.6; }
|
||||
.step-preview-more { font-size: 13px; color: var(--text-disabled); font-style: italic; line-height: 1.6; }
|
||||
|
||||
.card-actions { display: flex; flex-direction: row; gap: 8px; margin-top: 12px; }
|
||||
.card-actions { display: flex; flex-direction: row; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
|
||||
|
||||
/* 卡片按钮 — JS 用 .btn-card-primary / .btn-card-ghost */
|
||||
.btn-card-primary {
|
||||
|
||||
@@ -490,6 +490,9 @@
|
||||
|
||||
async function handleEnterMotion() {
|
||||
const btn = document.getElementById('btnEnterMotion');
|
||||
// 必须在 await 之前(用户手势上下文中)解锁音频与语音,供后续探测与播报使用
|
||||
unlockAudio();
|
||||
unlockSpeech();
|
||||
setButtonLoading(btn, '进入中…');
|
||||
const res = await runTrackedInteraction('设置激活 kit(motion_reminder)', function () {
|
||||
return apiFetch('PUT', '/api/app_order_box/state/active-kit', { kitId: 'motion_reminder' });
|
||||
@@ -503,6 +506,8 @@
|
||||
await refreshState(true);
|
||||
showView('motion');
|
||||
switchTab('cards');
|
||||
// 交互详情弹窗(debug 3s / normal 2s 自动关闭)会盖住探测弹窗,等它先收起再探测语音
|
||||
setTimeout(function () { probeSpeech(); }, getUiMode() === 'debug' ? 3200 : 2200);
|
||||
}
|
||||
|
||||
async function handleBackHub() {
|
||||
@@ -560,6 +565,7 @@
|
||||
'</button>' +
|
||||
'<button type="button" class="btn-card-ghost btn-edit-card"><span class="btn-text">编辑</span></button>' +
|
||||
'<button type="button" class="btn-card-ghost btn-delete-card"><span class="btn-text btn-text-danger">删除</span></button>' +
|
||||
'<button type="button" class="btn-card-ghost btn-regen-tts"><span class="btn-text">重生语音</span></button>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
@@ -600,6 +606,23 @@
|
||||
if (btn.classList.contains('btn-start-card')) handleStartCard(card, btn);
|
||||
else if (btn.classList.contains('btn-edit-card')) openEditor(card);
|
||||
else if (btn.classList.contains('btn-delete-card')) handleDeleteCard(card);
|
||||
else if (btn.classList.contains('btn-regen-tts')) handleRegenCardTTS(card, btn);
|
||||
}
|
||||
|
||||
/** 手动补救:强制重建该卡片的云端语音缓存;平时无需点击,编辑后遇到没声音时用 */
|
||||
async function handleRegenCardTTS(card, btn) {
|
||||
if (!Array.isArray(card.steps) || !card.steps.length) {
|
||||
showAlert('无法生成', '当前卡片没有可执行步骤');
|
||||
return;
|
||||
}
|
||||
setButtonLoading(btn, '生成中…');
|
||||
const res = await runTrackedInteraction('重生卡片语音', function () {
|
||||
return prepareCardTTS(card, true);
|
||||
});
|
||||
resetButton(btn);
|
||||
if (!res || !res.ok) return;
|
||||
const d = res.data || {};
|
||||
showToast('语音已重新生成(' + (Number(d.success) || 0) + '/' + (Number(d.total) || 0) + ')', 'success');
|
||||
}
|
||||
|
||||
function handleDeleteCard(card) {
|
||||
@@ -630,6 +653,8 @@
|
||||
// 必须在 await 之前(用户手势上下文中)解锁音频和语音
|
||||
unlockAudio();
|
||||
unlockSpeech();
|
||||
// 会话启动时批量预合成整卡云端语音(不阻塞启动,失败时运行期回退系统 TTS)
|
||||
prepareCardTTS(card);
|
||||
setButtonLoading(btn, '启动中…');
|
||||
const res = await runTrackedInteraction('启动执行会话', function () {
|
||||
return apiFetch('POST', '/api/app_order_box/kits/motion_reminder/sessions/start', { card: card });
|
||||
@@ -1344,19 +1369,173 @@
|
||||
}
|
||||
|
||||
// ==================== 语音播报 ====================
|
||||
// Web Speech API:动作 pitch=1.1,休息 pitch=0.9,rate=0.95,lang=zh-CN
|
||||
// 去重:同一步骤(sessionId:loop:stepIndex)不重复播报
|
||||
// 双通道策略(解决"有时能读、有时不能读、安卓平板完全不能读"):
|
||||
// 通道1 系统 TTS(Web Speech API):音质自然,iPhone 静音键下仍能发声,优先生效;
|
||||
// 通道2 云端 mp3(服务端 DashScope 预合成):系统 TTS 无引擎 / 静默失败时兜底。
|
||||
// 判定链路:进入 kit 播欢迎语探测 → 用户确认 → 失败切云端 → 两者皆不可用则显式预警。
|
||||
// 去重:同一步骤(sessionId:loop:stepIndex)不重复播报。
|
||||
|
||||
const WELCOME_TEXT = '欢迎回来,每天锻炼身体好!';
|
||||
// 部分安卓 Chrome 无语音引擎时既不触发 onstart 也不触发 onerror,只能靠超时兜底
|
||||
const SPEECH_PROBE_TIMEOUT_MS = 1200;
|
||||
|
||||
// 'unknown' 未判定 | 'system' 系统 TTS | 'cloud' 云端 mp3 | 'none' 两者皆不可用
|
||||
let speechEngineMode = 'unknown';
|
||||
let speechProbeDone = false;
|
||||
let voiceWarned = false;
|
||||
|
||||
// 文本 → 云端 mp3 URL 映射,由 prepareCardTTS 预合成填充
|
||||
let ttsUrlMap = {};
|
||||
// 已预合成卡片的内容指纹(不是 card.id):编辑流程后内容变化即视为新内容,需重新预合成
|
||||
let ttsPreparedCardKey = '';
|
||||
|
||||
// 云端音频播放器(单实例串行队列,避免多条播报叠在一起)
|
||||
let speechAudioEl = null;
|
||||
let cloudQueue = [];
|
||||
let cloudQueueBusy = false;
|
||||
|
||||
function showVoiceWarning(msg) {
|
||||
const el = document.getElementById('voiceWarning');
|
||||
if (!el) return;
|
||||
const descEl = document.getElementById('voiceWarningDesc');
|
||||
if (descEl && msg) descEl.textContent = msg;
|
||||
el.style.display = '';
|
||||
}
|
||||
|
||||
function hideVoiceWarning() {
|
||||
const el = document.getElementById('voiceWarning');
|
||||
if (el) el.style.display = 'none';
|
||||
}
|
||||
|
||||
/** 语音彻底不可用:显式预警,避免用户误以为正在静默播报 */
|
||||
function markVoiceUnavailable(reason) {
|
||||
speechEngineMode = 'none';
|
||||
showVoiceWarning(reason || '本机无法播放语音,请检查系统语音引擎与音量设置。');
|
||||
if (!voiceWarned) {
|
||||
voiceWarned = true;
|
||||
showToast('本机无法播放语音,请检查系统语音引擎或音量', 'error', 4000);
|
||||
}
|
||||
}
|
||||
|
||||
/** 系统 TTS 单句播报;用 onstart / onerror / 超时判断引擎是否真的启动 */
|
||||
function speakSystem(text, pitch) {
|
||||
return new Promise(function (resolve) {
|
||||
let settled = false;
|
||||
let timer = null;
|
||||
const finish = function (ok, error) {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
resolve({ ok: ok, error: error || '' });
|
||||
};
|
||||
try {
|
||||
if (!('speechSynthesis' in window) || typeof SpeechSynthesisUtterance === 'undefined') {
|
||||
finish(false, 'unsupported');
|
||||
return;
|
||||
}
|
||||
const synth = window.speechSynthesis;
|
||||
try { synth.cancel(); } catch (e) { /* 静默 */ }
|
||||
const utterance = new SpeechSynthesisUtterance(String(text || ''));
|
||||
utterance.lang = 'zh-CN';
|
||||
utterance.pitch = Number(pitch) || 1;
|
||||
utterance.rate = 0.95;
|
||||
timer = setTimeout(function () { finish(false, 'start-timeout'); }, SPEECH_PROBE_TIMEOUT_MS);
|
||||
utterance.onstart = function () { finish(true, ''); };
|
||||
utterance.onerror = function (ev) { finish(false, (ev && ev.error) || 'utterance-error'); };
|
||||
// cancel() 紧跟 speak() 在 Chrome 上存在竞态会丢句,延后一拍再播
|
||||
setTimeout(function () {
|
||||
try { synth.speak(utterance); } catch (e) { finish(false, String((e && e.message) || e)); }
|
||||
}, 60);
|
||||
} catch (e) {
|
||||
finish(false, String((e && e.message) || e));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** 云端 mp3 单条播放,Promise<boolean> */
|
||||
function playCloudUrl(url) {
|
||||
return new Promise(function (resolve) {
|
||||
let settled = false;
|
||||
const finish = function (ok) {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve(ok);
|
||||
};
|
||||
try {
|
||||
if (speechAudioEl) { try { speechAudioEl.pause(); } catch (e) { /* 静默 */ } }
|
||||
const audio = new Audio(url);
|
||||
audio.preload = 'auto';
|
||||
speechAudioEl = audio;
|
||||
audio.onended = function () { finish(true); };
|
||||
audio.onerror = function () { finish(false); };
|
||||
const playPromise = audio.play();
|
||||
// 自动播放策略拦截 / 静音键 / 解码失败:play() 会 reject
|
||||
if (playPromise && typeof playPromise.catch === 'function') {
|
||||
playPromise.catch(function () { finish(false); });
|
||||
}
|
||||
} catch (e) {
|
||||
finish(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function stopCloudSpeech() {
|
||||
cloudQueue = [];
|
||||
cloudQueueBusy = false;
|
||||
if (speechAudioEl) {
|
||||
try { speechAudioEl.pause(); } catch (e) { /* 静默 */ }
|
||||
speechAudioEl = null;
|
||||
}
|
||||
}
|
||||
|
||||
function runCloudQueue() {
|
||||
if (!cloudQueueBusy) return;
|
||||
const url = cloudQueue.shift();
|
||||
if (!url) {
|
||||
cloudQueueBusy = false;
|
||||
return;
|
||||
}
|
||||
playCloudUrl(url).then(function (ok) {
|
||||
if (!ok) {
|
||||
cloudQueueBusy = false;
|
||||
markVoiceUnavailable('云端语音播放失败,请检查音量与静音键。');
|
||||
return;
|
||||
}
|
||||
runCloudQueue();
|
||||
});
|
||||
}
|
||||
|
||||
/** 顺序播放一批云端 mp3;返回是否存在可播的缓存音频 */
|
||||
function playCloudAtoms(atoms) {
|
||||
const list = (Array.isArray(atoms) ? atoms : [atoms])
|
||||
.map(function (t) { return String(t || '').trim(); })
|
||||
.filter(Boolean);
|
||||
const urls = list.map(function (t) { return ttsUrlMap[t]; }).filter(Boolean);
|
||||
if (!urls.length) return false;
|
||||
stopCloudSpeech();
|
||||
cloudQueue = urls.slice();
|
||||
cloudQueueBusy = true;
|
||||
runCloudQueue();
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 统一播报入口:优先系统 TTS,静默失败时自动降级到云端 mp3 */
|
||||
function speakText(text, pitch) {
|
||||
try {
|
||||
if (!('speechSynthesis' in window)) return;
|
||||
window.speechSynthesis.cancel();
|
||||
const utterance = new SpeechSynthesisUtterance(text);
|
||||
utterance.lang = 'zh-CN';
|
||||
utterance.pitch = Number(pitch) || 1;
|
||||
utterance.rate = 0.95;
|
||||
window.speechSynthesis.speak(utterance);
|
||||
} catch (e) { /* 不支持时静默 */ }
|
||||
const t = String(text || '').trim();
|
||||
if (!t) return;
|
||||
if (speechEngineMode === 'cloud' && playCloudAtoms(t)) return;
|
||||
speakSystem(t, pitch).then(function (r) {
|
||||
if (r.ok) return;
|
||||
// 系统 TTS 本次失败:有云端音频就切过去,否则显式预警
|
||||
if (playCloudAtoms(t)) {
|
||||
if (speechEngineMode !== 'cloud') {
|
||||
speechEngineMode = 'cloud';
|
||||
showToast('已自动切换到云端语音播报', 'info', 2000);
|
||||
}
|
||||
return;
|
||||
}
|
||||
markVoiceUnavailable('系统语音与云端语音均不可用,请检查系统语音引擎或音量设置。');
|
||||
});
|
||||
}
|
||||
|
||||
/** 在用户手势中调用,解锁 iOS Safari 的语音合成限制 */
|
||||
@@ -1374,6 +1553,112 @@
|
||||
try {
|
||||
if ('speechSynthesis' in window) window.speechSynthesis.cancel();
|
||||
} catch (e) { /* 静默 */ }
|
||||
stopCloudSpeech();
|
||||
}
|
||||
|
||||
/**
|
||||
* 进入运动 kit 时的语音探测:用欢迎语本身当探测信号(无需另造探测音),
|
||||
* 引擎是否启动由 onstart/onerror 判定,「喇叭有没有真响」由用户点按钮确认。
|
||||
* 必须在用户手势上下文中调用;force=true 时忽略"已探测过"强制重测。
|
||||
*/
|
||||
function probeSpeech(force) {
|
||||
if (speechProbeDone && !force) return;
|
||||
speechProbeDone = true;
|
||||
let answered = false;
|
||||
const settle = function (mode, tip) {
|
||||
if (answered) return;
|
||||
answered = true;
|
||||
speechEngineMode = mode;
|
||||
// 必须收起探测弹窗:否则模态遮罩会一直盖住页面,导致后面所有按钮都点不动
|
||||
closeDialog();
|
||||
if (tip) showVoiceWarning(tip); else hideVoiceWarning();
|
||||
};
|
||||
speakSystem(WELCOME_TEXT, 1).then(function (r) {
|
||||
if (answered) return;
|
||||
if (r.ok) return; // 引擎已启动,等用户确认是否真的听到
|
||||
settle('cloud', '系统语音未能启动,已切换云端语音兜底(需先启动一次流程完成预合成)。');
|
||||
});
|
||||
showDialog('语音检查',
|
||||
'正在播报欢迎语:<br><span class="voice-probe-text">' + escHtml(WELCOME_TEXT) + '</span><br>请确认是否听到声音。',
|
||||
[
|
||||
{
|
||||
text: '没听到',
|
||||
style: 'default',
|
||||
onPress: function () { settle('cloud', '已切换到云端语音兜底(需先启动一次流程完成预合成)。'); }
|
||||
},
|
||||
{
|
||||
text: '听到了',
|
||||
style: 'primary',
|
||||
onPress: function () { settle('system', ''); }
|
||||
}
|
||||
]);
|
||||
// 兜底:用户一直不点确认时自动收起弹窗,避免模态遮罩长期挡住页面
|
||||
setTimeout(function () {
|
||||
if (answered) return;
|
||||
answered = true;
|
||||
closeDialog();
|
||||
}, 8000);
|
||||
}
|
||||
|
||||
/** 卡片内容指纹:步骤/循环任一变化即视为新内容;与 card.id 无关 */
|
||||
function cardTTSFingerprint(card) {
|
||||
try {
|
||||
return JSON.stringify({
|
||||
steps: (card.steps || []).map(function (s) {
|
||||
return [
|
||||
String(s.name || ''),
|
||||
Number(s.durationSec) || 0,
|
||||
String(s.voiceText || ''),
|
||||
String(s.type || '')
|
||||
];
|
||||
}),
|
||||
loopMode: card.loopMode,
|
||||
loopCount: card.loopCount
|
||||
});
|
||||
} catch (e) { return ''; }
|
||||
}
|
||||
|
||||
/** 会话启动时批量预合成整卡语音;失败不阻塞流程,运行期回退系统 TTS。force=true 用于手动重生 */
|
||||
function prepareCardTTS(card, force) {
|
||||
if (!card || !card.id) return Promise.resolve({ ok: false, error: 'no card' });
|
||||
const key = cardTTSFingerprint(card);
|
||||
if (!force && key && ttsPreparedCardKey === key) return Promise.resolve({ ok: true, skipped: true });
|
||||
ttsPreparedCardKey = key;
|
||||
const payload = {
|
||||
card: {
|
||||
id: card.id,
|
||||
title: card.title,
|
||||
steps: card.steps,
|
||||
loopMode: card.loopMode,
|
||||
loopCount: card.loopCount
|
||||
}
|
||||
};
|
||||
return apiFetch('POST', '/api/app_order_box/kits/motion_reminder/tts/prepare', payload).then(function (res) {
|
||||
if (res.ok && res.data && res.data.map) {
|
||||
Object.keys(res.data.map).forEach(function (text) { ttsUrlMap[text] = res.data.map[text]; });
|
||||
console.log('[order_box tts] 预合成完成 ' + res.data.success + '/' + res.data.total);
|
||||
if (speechEngineMode === 'cloud' && Object.keys(ttsUrlMap).length) hideVoiceWarning();
|
||||
} else {
|
||||
ttsPreparedCardKey = '';
|
||||
console.warn('[order_box tts] 预合成失败:' + (res.error || 'unknown'));
|
||||
}
|
||||
return res;
|
||||
}).catch(function (e) {
|
||||
ttsPreparedCardKey = '';
|
||||
console.warn('[order_box tts] 预合成异常:' + String((e && e.message) || e));
|
||||
return { ok: false, error: String((e && e.message) || e) };
|
||||
});
|
||||
}
|
||||
|
||||
/** 步骤播报的最小原子单元,必须与后端 buildCardTTSBatch 的取词逐字一致(云端缓存查表依赖) */
|
||||
function stepSpeechAtoms(loop, step, nextStep, loopMode) {
|
||||
const atoms = [];
|
||||
if (loopMode !== 'once') atoms.push('第' + loop + '轮');
|
||||
atoms.push((step.voiceText && String(step.voiceText).trim())
|
||||
? String(step.voiceText).trim()
|
||||
: (step.name + ',' + formatSpeechDuration(step.durationSec)));
|
||||
if (nextStep) atoms.push('下一步,' + (String(nextStep.name || '').trim() || '下一步'));
|
||||
return atoms;
|
||||
}
|
||||
|
||||
/** 步骤切换播报:"第N轮,{名称},{时长}。下一步,{nextName}" */
|
||||
@@ -1387,11 +1672,13 @@
|
||||
if (!step) return;
|
||||
const nextStep = runnerState.card.steps[runnerState.currentStepIndex + 1] || null;
|
||||
const pitch = step.type === 'action' ? 1.1 : 0.9;
|
||||
const title = (step.voiceText && String(step.voiceText).trim())
|
||||
? String(step.voiceText).trim()
|
||||
: (step.name + ',' + formatSpeechDuration(step.durationSec));
|
||||
const nextText = nextStep ? '。下一步,' + nextStep.name : '';
|
||||
speakText('第 ' + runnerState.currentLoop + ' 轮,' + title + nextText, pitch);
|
||||
const atoms = stepSpeechAtoms(runnerState.currentLoop, step, nextStep, runnerState.card.loopMode);
|
||||
if (speechEngineMode === 'cloud' && playCloudAtoms(atoms)) return;
|
||||
// 系统 TTS 用合并句播报更自然;'下一步' 前保留句号,保持原有语气
|
||||
const merged = atoms.map(function (t, i) {
|
||||
return (i > 0 && t.indexOf('下一步') === 0) ? '。' + t : t;
|
||||
}).join(',');
|
||||
speakText(merged, pitch);
|
||||
}
|
||||
|
||||
// ==================== 提示音 ====================
|
||||
@@ -1536,6 +1823,9 @@
|
||||
document.getElementById('btnBackHub').addEventListener('click', handleBackHub);
|
||||
document.getElementById('btnNewCard').addEventListener('click', function () { openEditor(null); });
|
||||
document.getElementById('btnCleanStale').addEventListener('click', handleCleanStale);
|
||||
// 逐元素判空绑定:任何一个元素缺失都不应该中断后续绑定(否则整页按钮会全部失效)
|
||||
const retryVoiceEl = document.getElementById('btnRetryVoice');
|
||||
if (retryVoiceEl) retryVoiceEl.addEventListener('click', function () { probeSpeech(true); });
|
||||
document.getElementById('btnEditorClose').addEventListener('click', closeEditor);
|
||||
document.getElementById('btnInteractionCopy').addEventListener('click', handleCopyInteraction);
|
||||
document.getElementById('cardsList').addEventListener('click', handleCardsListClick);
|
||||
|
||||
Reference in New Issue
Block a user