feat: 新增知资札记工具及相关功能

1. 新增知资札记完整站点:包含首页、详情页、管理后台
2. 添加站点图标、鉴权配置与后端API鉴权逻辑
3. 在市场页面新增知资札记入口跳转
4. 新增经典乡村音乐播放列表文件
5. 临时添加养生谚语批量导入脚本
This commit is contained in:
yangxiangyuan
2026-07-19 20:45:41 +08:00
parent 6a043687f5
commit 421a8aa1f7
20 changed files with 3752 additions and 14 deletions
+696
View File
@@ -0,0 +1,696 @@
/**
* 知资札记 - 后台CMS管理逻辑
* 图文内容管理列表页面
*/
(function () {
'use strict';
const API_BASE = '/api/zhizi_notes';
const $ = function (sel) { return document.querySelector(sel); };
const $$ = function (sel) { return document.querySelectorAll(sel); };
// ── 状态 ──
var adminState = {
page: 1,
pageSize: 20,
totalPages: 0,
total: 0,
filter: { search: '', category: '', contentType: '', status: '' },
selectedIds: new Set(),
editingId: null
};
// ── Toast ──
function showToast(msg, type) {
type = type || 'info';
var t = $('#za-toast');
if (!t) return;
t.textContent = msg;
t.className = 'za-toast ' + type + ' show';
clearTimeout(t._timer);
t._timer = setTimeout(function () { t.classList.remove('show'); }, type === 'error' ? 4000 : 2000);
}
// ── 链接清洗 ──
function clearAllLinks(html) {
if (!html || typeof html !== 'string') return '';
return html.replace(/<a\b[^>]*>(.*?)<\/a>/gi, '$1');
}
// ── API ──
async function apiGet(path) {
try {
var res = await fetch(API_BASE + path, { credentials: 'same-origin' });
return await res.json();
} catch (e) { return { ok: false, error: e.message }; }
}
async function apiPost(path, body) {
try {
var res = await fetch(API_BASE + path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify(body)
});
return await res.json();
} catch (e) { return { ok: false, error: e.message }; }
}
async function apiPut(path, body) {
try {
var res = await fetch(API_BASE + path, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify(body)
});
return await res.json();
} catch (e) { return { ok: false, error: e.message }; }
}
async function apiDelete(path) {
try {
var res = await fetch(API_BASE + path, { method: 'DELETE', credentials: 'same-origin' });
return await res.json();
} catch (e) { return { ok: false, error: e.message }; }
}
// ── 加载列表 ──
async function loadTableData() {
var params = new URLSearchParams();
params.set('page', adminState.page);
params.set('pageSize', adminState.pageSize);
if (adminState.filter.search) params.set('search', adminState.filter.search);
if (adminState.filter.category) params.set('category', adminState.filter.category);
if (adminState.filter.contentType) params.set('contentType', adminState.filter.contentType);
if (adminState.filter.status) params.set('status', adminState.filter.status);
var data = await apiGet('/articles?' + params.toString());
if (!data.ok) { showToast('加载失败:' + (data.error || '未知错误'), 'error'); return; }
adminState.totalPages = data.pagination ? data.pagination.totalPages : 0;
adminState.total = data.pagination ? data.pagination.total : 0;
adminState.selectedIds.clear();
renderTable(data.data || []);
renderPagination();
}
// ── 渲染表格 ──
function renderTable(articles) {
var tbody = $('#za-tbody');
if (!tbody) return;
tbody.innerHTML = '';
if (articles.length === 0) {
tbody.innerHTML = '<tr><td colspan="10" style="text-align:center;padding:40px;color:#999">暂无数据</td></tr>';
return;
}
articles.forEach(function (a) {
var tr = document.createElement('tr');
// 复选框
var tdCb = document.createElement('td');
var cb = document.createElement('input');
cb.type = 'checkbox';
cb.setAttribute('data-id', a.id);
cb.addEventListener('change', function () { toggleSelect(a.id, cb.checked); });
tdCb.appendChild(cb);
// 封面
var tdCover = document.createElement('td');
var coverDiv = document.createElement('div');
coverDiv.className = 'cell-cover';
if (a.cover_url) {
var img = document.createElement('img');
img.src = a.cover_url; img.alt = '';
coverDiv.appendChild(img);
} else {
coverDiv.innerHTML = '<div class="text-icon">文</div>';
}
tdCover.appendChild(coverDiv);
// 标题
var tdTitle = document.createElement('td');
var titleDiv = document.createElement('div');
titleDiv.className = 'cell-title';
titleDiv.textContent = a.title;
titleDiv.title = a.title;
tdTitle.appendChild(titleDiv);
// 分类
var tdCat = document.createElement('td');
tdCat.textContent = a.category;
// 内容类型
var tdType = document.createElement('td');
var typeSpan = document.createElement('span');
typeSpan.className = 'type-badge ' + (a.content_type === 'text' ? 'text' : 'image');
var imgCount = a.image_count || 0;
typeSpan.textContent = a.content_type === 'text' ? '纯文字' : '图文(' + imgCount + '张)';
tdType.appendChild(typeSpan);
// 来源
var tdSrc = document.createElement('td');
tdSrc.textContent = a.source || '-';
tdSrc.style.maxWidth = '100px';
tdSrc.style.overflow = 'hidden';
tdSrc.style.textOverflow = 'ellipsis';
tdSrc.style.whiteSpace = 'nowrap';
// 创建时间
var tdTime = document.createElement('td');
tdTime.textContent = (a.created_at || '').slice(0, 16);
tdTime.style.whiteSpace = 'nowrap';
// 阅读量
var tdViews = document.createElement('td');
tdViews.textContent = a.views || 0;
// 状态
var tdStatus = document.createElement('td');
var statusSpan = document.createElement('span');
statusSpan.className = 'status-badge ' + (a.status === 'normal' ? 'normal' : 'offline');
statusSpan.textContent = a.status === 'normal' ? '正常' : '已下架';
tdStatus.appendChild(statusSpan);
// 操作
var tdActions = document.createElement('td');
tdActions.className = 'actions';
var btnPreview = document.createElement('button');
btnPreview.className = 'act-preview';
btnPreview.textContent = '预览';
btnPreview.addEventListener('click', function () { window.open('/tools/zhizi_notes/detail.html?id=' + a.id, '_blank'); });
var btnEdit = document.createElement('button');
btnEdit.className = 'act-edit';
btnEdit.textContent = '编辑';
btnEdit.addEventListener('click', function () { openEditForm(a.id); });
var btnToggle = document.createElement('button');
btnToggle.className = 'act-toggle';
btnToggle.textContent = a.status === 'normal' ? '下架' : '上架';
btnToggle.addEventListener('click', function () { toggleStatus(a.id, a.status); });
var btnDel = document.createElement('button');
btnDel.className = 'act-delete';
btnDel.textContent = '删除';
btnDel.addEventListener('click', function () { confirmDelete(a.id, a.title); });
tdActions.appendChild(btnPreview);
tdActions.appendChild(btnEdit);
tdActions.appendChild(btnToggle);
tdActions.appendChild(btnDel);
tr.appendChild(tdCb);
tr.appendChild(tdCover);
tr.appendChild(tdTitle);
tr.appendChild(tdCat);
tr.appendChild(tdType);
tr.appendChild(tdSrc);
tr.appendChild(tdTime);
tr.appendChild(tdViews);
tr.appendChild(tdStatus);
tr.appendChild(tdActions);
tbody.appendChild(tr);
});
}
// ── 分页 ──
function renderPagination() {
var container = $('#za-pagination');
if (!container) return;
var info = $('#za-page-info');
if (info) info.textContent = '共 ' + adminState.total + ' 条,第 ' + adminState.page + '/' + adminState.totalPages + ' 页';
var btns = $('#za-page-btns');
if (!btns) return;
btns.innerHTML = '';
var total = adminState.totalPages;
var cur = adminState.page;
var prevBtn = document.createElement('button');
prevBtn.textContent = '上一页';
prevBtn.disabled = cur <= 1;
prevBtn.addEventListener('click', function () { if (cur > 1) { adminState.page--; loadTableData(); } });
btns.appendChild(prevBtn);
var start = Math.max(1, cur - 2);
var end = Math.min(total, cur + 2);
for (var i = start; i <= end; i++) {
var pageBtn = document.createElement('button');
pageBtn.textContent = i;
if (i === cur) pageBtn.classList.add('active');
pageBtn.addEventListener('click', function (p) {
return function () { adminState.page = p; loadTableData(); };
}(i));
btns.appendChild(pageBtn);
}
var nextBtn = document.createElement('button');
nextBtn.textContent = '下一页';
nextBtn.disabled = cur >= total;
nextBtn.addEventListener('click', function () { if (cur < total) { adminState.page++; loadTableData(); } });
btns.appendChild(nextBtn);
}
// ── 选择行 ──
function toggleSelect(id, checked) {
if (checked) adminState.selectedIds.add(id);
else adminState.selectedIds.delete(id);
updateBatchBtns();
}
function toggleSelectAll(checked) {
$$('#za-tbody input[type="checkbox"]').forEach(function (cb) {
cb.checked = checked;
var id = parseInt(cb.getAttribute('data-id'));
if (checked) adminState.selectedIds.add(id);
else adminState.selectedIds.delete(id);
});
updateBatchBtns();
}
function updateBatchBtns() {
var hasSelection = adminState.selectedIds.size > 0;
var btns = $$('.za-toolbar .btn:disabled, .za-toolbar .btn:not(:disabled)');
$$('#za-btn-batch-offline, #za-btn-batch-delete, #za-btn-batch-export').forEach(function (btn) {
btn.disabled = !hasSelection;
});
}
// ── 批量操作 ──
async function batchAction(action) {
if (adminState.selectedIds.size === 0) { showToast('请先选择文章', 'info'); return; }
var ids = Array.from(adminState.selectedIds);
var labels = { delete: '删除', offline: '下架', online: '上架' };
var label = labels[action] || action;
if (action === 'delete') {
showConfirm('确认批量' + label, '确定要批量' + label + ' ' + ids.length + ' 篇文章吗?此操作不可恢复。', async function () {
var data = await apiPost('/articles/batch', { ids: ids, action: action });
if (data.ok) { showToast('批量' + label + '成功', 'success'); loadTableData(); }
else showToast('操作失败:' + (data.error || '未知错误'), 'error');
});
} else {
var data = await apiPost('/articles/batch', { ids: ids, action: action });
if (data.ok) { showToast('批量' + label + '成功', 'success'); loadTableData(); }
else showToast('操作失败:' + (data.error || '未知错误'), 'error');
}
}
// ── 状态切换 ──
async function toggleStatus(id, currentStatus) {
var newStatus = currentStatus === 'normal' ? 'offline' : 'normal';
var label = newStatus === 'normal' ? '上架' : '下架';
var data = await apiPut('/articles/' + id, { status: newStatus });
if (data.ok) { showToast(label + '成功', 'success'); loadTableData(); }
else showToast('操作失败:' + (data.error || '未知错误'), 'error');
}
// ── 删除确认 ──
function confirmDelete(id, title) {
showConfirm('确认删除', '确定要永久删除文章「' + title + '」吗?关联图片也会一并删除,此操作不可恢复。', async function () {
var data = await apiDelete('/articles/' + id);
if (data.ok) { showToast('删除成功', 'success'); loadTableData(); }
else showToast('删除失败:' + (data.error || '未知错误'), 'error');
});
}
// ── 确认弹窗 ──
function showConfirm(title, message, onConfirm) {
var overlay = document.createElement('div');
overlay.className = 'za-modal-overlay';
overlay.innerHTML = '<div class="za-modal">' +
'<h3>' + escapeHtml(title) + '</h3>' +
'<div class="modal-body">' + escapeHtml(message) + '</div>' +
'<div class="modal-actions">' +
'<button class="btn btn-cancel" id="za-confirm-cancel">取消</button>' +
'<button class="btn btn-danger" id="za-confirm-ok">确认删除</button>' +
'</div></div>';
document.body.appendChild(overlay);
overlay.querySelector('#za-confirm-cancel').addEventListener('click', function () { overlay.remove(); });
overlay.querySelector('#za-confirm-ok').addEventListener('click', function () {
overlay.remove();
if (onConfirm) onConfirm();
});
overlay.addEventListener('click', function (e) { if (e.target === overlay) overlay.remove(); });
}
// ── 上传文件 ──
async function uploadFiles(files, articleId) {
var formData = new FormData();
formData.append('articleId', articleId || 0);
for (var i = 0; i < files.length; i++) {
formData.append('images', files[i]);
}
try {
var res = await fetch(API_BASE + '/upload', { method: 'POST', credentials: 'same-origin', body: formData });
return await res.json();
} catch (e) { return { ok: false, error: e.message }; }
}
// ── 新增/编辑表单 ──
async function openEditForm(id) {
adminState.editingId = id;
var article = null;
if (id) {
var data = await apiGet('/articles/' + id);
if (data.ok) article = data.data;
else { showToast('加载文章失败', 'error'); return; }
}
var overlay = document.createElement('div');
overlay.className = 'za-modal-overlay';
overlay.innerHTML = '<div class="za-modal form-modal">' +
'<h3>' + (id ? '编辑存档' : '新增存档') + '</h3>' +
'<div class="form-group"><label>标题</label><input id="za-form-title" value="' + escapeHtml(article ? article.title : '') + '" placeholder="输入文章标题" /></div>' +
'<div class="form-group"><label>分类</label><select id="za-form-category"><option value="">选择分类</option></select></div>' +
'<div class="form-group"><label>来源备注</label><input id="za-form-source" value="' + escapeHtml(article ? article.source : '') + '" placeholder="来源信息(纯文字)" /></div>' +
'<div class="form-group"><label>标签</label><div class="tag-input" id="za-form-tags"><input id="za-tag-input" placeholder="输入标签后回车" /></div></div>' +
'<div class="form-group"><label>正文</label><textarea id="za-form-content" placeholder="粘贴正文内容(自动清除外链)">' + escapeHtml(article ? article.content : '') + '</textarea></div>' +
'<div class="form-group"><label>内容类型</label><select id="za-form-contentType"><option value="text">纯文字</option><option value="image">图文</option></select></div>' +
'<div class="form-group"><label>置顶</label><select id="za-form-pinned"><option value="0">否</option><option value="1">是</option></select></div>' +
'<div class="form-group"><label>图片上传</label>' +
'<div class="upload-area" id="za-upload-area">' +
'<input type="file" id="za-file-input" accept="image/*" multiple style="display:none" />' +
'<button type="button" class="btn btn-outline" id="za-upload-btn">选择图片(可多选)</button>' +
'<div id="za-upload-preview" class="upload-preview"></div>' +
'</div>' +
'</div>' +
'<div class="modal-actions">' +
'<button class="btn btn-cancel" id="za-form-cancel">取消</button>' +
'<button class="btn btn-primary" id="za-form-save">保存</button>' +
'</div></div>';
document.body.appendChild(overlay);
// 填充分类
var cats = await apiGet('/categories');
var catSelect = overlay.querySelector('#za-form-category');
if (cats.ok && cats.data) {
cats.data.forEach(function (c) {
var opt = document.createElement('option');
opt.value = c.name; opt.textContent = c.name;
if (article && article.category === c.name) opt.selected = true;
catSelect.appendChild(opt);
});
}
if (article) {
overlay.querySelector('#za-form-contentType').value = article.content_type || 'text';
overlay.querySelector('#za-form-pinned').value = article.is_pinned ? '1' : '0';
}
// 标签管理
var tagContainer = overlay.querySelector('#za-form-tags');
var tagInput = overlay.querySelector('#za-tag-input');
var currentTags = [];
try { if (article && article.tags_json) currentTags = JSON.parse(article.tags_json); } catch (e) {}
function renderTagChips() {
var existing = tagContainer.querySelectorAll('.tag-chip');
existing.forEach(function (el) { el.remove(); });
currentTags.forEach(function (t, i) {
var chip = document.createElement('span');
chip.className = 'tag-chip';
chip.innerHTML = t + '<button class="remove-tag" data-idx="' + i + '">×</button>';
chip.querySelector('.remove-tag').addEventListener('click', function () {
currentTags.splice(i, 1);
renderTagChips();
});
tagContainer.insertBefore(chip, tagInput);
});
}
renderTagChips();
tagInput.addEventListener('keydown', function (e) {
if (e.key === 'Enter') {
e.preventDefault();
var val = tagInput.value.trim();
if (val && currentTags.indexOf(val) === -1) {
currentTags.push(val);
renderTagChips();
}
tagInput.value = '';
}
});
// ── 图片上传逻辑 ──
var selectedFiles = [];
var removedImageIds = []; // 编辑时删除的已有图片ID
var uploadPreview = overlay.querySelector('#za-upload-preview');
var fileInput = overlay.querySelector('#za-file-input');
var uploadBtn = overlay.querySelector('#za-upload-btn');
// 编辑时加载已有图片
if (article && article.images) {
article.images.forEach(function (img) {
var item = document.createElement('div');
item.className = 'preview-item';
item.innerHTML = '<img src="/uploads/zhizi_notes/' + img.filename + '" alt="" />' +
'<button class="preview-remove" data-filename="' + img.filename + '" data-id="' + (img.id || '') + '">×</button>';
uploadPreview.appendChild(item);
});
}
// 为所有已有图片的删除按钮绑定事件
uploadPreview.querySelectorAll('.preview-remove').forEach(function (btn) {
btn.addEventListener('click', function () {
var id = btn.getAttribute('data-id');
if (id) removedImageIds.push(parseInt(id));
btn.closest('.preview-item').remove();
});
});
uploadBtn.addEventListener('click', function () { fileInput.click(); });
fileInput.addEventListener('change', function () {
if (!fileInput.files) return;
Array.from(fileInput.files).forEach(function (file, idx) {
if (!file.type.startsWith('image/')) return;
selectedFiles.push(file);
var reader = new FileReader();
reader.onload = (function (i) {
return function (e) {
var item = document.createElement('div');
item.className = 'preview-item';
item.innerHTML = '<img src="' + e.target.result + '" alt="" />' +
'<button class="preview-remove">×</button>';
item.querySelector('.preview-remove').addEventListener('click', function () {
selectedFiles[i] = null;
item.remove();
});
uploadPreview.appendChild(item);
};
})(idx);
reader.readAsDataURL(file);
});
fileInput.value = '';
});
overlay.querySelector('#za-form-cancel').addEventListener('click', function () { overlay.remove(); });
overlay.querySelector('#za-form-save').addEventListener('click', async function () {
var saveBtn = overlay.querySelector('#za-form-save');
saveBtn.disabled = true;
saveBtn.textContent = '保存中…';
var title = overlay.querySelector('#za-form-title').value.trim();
if (!title) { showToast('标题不能为空', 'error'); saveBtn.disabled = false; saveBtn.textContent = '保存'; return; }
// 先保存文章获取 ID
var rawContent = overlay.querySelector('#za-form-content').value;
var content = clearAllLinks(rawContent);
var body = {
title: title,
content: content,
category: overlay.querySelector('#za-form-category').value || '未分类',
source: overlay.querySelector('#za-form-source').value.trim(),
tags: currentTags,
contentType: overlay.querySelector('#za-form-contentType').value,
isPinned: overlay.querySelector('#za-form-pinned').value === '1'
};
var result;
if (id) {
result = await apiPut('/articles/' + id, body);
result.data = { id: id };
} else {
result = await apiPost('/articles', body);
}
if (!result.ok) {
showToast('保存失败:' + (result.error || '未知错误'), 'error');
saveBtn.disabled = false;
saveBtn.textContent = '保存';
return;
}
var savedId = result.data ? result.data.id : id;
if (!savedId) savedId = id;
// 编辑时删除已被移除的图片
if (id && removedImageIds.length > 0) {
for (var k = 0; k < removedImageIds.length; k++) {
await apiDelete('/images/' + removedImageIds[k]);
}
}
// 过滤已删除的文件(标记为 null)
var filesToUpload = selectedFiles.filter(function (f) { return f !== null; });
// 上传图片
if (filesToUpload.length > 0 && savedId) {
saveBtn.textContent = '上传图片中…';
var uploadResult = await uploadFiles(filesToUpload, savedId);
if (!uploadResult.ok) {
showToast('图片上传失败:' + (uploadResult.error || '未知错误'), 'error');
saveBtn.disabled = false;
saveBtn.textContent = '保存';
return;
}
// 更新封面为新上传的第一张图
if (uploadResult.data && uploadResult.data.length > 0) {
body.coverUrl = uploadResult.data[0].url;
await apiPut('/articles/' + savedId, body);
}
}
showToast(id ? '更新成功' : '创建成功', 'success');
overlay.remove();
loadTableData();
});
overlay.addEventListener('click', function (e) { if (e.target === overlay) overlay.remove(); });
}
function escapeHtml(str) {
if (!str) return '';
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// ── 导出 ──
async function exportSelected() {
if (adminState.selectedIds.size === 0) { showToast('请先选择文章', 'info'); return; }
var ids = Array.from(adminState.selectedIds);
try {
var res = await fetch(API_BASE + '/export', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ ids: ids })
});
if (res.ok) {
var blob = await res.blob();
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url; a.download = '知资札记_导出.txt'; a.click();
URL.revokeObjectURL(url);
showToast('导出成功', 'success');
} else {
showToast('导出失败', 'error');
}
} catch (e) {
showToast('导出失败:' + e.message, 'error');
}
}
// ── 初始化 ──
function init() {
// 全选
var selectAllCb = $('#za-select-all');
if (selectAllCb) {
selectAllCb.addEventListener('change', function () { toggleSelectAll(selectAllCb.checked); });
}
// 搜索
var searchInput = $('#za-search');
var searchBtn = $('#za-search-btn');
if (searchBtn && searchInput) {
searchBtn.addEventListener('click', function () {
adminState.filter.search = searchInput.value.trim();
adminState.page = 1;
loadTableData();
});
searchInput.addEventListener('keydown', function (e) {
if (e.key === 'Enter') {
adminState.filter.search = searchInput.value.trim();
adminState.page = 1;
loadTableData();
}
});
}
// 分类筛选
var catFilter = $('#za-filter-category');
if (catFilter) {
catFilter.addEventListener('change', function () {
adminState.filter.category = catFilter.value;
adminState.page = 1;
loadTableData();
});
}
// 内容类型筛选
var typeFilter = $('#za-filter-type');
if (typeFilter) {
typeFilter.addEventListener('change', function () {
adminState.filter.contentType = typeFilter.value;
adminState.page = 1;
loadTableData();
});
}
// 状态筛选
var statusFilter = $('#za-filter-status');
if (statusFilter) {
statusFilter.addEventListener('change', function () {
adminState.filter.status = statusFilter.value;
adminState.page = 1;
loadTableData();
});
}
// 每页条数
var pageSizeSel = $('#za-page-size');
if (pageSizeSel) {
pageSizeSel.addEventListener('change', function () {
adminState.pageSize = parseInt(pageSizeSel.value) || 20;
adminState.page = 1;
loadTableData();
});
}
// 批量操作按钮
var btnBatchOffline = $('#za-btn-batch-offline');
var btnBatchDelete = $('#za-btn-batch-delete');
var btnBatchExport = $('#za-btn-batch-export');
if (btnBatchOffline) btnBatchOffline.addEventListener('click', function () { batchAction('offline'); });
if (btnBatchDelete) btnBatchDelete.addEventListener('click', function () { batchAction('delete'); });
if (btnBatchExport) btnBatchExport.addEventListener('click', function () { exportSelected(); });
// 新增按钮
var btnNew = $('#za-btn-new');
if (btnNew) btnNew.addEventListener('click', function () { openEditForm(null); });
// 加载分类选项
loadFilterOptions();
loadTableData();
}
async function loadFilterOptions() {
var cats = await apiGet('/categories');
var catFilter = $('#za-filter-category');
if (catFilter && cats.ok && cats.data) {
cats.data.forEach(function (c) {
var opt = document.createElement('option');
opt.value = c.name; opt.textContent = c.name;
catFilter.appendChild(opt);
});
}
}
document.addEventListener('DOMContentLoaded', init);
})();