feat(personal_collect,motion_reminder): 新增回收站与云端TTS语音功能

- 新增小程序个人收藏回收站页面,支持查看已删除收藏、恢复与彻底删除操作
- 修复个人收藏文件上传时的中文名乱码问题,新增原文件名透传参数
- 接入阿里云百炼TTS服务,实现运动提醒模块的云端语音合成与本地缓存,支持系统TTS与云端MP3双降级播放
- 新增语音引擎探测与异常预警UI,覆盖web端与移动端
- 优化web端订单盒页面布局,修复卡片操作按钮换行问题
- 新增调试探针脚本、ESLint配置与相关lint脚本
- 完善后端接口与路由,更新项目依赖与配置文件
This commit is contained in:
yangxiangyuan
2026-09-17 16:26:27 +08:00
parent d4bf3627fd
commit bbc7ca6523
29 changed files with 3228 additions and 163 deletions
+460 -78
View File
@@ -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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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"]');