Files
Toolbox/public/index.js
T
yangxiangyuan bbc7ca6523 feat(personal_collect,motion_reminder): 新增回收站与云端TTS语音功能
- 新增小程序个人收藏回收站页面,支持查看已删除收藏、恢复与彻底删除操作
- 修复个人收藏文件上传时的中文名乱码问题,新增原文件名透传参数
- 接入阿里云百炼TTS服务,实现运动提醒模块的云端语音合成与本地缓存,支持系统TTS与云端MP3双降级播放
- 新增语音引擎探测与异常预警UI,覆盖web端与移动端
- 优化web端订单盒页面布局,修复卡片操作按钮换行问题
- 新增调试探针脚本、ESLint配置与相关lint脚本
- 完善后端接口与路由,更新项目依赖与配置文件
2026-09-17 16:26:27 +08:00

1905 lines
72 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.
// 多宫格导航页面功能 - 炫酷有氛围版本
class ToolboxHome {
constructor() {
this.currentTheme = 0;
this.openInModal = true;
this.localBgMode = false;
this.localBgFiles = [];
this.localBgIndex = 0;
this.localRotationInterval = null;
// 从 localStorage 恢复状态
try {
this.localBgMode = localStorage.getItem('tb_home_local_bg') === '1';
this.openInModal = localStorage.getItem('tb_home_open_mode') !== '0';
} catch {}
this.themes = [
{
name: '夜空星轨',
background: 'https://images.unsplash.com/photo-1534796636912-3b95b3ab5986?ixlib=rb-4.0.3&auto=format&fit=crop&w=1920&q=80',
effect: 'meteor',
cardGradient: 'linear-gradient(135deg, rgba(59, 130, 246, 0.2), rgba(168, 85, 247, 0.2))',
textColor: '#f8fafc'
},
{
name: '蓝天白云',
background: 'https://images.unsplash.com/photo-1501594907352-04cda38ebc29?ixlib=rb-4.0.3&auto=format&fit=crop&w=1920&q=80',
effect: 'meteor',
cardGradient: 'linear-gradient(135deg, rgba(14, 165, 233, 0.2), rgba(56, 189, 248, 0.2))',
textColor: '#1e293b'
},
{
name: '治愈森林',
background: 'https://images.unsplash.com/photo-1448375240586-882707db888b?ixlib=rb-4.0.3&auto=format&fit=crop&w=1920&q=80',
effect: 'meteor',
cardGradient: 'linear-gradient(135deg, rgba(34, 197, 94, 0.2), rgba(132, 204, 22, 0.2))',
textColor: '#f8fafc'
},
{
name: '运动风',
background: 'https://images.unsplash.com/photo-1521412644187-9d2e0ad0a2f5?ixlib=rb-4.0.3&auto=format&fit=crop&w=1920&q=80',
effect: 'meteor',
cardGradient: 'linear-gradient(135deg, rgba(234, 88, 12, 0.24), rgba(59, 130, 246, 0.24))',
textColor: '#ffffff'
},
{
name: '科技霓虹',
background: 'https://images.unsplash.com/photo-1496307042754-b4aa456c4a2d?ixlib=rb-4.0.3&auto=format&fit=crop&w=1920&q=80',
effect: 'meteor',
cardGradient: 'linear-gradient(135deg, rgba(99, 102, 241, 0.26), rgba(236, 72, 153, 0.26))',
textColor: '#ffffff'
},
{
name: '商务简约',
background: 'https://images.unsplash.com/photo-1487015307662-6ce621f0df3f?ixlib=rb-4.0.3&auto=format&fit=crop&w=1920&q=80',
effect: 'meteor',
cardGradient: 'linear-gradient(135deg, rgba(226, 232, 240, 0.35), rgba(148, 163, 184, 0.35))',
textColor: '#0b1220'
},
{
name: '暖色日落',
background: 'https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?ixlib=rb-4.0.3&auto=format&fit=crop&w=1920&q=80',
effect: 'meteor',
cardGradient: 'linear-gradient(135deg, rgba(244, 114, 182, 0.25), rgba(251, 146, 60, 0.25))',
textColor: '#ffffff'
},
{
name: '冷色极夜',
background: 'https://images.unsplash.com/photo-1469474968028-56623f02e42e?ixlib=rb-4.0.3&auto=format&fit=crop&w=1920&q=80',
effect: 'meteor',
cardGradient: 'linear-gradient(135deg, rgba(14, 165, 233, 0.25), rgba(5, 150, 105, 0.25))',
textColor: '#e6f7ff'
}
];
this.highFreqTools = [];
this.lowFreqTools = [];
this.tools = []; // 保留向后兼容
this.categories = [];
this.bookmarks = [];
this.availableIcons = [];
this.collapsedCategories = {};
this.searchQuery = '';
this.adminVisible = false;
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 = [];
this.init();
}
init() {
this.createDOM();
this.setupEventListeners();
this.applyTheme(this.currentTheme);
this.startEffects();
this.startBackgroundRotation();
this.loadIcons();
this.loadBookmarks();
this.loadTools();
this.loadThoughtLabs();
this.loadFitnessDiaryReminder();
this.loadWeeklyReportReminder();
}
createDOM() {
// 创建背景容器
const backgroundContainer = document.createElement('div');
backgroundContainer.className = 'background-container';
// 创建背景图片
this.themes.forEach((theme, index) => {
const bgImg = document.createElement('img');
bgImg.className = 'background-image';
bgImg.alt = theme.name;
if (index === 0) bgImg.classList.add('active');
backgroundContainer.appendChild(bgImg);
});
// 创建背景遮罩
const overlay = document.createElement('div');
overlay.className = 'background-overlay';
backgroundContainer.appendChild(overlay);
// 创建特效容器
const effectsContainer = document.createElement('div');
effectsContainer.className = 'effects-container';
// 创建主容器
const mainContainer = document.createElement('div');
mainContainer.className = 'main-container';
// 创建标题区域
const header = document.createElement('div');
header.className = 'header';
header.innerHTML = ``;
// <h1>Yang's Toolbox</h1>
// <p>个人工具箱入口 - 简洁高效</p>
const tabBar = document.createElement('div');
tabBar.className = 'tab-bar';
tabBar.innerHTML = `
<button class="tab-btn active" data-tab="high" type="button">Yang's 高频 Tools</button>
<button class="tab-btn" data-tab="low" type="button">Yang's 低频 Tools</button>
<button class="tab-btn" data-tab="labs" type="button">Yang's 思想实验室</button>
<button class="tab-btn" data-tab="bookmarks" type="button">Yang's 书签导航</button>
`;
const tabPages = document.createElement('div');
tabPages.className = 'tab-pages';
// 高频 Tools 页面
const highToolsPage = document.createElement('div');
highToolsPage.className = 'tab-page active';
highToolsPage.dataset.tab = 'high';
const highToolGrid = document.createElement('div');
highToolGrid.className = 'tool-grid';
highToolGrid.id = 'tool-grid-high';
highToolsPage.appendChild(highToolGrid);
// 低频 Tools 页面
const lowToolsPage = document.createElement('div');
lowToolsPage.className = 'tab-page';
lowToolsPage.dataset.tab = 'low';
const lowToolGrid = document.createElement('div');
lowToolGrid.className = 'tool-grid';
lowToolGrid.id = 'tool-grid-low';
lowToolsPage.appendChild(lowToolGrid);
// 思想实验室页面(卡片形式与高/低频一致,数据来自 /api/thought_lab/labs)
const labsPage = document.createElement('div');
labsPage.className = 'tab-page';
labsPage.dataset.tab = 'labs';
const labGrid = document.createElement('div');
labGrid.className = 'tool-grid';
labGrid.id = 'tool-grid-labs';
labsPage.appendChild(labGrid);
// 书签导航页面
const bookmarksPage = document.createElement('div');
bookmarksPage.className = 'tab-page';
bookmarksPage.dataset.tab = 'bookmarks';
const bookmarkToolbar = document.createElement('div');
bookmarkToolbar.className = 'bookmark-toolbar';
bookmarkToolbar.innerHTML = `
<input id="bookmark-search" class="bookmark-search" placeholder="搜索书签..." />
<button class="bookmark-btn" id="bookmark-search-clear" type="button">清空</button>
<button class="bookmark-btn primary" id="bookmark-admin-btn" type="button">维护书签</button>
`;
const bookmarkSections = document.createElement('div');
bookmarkSections.className = 'bookmark-sections';
const bookmarkEmpty = document.createElement('div');
bookmarkEmpty.className = 'bookmark-empty';
bookmarkEmpty.textContent = '暂无书签';
const bookmarkAdmin = document.createElement('div');
bookmarkAdmin.className = 'bookmark-admin';
bookmarkAdmin.innerHTML = `
<div class="admin-header">
<div class="admin-title">书签后台维护</div>
<button class="bookmark-btn" id="bookmark-admin-close" type="button">返回书签</button>
</div>
<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 placeholder="如 12 / dev / life" />
</div>
<div class="form-row">
<label>名称</label>
<input name="name" required placeholder="分类名称" />
</div>
<div class="form-row">
<label>排序</label>
<input name="sort_order" type="number" value="0" />
</div>
<div class="form-actions">
<button class="bookmark-btn primary" type="submit">保存</button>
<button class="bookmark-btn" type="button" data-action="cancel-category">取消</button>
</div>
</form>
<form class="admin-form nav-form" id="admin-bookmark-form" style="display:none">
<div class="form-row">
<label>名称</label>
<input name="name" required placeholder="书签名称" />
</div>
<div class="form-row">
<label>网址</label>
<textarea name="url" rows="3" required placeholder="https://..."></textarea>
</div>
<div class="form-row">
<label>分类</label>
<select name="category_code"></select>
</div>
<div class="form-row">
<label>图标</label>
<div class="icon-input-row">
<input name="icon" placeholder="选择图标或手动输入" />
<button class="bookmark-btn" type="button" data-action="pick-icon">选择</button>
<button class="bookmark-btn" type="button" data-action="clear-icon">清空</button>
</div>
<div class="icon-preview" data-field="icon-preview"></div>
</div>
<div class="form-row">
<label>备注</label>
<textarea name="remark" rows="2"></textarea>
</div>
<div class="form-row">
<label>排序</label>
<input name="sort_order" type="number" value="0" />
</div>
<div class="form-row">
<label>创建时间</label>
<div class="readonly" data-field="created_at">-</div>
</div>
<div class="form-actions">
<button class="bookmark-btn primary" type="submit">保存</button>
<button class="bookmark-btn" type="button" data-action="cancel-bookmark">取消</button>
</div>
</form>
</div>
</div>
`;
bookmarksPage.appendChild(bookmarkToolbar);
bookmarksPage.appendChild(bookmarkSections);
bookmarksPage.appendChild(bookmarkEmpty);
bookmarksPage.appendChild(bookmarkAdmin);
tabPages.appendChild(highToolsPage);
tabPages.appendChild(lowToolsPage);
tabPages.appendChild(bookmarksPage);
tabPages.appendChild(labsPage);
// 组装页面
mainContainer.appendChild(header);
mainContainer.appendChild(tabBar);
mainContainer.appendChild(tabPages);
document.body.innerHTML = '';
document.body.appendChild(backgroundContainer);
document.body.appendChild(effectsContainer);
document.body.appendChild(mainContainer);
// 顶部右侧滑块(弹窗/本地切换)
const controls = document.createElement('div');
controls.className = 'top-right-controls';
controls.innerHTML = `
<label class="ios-switch" title="弹窗打开/本地替换">
<input type="checkbox" id="open-mode-switch" aria-label="open-mode-toggle" checked>
<span class="slider"></span>
</label>
`;
document.body.appendChild(controls);
// 换背景按钮容器(图标按钮 + 本地模式开关,水平排列,放底部)
const bgBtnWrapper = document.createElement('div');
bgBtnWrapper.className = 'bg-btn-wrapper';
bgBtnWrapper.innerHTML = `
<button class="change-background-btn" title="切换背景(或按 B 键)" aria-label="change-background"></button>
<label class="ios-switch bg-local-switch" title="本地默认背景模式">
<input type="checkbox" id="local-bg-switch" aria-label="local-bg-toggle">
<span class="slider"></span>
</label>
`;
document.body.appendChild(bgBtnWrapper);
// 根据 localStorage 状态初始化背景模式
if (this.localBgMode) {
this.startLocalBgRotation();
} else {
this.ensureBgLoaded(0, true);
this.startThemeRotation();
}
const iconPicker = document.createElement('div');
iconPicker.className = 'icon-picker';
iconPicker.innerHTML = `
<div class="icon-picker-panel">
<div class="icon-picker-header">
<div>选择图标</div>
<button class="bookmark-btn" type="button" data-action="close-icon-picker">关闭</button>
</div>
<div class="icon-picker-grid"></div>
</div>
`;
document.body.appendChild(iconPicker);
document.body.appendChild(adminModal);
const fitnessDiaryReminder = document.createElement('div');
fitnessDiaryReminder.className = 'fitness-diary-reminder';
fitnessDiaryReminder.innerHTML = `
<div class="fitness-diary-reminder-backdrop" data-action="close-fitness-diary-reminder"></div>
<div class="fitness-diary-reminder-panel" role="dialog" aria-modal="true" aria-labelledby="fitness-diary-reminder-title">
<button class="fitness-diary-reminder-close" type="button" data-action="close-fitness-diary-reminder" aria-label="关闭提醒">×</button>
<div class="fitness-diary-reminder-badge">每日打卡提醒</div>
<h3 id="fitness-diary-reminder-title" class="fitness-diary-reminder-title">运动日记待补写</h3>
<p class="fitness-diary-reminder-text" id="fitness-diary-reminder-text"></p>
<div class="fitness-diary-reminder-dates" id="fitness-diary-reminder-dates"></div>
<div class="fitness-diary-reminder-actions">
<button class="bookmark-btn" type="button" data-action="close-fitness-diary-reminder">稍后再说</button>
<button class="bookmark-btn primary" type="button" data-action="open-fitness-diary">去写</button>
</div>
</div>
`;
document.body.appendChild(fitnessDiaryReminder);
const weeklyReportReminder = document.createElement('div');
weeklyReportReminder.className = 'weekly-report-reminder';
weeklyReportReminder.innerHTML = `
<div class="weekly-report-reminder-backdrop" data-action="close-weekly-report-reminder"></div>
<div class="weekly-report-reminder-panel" role="dialog" aria-modal="true" aria-labelledby="weekly-report-reminder-title">
<button class="weekly-report-reminder-close" type="button" data-action="close-weekly-report-reminder" aria-label="关闭提醒">×</button>
<div class="weekly-report-reminder-badge">每周周报提醒</div>
<h3 id="weekly-report-reminder-title" class="weekly-report-reminder-title">周报待补写</h3>
<p class="weekly-report-reminder-text" id="weekly-report-reminder-text"></p>
<div class="weekly-report-reminder-dates" id="weekly-report-reminder-dates"></div>
<div class="weekly-report-reminder-actions">
<button class="bookmark-btn" type="button" data-action="close-weekly-report-reminder">稍后再说</button>
<button class="bookmark-btn primary" type="button" data-action="open-weekly-report">去写</button>
</div>
</div>
`;
document.body.appendChild(weeklyReportReminder);
this.tabBar = tabBar;
this.highToolGrid = highToolGrid;
this.lowToolGrid = lowToolGrid;
this.labGrid = labGrid;
this.toolsPage = highToolsPage;
this.bookmarksPage = bookmarksPage;
this.labsPage = labsPage;
this.labTools = [];
this.tabPages = [highToolsPage, lowToolsPage, bookmarksPage, labsPage];
this.bookmarkContainer = bookmarkSections;
this.bookmarkEmpty = bookmarkEmpty;
this.bookmarkAdmin = bookmarkAdmin;
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 = 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;
this.fitnessDiaryReminderText = fitnessDiaryReminder.querySelector('#fitness-diary-reminder-text');
this.fitnessDiaryReminderDates = fitnessDiaryReminder.querySelector('#fitness-diary-reminder-dates');
this.weeklyReportReminder = weeklyReportReminder;
this.weeklyReportReminderText = weeklyReportReminder.querySelector('#weekly-report-reminder-text');
this.weeklyReportReminderDates = weeklyReportReminder.querySelector('#weekly-report-reminder-dates');
}
async loadIcons() {
try {
const iconsRes = await fetch('/common/icons_list.json?t=' + Date.now());
if (iconsRes.ok) {
const data = await iconsRes.json();
if (Array.isArray(data)) this.availableIcons = data;
}
} catch {}
this.renderIconPicker();
}
async loadBookmarks() {
try {
const res = await fetch('/api/nav_bookmarks/state?t=' + Date.now());
if (!res.ok) return;
const data = await res.json();
if (!data || !data.ok) return;
this.categories = Array.isArray(data.categories) ? data.categories : [];
this.bookmarks = Array.isArray(data.bookmarks) ? data.bookmarks : [];
this.renderBookmarks();
this.renderAdmin();
} catch {}
}
renderIconPicker() {
if (!this.iconPickerGrid) return;
this.iconPickerGrid.innerHTML = '';
this.availableIcons.forEach(name => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'icon-picker-item';
btn.dataset.icon = name;
const img = document.createElement('img');
img.src = `/icons/16X16PNG/${name}`;
img.alt = name;
btn.appendChild(img);
this.iconPickerGrid.appendChild(btn);
});
}
setActiveTab(tab) {
if (!this.tabBar) return;
this.tabBar.querySelectorAll('.tab-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.tab === tab);
});
(this.tabPages || []).forEach(page => {
if (!page) return;
page.classList.toggle('active', page.dataset.tab === tab);
});
}
async loadTools() {
try {
const res = await fetch('/api/tools?t=' + Date.now());
if (!res.ok) return;
const data = await res.json();
const tools = Array.isArray(data.tools) ? data.tools : [];
this.highFreqTools = tools.filter(t => t.type === 'high');
this.lowFreqTools = tools.filter(t => t.type === 'low');
this.renderToolGrids();
} catch (err) {
console.error('加载 tools.json 失败:', err);
}
}
renderToolGrids() {
const renderGrid = (gridEl, tools) => {
if (!gridEl) return;
gridEl.innerHTML = '';
tools.forEach(tool => {
const card = document.createElement('a');
card.className = 'tool-card';
card.href = tool.href;
card.innerHTML = `
<div class="tool-icon">${tool.icon}</div>
<div class="tool-text">${tool.name}</div>
`;
gridEl.appendChild(card);
});
};
renderGrid(this.highToolGrid, this.highFreqTools);
renderGrid(this.lowToolGrid, this.lowFreqTools);
}
async loadThoughtLabs() {
try {
const res = await fetch('/api/thought_lab/labs?t=' + Date.now());
if (!res.ok) return;
const data = await res.json();
if (!data || !data.ok) return;
this.labTools = Array.isArray(data.labs) ? data.labs : [];
this.renderLabs();
} catch (err) {
console.error('加载思想实验室列表失败:', err);
}
}
renderLabs() {
if (!this.labGrid) return;
this.labGrid.innerHTML = '';
this.labTools.forEach(lab => {
const card = document.createElement('a');
card.className = 'tool-card';
card.href = lab.href || '#';
if (lab.desc) card.title = lab.desc;
card.innerHTML = `
<div class="tool-icon">${lab.icon || '🧪'}</div>
<div class="tool-text">${lab.name || lab.id || '未命名 lab'}</div>
`;
this.labGrid.appendChild(card);
});
}
openAdmin() {
this.adminVisible = true;
if (this.bookmarkAdmin) this.bookmarkAdmin.classList.add('active');
if (this.bookmarksPage) this.bookmarksPage.classList.add('admin-visible');
this.renderAdmin();
this.bookmarkAdmin.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
closeAdmin() {
this.adminVisible = false;
if (this.bookmarkAdmin) this.bookmarkAdmin.classList.remove('active');
if (this.bookmarksPage) this.bookmarksPage.classList.remove('admin-visible');
this.closeAdminModal();
}
getIconUrl(icon) {
const iconSrc = String(icon || '').trim();
if (!iconSrc) {
if (this.availableIcons.length > 0) return `/icons/16X16PNG/${this.availableIcons[0]}`;
return '';
}
if (iconSrc.includes('/')) return iconSrc;
return `/icons/16X16PNG/${iconSrc}`;
}
renderBookmarks() {
if (!this.bookmarkContainer) return;
const query = String(this.searchQuery || '').trim().toLowerCase();
const categoryMap = new Map();
this.categories.forEach(c => categoryMap.set(String(c.code), c));
const buckets = {};
this.bookmarks.forEach(b => {
const code = String(b.category_code || '');
if (!buckets[code]) buckets[code] = [];
buckets[code].push(b);
});
const categories = this.categories.slice().sort((a, b) => {
const sa = Number(a.sort_order || 0);
const sb = Number(b.sort_order || 0);
if (sa !== sb) return sa - sb;
return String(a.code).localeCompare(String(b.code));
});
if (!categoryMap.has('') && buckets['']) {
categories.push({ code: '', name: '未分类', sort_order: 9999 });
}
this.bookmarkContainer.innerHTML = '';
let hasAny = false;
categories.forEach(cat => {
const list = (buckets[String(cat.code)] || []).slice().sort((a, b) => {
const sa = Number(a.sort_order || 0);
const sb = Number(b.sort_order || 0);
if (sa !== sb) return sa - sb;
return String(a.created_at || '').localeCompare(String(b.created_at || ''));
});
const filtered = query ? list.filter(item => {
const name = String(item.name || '').toLowerCase();
const url = String(item.url || '').toLowerCase();
const remark = String(item.remark || '').toLowerCase();
return name.includes(query) || url.includes(query) || remark.includes(query);
}) : list;
if (!filtered.length) return;
hasAny = true;
const section = document.createElement('div');
section.className = 'bookmark-section';
const header = document.createElement('div');
header.className = 'bookmark-section-header';
const title = document.createElement('div');
title.className = 'bookmark-section-title';
title.textContent = `${cat.name || '未分类'} (${filtered.length})`;
const toggle = document.createElement('button');
toggle.type = 'button';
toggle.className = 'category-toggle';
const isCollapsed = !!this.collapsedCategories[String(cat.code)];
toggle.textContent = isCollapsed ? '+' : '-';
toggle.dataset.code = String(cat.code);
header.appendChild(title);
header.appendChild(toggle);
const grid = document.createElement('div');
grid.className = 'bookmark-grid';
if (isCollapsed) section.classList.add('collapsed');
filtered.forEach(item => {
const link = document.createElement('a');
link.className = 'bookmark-item';
link.href = item.url || '#';
link.target = '_blank';
const iconUrl = this.getIconUrl(item.icon);
if (iconUrl) {
const img = document.createElement('img');
img.src = iconUrl;
img.className = 'bookmark-icon';
img.alt = item.name || '';
link.appendChild(img);
}
const span = document.createElement('span');
span.className = 'bookmark-text';
span.textContent = item.name || '未命名';
link.appendChild(span);
const tooltip = String(item.remark || item.name || '').trim();
if (tooltip) link.title = tooltip;
link.addEventListener('click', (e) => {
if (this.openInModal) {
e.preventDefault();
const url = link.getAttribute('href') || '/';
window.open(url, '_blank');
}
});
grid.appendChild(link);
});
section.appendChild(header);
section.appendChild(grid);
this.bookmarkContainer.appendChild(section);
});
if (this.bookmarkEmpty) {
this.bookmarkEmpty.style.display = hasAny ? 'none' : 'block';
}
}
renderAdmin() {
if (!this.bookmarkAdmin) return;
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();
}
}
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="${this.esc(cat.code)}">编辑</button>
<button class="bookmark-btn danger" type="button" data-action="delete-category" data-code="${this.esc(cat.code)}">删除</button>
</div>
</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;
}
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());
}
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() {
// 换背景按钮事件
document.querySelector('.change-background-btn').addEventListener('click', () => {
this.nextTheme();
});
// 工具卡片点击效果 + 打开方式(委托方式,支持动态加载)
document.querySelector('.tab-pages').addEventListener('click', (e) => {
const card = e.target.closest('.tool-card');
if (!card) return;
card.style.transform = 'scale(0.95)';
setTimeout(() => { card.style.transform = ''; }, 150);
if (this.openInModal) {
e.preventDefault();
window.open(card.getAttribute('href') || '/', '_blank');
}
});
// 键盘快捷键
document.addEventListener('keydown', (e) => {
if (e.key === 'b' || e.key === 'B') {
e.preventDefault();
this.nextTheme();
}
});
// 滑块切换事件
const localBgSwitch = document.getElementById('local-bg-switch');
const openModeSwitch = document.getElementById('open-mode-switch');
if (localBgSwitch) {
localBgSwitch.checked = this.localBgMode;
localBgSwitch.addEventListener('change', () => {
this.localBgMode = !!localBgSwitch.checked;
try { localStorage.setItem('tb_home_local_bg', this.localBgMode ? '1' : '0'); } catch {}
if (this.localBgMode) {
this.stopThemeRotation();
this.startLocalBgRotation();
} else {
this.stopLocalBgRotation();
this.ensureBgLoaded(this.currentTheme, true);
this.startThemeRotation();
}
});
}
if (openModeSwitch) {
openModeSwitch.checked = this.openInModal;
openModeSwitch.addEventListener('change', () => {
this.openInModal = !!openModeSwitch.checked;
try { localStorage.setItem('tb_home_open_mode', this.openInModal ? '1' : '0'); } catch {}
});
}
if (this.tabBar) {
this.tabBar.addEventListener('click', (e) => {
const btn = e.target.closest('.tab-btn');
if (!btn) return;
const tab = btn.dataset.tab;
if (tab) this.setActiveTab(tab);
});
}
const clearBtn = document.getElementById('bookmark-search-clear');
if (this.bookmarkSearchInput) {
this.bookmarkSearchInput.addEventListener('input', () => {
this.searchQuery = this.bookmarkSearchInput.value;
this.renderBookmarks();
});
}
if (clearBtn) {
clearBtn.addEventListener('click', () => {
if (this.bookmarkSearchInput) this.bookmarkSearchInput.value = '';
this.searchQuery = '';
this.renderBookmarks();
});
}
const adminBtn = document.getElementById('bookmark-admin-btn');
if (adminBtn) {
adminBtn.addEventListener('click', () => {
this.openAdmin();
});
}
const adminClose = document.getElementById('bookmark-admin-close');
if (adminClose) {
adminClose.addEventListener('click', () => {
this.closeAdmin();
});
}
// 后台维护:管理员 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');
if (!toggle) return;
const code = String(toggle.dataset.code || '');
this.collapsedCategories[code] = !this.collapsedCategories[code];
this.renderBookmarks();
});
}
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);
const payload = {
code: String(data.get('code') || '').trim(),
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)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
} else {
await this.apiRequest('/api/nav_bookmarks/categories', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
}
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);
const payload = {
name: String(data.get('name') || '').trim(),
url: String(data.get('url') || '').trim(),
category_code: String(data.get('category_code') || '').trim(),
icon: String(data.get('icon') || '').trim(),
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)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
} else {
await this.apiRequest('/api/nav_bookmarks/bookmarks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
}
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) => {
if (e.target && e.target.name === 'icon') this.updateIconPreview();
});
const pickBtn = this.bookmarkForm.querySelector('[data-action="pick-icon"]');
const clearBtn2 = this.bookmarkForm.querySelector('[data-action="clear-icon"]');
if (pickBtn) {
pickBtn.addEventListener('click', () => {
this.iconPickerTarget = this.bookmarkForm.querySelector('input[name="icon"]');
if (this.iconPicker) this.iconPicker.classList.add('active');
});
}
if (clearBtn2) {
clearBtn2.addEventListener('click', () => {
const input = this.bookmarkForm.querySelector('input[name="icon"]');
if (input) input.value = '';
this.updateIconPreview();
});
}
}
if (this.categoryListEl) {
this.categoryListEl.addEventListener('click', async (e) => {
const btn = e.target.closest('button[data-action]');
if (!btn) return;
const action = btn.dataset.action;
const code = btn.dataset.code;
if (action === 'edit-category') {
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 || '删除失败');
}
}
});
}
if (this.bookmarkListEl) {
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.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;
this.apiRequest(`/api/nav_bookmarks/bookmarks/${encodeURIComponent(id)}`, { method: 'DELETE' }).then(async () => {
await this.loadBookmarks();
await this.loadAdminBookmarks();
}).catch(err => alert(err.message || '删除失败'));
}
});
}
if (this.iconPicker) {
this.iconPicker.addEventListener('click', (e) => {
const closeBtn = e.target.closest('[data-action="close-icon-picker"]');
if (closeBtn) {
this.iconPicker.classList.remove('active');
return;
}
const btn = e.target.closest('.icon-picker-item');
if (btn && this.iconPickerTarget) {
this.iconPickerTarget.value = btn.dataset.icon || '';
this.updateIconPreview();
this.iconPicker.classList.remove('active');
return;
}
if (e.target === this.iconPicker) {
this.iconPicker.classList.remove('active');
}
});
}
if (this.fitnessDiaryReminder) {
this.fitnessDiaryReminder.addEventListener('click', (e) => {
const actionEl = e.target.closest('[data-action]');
if (!actionEl) return;
const action = actionEl.dataset.action;
if (action === 'close-fitness-diary-reminder') {
this.hideFitnessDiaryReminder();
return;
}
if (action === 'open-fitness-diary') {
this.hideFitnessDiaryReminder();
window.open('/tools/fitness_diary', '_blank', 'noopener');
}
});
}
if (this.weeklyReportReminder) {
this.weeklyReportReminder.addEventListener('click', (e) => {
const actionEl = e.target.closest('[data-action]');
if (!actionEl) return;
const action = actionEl.dataset.action;
if (action === 'close-weekly-report-reminder') {
this.hideWeeklyReportReminder();
return;
}
if (action === 'open-weekly-report') {
this.hideWeeklyReportReminder();
window.open('/tools/weekly', '_blank', 'noopener');
}
});
}
}
queueReminder(reminderKey, openFn) {
if (!reminderKey || typeof openFn !== 'function') return;
if (this.activeReminderKey === reminderKey) return;
if (this.reminderQueue.some(item => item.key === reminderKey)) return;
const reminder = { key: reminderKey, openFn };
if (!this.activeReminderKey) {
this.activeReminderKey = reminderKey;
openFn();
return;
}
this.reminderQueue.push(reminder);
}
finishReminder(reminderKey) {
if (!reminderKey) return;
if (this.activeReminderKey === reminderKey) {
this.activeReminderKey = '';
const nextReminder = this.reminderQueue.shift();
if (nextReminder) {
this.activeReminderKey = nextReminder.key;
nextReminder.openFn();
}
return;
}
this.reminderQueue = this.reminderQueue.filter(item => item.key !== reminderKey);
}
formatDate(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
getFitnessDiaryDateRange(days = 5) {
const end = new Date();
const start = new Date(end);
start.setDate(end.getDate() - (days - 1));
return {
start: this.formatDate(start),
end: this.formatDate(end)
};
}
getFitnessDiaryReminderSummary(records, days = 5) {
const existingDates = new Set(
(Array.isArray(records) ? records : [])
.map(item => String(item && item.record_date ? item.record_date : '').slice(0, 10))
.filter(Boolean)
);
const missingDates = [];
const today = new Date();
for (let i = 0; i < days; i += 1) {
const date = new Date(today);
date.setDate(today.getDate() - i);
const dateStr = this.formatDate(date);
if (!existingDates.has(dateStr)) missingDates.push(dateStr);
}
return {
days,
missingDates: missingDates.reverse(),
missingCount: missingDates.length
};
}
renderFitnessDiaryReminder(summary) {
if (!this.fitnessDiaryReminder || !this.fitnessDiaryReminderText || !this.fitnessDiaryReminderDates) return;
const missingCount = Number(summary && summary.missingCount ? summary.missingCount : 0);
if (missingCount <= 0) {
this.hideFitnessDiaryReminder();
return;
}
this.fitnessDiaryReminderText.textContent = `最近${summary.days}天(含今天)应写${summary.days}篇运动日记,你现在还缺${missingCount}篇。`;
this.fitnessDiaryReminderDates.innerHTML = summary.missingDates
.map(date => `<span class="fitness-diary-reminder-date">${date}</span>`)
.join('');
this.queueReminder('fitness-diary', () => {
if (this.fitnessDiaryReminder) this.fitnessDiaryReminder.classList.add('active');
});
}
hideFitnessDiaryReminder() {
if (!this.fitnessDiaryReminder) return;
this.fitnessDiaryReminder.classList.remove('active');
this.finishReminder('fitness-diary');
}
async loadFitnessDiaryReminder() {
try {
const range = this.getFitnessDiaryDateRange(5);
const res = await fetch(`/api/fitness_diary/records?start=${range.start}&end=${range.end}&t=${Date.now()}`, {
cache: 'no-store'
});
if (!res.ok) return;
const data = await res.json();
if (!data || data.ok !== true) return;
const summary = this.getFitnessDiaryReminderSummary(data.data, 5);
this.renderFitnessDiaryReminder(summary);
} catch (err) {
console.warn('加载运动日记提醒失败:', err);
}
}
renderWeeklyReportReminder(summary) {
if (!this.weeklyReportReminder || !this.weeklyReportReminderText || !this.weeklyReportReminderDates) return;
const missingCount = Number(summary && summary.missingCount ? summary.missingCount : 0);
if (missingCount <= 0) {
this.hideWeeklyReportReminder();
return;
}
this.weeklyReportReminderText.textContent = `最近${summary.weeks}周(含本周)应写${summary.weeks}篇周报,你现在还缺${missingCount}篇。`;
this.weeklyReportReminderDates.innerHTML = (summary.missingWeeks || [])
.map(item => `<span class="weekly-report-reminder-date">${item.week_id} (${item.start} ~ ${item.end})</span>`)
.join('');
this.queueReminder('weekly-report', () => {
if (this.weeklyReportReminder) this.weeklyReportReminder.classList.add('active');
});
}
hideWeeklyReportReminder() {
if (!this.weeklyReportReminder) return;
this.weeklyReportReminder.classList.remove('active');
this.finishReminder('weekly-report');
}
async loadWeeklyReportReminder() {
try {
// 【首页提醒专用】直接调后端聚合接口 /api/weekly/reminder/status。
// 判定由服务端读库完成,不依赖周报 weekly_auth 登录态(仅需 weekly_gate 门禁);
// 相对路径同源调用:本机访问即 localhost:8976,外网访问自动走 8975 反代,接口本身未授权一律 401。
const res = await fetch(`/api/weekly/reminder/status?t=${Date.now()}`, { cache: 'no-store' });
if (!res.ok) return; // 无门禁 / 接口异常:静默隐藏提醒
const data = await res.json();
if (!data || data.ok !== true) return;
this.renderWeeklyReportReminder({
weeks: Number(data.weeks || 3),
missingCount: Number(data.missingCount || 0),
missingWeeks: Array.isArray(data.missingWeeks) ? data.missingWeeks : []
});
} catch (err) {
console.warn('加载周报提醒失败:', err);
}
}
async apiRequest(url, options) {
const res = await fetch(url, options || {});
let data = null;
try { data = await res.json(); } catch {}
if (!res.ok || (data && data.ok === false)) {
const msg = data && data.error ? data.error : res.statusText;
throw new Error(msg || '请求失败');
}
return data;
}
resetCategoryForm() {
if (!this.categoryForm) return;
this.editingCategoryCode = '';
this.categoryForm.reset();
const codeInput = this.categoryForm.querySelector('input[name="code"]');
if (codeInput) codeInput.disabled = false;
}
resetBookmarkForm() {
if (!this.bookmarkForm) return;
this.editingBookmarkId = '';
this.bookmarkForm.reset();
if (this.bookmarkCreatedAtEl) this.bookmarkCreatedAtEl.textContent = '-';
this.updateIconPreview();
}
fillCategoryForm(cat) {
if (!this.categoryForm) return;
this.editingCategoryCode = String(cat.code);
const codeInput = this.categoryForm.querySelector('input[name="code"]');
const nameInput = this.categoryForm.querySelector('input[name="name"]');
const sortInput = this.categoryForm.querySelector('input[name="sort_order"]');
if (codeInput) {
codeInput.value = cat.code;
codeInput.disabled = true;
}
if (nameInput) nameInput.value = cat.name || '';
if (sortInput) sortInput.value = Number(cat.sort_order || 0);
}
fillBookmarkForm(item) {
if (!this.bookmarkForm) return;
this.editingBookmarkId = String(item.id);
const nameInput = this.bookmarkForm.querySelector('input[name="name"]');
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"]');
const sortInput = this.bookmarkForm.querySelector('input[name="sort_order"]');
if (nameInput) nameInput.value = item.name || '';
if (urlInput) urlInput.value = item.url || '';
if (catSelect) catSelect.value = item.category_code || '';
if (iconInput) iconInput.value = item.icon || '';
if (remarkInput) remarkInput.value = item.remark || '';
if (sortInput) sortInput.value = Number(item.sort_order || 0);
if (this.bookmarkCreatedAtEl) this.bookmarkCreatedAtEl.textContent = item.created_at || '-';
this.updateIconPreview();
}
updateIconPreview() {
if (!this.bookmarkForm || !this.iconPreviewEl) return;
const iconInput = this.bookmarkForm.querySelector('input[name="icon"]');
if (!iconInput) return;
const iconUrl = this.getIconUrl(iconInput.value);
this.iconPreviewEl.innerHTML = '';
if (iconUrl) {
const img = document.createElement('img');
img.src = iconUrl;
img.alt = iconInput.value || '';
this.iconPreviewEl.appendChild(img);
} else {
this.iconPreviewEl.textContent = '未选择';
}
}
applyTheme(themeIndex) {
const theme = this.themes[themeIndex];
// 更新背景图片
document.querySelectorAll('.background-image').forEach((img, index) => {
img.classList.toggle('active', index === themeIndex);
});
this.ensureBgLoaded(themeIndex, true);
// 强制使用纯白文字
document.body.style.color = '#ffffff';
document.documentElement.style.setProperty('--card-text-color', '#ffffff');
// 移除对 tool-card 的个别样式覆盖,统一使用 CSS 类控制
/*
document.querySelectorAll('.tool-card').forEach(card => {
card.style.background = theme.cardGradient;
card.style.color = theme.textColor;
});
*/
// 清除所有特效
this.clearEffects();
// 应用新特效
setTimeout(() => {
this.startEffect(theme.effect);
}, 500);
}
async ensureBgLoaded(themeIndex, preferLocal = true) {
try {
const theme = this.themes[themeIndex];
const imgs = document.querySelectorAll('.background-image');
const img = imgs[themeIndex];
if (!img || !theme) return;
const r = await fetch(`/api/home/bg?theme=${encodeURIComponent(theme.name)}&url=${encodeURIComponent(theme.background)}&prefer_local=${preferLocal ? '1' : '0'}`);
const j = await r.json();
img.src = (j && j.url) ? j.url : theme.background;
} catch {
const theme = this.themes[themeIndex];
const imgs = document.querySelectorAll('.background-image');
const img = imgs[themeIndex];
if (img && theme) img.src = theme.background;
}
}
nextTheme() {
if (this.localBgMode) {
this.nextLocalBg();
return;
}
this.currentTheme = (this.currentTheme + 1) % this.themes.length;
this.applyTheme(this.currentTheme);
// 显示当前主题名称
const themeName = this.themes[this.currentTheme].name;
this.showNotification(`切换到: ${themeName}`);
}
startEffects() {
const currentTheme = this.themes[this.currentTheme];
this.startEffect(currentTheme.effect);
}
startEffect(effectType) {
const effectsContainer = document.querySelector('.effects-container');
switch (effectType) {
case 'meteor':
this.startMeteorEffect(effectsContainer);
break;
default:
this.startMeteorEffect(effectsContainer);
break;
}
}
startMeteorEffect(container) {
// 创建流星
const createMeteor = () => {
const meteor = document.createElement('div');
meteor.className = 'meteor';
// 随机位置
const left = Math.random() * 100;
const top = Math.random() * 50;
meteor.style.left = `${left}%`;
meteor.style.top = `${top}%`;
// 随机大小和速度
const size = 1 + Math.random() * 2;
const duration = 8 + Math.random() * 4;
meteor.style.width = `${size}px`;
meteor.style.height = `${size}px`;
meteor.style.animationDuration = `${duration}s`;
container.appendChild(meteor);
// 移除流星
setTimeout(() => {
if (meteor.parentNode) {
meteor.parentNode.removeChild(meteor);
}
}, duration * 1000);
};
// 定期创建流星
this.meteorInterval = setInterval(createMeteor, 3000);
// 初始创建几个流星
for (let i = 0; i < 3; i++) {
setTimeout(createMeteor, i * 1000);
}
}
startPaperPlaneEffect(container) {
// 创建纸飞机
const createPaperPlane = () => {
const plane = document.createElement('div');
plane.className = 'paper-plane';
plane.innerHTML = '✈️';
// 随机位置
const top = 10 + Math.random() * 70;
plane.style.top = `${top}%`;
// 随机延迟和速度
const delay = Math.random() * 5;
const duration = 12 + Math.random() * 6;
plane.style.animationDelay = `${delay}s`;
plane.style.animationDuration = `${duration}s`;
container.appendChild(plane);
// 移除飞机
setTimeout(() => {
if (plane.parentNode) {
plane.parentNode.removeChild(plane);
}
}, (duration + delay) * 1000);
};
// 定期创建纸飞机
this.planeInterval = setInterval(createPaperPlane, 8000);
// 初始创建几个纸飞机
for (let i = 0; i < 2; i++) {
setTimeout(createPaperPlane, i * 2000);
}
}
startForestEffect(container) {
// 森林主题的飘叶效果
const createLeaf = () => {
const leaf = document.createElement('div');
leaf.className = 'paper-plane';
leaf.innerHTML = '🍃';
leaf.style.fontSize = '16px';
// 随机位置
const left = Math.random() * 100;
const top = Math.random() * 100;
leaf.style.left = `${left}%`;
leaf.style.top = `${top}%`;
// 随机动画参数
const delay = Math.random() * 3;
const duration = 10 + Math.random() * 8;
leaf.style.animationDelay = `${delay}s`;
leaf.style.animationDuration = `${duration}s`;
container.appendChild(leaf);
// 移除叶子
setTimeout(() => {
if (leaf.parentNode) {
leaf.parentNode.removeChild(leaf);
}
}, (duration + delay) * 1000);
};
// 定期创建叶子
this.leafInterval = setInterval(createLeaf, 4000);
// 初始创建几个叶子
for (let i = 0; i < 4; i++) {
setTimeout(createLeaf, i * 1000);
}
}
clearEffects() {
// 清除所有定时器
if (this.meteorInterval) clearInterval(this.meteorInterval);
if (this.planeInterval) clearInterval(this.planeInterval);
if (this.leafInterval) clearInterval(this.leafInterval);
// 清除所有特效元素
const effectsContainer = document.querySelector('.effects-container');
if (effectsContainer) {
effectsContainer.innerHTML = '';
}
}
startBackgroundRotation() {
this.startThemeRotation();
}
startThemeRotation() {
if (this.localBgMode) return;
this.stopThemeRotation();
this.rotationInterval = setInterval(() => {
this.nextTheme();
}, 30000);
}
stopThemeRotation() {
if (this.rotationInterval) {
clearInterval(this.rotationInterval);
this.rotationInterval = null;
}
}
startLocalBgRotation() {
this.loadLocalBgFiles().then(() => {
this.stopLocalBgRotation();
this.localRotationInterval = setInterval(() => {
this.nextLocalBg();
}, 30000);
});
}
stopLocalBgRotation() {
if (this.localRotationInterval) {
clearInterval(this.localRotationInterval);
this.localRotationInterval = null;
}
}
async loadLocalBgFiles() {
if (this.localBgFiles.length > 0) return this.localBgFiles;
try {
const r = await fetch('/api/home/bg/default-list?t=' + Date.now());
const j = await r.json();
if (j.ok && j.files.length) {
this.localBgFiles = j.files;
this.localBgIndex = 0;
// 加载第一张
const imgs = document.querySelectorAll('.background-image');
if (imgs.length) {
imgs.forEach(img => { img.src = `/home-bg/default-c21f969b/${this.localBgFiles[0]}?v=${Date.now()}`; });
}
}
} catch {}
return this.localBgFiles;
}
async nextLocalBg() {
if (!this.localBgFiles.length) {
await this.loadLocalBgFiles();
if (!this.localBgFiles.length) return;
}
// 随机选取,避免重复当前
let newIndex;
if (this.localBgFiles.length > 1) {
do {
newIndex = Math.floor(Math.random() * this.localBgFiles.length);
} while (newIndex === this.localBgIndex);
} else {
newIndex = 0;
}
this.localBgIndex = newIndex;
const file = this.localBgFiles[this.localBgIndex];
const imgs = document.querySelectorAll('.background-image');
if (imgs.length) {
imgs.forEach(img => { img.src = `/home-bg/default-c21f969b/${file}?v=${Date.now()}`; });
}
this.showNotification(`本地背景: ${this.localBgIndex + 1}/${this.localBgFiles.length}`);
}
showNotification(message) {
// 创建通知元素
const notification = document.createElement('div');
notification.style.cssText = `
position: fixed;
top: 2rem;
left: 50%;
transform: translateX(-50%);
background: rgba(255, 255, 255, 0.2);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.3);
color: var(--text-light);
padding: 0.75rem 1.5rem;
border-radius: 25px;
font-size: 0.9rem;
z-index: 1000;
opacity: 0;
transition: opacity 0.3s ease;
`;
notification.textContent = message;
document.body.appendChild(notification);
// 显示动画
setTimeout(() => {
notification.style.opacity = '1';
}, 10);
// 隐藏动画
setTimeout(() => {
notification.style.opacity = '0';
setTimeout(() => {
if (notification.parentNode) {
notification.parentNode.removeChild(notification);
}
}, 300);
}, 2000);
}
}
// 页面加载完成后初始化
document.addEventListener('DOMContentLoaded', () => {
new ToolboxHome();
});