Files
Toolbox/public/tools/zhizi_notes/admin.js
T
yangxiangyuan 421a8aa1f7 feat: 新增知资札记工具及相关功能
1. 新增知资札记完整站点:包含首页、详情页、管理后台
2. 添加站点图标、鉴权配置与后端API鉴权逻辑
3. 在市场页面新增知资札记入口跳转
4. 新增经典乡村音乐播放列表文件
5. 临时添加养生谚语批量导入脚本
2026-07-19 20:45:41 +08:00

696 lines
26 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.
/**
* 知资札记 - 后台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);
})();