feat: 收藏夹系统 + Studio 趋势图 + 通知偏好设置

- 新增收藏夹系统 (Folder/FolderItem 模型、CRUD API、前端管理页)
- 新增文章详情页收藏按钮 (书签图标、ToggleFavorite 一键收藏)
- 新增 Studio 趋势图 (赋能值/评论/点赞/收藏 4 线图, 7d/30d/90d)
- 新增通知偏好设置页 (评论/回复/点赞/关注 4 类开关)
- 后端通知列表支持按偏好过滤 (排除已关闭的通知类型)
- 下载 Chart.js 到本地静态资源 (static/js/chart.umd.min.js)
- 补充收藏夹卡片、开关滑块、趋势图网格等 CSS 样式
This commit is contained in:
2026-06-01 17:33:59 +08:00
parent 680df7371a
commit 4d212a8f8a
27 changed files with 2005 additions and 33 deletions

View File

@ -40,6 +40,20 @@
</svg>
我的域能
</a>
<a href="/settings/favorites"
class="settings-nav-item {{if eq .ActiveTab "favorites"}}active{{end}}">
<svg class="settings-nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/>
</svg>
我的收藏夹
</a>
<a href="/settings/notify"
class="settings-nav-item {{if eq .ActiveTab "notify"}}active{{end}}">
<svg class="settings-nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/>
</svg>
通知偏好
</a>
</nav>
</aside>
@ -181,6 +195,33 @@
</div>
</div>
</div>
{{else if eq .ActiveTab "favorites"}}
<!-- 我的收藏夹 -->
<div class="settings-card">
<div class="settings-card-header">
<h2>我的收藏夹</h2>
<p class="settings-card-desc">管理和查看你的收藏夹</p>
</div>
<div class="settings-card-body">
<div class="fav-toolbar">
<button class="settings-save-btn" id="createFolderBtn">+ 新建收藏夹</button>
</div>
<div class="fav-list" id="favFolderList">
<div class="energy-log-empty">加载中...</div>
</div>
</div>
</div>
{{else if eq .ActiveTab "notify"}}
<!-- 通知偏好 -->
<div class="settings-card">
<div class="settings-card-header">
<h2>通知偏好</h2>
<p class="settings-card-desc">管理你的通知推送偏好,关闭后不影响通知记录存储</p>
</div>
<div class="settings-card-body" id="notifyPrefsBody">
<div class="energy-log-empty">加载中...</div>
</div>
</div>
{{else}}
<!-- 账号信息 -->
<div class="settings-card">
@ -981,10 +1022,172 @@
function formatTime(isoStr) {
if (!isoStr) return '';
// '2026-06-01T12:39:41+08:00' → '2026-06-01 12:39:41'
return isoStr.replace('T', ' ').replace(/\+.*/, '');
}
})();
// ===== 收藏夹 Tab =====
(function () {
var listEl = document.getElementById('favFolderList');
var createBtn = document.getElementById('createFolderBtn');
if (!listEl) return;
function loadFolders() {
fetch('/api/folders')
.then(function (r) { return r.json(); })
.then(function (d) {
if (!d.success || !d.data || !d.data.folders || d.data.folders.length === 0) {
listEl.innerHTML = '<div class="energy-log-empty">暂无收藏夹</div>';
return;
}
var html = '';
d.data.folders.forEach(function (f) {
var isDefault = f.is_default ? ' (默认)' : '';
var pub = f.is_public ? '公开' : '私密';
html += '<div class="fav-folder-card">'
+ '<div class="fav-folder-info">'
+ '<span class="fav-folder-name">' + escapeHTML(f.name) + isDefault + '</span>'
+ '<span class="fav-folder-meta">' + (f.item_count || 0) + ' 篇文章 · ' + pub + '</span>'
+ '</div>'
+ '<div class="fav-folder-actions">'
+ (f.is_default ? '' : '<button class="fav-folder-del" data-id="' + f.id + '">删除</button>')
+ '</div>'
+ '</div>';
});
listEl.innerHTML = html;
// 绑定删除按钮
listEl.querySelectorAll('.fav-folder-del').forEach(function (btn) {
btn.addEventListener('click', function () {
var id = btn.getAttribute('data-id');
if (!confirm('确定要删除此收藏夹?其中的收藏内容也将被清除。')) return;
var xhr = new XMLHttpRequest();
xhr.open('DELETE', '/api/folders/' + id, true);
xhr.onreadystatechange = function () {
if (xhr.readyState !== 4) return;
try {
var r = JSON.parse(xhr.responseText);
if (r.success) {
showSettingsToast('收藏夹已删除');
loadFolders();
} else {
showSettingsToast(r.message || '删除失败', true);
}
} catch (e) {
showSettingsToast('删除失败', true);
}
};
xhr.send();
});
});
})
.catch(function () {
listEl.innerHTML = '<div class="energy-log-empty">加载失败</div>';
});
}
if (createBtn) {
createBtn.addEventListener('click', function () {
var name = prompt('请输入收藏夹名称最多50个字符');
if (!name || !name.trim()) return;
if (name.trim().length > 50) {
showSettingsToast('收藏夹名称不能超过 50 个字符', true);
return;
}
var xhr = new XMLHttpRequest();
xhr.open('POST', '/api/folders', true);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onreadystatechange = function () {
if (xhr.readyState !== 4) return;
try {
var r = JSON.parse(xhr.responseText);
if (r.success) {
showSettingsToast('收藏夹已创建');
loadFolders();
} else {
showSettingsToast(r.message || '创建失败', true);
}
} catch (e) {
showSettingsToast('创建失败', true);
}
};
xhr.send(JSON.stringify({ name: name.trim(), description: '', is_public: false }));
});
}
loadFolders();
})();
// ===== 通知偏好 Tab =====
(function () {
var bodyEl = document.getElementById('notifyPrefsBody');
if (!bodyEl) return;
function loadPrefs() {
fetch('/api/settings/notify-prefs')
.then(function (r) { return r.json(); })
.then(function (d) {
if (!d.success || !d.data) {
bodyEl.innerHTML = '<div class="energy-log-empty">加载失败</div>';
return;
}
var prefs = d.data;
var items = [
{ key: 'comment', label: '评论通知', desc: '有人评论我的文章时通知我' },
{ key: 'comment_reply', label: '回复通知', desc: '有人回复我的评论时通知我' },
{ key: 'like_aggregated', label: '点赞通知', desc: '每日汇总的点赞通知' },
{ key: 'follow', label: '关注通知', desc: '有人关注我时通知我' }
];
var html = '';
items.forEach(function (item) {
var checked = prefs[item.key] ? 'checked' : '';
html += '<div class="notify-pref-row">'
+ '<div class="notify-pref-info">'
+ '<span class="notify-pref-label">' + item.label + '</span>'
+ '<span class="notify-pref-desc">' + item.desc + '</span>'
+ '</div>'
+ '<label class="notify-pref-toggle">'
+ '<input type="checkbox" class="notify-pref-cb" data-key="' + item.key + '" ' + checked + '>'
+ '<span class="notify-pref-slider"></span>'
+ '</label>'
+ '</div>';
});
bodyEl.innerHTML = html;
// 绑定切换事件
bodyEl.querySelectorAll('.notify-pref-cb').forEach(function (cb) {
cb.addEventListener('change', function () {
var key = cb.getAttribute('data-key');
var enabled = cb.checked;
var xhr = new XMLHttpRequest();
xhr.open('PUT', '/api/settings/notify-prefs', true);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onreadystatechange = function () {
if (xhr.readyState !== 4) return;
try {
var r = JSON.parse(xhr.responseText);
if (r.success) {
showSettingsToast('通知偏好已更新');
} else {
showSettingsToast(r.message || '更新失败', true);
cb.checked = !enabled;
}
} catch (e) {
showSettingsToast('更新失败', true);
cb.checked = !enabled;
}
};
xhr.send(JSON.stringify({ key: key, enabled: enabled }));
});
});
})
.catch(function () {
bodyEl.innerHTML = '<div class="energy-log-empty">加载失败</div>';
});
}
loadPrefs();
})();
})();
</script>
</body>