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:
@ -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>
|
||||
|
||||
Reference in New Issue
Block a user