feat: 新增知资札记工具及相关功能
1. 新增知资札记完整站点:包含首页、详情页、管理后台 2. 添加站点图标、鉴权配置与后端API鉴权逻辑 3. 在市场页面新增知资札记入口跳转 4. 新增经典乡村音乐播放列表文件 5. 临时添加养生谚语批量导入脚本
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* 养生谚语批量插入日历提醒库
|
||||
* 用法:在 calendar_reminder 页面的浏览器控制台粘贴执行
|
||||
* 规则:61条谚语,每天1条,循环推送,截至2027-03-31
|
||||
*/
|
||||
(function () {
|
||||
const proverbs = [
|
||||
{ text: "早不酒,晚不茶", note: "早晨饮酒伤胃,晚上饮茶影响睡眠。" },
|
||||
{ text: "睡前泡脚,胜吃补药", note: "泡脚促进血液循环,帮助入睡,比吃补品还有效。" },
|
||||
{ text: "春捂秋冻,无病无痛", note: "春天别急着减衣,秋天别急着加衣,让身体适应季节变化。" },
|
||||
{ text: "五谷加大枣,胜吃灵芝草", note: "粗粮配大枣,营养全面,比名贵药材更养人。" },
|
||||
{ text: "头要凉,脚要暖,肚子要不满", note: "头部宜清凉,脚部宜保暖,吃饭七分饱最健康。" },
|
||||
{ text: "春三月,早卧早起;夏三月,夜卧早起秋三月,早卧早起;冬三月,早卧晚起", note: "顺应四季作息规律,春夏养阳、秋冬养阴。" },
|
||||
{ text: "出汗不迎风,跑步莫凹胸", note: "出汗后避风防感冒,跑步挺胸护心肺。" },
|
||||
{ text: "大蒜是个宝,常吃身体好", note: "大蒜杀菌消炎,增强免疫力。" },
|
||||
{ text: "寒从脚上起,病从口中入", note: "脚部受凉易生病,饮食不洁是百病之源。" },
|
||||
{ text: "三分医,七分养,十分防", note: "治病三分靠医,七分靠养,十分靠预防。" },
|
||||
{ text: "中午睡眼好,犹如捡个宝", note: "午间小憩恢复精力,胜过任何补品。" },
|
||||
{ text: "汗水没干,冷水莫沾", note: "运动出汗后毛孔张开,喝冷水易伤脾胃。" },
|
||||
{ text: "三天不吃青,两眼冒金星", note: "不吃蔬菜会缺乏维生素,导致头晕眼花。" },
|
||||
{ text: "吃米带点糖,营养又健康", note: "米饭中加少许糖,补充能量又开胃。" },
|
||||
{ text: "若要人不失眠,煮粥加白莲", note: "白莲(莲子)安神助眠,煮粥食用效果佳。" },
|
||||
{ text: "一日三笑,人生难老", note: "乐观爱笑的人心态年轻,衰老更慢。" },
|
||||
{ text: "粗粮养人,粗活益身", note: "吃粗粮、干粗活,身体更结实。" },
|
||||
{ text: "臭鱼烂虾,索命冤家", note: "不新鲜的鱼虾含有毒素,吃了伤身。" },
|
||||
{ text: "夏天一碗绿豆汤,解毒去暑赛仙方", note: "绿豆汤清热解毒,是夏日最佳饮品。" },
|
||||
{ text: "三天吃一只羊,不如洗脚再上床", note: "睡前泡脚比吃补品更能养生。" },
|
||||
{ text: "食服常温一体皆春", note: "饮食温度适宜,身体四季如春。" },
|
||||
{ text: "吃药不忌嘴,跑断医生腿", note: "服药期间不忌口,药效大打折扣。" },
|
||||
{ text: "宁可食无肉,不可饭无汤", note: "饭前喝汤养胃,比吃肉更重要。" },
|
||||
{ text: "汗水未干,冷水莫沾", note: "与第12条同义,强调运动后忌冷水。" },
|
||||
{ text: "吃面多喝汤,胜似开药方", note: "面汤助消化,养胃健脾。" },
|
||||
{ text: "养生在勤,养心在静", note: "身体要勤运动,心灵要静修养。" },
|
||||
{ text: "核桃山中宝,补肾又健脑", note: "核桃富含不饱和脂肪酸,补脑益肾。" },
|
||||
{ text: "喝了空腹茶,疾病身上爬", note: "空腹喝茶伤胃,容易引起胃部不适。" },
|
||||
{ text: "多喝凉白开,健康自然来", note: "白开水是最好的饮料,促进新陈代谢。" },
|
||||
{ text: "喝茶不洗杯,阎王把命催", note: "茶杯不清洗会滋生细菌,危害健康。" },
|
||||
{ text: "多吃咸盐,少活十年", note: "高盐饮食导致高血压,缩短寿命。" },
|
||||
{ text: "尽量少喝酒,病魔绕道走", note: "适量饮酒或戒酒,远离肝病和心脑血管疾病。" },
|
||||
{ text: "晚饭少吃口,活到九十九", note: "晚餐七分饱,减轻肠胃负担,延年益寿。" },
|
||||
{ text: "一日三脑,不老也老", note: "过度用脑不休息,加速衰老。应劳逸结合。" },
|
||||
{ text: "萝卜出了地,郎中没生意", note: "萝卜消食化滞,常吃萝卜少生病。" },
|
||||
{ text: "饱不洗头,饿不洗澡", note: "吃饱洗头影响消化,空腹洗澡易头晕。" },
|
||||
{ text: "夏不睡石,秋不睡板。春不露脐,冬不蒙头", note: "夏天别睡石头地,秋天别睡硬板;春天护肚脐,冬天别蒙头睡。" },
|
||||
{ text: "宁可锅中存放,不让肚子饱胀", note: "饭菜宁可留着下顿吃,也不要一次吃撑。" },
|
||||
{ text: "饱食即卧,乃生百病", note: "吃饱就躺下,食物不消化,百病丛生。" },
|
||||
{ text: "早起不在鸡鸣前,晚起不在日出后", note: "作息顺应自然,不早于鸡鸣,不晚于日出。" },
|
||||
{ text: "春夏养阳,秋冬养阴", note: "中医养生核心:春夏补阳气,秋冬养阴精。" },
|
||||
{ text: "宁可无肉,不可无豆", note: "豆类富含植物蛋白,比肉类更健康。" },
|
||||
{ text: "一日两苹果,毛病绕道过", note: "苹果富含维生素和纤维,每天两个保健康。" },
|
||||
{ text: "冠心病,吃银杏", note: "银杏叶提取物有助于改善心血管循环。" },
|
||||
{ text: "吃鲜橙,防卒中", note: "橙子富含维C和钾,有助于预防中风。" },
|
||||
{ text: "吃西柚,防血稠", note: "西柚(葡萄柚)有助于降低血液黏稠度。" },
|
||||
{ text: "蘑菇餐,防血栓", note: "蘑菇含多糖体,有助于预防血栓形成。" },
|
||||
{ text: "日出后,晨练宜,空气新鲜利身体", note: "太阳出来后再晨练,避开清晨浊气。" },
|
||||
{ text: "齿常叩,舌常转,生津开胃齿固坚", note: "叩齿转舌是传统养生法,促进唾液分泌,坚固牙齿。" },
|
||||
{ text: "年轻不养生,老来养医生", note: "年轻时不注重养生,老了只能靠医生。" },
|
||||
{ text: "睡前洗脚,胜吃补药", note: "与第2条同义,强调睡前泡脚的重要性。" },
|
||||
{ text: "常饮菊花茶,老来不眼花", note: "菊花清肝明目,长期饮用保护视力。" },
|
||||
{ text: "百步走,活到九十九", note: "饭后散步百步,助消化、延年益寿。" },
|
||||
{ text: "晨吃三片姜,如喝人参汤", note: "生姜温胃散寒,早晨吃姜胜过补品。" },
|
||||
{ text: "屋有七叶一枝花,毒蛇绕着不进家", note: "七叶一枝花(重楼)是草药,也有驱蛇之效。" },
|
||||
{ text: "生瓜梨枣,多吃不好", note: "生冷瓜果吃多伤脾胃,宜适量。" },
|
||||
{ text: "春不露肚脐,冬不遮过头", note: "春天护好肚脐防受凉,冬天别戴太厚的帽子捂过头。" },
|
||||
{ text: "睡觉不开灯,早起头不晕", note: "开灯睡觉影响睡眠质量,导致晨起头晕。" },
|
||||
{ text: "女子三日不断藕,男子三日不断姜", note: "藕养血适合女性,姜温阳适合男性。" },
|
||||
{ text: "烟熏火燎,不吃为好", note: "烧烤烟熏食物含致癌物,应尽量少吃。" },
|
||||
{ text: "若要百病不生,常带饥饿三分", note: "吃饭留三分饥饿感,不过饱,是养生最高境界。" }
|
||||
];
|
||||
|
||||
const startDate = new Date('2026-07-17T00:00:00.000Z'); // 北京时间 08:00
|
||||
const endDate = new Date('2027-03-31T00:00:00.000Z');
|
||||
const title = "每日一念,养生谚语";
|
||||
|
||||
const totalDays = Math.floor((endDate - startDate) / 86400000) + 1;
|
||||
console.log("共生成 " + totalDays + " 条记录,循环 " + Math.floor(totalDays / 61) + " 轮余 " + (totalDays % 61) + " 条");
|
||||
|
||||
let count = 0;
|
||||
const promises = [];
|
||||
|
||||
for (let d = new Date(startDate); d <= endDate; d.setUTCDate(d.getUTCDate() + 1)) {
|
||||
const dayIndex = Math.floor((d - startDate) / 86400000);
|
||||
const p = proverbs[dayIndex % 61];
|
||||
const content = p.text + "\n======================\n" + p.note;
|
||||
|
||||
// end: 每天北京时间 17:00 = UTC 09:00
|
||||
const endD = new Date(d);
|
||||
endD.setUTCHours(9, 0, 0, 0);
|
||||
|
||||
const ev = Models.createEvent({
|
||||
title: title,
|
||||
content: content,
|
||||
start: d.toISOString(),
|
||||
end: endD.toISOString(),
|
||||
allDay: false,
|
||||
remindAt: d.toISOString()
|
||||
});
|
||||
|
||||
promises.push(DB.addEvent(ev));
|
||||
count++;
|
||||
}
|
||||
|
||||
Promise.all(promises).then(function () {
|
||||
console.log("插入完成,共 " + count + " 条养生谚语记录");
|
||||
}).catch(function (err) {
|
||||
console.error("插入失败:", err);
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* 养生谚语批量插入日历提醒服务器 SQLite 数据库
|
||||
* 规则:61条谚语,每天1条,循环推送,截至2027-03-31
|
||||
* 执行:node dev_test_scripts/debug/insert_health_proverbs_server.js
|
||||
*/
|
||||
const Database = require('better-sqlite3');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const DB_PATH = path.join(process.cwd(), 'data', 'calendar_reminder.db');
|
||||
const db = new Database(DB_PATH);
|
||||
db.pragma('journal_mode = WAL');
|
||||
|
||||
const proverbs = [
|
||||
{ text: "早不酒,晚不茶", note: "早晨饮酒伤胃,晚上饮茶影响睡眠。" },
|
||||
{ text: "睡前泡脚,胜吃补药", note: "泡脚促进血液循环,帮助入睡,比吃补品还有效。" },
|
||||
{ text: "春捂秋冻,无病无痛", note: "春天别急着减衣,秋天别急着加衣,让身体适应季节变化。" },
|
||||
{ text: "五谷加大枣,胜吃灵芝草", note: "粗粮配大枣,营养全面,比名贵药材更养人。" },
|
||||
{ text: "头要凉,脚要暖,肚子要不满", note: "头部宜清凉,脚部宜保暖,吃饭七分饱最健康。" },
|
||||
{ text: "春三月,早卧早起;夏三月,夜卧早起秋三月,早卧早起;冬三月,早卧晚起", note: "顺应四季作息规律,春夏养阳、秋冬养阴。" },
|
||||
{ text: "出汗不迎风,跑步莫凹胸", note: "出汗后避风防感冒,跑步挺胸护心肺。" },
|
||||
{ text: "大蒜是个宝,常吃身体好", note: "大蒜杀菌消炎,增强免疫力。" },
|
||||
{ text: "寒从脚上起,病从口中入", note: "脚部受凉易生病,饮食不洁是百病之源。" },
|
||||
{ text: "三分医,七分养,十分防", note: "治病三分靠医,七分靠养,十分靠预防。" },
|
||||
{ text: "中午睡眼好,犹如捡个宝", note: "午间小憩恢复精力,胜过任何补品。" },
|
||||
{ text: "汗水没干,冷水莫沾", note: "运动出汗后毛孔张开,喝冷水易伤脾胃。" },
|
||||
{ text: "三天不吃青,两眼冒金星", note: "不吃蔬菜会缺乏维生素,导致头晕眼花。" },
|
||||
{ text: "吃米带点糖,营养又健康", note: "米饭中加少许糖,补充能量又开胃。" },
|
||||
{ text: "若要人不失眠,煮粥加白莲", note: "白莲(莲子)安神助眠,煮粥食用效果佳。" },
|
||||
{ text: "一日三笑,人生难老", note: "乐观爱笑的人心态年轻,衰老更慢。" },
|
||||
{ text: "粗粮养人,粗活益身", note: "吃粗粮、干粗活,身体更结实。" },
|
||||
{ text: "臭鱼烂虾,索命冤家", note: "不新鲜的鱼虾含有毒素,吃了伤身。" },
|
||||
{ text: "夏天一碗绿豆汤,解毒去暑赛仙方", note: "绿豆汤清热解毒,是夏日最佳饮品。" },
|
||||
{ text: "三天吃一只羊,不如洗脚再上床", note: "睡前泡脚比吃补品更能养生。" },
|
||||
{ text: "食服常温一体皆春", note: "饮食温度适宜,身体四季如春。" },
|
||||
{ text: "吃药不忌嘴,跑断医生腿", note: "服药期间不忌口,药效大打折扣。" },
|
||||
{ text: "宁可食无肉,不可饭无汤", note: "饭前喝汤养胃,比吃肉更重要。" },
|
||||
{ text: "汗水未干,冷水莫沾", note: "与第12条同义,强调运动后忌冷水。" },
|
||||
{ text: "吃面多喝汤,胜似开药方", note: "面汤助消化,养胃健脾。" },
|
||||
{ text: "养生在勤,养心在静", note: "身体要勤运动,心灵要静修养。" },
|
||||
{ text: "核桃山中宝,补肾又健脑", note: "核桃富含不饱和脂肪酸,补脑益肾。" },
|
||||
{ text: "喝了空腹茶,疾病身上爬", note: "空腹喝茶伤胃,容易引起胃部不适。" },
|
||||
{ text: "多喝凉白开,健康自然来", note: "白开水是最好的饮料,促进新陈代谢。" },
|
||||
{ text: "喝茶不洗杯,阎王把命催", note: "茶杯不清洗会滋生细菌,危害健康。" },
|
||||
{ text: "多吃咸盐,少活十年", note: "高盐饮食导致高血压,缩短寿命。" },
|
||||
{ text: "尽量少喝酒,病魔绕道走", note: "适量饮酒或戒酒,远离肝病和心脑血管疾病。" },
|
||||
{ text: "晚饭少吃口,活到九十九", note: "晚餐七分饱,减轻肠胃负担,延年益寿。" },
|
||||
{ text: "一日三脑,不老也老", note: "过度用脑不休息,加速衰老。应劳逸结合。" },
|
||||
{ text: "萝卜出了地,郎中没生意", note: "萝卜消食化滞,常吃萝卜少生病。" },
|
||||
{ text: "饱不洗头,饿不洗澡", note: "吃饱洗头影响消化,空腹洗澡易头晕。" },
|
||||
{ text: "夏不睡石,秋不睡板。春不露脐,冬不蒙头", note: "夏天别睡石头地,秋天别睡硬板;春天护肚脐,冬天别蒙头睡。" },
|
||||
{ text: "宁可锅中存放,不让肚子饱胀", note: "饭菜宁可留着下顿吃,也不要一次吃撑。" },
|
||||
{ text: "饱食即卧,乃生百病", note: "吃饱就躺下,食物不消化,百病丛生。" },
|
||||
{ text: "早起不在鸡鸣前,晚起不在日出后", note: "作息顺应自然,不早于鸡鸣,不晚于日出。" },
|
||||
{ text: "春夏养阳,秋冬养阴", note: "中医养生核心:春夏补阳气,秋冬养阴精。" },
|
||||
{ text: "宁可无肉,不可无豆", note: "豆类富含植物蛋白,比肉类更健康。" },
|
||||
{ text: "一日两苹果,毛病绕道过", note: "苹果富含维生素和纤维,每天两个保健康。" },
|
||||
{ text: "冠心病,吃银杏", note: "银杏叶提取物有助于改善心血管循环。" },
|
||||
{ text: "吃鲜橙,防卒中", note: "橙子富含维C和钾,有助于预防中风。" },
|
||||
{ text: "吃西柚,防血稠", note: "西柚(葡萄柚)有助于降低血液黏稠度。" },
|
||||
{ text: "蘑菇餐,防血栓", note: "蘑菇含多糖体,有助于预防血栓形成。" },
|
||||
{ text: "日出后,晨练宜,空气新鲜利身体", note: "太阳出来后再晨练,避开清晨浊气。" },
|
||||
{ text: "齿常叩,舌常转,生津开胃齿固坚", note: "叩齿转舌是传统养生法,促进唾液分泌,坚固牙齿。" },
|
||||
{ text: "年轻不养生,老来养医生", note: "年轻时不注重养生,老了只能靠医生。" },
|
||||
{ text: "睡前洗脚,胜吃补药", note: "与第2条同义,强调睡前泡脚的重要性。" },
|
||||
{ text: "常饮菊花茶,老来不眼花", note: "菊花清肝明目,长期饮用保护视力。" },
|
||||
{ text: "百步走,活到九十九", note: "饭后散步百步,助消化、延年益寿。" },
|
||||
{ text: "晨吃三片姜,如喝人参汤", note: "生姜温胃散寒,早晨吃姜胜过补品。" },
|
||||
{ text: "屋有七叶一枝花,毒蛇绕着不进家", note: "七叶一枝花(重楼)是草药,也有驱蛇之效。" },
|
||||
{ text: "生瓜梨枣,多吃不好", note: "生冷瓜果吃多伤脾胃,宜适量。" },
|
||||
{ text: "春不露肚脐,冬不遮过头", note: "春天护好肚脐防受凉,冬天别戴太厚的帽子捂过头。" },
|
||||
{ text: "睡觉不开灯,早起头不晕", note: "开灯睡觉影响睡眠质量,导致晨起头晕。" },
|
||||
{ text: "女子三日不断藕,男子三日不断姜", note: "藕养血适合女性,姜温阳适合男性。" },
|
||||
{ text: "烟熏火燎,不吃为好", note: "烧烤烟熏食物含致癌物,应尽量少吃。" },
|
||||
{ text: "若要百病不生,常带饥饿三分", note: "吃饭留三分饥饿感,不过饱,是养生最高境界。" }
|
||||
];
|
||||
|
||||
const title = "每日一念,养生谚语";
|
||||
const startDate = new Date('2026-07-17T00:00:00.000Z'); // 北京时间 08:00 = UTC 00:00
|
||||
const endDate = new Date('2027-03-31T00:00:00.000Z');
|
||||
|
||||
const totalDays = Math.floor((endDate - startDate) / 86400000) + 1;
|
||||
console.log("共生成 " + totalDays + " 条记录,循环 " + Math.floor(totalDays / 61) + " 轮余 " + (totalDays % 61) + " 条");
|
||||
|
||||
// 检查是否已有养生谚语记录
|
||||
const existingCount = db.prepare("SELECT COUNT(*) as cnt FROM events WHERE title = ?").get(title).cnt;
|
||||
if (existingCount > 0) {
|
||||
console.log("数据库中已有 " + existingCount + " 条养生谚语记录,先删除旧数据...");
|
||||
db.prepare("DELETE FROM events WHERE title = ?").run(title);
|
||||
}
|
||||
|
||||
const stmt = db.prepare(`INSERT INTO events
|
||||
(id, title, content, start, end, allDay, remindAt, recurrence, createdAt, updatedAt, completed, deleted)
|
||||
VALUES (@id, @title, @content, @start, @end, @allDay, @remindAt, @recurrence, @createdAt, @updatedAt, @completed, @deleted)`);
|
||||
|
||||
const insertMany = db.transaction((records) => {
|
||||
for (const r of records) stmt.run(r);
|
||||
});
|
||||
|
||||
const records = [];
|
||||
let count = 0;
|
||||
|
||||
for (let d = new Date(startDate); d <= endDate; d.setUTCDate(d.getUTCDate() + 1)) {
|
||||
const dayIndex = Math.floor((d - startDate) / 86400000);
|
||||
const p = proverbs[dayIndex % 61];
|
||||
const content = p.text + "\n======================\n" + p.note;
|
||||
|
||||
// end: 每天北京时间 17:00 = UTC 09:00
|
||||
const endD = new Date(d);
|
||||
endD.setUTCHours(9, 0, 0, 0);
|
||||
|
||||
const now = new Date().toISOString();
|
||||
records.push({
|
||||
id: crypto.randomUUID(),
|
||||
title: title,
|
||||
content: content,
|
||||
start: d.toISOString(),
|
||||
end: endD.toISOString(),
|
||||
allDay: 0,
|
||||
remindAt: d.toISOString(),
|
||||
recurrence: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
completed: 0,
|
||||
deleted: 0
|
||||
});
|
||||
count++;
|
||||
}
|
||||
|
||||
insertMany(records);
|
||||
console.log("插入完成,共 " + count + " 条养生谚语记录到服务器数据库");
|
||||
|
||||
// 验证
|
||||
const verifyCount = db.prepare("SELECT COUNT(*) as cnt FROM events WHERE title = ?").get(title).cnt;
|
||||
console.log("验证:数据库中现有 " + verifyCount + " 条养生谚语记录");
|
||||
|
||||
const todayRecord = db.prepare("SELECT * FROM events WHERE title = ? AND start LIKE ?").get(title, '2026-07-17%');
|
||||
if (todayRecord) {
|
||||
console.log("今天(07-17)的记录:", todayRecord.title, "-", todayRecord.content.substring(0, 40));
|
||||
} else {
|
||||
console.log("警告:未找到今天的记录!");
|
||||
}
|
||||
|
||||
db.close();
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="6" fill="#162A46"/>
|
||||
<text x="16" y="22" text-anchor="middle" font-size="18" font-family="sans-serif" fill="white" font-weight="bold">札</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 271 B |
@@ -25,6 +25,7 @@
|
||||
<button id="m-tab-weekly" class="subtab">指数周报</button>
|
||||
<button id="m-tab-news" class="subtab">全球新闻</button>
|
||||
<button id="m-tab-wisdom" class="subtab">理论与实践</button>
|
||||
<button id="m-tab-zhizi" class="subtab">知资札记</button>
|
||||
<button id="m-tab-tools" class="subtab">工具箱</button>
|
||||
<div id="m-status" class="status status-inline" style="display:none"></div>
|
||||
</div>
|
||||
@@ -125,9 +126,7 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="m-panel-wisdom" class="panel" style="display:none">
|
||||
<iframe id="wisdom-frame" src="/tools/markets/wallstreet_wisdom.html?v=10" style="width:100%;height:calc(100vh - 140px);border:none;"></iframe>
|
||||
</section>
|
||||
<!-- 理论与实践:点击tab直接新窗口打开 -->
|
||||
|
||||
<section id="m-panel-tools" class="panel" style="display:none">
|
||||
<div class="tools-container">
|
||||
|
||||
@@ -1710,6 +1710,7 @@ const switchPanel = which => {
|
||||
const wkTab = el('m-tab-weekly')
|
||||
const newsTab = el('m-tab-news')
|
||||
const wisdomTab = el('m-tab-wisdom')
|
||||
const zhiziTab = el('m-tab-zhizi')
|
||||
const toolsTab = el('m-tab-tools')
|
||||
const rt = el('m-panel-realtime')
|
||||
const ar = el('m-panel-archive')
|
||||
@@ -1720,36 +1721,35 @@ const switchPanel = which => {
|
||||
const tools = el('m-panel-tools')
|
||||
if (!rtTab || !arTab || !calTab || !wkTab || !rt || !ar || !cal || !wk) return
|
||||
if (which === 'archive') {
|
||||
rtTab.classList.remove('active'); arTab.classList.add('active'); calTab.classList.remove('active'); wkTab.classList.remove('active'); if (newsTab) newsTab.classList.remove('active'); if (wisdomTab) wisdomTab.classList.remove('active'); if (toolsTab) toolsTab.classList.remove('active')
|
||||
rtTab.classList.remove('active'); arTab.classList.add('active'); calTab.classList.remove('active'); wkTab.classList.remove('active'); if (newsTab) newsTab.classList.remove('active'); if (wisdomTab) wisdomTab.classList.remove('active'); if (zhiziTab) zhiziTab.classList.remove('active'); if (toolsTab) toolsTab.classList.remove('active')
|
||||
rt.style.display = 'none'; ar.style.display = ''; cal.style.display = 'none'; wk.style.display = 'none'; if (news) news.style.display = 'none'; if (wisdom) wisdom.style.display = 'none'; if (tools) tools.style.display = 'none'
|
||||
loadArchive()
|
||||
} else if (which === 'calendar') {
|
||||
rtTab.classList.remove('active'); arTab.classList.remove('active'); calTab.classList.add('active'); wkTab.classList.remove('active'); if (newsTab) newsTab.classList.remove('active'); if (wisdomTab) wisdomTab.classList.remove('active'); if (toolsTab) toolsTab.classList.remove('active')
|
||||
rtTab.classList.remove('active'); arTab.classList.remove('active'); calTab.classList.add('active'); wkTab.classList.remove('active'); if (newsTab) newsTab.classList.remove('active'); if (wisdomTab) wisdomTab.classList.remove('active'); if (zhiziTab) zhiziTab.classList.remove('active'); if (toolsTab) toolsTab.classList.remove('active')
|
||||
rt.style.display = 'none'; ar.style.display = 'none'; cal.style.display = ''; wk.style.display = 'none'; if (news) news.style.display = 'none'; if (wisdom) wisdom.style.display = 'none'; if (tools) tools.style.display = 'none'
|
||||
if (!calendarState.month || !Array.isArray(calendarState.days) || calendarState.days.length === 0) loadSecurityCalendar(false)
|
||||
} else if (which === 'weekly') {
|
||||
rtTab.classList.remove('active'); arTab.classList.remove('active'); calTab.classList.remove('active'); wkTab.classList.add('active'); if (newsTab) newsTab.classList.remove('active'); if (wisdomTab) wisdomTab.classList.remove('active'); if (toolsTab) toolsTab.classList.remove('active')
|
||||
rtTab.classList.remove('active'); arTab.classList.remove('active'); calTab.classList.remove('active'); wkTab.classList.add('active'); if (newsTab) newsTab.classList.remove('active'); if (wisdomTab) wisdomTab.classList.remove('active'); if (zhiziTab) zhiziTab.classList.remove('active'); if (toolsTab) toolsTab.classList.remove('active')
|
||||
rt.style.display = 'none'; ar.style.display = 'none'; cal.style.display = 'none'; wk.style.display = ''; if (news) news.style.display = 'none'; if (wisdom) wisdom.style.display = 'none'; if (tools) tools.style.display = 'none'
|
||||
if (!weeklyState.weekStart || !Array.isArray(weeklyState.rows) || weeklyState.rows.length === 0) {
|
||||
setWeeklyIdleState('请选择周后点击“加载”读取历史,或点“刷新并补存”主动更新。', { clearData: false })
|
||||
}
|
||||
} else if (which === 'news') {
|
||||
rtTab.classList.remove('active'); arTab.classList.remove('active'); calTab.classList.remove('active'); wkTab.classList.remove('active'); if (wisdomTab) wisdomTab.classList.remove('active'); if (toolsTab) toolsTab.classList.remove('active')
|
||||
rtTab.classList.remove('active'); arTab.classList.remove('active'); calTab.classList.remove('active'); wkTab.classList.remove('active'); if (wisdomTab) wisdomTab.classList.remove('active'); if (zhiziTab) zhiziTab.classList.remove('active'); if (toolsTab) toolsTab.classList.remove('active')
|
||||
if (newsTab) newsTab.classList.add('active')
|
||||
rt.style.display = 'none'; ar.style.display = 'none'; cal.style.display = 'none'; wk.style.display = 'none'; if (news) news.style.display = ''; if (wisdom) wisdom.style.display = 'none'; if (tools) tools.style.display = 'none'
|
||||
loadGlobalNews(false)
|
||||
} else if (which === 'wisdom') {
|
||||
rtTab.classList.remove('active'); arTab.classList.remove('active'); calTab.classList.remove('active'); wkTab.classList.remove('active')
|
||||
if (newsTab) newsTab.classList.remove('active')
|
||||
if (wisdomTab) wisdomTab.classList.add('active')
|
||||
rt.style.display = 'none'; ar.style.display = 'none'; cal.style.display = 'none'; wk.style.display = 'none'
|
||||
if (news) news.style.display = 'none'
|
||||
if (wisdom) wisdom.style.display = ''
|
||||
if (tools) tools.style.display = 'none'
|
||||
window.open('https://traesite.umersoft.com:8975/tools/markets/wallstreet_wisdom.html', '_blank')
|
||||
return
|
||||
} else if (which === 'zhizi') {
|
||||
window.open('https://traesite.umersoft.com:8975/tools/zhizi_notes/index.html', '_blank')
|
||||
return
|
||||
} else if (which === 'tools') {
|
||||
rtTab.classList.remove('active'); arTab.classList.remove('active'); calTab.classList.remove('active'); wkTab.classList.remove('active')
|
||||
if (newsTab) newsTab.classList.remove('active')
|
||||
if (wisdomTab) wisdomTab.classList.remove('active')
|
||||
if (zhiziTab) zhiziTab.classList.remove('active')
|
||||
if (toolsTab) toolsTab.classList.add('active')
|
||||
rt.style.display = 'none'; ar.style.display = 'none'; cal.style.display = 'none'; wk.style.display = 'none'
|
||||
if (news) news.style.display = 'none'
|
||||
@@ -1758,6 +1758,7 @@ const switchPanel = which => {
|
||||
} else {
|
||||
rtTab.classList.add('active'); arTab.classList.remove('active'); calTab.classList.remove('active'); wkTab.classList.remove('active'); if (newsTab) newsTab.classList.remove('active')
|
||||
if (wisdomTab) wisdomTab.classList.remove('active')
|
||||
if (zhiziTab) zhiziTab.classList.remove('active')
|
||||
if (toolsTab) toolsTab.classList.remove('active')
|
||||
rt.style.display = ''; ar.style.display = 'none'; cal.style.display = 'none'; wk.style.display = 'none'; if (news) news.style.display = 'none'
|
||||
if (wisdom) wisdom.style.display = 'none'
|
||||
@@ -1790,6 +1791,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const stWk = el('m-tab-weekly'); if (stWk) stWk.addEventListener('click', () => switchPanel('weekly'))
|
||||
const stNews = el('m-tab-news'); if (stNews) stNews.addEventListener('click', () => switchPanel('news'))
|
||||
const stWisdom = el('m-tab-wisdom'); if (stWisdom) stWisdom.addEventListener('click', () => switchPanel('wisdom'))
|
||||
const stZhizi = el('m-tab-zhizi'); if (stZhizi) stZhizi.addEventListener('click', () => switchPanel('zhizi'))
|
||||
const stTools = el('m-tab-tools'); if (stTools) stTools.addEventListener('click', () => switchPanel('tools'))
|
||||
const cbp = el('cal-btn-prev'); if (cbp) cbp.addEventListener('click', () => navigateMonthCalendar(-1))
|
||||
const cbc = el('cal-btn-current'); if (cbc) cbc.addEventListener('click', () => { const i = el('cal-month'); if (i) i.value = todayMonthLocal(); loadSecurityCalendar(false) })
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
[
|
||||
{
|
||||
"title": "He Stopped Loving Her Today",
|
||||
"artist": "George Jones",
|
||||
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/classic-country-music/mp3/01%20-%20He%20Stopped%20Loving%20Her%20Today%20-%20George%20Jones.mp3",
|
||||
"cover": ""
|
||||
},
|
||||
{
|
||||
"title": "I Walk the Line",
|
||||
"artist": "Johnny Cash",
|
||||
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/classic-country-music/mp3/02%20-%20I%20Walk%20the%20Line%20-%20Johnny%20Cash.mp3",
|
||||
"cover": ""
|
||||
},
|
||||
{
|
||||
"title": "Coal Miner's Daughter",
|
||||
"artist": "Loretta Lynn",
|
||||
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/classic-country-music/mp3/04%20-%20Coal%20Miner%27s%20Daughter%20-%20Loretta%20Lynn.mp3",
|
||||
"cover": ""
|
||||
},
|
||||
{
|
||||
"title": "Your Cheatin' Heart",
|
||||
"artist": "Hank Williams",
|
||||
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/classic-country-music/mp3/05%20-%20Your%20Cheatin%27%20Heart%20-%20Hank%20Williams.mp3",
|
||||
"cover": ""
|
||||
},
|
||||
{
|
||||
"title": "The Dance",
|
||||
"artist": "Garth Brooks",
|
||||
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/classic-country-music/mp3/07%20-%20The%20Dance%20-%20Garth%20Brooks.mp3",
|
||||
"cover": ""
|
||||
},
|
||||
{
|
||||
"title": "Friends in Low Places",
|
||||
"artist": "Garth Brooks",
|
||||
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/classic-country-music/mp3/09%20-%20Friends%20in%20Low%20Places%20-%20Garth%20Brooks.mp3",
|
||||
"cover": ""
|
||||
},
|
||||
{
|
||||
"title": "Okie from Muskogee",
|
||||
"artist": "Merle Haggard",
|
||||
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/classic-country-music/mp3/13%20-%20Okie%20from%20Muskogee%20-%20Merle%20Haggard.mp3",
|
||||
"cover": ""
|
||||
},
|
||||
{
|
||||
"title": "On the Road Again",
|
||||
"artist": "Willie Nelson",
|
||||
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/classic-country-music/mp3/14%20-%20On%20the%20Road%20Again%20-%20Willie%20Nelson.mp3",
|
||||
"cover": ""
|
||||
},
|
||||
{
|
||||
"title": "The Gambler",
|
||||
"artist": "Kenny Rogers",
|
||||
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/classic-country-music/mp3/16%20-%20The%20Gambler%20-%20Kenny%20Rogers.mp3",
|
||||
"cover": ""
|
||||
},
|
||||
{
|
||||
"title": "I Fall to Pieces",
|
||||
"artist": "Patsy Cline",
|
||||
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/classic-country-music/mp3/17%20-%20I%20Fall%20to%20Pieces%20-%20Patsy%20Cline.mp3",
|
||||
"cover": ""
|
||||
},
|
||||
{
|
||||
"title": "Whiskey River",
|
||||
"artist": "Willie Nelson",
|
||||
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/classic-country-music/mp3/18%20-%20Whiskey%20River%20-%20Willie%20Nelson.mp3",
|
||||
"cover": ""
|
||||
},
|
||||
{
|
||||
"title": "A Boy Named Sue",
|
||||
"artist": "Johnny Cash",
|
||||
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/classic-country-music/mp3/19%20-%20A%20Boy%20Named%20Sue%20-%20Johnny%20Cash.mp3",
|
||||
"cover": ""
|
||||
},
|
||||
{
|
||||
"title": "Lucille",
|
||||
"artist": "Kenny Rogers",
|
||||
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/classic-country-music/mp3/20%20-%20Lucille%20-%20Kenny%20Rogers.mp3",
|
||||
"cover": ""
|
||||
},
|
||||
{
|
||||
"title": "Harper Valley PTA",
|
||||
"artist": "Jeannie C. Riley",
|
||||
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/classic-country-music/mp3/21%20-%20Harper%20Valley%20PTA%20-%20Jeannie%20C.%20Riley.mp3",
|
||||
"cover": ""
|
||||
},
|
||||
{
|
||||
"title": "The Night the Lights Went Out in Georgia",
|
||||
"artist": "Vicki Lawrence",
|
||||
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/classic-country-music/mp3/22%20-%20The%20Night%20the%20Lights%20Went%20Out%20in%20Georgia%20-%20Vicki%20Lawrence.mp3",
|
||||
"cover": ""
|
||||
},
|
||||
{
|
||||
"title": "Thank God I'm a Country Boy",
|
||||
"artist": "John Denver",
|
||||
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/classic-country-music/mp3/25%20-%20Thank%20God%20I%27m%20a%20Country%20Boy%20-%20John%20Denver.mp3",
|
||||
"cover": ""
|
||||
},
|
||||
{
|
||||
"title": "Rocky Top",
|
||||
"artist": "Osborne Brothers",
|
||||
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/classic-country-music/mp3/26%20-%20Rocky%20Top%20-%20Osborne%20Brothers.mp3",
|
||||
"cover": ""
|
||||
},
|
||||
{
|
||||
"title": "Achy Breaky Heart",
|
||||
"artist": "Billy Ray Cyrus",
|
||||
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/classic-country-music/mp3/29%20-%20Achy%20Breaky%20Heart%20-%20Billy%20Ray%20Cyrus.mp3",
|
||||
"cover": ""
|
||||
},
|
||||
{
|
||||
"title": "Amazed",
|
||||
"artist": "Lonestar",
|
||||
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/classic-country-music/mp3/30%20-%20Amazed%20-%20Lonestar.mp3",
|
||||
"cover": ""
|
||||
},
|
||||
{
|
||||
"title": "Breathe",
|
||||
"artist": "Faith Hill",
|
||||
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/classic-country-music/mp3/31%20-%20Breathe%20-%20Faith%20Hill.mp3",
|
||||
"cover": ""
|
||||
},
|
||||
{
|
||||
"title": "Before He Cheats",
|
||||
"artist": "Carrie Underwood",
|
||||
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/classic-country-music/mp3/32%20-%20Before%20He%20Cheats%20-%20Carrie%20Underwood.mp3",
|
||||
"cover": ""
|
||||
},
|
||||
{
|
||||
"title": "Need You Now",
|
||||
"artist": "Lady Antebellum",
|
||||
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/classic-country-music/mp3/33%20-%20Need%20You%20Now%20-%20Lady%20Antebellum.mp3",
|
||||
"cover": ""
|
||||
},
|
||||
{
|
||||
"title": "Body Like a Back Road",
|
||||
"artist": "Sam Hunt",
|
||||
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/classic-country-music/mp3/35%20-%20Body%20Like%20a%20Back%20Road%20-%20Sam%20Hunt.mp3",
|
||||
"cover": ""
|
||||
},
|
||||
{
|
||||
"title": "Meant to Be",
|
||||
"artist": "Bebe Rexha Florida Georgia Line",
|
||||
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/classic-country-music/mp3/36%20-%20Meant%20to%20Be%20-%20Bebe%20Rexha%20Florida%20Georgia%20Line.mp3",
|
||||
"cover": ""
|
||||
},
|
||||
{
|
||||
"title": "Live Like You Were Dying",
|
||||
"artist": "Tim McGraw",
|
||||
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/classic-country-music/mp3/40%20-%20Live%20Like%20You%20Were%20Dying%20-%20Tim%20McGraw.mp3",
|
||||
"cover": ""
|
||||
},
|
||||
{
|
||||
"title": "Chicken Fried",
|
||||
"artist": "Zac Brown Band",
|
||||
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/classic-country-music/mp3/42%20-%20Chicken%20Fried%20-%20Zac%20Brown%20Band.mp3",
|
||||
"cover": ""
|
||||
},
|
||||
{
|
||||
"title": "Five O'Clock Somewhere",
|
||||
"artist": "Alan Jackson Jimmy Buffett",
|
||||
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/classic-country-music/mp3/43%20-%20Five%20O%27Clock%20Somewhere%20-%20Alan%20Jackson%20Jimmy%20Buffett.mp3",
|
||||
"cover": ""
|
||||
},
|
||||
{
|
||||
"title": "Redneck Woman",
|
||||
"artist": "Gretchen Wilson",
|
||||
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/classic-country-music/mp3/44%20-%20Redneck%20Woman%20-%20Gretchen%20Wilson.mp3",
|
||||
"cover": ""
|
||||
},
|
||||
{
|
||||
"title": "Check Yes or No",
|
||||
"artist": "George Strait",
|
||||
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/classic-country-music/mp3/46%20-%20Check%20Yes%20or%20No%20-%20George%20Strait.mp3",
|
||||
"cover": ""
|
||||
},
|
||||
{
|
||||
"title": "I Can Love You Like That",
|
||||
"artist": "John Michael Montgomery",
|
||||
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/classic-country-music/mp3/50%20-%20I%20Can%20Love%20You%20Like%20That%20-%20John%20Michael%20Montgomery.mp3",
|
||||
"cover": ""
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,245 @@
|
||||
/* ═══════════════════════════════════════════════════════════
|
||||
知资札记 - 后台CMS管理样式
|
||||
主色:#162A46 墨蓝 | 删除红:#D94452
|
||||
═══════════════════════════════════════════════════════════ */
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html { font-size: 16px; }
|
||||
body {
|
||||
font-family: 'Microsoft YaHei', 'Source Han Sans CN', -apple-system, sans-serif;
|
||||
color: #333; background: #F5F6FA; line-height: 1.6;
|
||||
}
|
||||
a { text-decoration: none; color: inherit; }
|
||||
button { cursor: pointer; font-family: inherit; border: none; outline: none; }
|
||||
input, select { font-family: inherit; outline: none; }
|
||||
|
||||
/* ── 布局 ── */
|
||||
.za-layout { display: flex; min-height: 100vh; }
|
||||
|
||||
/* ── 左侧菜单 ── */
|
||||
.za-sidebar {
|
||||
width: 220px; min-width: 220px; background: #162A46; color: #fff;
|
||||
padding: 20px 0; position: fixed; top: 0; left: 0; bottom: 0;
|
||||
overflow-y: auto; z-index: 100;
|
||||
}
|
||||
.za-sidebar .sb-logo {
|
||||
font-size: 1.1rem; font-weight: 700; letter-spacing: 2px;
|
||||
padding: 0 20px 20px; border-bottom: 1px solid rgba(255,255,255,0.1);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.za-sidebar .sb-menu { list-style: none; }
|
||||
.za-sidebar .sb-menu li a {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 10px 20px; color: rgba(255,255,255,0.7);
|
||||
font-size: 0.9rem; transition: all 0.2s;
|
||||
}
|
||||
.za-sidebar .sb-menu li a:hover,
|
||||
.za-sidebar .sb-menu li a.active { color: #fff; background: rgba(255,255,255,0.08); }
|
||||
.za-sidebar .sb-menu .sub-menu { padding-left: 16px; }
|
||||
.za-sidebar .sb-menu .sub-menu a { font-size: 0.82rem; padding: 7px 20px; }
|
||||
|
||||
/* ── 主内容区 ── */
|
||||
.za-main { flex: 1; margin-left: 220px; padding: 20px; }
|
||||
|
||||
/* ── 顶部操作栏 ── */
|
||||
.za-toolbar {
|
||||
background: #fff; border-radius: 8px; padding: 16px;
|
||||
margin-bottom: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.06);
|
||||
display: flex; flex-wrap: wrap; align-items: center; gap: 10px;
|
||||
}
|
||||
.za-toolbar .search-box {
|
||||
display: flex; align-items: center; border: 1px solid #E0E4E8;
|
||||
border-radius: 6px; padding: 6px 12px; flex: 1; min-width: 200px;
|
||||
}
|
||||
.za-toolbar .search-box input {
|
||||
border: none; flex: 1; font-size: 0.85rem; background: transparent;
|
||||
}
|
||||
.za-toolbar .search-box button { background: transparent; color: #999; font-size: 0.9rem; }
|
||||
.za-toolbar select {
|
||||
border: 1px solid #E0E4E8; border-radius: 6px;
|
||||
padding: 7px 12px; font-size: 0.85rem; background: #fff;
|
||||
}
|
||||
.za-toolbar .btn {
|
||||
padding: 7px 16px; border-radius: 6px; font-size: 0.85rem;
|
||||
font-weight: 500; transition: all 0.2s;
|
||||
}
|
||||
.za-toolbar .btn-primary { background: #162A46; color: #fff; }
|
||||
.za-toolbar .btn-primary:hover { background: #1E3A5F; }
|
||||
.za-toolbar .btn-danger { background: #D94452; color: #fff; }
|
||||
.za-toolbar .btn-danger:hover { background: #C0392B; }
|
||||
.za-toolbar .btn-outline { background: #fff; color: #162A46; border: 1px solid #162A46; }
|
||||
.za-toolbar .btn-outline:hover { background: #F0F4F8; }
|
||||
.za-toolbar .btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
/* ── 表格 ── */
|
||||
.za-table-wrap {
|
||||
background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.06);
|
||||
overflow: hidden;
|
||||
}
|
||||
.za-table {
|
||||
width: 100%; border-collapse: collapse; font-size: 0.85rem;
|
||||
}
|
||||
.za-table thead { background: #F8FAFB; }
|
||||
.za-table th {
|
||||
padding: 12px 14px; text-align: left; font-weight: 600;
|
||||
color: #666; border-bottom: 2px solid #E0E4E8;
|
||||
white-space: nowrap; font-size: 0.8rem;
|
||||
}
|
||||
.za-table td {
|
||||
padding: 10px 14px; border-bottom: 1px solid #F0F2F5;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.za-table tbody tr:hover { background: #F8FAFB; }
|
||||
.za-table .cell-title {
|
||||
max-width: 240px; white-space: nowrap; overflow: hidden;
|
||||
text-overflow: ellipsis; cursor: default;
|
||||
}
|
||||
.za-table .cell-cover {
|
||||
width: 60px; height: 40px; border-radius: 4px; overflow: hidden;
|
||||
background: #E8EDF2; display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.za-table .cell-cover img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.za-table .cell-cover .text-icon {
|
||||
width: 100%; height: 100%; display: flex; align-items: center; justify-content: center;
|
||||
background: #162A46; color: #fff; font-size: 0.8rem; font-weight: 700;
|
||||
}
|
||||
.za-table .type-badge {
|
||||
display: inline-block; padding: 2px 8px; border-radius: 10px;
|
||||
font-size: 0.72rem; font-weight: 500;
|
||||
}
|
||||
.za-table .type-badge.text { background: #E8F0FE; color: #1E3A5F; }
|
||||
.za-table .type-badge.image { background: #E8F5F0; color: #467974; }
|
||||
.za-table .status-badge {
|
||||
display: inline-block; padding: 2px 10px; border-radius: 10px;
|
||||
font-size: 0.72rem; font-weight: 500;
|
||||
}
|
||||
.za-table .status-badge.normal { background: #E8F5E9; color: #2E7D32; }
|
||||
.za-table .status-badge.offline { background: #FFF3E0; color: #E65100; }
|
||||
.za-table .actions { display: flex; gap: 6px; }
|
||||
.za-table .actions button {
|
||||
padding: 4px 10px; border-radius: 4px; font-size: 0.75rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.za-table .actions .act-preview { background: #E8F0FE; color: #1E3A5F; }
|
||||
.za-table .actions .act-preview:hover { background: #D0E0FC; }
|
||||
.za-table .actions .act-edit { background: #E8F5F0; color: #467974; }
|
||||
.za-table .actions .act-edit:hover { background: #D0EBE0; }
|
||||
.za-table .actions .act-toggle { background: #FFF3E0; color: #E65100; }
|
||||
.za-table .actions .act-toggle:hover { background: #FFE0B2; }
|
||||
.za-table .actions .act-delete { background: #FFEBEE; color: #D94452; }
|
||||
.za-table .actions .act-delete:hover { background: #FFCDD2; }
|
||||
|
||||
/* ── 分页 ── */
|
||||
.za-pagination {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 12px 16px; background: #fff; border-top: 1px solid #F0F2F5;
|
||||
font-size: 0.85rem; color: #666;
|
||||
}
|
||||
.za-pagination .page-btns { display: flex; gap: 4px; align-items: center; }
|
||||
.za-pagination .page-btns button {
|
||||
padding: 5px 10px; border-radius: 4px; font-size: 0.8rem;
|
||||
background: #fff; border: 1px solid #E0E4E8; color: #333;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.za-pagination .page-btns button:hover:not(:disabled) { background: #F0F4F8; }
|
||||
.za-pagination .page-btns button.active { background: #162A46; color: #fff; border-color: #162A46; }
|
||||
.za-pagination .page-btns button:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.za-pagination select { border: 1px solid #E0E4E8; border-radius: 4px; padding: 4px 8px; }
|
||||
|
||||
/* ── 弹窗 ── */
|
||||
.za-modal-overlay {
|
||||
position: fixed; inset: 0; z-index: 5000;
|
||||
background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.za-modal-overlay.hidden { display: none; }
|
||||
.za-modal {
|
||||
background: #fff; border-radius: 12px; padding: 24px;
|
||||
max-width: 460px; width: 90%; box-shadow: 0 8px 32px rgba(0,0,0,0.2);
|
||||
}
|
||||
.za-modal h3 { margin-bottom: 12px; color: #333; font-size: 1.05rem; }
|
||||
.za-modal .modal-body { font-size: 0.85rem; color: #666; margin-bottom: 20px; line-height: 1.6; }
|
||||
.za-modal .modal-actions { display: flex; gap: 10px; justify-content: flex-end; }
|
||||
.za-modal .btn {
|
||||
padding: 8px 20px; border-radius: 6px; font-size: 0.85rem; font-weight: 500;
|
||||
}
|
||||
.za-modal .btn-primary { background: #162A46; color: #fff; }
|
||||
.za-modal .btn-primary:hover { background: #1E3A5F; }
|
||||
.za-modal .btn-danger { background: #D94452; color: #fff; }
|
||||
.za-modal .btn-danger:hover { background: #C0392B; }
|
||||
.za-modal .btn-cancel { background: #F0F2F5; color: #666; }
|
||||
.za-modal .btn-cancel:hover { background: #E0E4E8; }
|
||||
|
||||
/* ── Toast ── */
|
||||
.za-toast {
|
||||
position: fixed; top: 20px; left: 50%; transform: translateX(-50%);
|
||||
z-index: 9999; padding: 10px 24px; border-radius: 8px;
|
||||
font-size: 0.85rem; color: #fff; opacity: 0;
|
||||
transition: opacity 0.3s; pointer-events: none;
|
||||
}
|
||||
.za-toast.show { opacity: 1; }
|
||||
.za-toast.info { background: #3B82F6; }
|
||||
.za-toast.success { background: #10B981; }
|
||||
.za-toast.error { background: #EF4444; }
|
||||
|
||||
/* ── 新增/编辑表单弹窗 ── */
|
||||
.za-modal.form-modal { max-width: 700px; max-height: 85vh; overflow-y: auto; }
|
||||
.za-modal .form-group { margin-bottom: 14px; }
|
||||
.za-modal .form-group label {
|
||||
display: block; font-size: 0.82rem; font-weight: 600;
|
||||
color: #555; margin-bottom: 4px;
|
||||
}
|
||||
.za-modal .form-group input,
|
||||
.za-modal .form-group select,
|
||||
.za-modal .form-group textarea {
|
||||
width: 100%; padding: 8px 12px; border: 1px solid #E0E4E8;
|
||||
border-radius: 6px; font-size: 0.85rem; transition: border-color 0.2s;
|
||||
}
|
||||
.za-modal .form-group input:focus,
|
||||
.za-modal .form-group textarea:focus,
|
||||
.za-modal .form-group select:focus { border-color: #162A46; }
|
||||
.za-modal .form-group textarea { min-height: 200px; resize: vertical; font-family: inherit; }
|
||||
.za-modal .form-group .tag-input {
|
||||
display: flex; flex-wrap: wrap; gap: 4px; align-items: center;
|
||||
border: 1px solid #E0E4E8; border-radius: 6px; padding: 6px;
|
||||
min-height: 38px;
|
||||
}
|
||||
.za-modal .form-group .tag-input .tag-chip {
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
padding: 2px 8px; border-radius: 10px; font-size: 0.75rem;
|
||||
background: #E8F0FE; color: #1E3A5F;
|
||||
}
|
||||
.za-modal .form-group .tag-input .tag-chip .remove-tag {
|
||||
background: transparent; color: #1E3A5F; font-size: 0.8rem; padding: 0; cursor: pointer;
|
||||
}
|
||||
.za-modal .form-group .tag-input input {
|
||||
border: none; flex: 1; min-width: 80px; padding: 4px; font-size: 0.82rem;
|
||||
}
|
||||
|
||||
/* ── 图片上传预览 ── */
|
||||
.za-modal .form-group .upload-area { margin-top: 4px; }
|
||||
.za-modal .form-group .upload-preview {
|
||||
display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px;
|
||||
}
|
||||
.za-modal .form-group .preview-item {
|
||||
position: relative; width: 80px; height: 80px;
|
||||
border-radius: 6px; overflow: hidden; border: 1px solid #E0E4E8;
|
||||
}
|
||||
.za-modal .form-group .preview-item img {
|
||||
width: 100%; height: 100%; object-fit: cover;
|
||||
}
|
||||
.za-modal .form-group .preview-item .preview-remove {
|
||||
position: absolute; top: 2px; right: 2px;
|
||||
width: 18px; height: 18px; border-radius: 50%;
|
||||
background: rgba(0,0,0,0.5); color: #fff;
|
||||
font-size: 0.7rem; display: flex; align-items: center; justify-content: center;
|
||||
padding: 0; line-height: 1;
|
||||
}
|
||||
|
||||
/* ── 响应式 ── */
|
||||
@media (max-width: 900px) {
|
||||
.za-sidebar { display: none; }
|
||||
.za-main { margin-left: 0; }
|
||||
.za-table { font-size: 0.78rem; }
|
||||
.za-table th, .za-table td { padding: 8px 8px; }
|
||||
.za-table .cell-title { max-width: 120px; }
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>知资札记 · 后台管理</title>
|
||||
<link rel="icon" href="/icons/zhizi_notes.svg" type="image/svg+xml" />
|
||||
<link rel="stylesheet" href="/common/common.css" />
|
||||
<link rel="stylesheet" href="/tools/zhizi_notes/admin.css" />
|
||||
</head>
|
||||
<body>
|
||||
<!-- Toast -->
|
||||
<div id="za-toast" class="za-toast"></div>
|
||||
|
||||
<!-- ═══════════════ 布局 ═══════════════ -->
|
||||
<div class="za-layout">
|
||||
<!-- 左侧菜单 -->
|
||||
<aside class="za-sidebar">
|
||||
<div class="sb-logo">知资札记</div>
|
||||
<ul class="sb-menu">
|
||||
<li><a href="#">📊 数据仪表盘</a></li>
|
||||
<li><a href="#" class="active">📄 图文内容管理</a>
|
||||
<ul class="sub-menu">
|
||||
<li><a href="#" class="active">全部存档列表</a></li>
|
||||
<li><a href="#" id="za-menu-new">+ 新增存档</a></li>
|
||||
<li><a href="#">🖼 图片资源库</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><a href="#">🏷 分类标签管理</a></li>
|
||||
<li><a href="#">👤 用户管理</a></li>
|
||||
<li><a href="#">🕷 爬虫任务日志</a></li>
|
||||
<li><a href="#">⚙ 系统设置</a></li>
|
||||
</ul>
|
||||
</aside>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<main class="za-main">
|
||||
<!-- 顶部操作栏 -->
|
||||
<div class="za-toolbar">
|
||||
<div class="search-box">
|
||||
<input id="za-search" type="text" placeholder="搜索标题/标签…" />
|
||||
<button id="za-search-btn">🔍</button>
|
||||
</div>
|
||||
<select id="za-filter-category">
|
||||
<option value="">全部分类</option>
|
||||
</select>
|
||||
<select id="za-filter-type">
|
||||
<option value="">全部类型</option>
|
||||
<option value="text">纯文字</option>
|
||||
<option value="image">图文</option>
|
||||
</select>
|
||||
<select id="za-filter-status">
|
||||
<option value="">全部状态</option>
|
||||
<option value="normal">正常</option>
|
||||
<option value="offline">已下架</option>
|
||||
</select>
|
||||
<button class="btn btn-outline" id="za-btn-batch-offline" disabled>批量下架</button>
|
||||
<button class="btn btn-danger" id="za-btn-batch-delete" disabled>批量删除</button>
|
||||
<button class="btn btn-outline" id="za-btn-batch-export" disabled>批量导出</button>
|
||||
<button class="btn btn-primary" id="za-btn-new">+ 新增存档</button>
|
||||
</div>
|
||||
|
||||
<!-- 数据表格 -->
|
||||
<div class="za-table-wrap">
|
||||
<table class="za-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:40px"><input type="checkbox" id="za-select-all" title="全选" /></th>
|
||||
<th style="width:70px">封面</th>
|
||||
<th>标题</th>
|
||||
<th style="width:90px">分类</th>
|
||||
<th style="width:90px">类型</th>
|
||||
<th style="width:100px">来源</th>
|
||||
<th style="width:130px">创建时间</th>
|
||||
<th style="width:60px">阅读</th>
|
||||
<th style="width:70px">状态</th>
|
||||
<th style="width:200px">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="za-tbody"></tbody>
|
||||
</table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="za-pagination" id="za-pagination">
|
||||
<div>
|
||||
<span id="za-page-info">共 0 条</span>
|
||||
<span style="margin-left:12px">每页</span>
|
||||
<select id="za-page-size">
|
||||
<option value="10">10</option>
|
||||
<option value="20" selected>20</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
</select>
|
||||
<span>条</span>
|
||||
</div>
|
||||
<div class="page-btns" id="za-page-btns"></div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/tools/zhizi_notes/admin.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// ── 导出 ──
|
||||
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);
|
||||
})();
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"_comment": "知资札记工具的前端鉴权配置",
|
||||
"enable_auth_comment": "true:启用鉴权;false:暂时对所有访问者开放",
|
||||
"enable_auth": true,
|
||||
"max_age_days_comment": "鉴权Cookie有效期(天),范围建议1~365",
|
||||
"max_age_days": 30,
|
||||
"cookieName_comment": "鉴权Cookie名称",
|
||||
"cookieName": "zhizi_notes_gate",
|
||||
"guidParam_comment": "URL中的GUID参数名",
|
||||
"guidParam": "guid",
|
||||
"guidValue_comment": "GUID参数值(留空表示不启用GUID)",
|
||||
"guidValue": "b1c4ejKa0TAD7MQSIEBf94RMOG6SJ2-nUTJNUEiw",
|
||||
"requireGuid_comment": "true:强制需要GUID参数才能访问;false:允许仅凭token或已有会话访问",
|
||||
"requireGuid": false
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/* ═══════════════════════════════════════════════════════════
|
||||
知资札记 - 文章详情页样式
|
||||
复用 zhizi_notes.css 变量体系
|
||||
═══════════════════════════════════════════════════════════ */
|
||||
|
||||
/* ── 布局 ── */
|
||||
.zd-layout {
|
||||
margin-top: 56px; min-height: calc(100vh - 56px);
|
||||
max-width: 900px; margin-left: auto; margin-right: auto;
|
||||
padding: 0 20px;
|
||||
}
|
||||
.zd-main { padding: 30px 0 60px; }
|
||||
|
||||
/* ── 文章头部 ── */
|
||||
.zd-header { margin-bottom: 24px; padding-bottom: 20px; border-bottom: 1px solid var(--border); }
|
||||
.zd-header h1 {
|
||||
font-size: 1.6rem; font-weight: 700; color: var(--text);
|
||||
line-height: 1.5; margin-bottom: 12px;
|
||||
}
|
||||
.zd-meta {
|
||||
display: flex; flex-wrap: wrap; align-items: center; gap: 12px;
|
||||
font-size: 0.82rem; color: var(--text-light);
|
||||
}
|
||||
.zd-meta .zd-cat {
|
||||
display: inline-block; padding: 2px 10px; border-radius: 10px;
|
||||
background: var(--primary); color: #fff; font-size: 0.75rem;
|
||||
}
|
||||
.zd-meta .fav-btn {
|
||||
background: transparent; color: var(--text-light); font-size: 1.1rem;
|
||||
padding: 2px 4px; transition: all var(--transition); border: none; cursor: pointer;
|
||||
}
|
||||
.zd-meta .fav-btn.favorited { color: #E74C3C; }
|
||||
.zd-meta .fav-btn:hover { color: #E74C3C; }
|
||||
|
||||
/* ── 标签 ── */
|
||||
.zd-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; }
|
||||
.zd-tag {
|
||||
padding: 3px 10px; border-radius: 12px; font-size: 0.75rem;
|
||||
background: var(--bg-tag); color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* ── 封面图 ── */
|
||||
#zd-cover {
|
||||
margin-bottom: 24px; border-radius: var(--radius);
|
||||
overflow: hidden; max-height: 400px;
|
||||
}
|
||||
#zd-cover img { width: 100%; height: 100%; object-fit: cover; }
|
||||
|
||||
/* ── 正文 ── */
|
||||
.zd-content {
|
||||
font-size: 0.95rem; line-height: 1.85; color: var(--text);
|
||||
word-break: break-word; white-space: pre-wrap;
|
||||
}
|
||||
.zd-content p { margin-bottom: 1em; }
|
||||
.zd-content img { max-width: 100%; border-radius: 6px; margin: 12px 0; }
|
||||
.zd-content blockquote {
|
||||
border-left: 3px solid var(--accent); padding: 8px 16px;
|
||||
margin: 12px 0; color: var(--text-secondary);
|
||||
background: var(--bg);
|
||||
}
|
||||
.zd-content h1,.zd-content h2,.zd-content h3,.zd-content h4 {
|
||||
margin: 1.2em 0 0.6em; font-weight: 600;
|
||||
}
|
||||
.zd-content h2 { font-size: 1.2rem; }
|
||||
.zd-content h3 { font-size: 1.05rem; }
|
||||
.zd-content ul,.zd-content ol { padding-left: 1.5em; margin-bottom: 1em; }
|
||||
.zd-content li { margin-bottom: 0.3em; }
|
||||
.zd-content pre {
|
||||
background: var(--bg); padding: 12px 16px; border-radius: 6px;
|
||||
overflow-x: auto; font-size: 0.85rem; margin: 12px 0;
|
||||
}
|
||||
.zd-content code {
|
||||
background: var(--bg-tag); padding: 2px 6px; border-radius: 4px;
|
||||
font-size: 0.88em;
|
||||
}
|
||||
.zd-content pre code { background: transparent; padding: 0; }
|
||||
|
||||
/* ── 图片集 ── */
|
||||
.zd-gallery { margin-top: 30px; padding-top: 20px; border-top: 1px solid var(--border); }
|
||||
.zd-gallery h3 { font-size: 1rem; color: var(--text); margin-bottom: 14px; }
|
||||
.gallery-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; }
|
||||
.gallery-item {
|
||||
border-radius: var(--radius); overflow: hidden;
|
||||
background: var(--bg-tag); cursor: pointer;
|
||||
transition: transform var(--transition);
|
||||
}
|
||||
.gallery-item:hover { transform: scale(1.02); }
|
||||
.gallery-item img { width: 100%; height: 180px; object-fit: cover; display: block; }
|
||||
|
||||
/* ── 响应式 ── */
|
||||
@media (max-width: 767px) {
|
||||
.zd-layout { padding: 0 12px; }
|
||||
.zd-header h1 { font-size: 1.3rem; }
|
||||
.zd-content { font-size: 0.9rem; }
|
||||
.gallery-grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); }
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>知资札记 · 文章详情</title>
|
||||
<link rel="icon" href="/icons/zhizi_notes.svg" type="image/svg+xml" />
|
||||
<link rel="stylesheet" href="/common/common.css" />
|
||||
<link rel="stylesheet" href="/tools/zhizi_notes/zhizi_notes.css" />
|
||||
<link rel="stylesheet" href="/tools/zhizi_notes/detail.css" />
|
||||
</head>
|
||||
<body>
|
||||
<!-- Toast -->
|
||||
<div id="zd-toast" class="zs-toast"></div>
|
||||
|
||||
<!-- ═══════════════ 顶部 Header ═══════════════ -->
|
||||
<header class="zs-header">
|
||||
<button class="hamburger" id="zd-back" title="返回" style="display:flex">←</button>
|
||||
<div class="logo">知资札记</div>
|
||||
<nav class="nav-menu">
|
||||
<button data-nav="home">首页</button>
|
||||
</nav>
|
||||
<div class="header-right">
|
||||
<button id="zs-theme-btn" class="theme-btn" title="切换暗黑模式">🌙</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- ═══════════════ 主体 ═══════════════ -->
|
||||
<div class="zd-layout">
|
||||
<main class="zd-main">
|
||||
<!-- 加载中 -->
|
||||
<div id="zd-loading" class="zs-loading">
|
||||
<div class="spinner"></div>
|
||||
<div>加载中…</div>
|
||||
</div>
|
||||
|
||||
<!-- 文章内容 -->
|
||||
<article id="zd-article" style="display:none">
|
||||
<!-- 标题区 -->
|
||||
<header class="zd-header">
|
||||
<h1 id="zd-title"></h1>
|
||||
<div class="zd-meta">
|
||||
<span id="zd-category" class="zd-cat"></span>
|
||||
<span id="zd-source"></span>
|
||||
<span id="zd-date"></span>
|
||||
<span id="zd-views"></span>
|
||||
<button id="zd-fav-btn" class="fav-btn" title="收藏">♡</button>
|
||||
</div>
|
||||
<div id="zd-tags" class="zd-tags"></div>
|
||||
</header>
|
||||
|
||||
<!-- 封面图 -->
|
||||
<div id="zd-cover" style="display:none">
|
||||
<img id="zd-cover-img" alt="" />
|
||||
</div>
|
||||
|
||||
<!-- 正文 -->
|
||||
<div id="zd-content" class="zd-content"></div>
|
||||
|
||||
<!-- 图片集 -->
|
||||
<div id="zd-gallery" class="zd-gallery" style="display:none">
|
||||
<h3>附图</h3>
|
||||
<div id="zd-gallery-grid" class="gallery-grid"></div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<!-- 错误 -->
|
||||
<div id="zd-error" class="zs-empty" style="display:none">
|
||||
<div id="zd-error-msg">文章不存在或已被删除</div>
|
||||
<div style="margin-top:12px">
|
||||
<a href="/tools/zhizi_notes/index.html" style="color:var(--accent)">← 返回首页</a>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/tools/zhizi_notes/detail.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* 知资札记 - 文章详情页逻辑
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const API_BASE = '/api/zhizi_notes';
|
||||
const STORAGE_KEY = 'zhizi_notes_theme';
|
||||
const $ = function (sel) { return document.querySelector(sel); };
|
||||
|
||||
let articleId = null;
|
||||
let isFavorited = false;
|
||||
|
||||
// ── 解析 URL 参数 ──
|
||||
function getParam(name) {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
return params.get(name);
|
||||
}
|
||||
|
||||
// ── Toast ──
|
||||
function showToast(msg, type) {
|
||||
type = type || 'info';
|
||||
const t = $('#zd-toast');
|
||||
if (!t) return;
|
||||
t.textContent = msg;
|
||||
t.className = 'zs-toast ' + type + ' show';
|
||||
clearTimeout(t._timer);
|
||||
t._timer = setTimeout(function () { t.classList.remove('show'); }, type === 'error' ? 4000 : 2000);
|
||||
}
|
||||
|
||||
// ── 暗黑模式 ──
|
||||
function initTheme() {
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
if (saved === 'dark') {
|
||||
document.documentElement.setAttribute('data-theme', 'dark');
|
||||
}
|
||||
const btn = $('#zs-theme-btn');
|
||||
if (btn) {
|
||||
btn.textContent = saved === 'dark' ? '☀️' : '🌙';
|
||||
btn.addEventListener('click', function () {
|
||||
const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
|
||||
if (isDark) {
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
localStorage.setItem(STORAGE_KEY, 'light');
|
||||
btn.textContent = '🌙';
|
||||
} else {
|
||||
document.documentElement.setAttribute('data-theme', 'dark');
|
||||
localStorage.setItem(STORAGE_KEY, 'dark');
|
||||
btn.textContent = '☀️';
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── API ──
|
||||
async function apiGet(path) {
|
||||
try {
|
||||
const 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 {
|
||||
const 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 loadDetail() {
|
||||
articleId = getParam('id');
|
||||
if (!articleId) {
|
||||
showError('缺少文章ID');
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await apiGet('/articles/' + articleId);
|
||||
const loading = $('#zd-loading');
|
||||
if (loading) loading.style.display = 'none';
|
||||
|
||||
if (!data.ok) {
|
||||
showError(data.error || '文章不存在');
|
||||
return;
|
||||
}
|
||||
|
||||
renderArticle(data.data);
|
||||
}
|
||||
|
||||
function showError(msg) {
|
||||
const loading = $('#zd-loading');
|
||||
const error = $('#zd-error');
|
||||
if (loading) loading.style.display = 'none';
|
||||
if (error) {
|
||||
error.style.display = 'block';
|
||||
$('#zd-error-msg').textContent = msg;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 渲染文章 ──
|
||||
function renderArticle(article) {
|
||||
const articleEl = $('#zd-article');
|
||||
if (!articleEl) return;
|
||||
articleEl.style.display = 'block';
|
||||
|
||||
// 标题
|
||||
$('#zd-title').textContent = article.title;
|
||||
document.title = article.title + ' · 知资札记';
|
||||
|
||||
// 元信息
|
||||
$('#zd-category').textContent = article.category || '未分类';
|
||||
$('#zd-source').textContent = article.source ? '来源:' + article.source : '';
|
||||
$('#zd-date').textContent = (article.created_at || '').slice(0, 10);
|
||||
$('#zd-views').textContent = (article.views || 0) + ' 阅读';
|
||||
|
||||
// 收藏按钮
|
||||
isFavorited = article.fav_count > 0;
|
||||
updateFavBtn();
|
||||
const favBtn = $('#zd-fav-btn');
|
||||
if (favBtn) {
|
||||
favBtn.addEventListener('click', function () { toggleFavorite(); });
|
||||
}
|
||||
|
||||
// 标签
|
||||
const tagsEl = $('#zd-tags');
|
||||
try {
|
||||
const tags = JSON.parse(article.tags_json || '[]');
|
||||
if (tags.length > 0 && tagsEl) {
|
||||
tagsEl.innerHTML = '';
|
||||
tags.forEach(function (t) {
|
||||
const span = document.createElement('span');
|
||||
span.className = 'zd-tag';
|
||||
span.textContent = t;
|
||||
tagsEl.appendChild(span);
|
||||
});
|
||||
} else if (tagsEl) {
|
||||
tagsEl.style.display = 'none';
|
||||
}
|
||||
} catch (e) { if (tagsEl) tagsEl.style.display = 'none'; }
|
||||
|
||||
// 封面图
|
||||
if (article.cover_url) {
|
||||
const cover = $('#zd-cover');
|
||||
const coverImg = $('#zd-cover-img');
|
||||
if (cover) cover.style.display = 'block';
|
||||
if (coverImg) coverImg.src = article.cover_url;
|
||||
}
|
||||
|
||||
// 正文
|
||||
const contentEl = $('#zd-content');
|
||||
if (contentEl) {
|
||||
contentEl.innerHTML = article.content || article.content_plain || '';
|
||||
}
|
||||
|
||||
// 图片集
|
||||
if (article.images && article.images.length > 0) {
|
||||
const gallery = $('#zd-gallery');
|
||||
const grid = $('#zd-gallery-grid');
|
||||
if (gallery) gallery.style.display = 'block';
|
||||
if (grid) {
|
||||
grid.innerHTML = '';
|
||||
article.images.forEach(function (img) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'gallery-item';
|
||||
const imgEl = document.createElement('img');
|
||||
imgEl.src = '/uploads/zhizi_notes/' + img.filename;
|
||||
imgEl.alt = img.original_name || '';
|
||||
imgEl.loading = 'lazy';
|
||||
item.appendChild(imgEl);
|
||||
grid.appendChild(item);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 收藏切换 ──
|
||||
async function toggleFavorite() {
|
||||
const data = await apiPost('/articles/' + articleId + '/toggle_favorite', {});
|
||||
if (data.ok) {
|
||||
isFavorited = data.data ? data.data.favorited : !isFavorited;
|
||||
updateFavBtn();
|
||||
showToast(isFavorited ? '已收藏' : '已取消收藏', isFavorited ? 'success' : 'info');
|
||||
} else {
|
||||
showToast('操作失败:' + (data.error || '未知错误'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function updateFavBtn() {
|
||||
const btn = $('#zd-fav-btn');
|
||||
if (!btn) return;
|
||||
if (isFavorited) {
|
||||
btn.classList.add('favorited');
|
||||
btn.textContent = '♥';
|
||||
} else {
|
||||
btn.classList.remove('favorited');
|
||||
btn.textContent = '♡';
|
||||
}
|
||||
}
|
||||
|
||||
// ── 初始化 ──
|
||||
function init() {
|
||||
initTheme();
|
||||
loadDetail();
|
||||
|
||||
// 返回按钮
|
||||
const backBtn = $('#zd-back');
|
||||
if (backBtn) {
|
||||
backBtn.addEventListener('click', function () {
|
||||
if (document.referrer && document.referrer.indexOf('/tools/zhizi_notes') !== -1) {
|
||||
history.back();
|
||||
} else {
|
||||
location.href = '/tools/zhizi_notes/index.html';
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
})();
|
||||
@@ -0,0 +1,147 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>知资札记 · 证券投资图文存档站</title>
|
||||
<link rel="icon" href="/icons/zhizi_notes.svg" type="image/svg+xml" />
|
||||
<link rel="stylesheet" href="/common/common.css" />
|
||||
<link rel="stylesheet" href="/tools/zhizi_notes/zhizi_notes.css" />
|
||||
</head>
|
||||
<body>
|
||||
<!-- Toast -->
|
||||
<div id="zs-toast" class="zs-toast"></div>
|
||||
|
||||
<!-- 侧边栏遮罩(手机) -->
|
||||
<div id="zs-sidebar-overlay" class="zs-sidebar-overlay"></div>
|
||||
|
||||
<!-- ═══════════════ 顶部 Header ═══════════════ -->
|
||||
<header class="zs-header">
|
||||
<button id="zs-hamburger" class="hamburger" title="菜单">☰</button>
|
||||
<div class="logo">知资札记</div>
|
||||
<nav class="nav-menu">
|
||||
<button class="active" data-nav="home">首页</button>
|
||||
<button data-nav="archive">分类归档</button>
|
||||
<button data-nav="favorites">我的收藏</button>
|
||||
<button data-nav="submit">提交存档</button>
|
||||
</nav>
|
||||
<div class="header-right">
|
||||
<div class="search-box">
|
||||
<input id="zs-search" type="text" placeholder="搜索标题/标签…" />
|
||||
<button id="zs-search-btn" title="搜索">🔍</button>
|
||||
</div>
|
||||
<button id="zs-theme-btn" class="theme-btn" title="切换暗黑模式">🌙</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- ═══════════════ 主体布局 ═══════════════ -->
|
||||
<div class="zs-layout">
|
||||
<!-- 左侧筛选栏 -->
|
||||
<aside id="zs-sidebar" class="zs-sidebar">
|
||||
<!-- 内容分类 -->
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-title">内容分类</div>
|
||||
<div id="zs-categories">
|
||||
<button class="cat-item active" data-cat="">全部</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 热门标签 -->
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-title">热门标签</div>
|
||||
<div id="zs-tags" class="tag-cloud"></div>
|
||||
</div>
|
||||
|
||||
<!-- 时间筛选 -->
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-title">时间筛选</div>
|
||||
<div class="time-filter">
|
||||
<button class="time-item active" data-time="">全部</button>
|
||||
<button class="time-item" data-time="week">本周存档</button>
|
||||
<button class="time-item" data-time="month">本月</button>
|
||||
<button class="time-item" data-time="quarter">季度</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 内容类型 -->
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-title">内容类型</div>
|
||||
<div class="type-toggle">
|
||||
<button class="type-btn active" data-type="">全部</button>
|
||||
<button class="type-btn" data-type="text">纯文字</button>
|
||||
<button class="type-btn" data-type="image">图文多图</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 站点统计 -->
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-title">站点统计</div>
|
||||
<div class="stats-card">
|
||||
<div class="stat-row"><span>总存档文章</span><span class="stat-val" id="zs-stat-articles">0</span></div>
|
||||
<div class="stat-row"><span>总存档图片</span><span class="stat-val" id="zs-stat-images">0</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- 中间主内容区 -->
|
||||
<main class="zs-main">
|
||||
<!-- 视图切换 + 筛选标签 -->
|
||||
<div class="view-toggle">
|
||||
<button class="active" data-view="card">📐 瀑布卡片</button>
|
||||
<button data-view="list">📋 简洁列表</button>
|
||||
<div id="zs-filter-tags" class="filter-tags" style="flex:1;margin-left:8px"></div>
|
||||
</div>
|
||||
|
||||
<!-- 卡片网格 -->
|
||||
<div id="zs-card-grid" class="card-grid"></div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div id="zs-loading" class="zs-loading" style="display:none">
|
||||
<div class="spinner"></div>
|
||||
<div>加载中…</div>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div id="zs-empty" class="zs-empty" style="display:none">
|
||||
<div>暂无存档内容</div>
|
||||
<div style="font-size:0.8rem;margin-top:4px">点击右侧「提交新存档」开始记录</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 右侧辅助栏 -->
|
||||
<aside class="zs-rightbar">
|
||||
<!-- 站长精选 -->
|
||||
<div class="right-section">
|
||||
<div class="right-title">站长精选</div>
|
||||
<div id="zs-pinned">
|
||||
<div class="pinned-item" style="color:var(--text-light)">暂无置顶</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 最新入库 -->
|
||||
<div class="right-section">
|
||||
<div class="right-title">最新入库</div>
|
||||
<div id="zs-latest">
|
||||
<div class="latest-item" style="color:var(--text-light)">暂无存档</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分类占比 -->
|
||||
<div class="right-section">
|
||||
<div class="right-title">分类占比</div>
|
||||
<div id="zs-chart">
|
||||
<div style="color:var(--text-light);font-size:0.8rem">暂无数据</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 提交存档 -->
|
||||
<div class="right-section">
|
||||
<button id="zs-submit-btn" class="submit-btn">提交新存档</button>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<!-- 脚本 -->
|
||||
<script src="/tools/zhizi_notes/zhizi_notes.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,416 @@
|
||||
/* ═══════════════════════════════════════════════════════════════════
|
||||
知资札记 - 全局样式
|
||||
主色:#162A46 墨蓝 | 辅助色:#467974 青灰
|
||||
日间背景:#F7F9FC | 暗黑背景:#0F1726
|
||||
═══════════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* ── CSS 变量(日间默认) ── */
|
||||
:root {
|
||||
--primary: #162A46;
|
||||
--primary-light: #1E3A5F;
|
||||
--primary-dark: #0F1D33;
|
||||
--accent: #467974;
|
||||
--accent-light: #5A9690;
|
||||
--text: #333;
|
||||
--text-secondary: #666;
|
||||
--text-light: #999;
|
||||
--bg: #F7F9FC;
|
||||
--bg-card: #FFFFFF;
|
||||
--bg-sidebar: #FFFFFF;
|
||||
--bg-header: #162A46;
|
||||
--bg-tag: #E8EDF2;
|
||||
--bg-tag-hover: #D0D8E0;
|
||||
--border: #E0E4E8;
|
||||
--shadow: 0 1px 3px rgba(0,0,0,0.08);
|
||||
--shadow-hover: 0 4px 16px rgba(0,0,0,0.12);
|
||||
--radius: 8px;
|
||||
--transition: 0.2s ease;
|
||||
--font: 'Microsoft YaHei', 'Source Han Sans CN', -apple-system, sans-serif;
|
||||
--line-height: 1.75;
|
||||
}
|
||||
|
||||
/* ── 暗黑模式 ── */
|
||||
[data-theme="dark"] {
|
||||
--primary: #1E3A5F;
|
||||
--primary-light: #2A4D7A;
|
||||
--primary-dark: #0F1D33;
|
||||
--accent: #5A9690;
|
||||
--accent-light: #6DB0A9;
|
||||
--text: #E0E4E8;
|
||||
--text-secondary: #A0A8B4;
|
||||
--text-light: #6B7280;
|
||||
--bg: #0F1726;
|
||||
--bg-card: #1A2332;
|
||||
--bg-sidebar: #1A2332;
|
||||
--bg-header: #0F1D33;
|
||||
--bg-tag: #1E2D3D;
|
||||
--bg-tag-hover: #2A3D52;
|
||||
--border: #2A3D52;
|
||||
--shadow: 0 1px 3px rgba(0,0,0,0.3);
|
||||
--shadow-hover: 0 4px 16px rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
/* ── 全局重置 ── */
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html { font-size: 16px; scroll-behavior: smooth; }
|
||||
body {
|
||||
font-family: var(--font);
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
line-height: var(--line-height);
|
||||
min-height: 100vh;
|
||||
transition: background var(--transition), color var(--transition);
|
||||
}
|
||||
a { text-decoration: none; color: inherit; }
|
||||
img { max-width: 100%; display: block; }
|
||||
button { cursor: pointer; font-family: inherit; border: none; outline: none; }
|
||||
input, select, textarea { font-family: inherit; outline: none; }
|
||||
|
||||
/* ── 顶部 Header ── */
|
||||
.zs-header {
|
||||
position: fixed; top: 0; left: 0; right: 0; z-index: 1000;
|
||||
background: var(--bg-header); color: #fff;
|
||||
height: 56px; display: flex; align-items: center;
|
||||
padding: 0 20px; gap: 16px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
||||
}
|
||||
.zs-header .logo {
|
||||
font-size: 1.25rem; font-weight: 700;
|
||||
letter-spacing: 2px; white-space: nowrap;
|
||||
user-select: none;
|
||||
}
|
||||
.zs-header .nav-menu {
|
||||
display: flex; gap: 4px; flex: 1; justify-content: center;
|
||||
}
|
||||
.zs-header .nav-menu button {
|
||||
background: transparent; color: rgba(255,255,255,0.75);
|
||||
padding: 6px 16px; border-radius: 6px; font-size: 0.9rem;
|
||||
transition: all var(--transition);
|
||||
}
|
||||
.zs-header .nav-menu button:hover,
|
||||
.zs-header .nav-menu button.active { color: #fff; background: rgba(255,255,255,0.12); }
|
||||
.zs-header .header-right { display: flex; align-items: center; gap: 10px; }
|
||||
.zs-header .search-box {
|
||||
display: flex; align-items: center; background: rgba(255,255,255,0.12);
|
||||
border-radius: 20px; padding: 4px 12px; gap: 6px;
|
||||
}
|
||||
.zs-header .search-box input {
|
||||
background: transparent; border: none; color: #fff;
|
||||
width: 160px; font-size: 0.85rem;
|
||||
}
|
||||
.zs-header .search-box input::placeholder { color: rgba(255,255,255,0.5); }
|
||||
.zs-header .search-box button {
|
||||
background: transparent; color: rgba(255,255,255,0.7);
|
||||
font-size: 1rem; padding: 2px;
|
||||
}
|
||||
.zs-header .theme-btn {
|
||||
background: rgba(255,255,255,0.12); color: #fff;
|
||||
width: 34px; height: 34px; border-radius: 50%;
|
||||
font-size: 1.1rem; display: flex; align-items: center; justify-content: center;
|
||||
transition: all var(--transition);
|
||||
}
|
||||
.zs-header .theme-btn:hover { background: rgba(255,255,255,0.2); }
|
||||
|
||||
/* ── 汉堡菜单按钮(手机) ── */
|
||||
.hamburger {
|
||||
display: none; background: transparent; color: #fff;
|
||||
font-size: 1.5rem; width: 36px; height: 36px;
|
||||
align-items: center; justify-content: center;
|
||||
}
|
||||
|
||||
/* ── 主体布局 ── */
|
||||
.zs-layout {
|
||||
display: flex; margin-top: 56px; min-height: calc(100vh - 56px);
|
||||
max-width: 1400px; margin-left: auto; margin-right: auto;
|
||||
}
|
||||
|
||||
/* ── 左侧筛选栏 ── */
|
||||
.zs-sidebar {
|
||||
width: 220px; min-width: 220px; background: var(--bg-sidebar);
|
||||
border-right: 1px solid var(--border);
|
||||
padding: 16px 12px; overflow-y: auto;
|
||||
position: sticky; top: 56px; height: calc(100vh - 56px);
|
||||
transition: transform var(--transition);
|
||||
}
|
||||
.zs-sidebar .sidebar-section { margin-bottom: 20px; }
|
||||
.zs-sidebar .sidebar-title {
|
||||
font-size: 0.8rem; color: var(--text-light);
|
||||
text-transform: uppercase; letter-spacing: 1px;
|
||||
margin-bottom: 8px; padding-left: 4px;
|
||||
}
|
||||
.zs-sidebar .cat-item {
|
||||
display: block; width: 100%; text-align: left;
|
||||
padding: 8px 12px; border-radius: 6px; font-size: 0.9rem;
|
||||
color: var(--text-secondary); background: transparent;
|
||||
transition: all var(--transition);
|
||||
}
|
||||
.zs-sidebar .cat-item:hover { background: var(--bg-tag); color: var(--text); }
|
||||
.zs-sidebar .cat-item.active { background: var(--primary); color: #fff; }
|
||||
.zs-sidebar .tag-cloud { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.zs-sidebar .tag-item {
|
||||
padding: 4px 10px; border-radius: 12px; font-size: 0.78rem;
|
||||
background: var(--bg-tag); color: var(--text-secondary);
|
||||
transition: all var(--transition);
|
||||
}
|
||||
.zs-sidebar .tag-item:hover { background: var(--bg-tag-hover); color: var(--text); }
|
||||
.zs-sidebar .tag-item.active { background: var(--accent); color: #fff; }
|
||||
.zs-sidebar .time-filter { display: flex; flex-direction: column; gap: 4px; }
|
||||
.zs-sidebar .time-item {
|
||||
display: block; width: 100%; text-align: left;
|
||||
padding: 6px 12px; border-radius: 6px; font-size: 0.85rem;
|
||||
color: var(--text-secondary); background: transparent;
|
||||
transition: all var(--transition);
|
||||
}
|
||||
.zs-sidebar .time-item:hover { background: var(--bg-tag); }
|
||||
.zs-sidebar .time-item.active { background: var(--accent); color: #fff; }
|
||||
.zs-sidebar .type-toggle { display: flex; gap: 6px; }
|
||||
.zs-sidebar .type-btn {
|
||||
flex: 1; padding: 6px 8px; border-radius: 6px; font-size: 0.8rem;
|
||||
background: var(--bg-tag); color: var(--text-secondary);
|
||||
transition: all var(--transition);
|
||||
}
|
||||
.zs-sidebar .type-btn.active { background: var(--accent); color: #fff; }
|
||||
.zs-sidebar .stats-card {
|
||||
background: var(--bg); border-radius: var(--radius);
|
||||
padding: 12px; font-size: 0.85rem;
|
||||
}
|
||||
.zs-sidebar .stats-card .stat-row {
|
||||
display: flex; justify-content: space-between;
|
||||
padding: 4px 0; color: var(--text-secondary);
|
||||
}
|
||||
.zs-sidebar .stats-card .stat-val { font-weight: 700; color: var(--primary); }
|
||||
|
||||
/* ── 中间主内容区 ── */
|
||||
.zs-main {
|
||||
flex: 1; padding: 20px; min-width: 0;
|
||||
}
|
||||
.zs-main .view-toggle {
|
||||
display: flex; align-items: center; gap: 8px; margin-bottom: 16px;
|
||||
}
|
||||
.zs-main .view-toggle button {
|
||||
padding: 6px 14px; border-radius: 6px; font-size: 0.85rem;
|
||||
background: var(--bg-tag); color: var(--text-secondary);
|
||||
transition: all var(--transition);
|
||||
}
|
||||
.zs-main .view-toggle button.active { background: var(--primary); color: #fff; }
|
||||
.zs-main .filter-tags {
|
||||
display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px;
|
||||
}
|
||||
.zs-main .filter-tag {
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
padding: 3px 10px; border-radius: 12px; font-size: 0.78rem;
|
||||
background: var(--accent); color: #fff;
|
||||
}
|
||||
.zs-main .filter-tag .remove-filter {
|
||||
background: transparent; color: rgba(255,255,255,0.7);
|
||||
font-size: 0.85rem; line-height: 1; padding: 0;
|
||||
}
|
||||
.zs-main .filter-tag .remove-filter:hover { color: #fff; }
|
||||
|
||||
/* ── 卡片网格视图 ── */
|
||||
.card-grid {
|
||||
display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
.card-grid.list-view {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
/* ── 图文卡片 ── */
|
||||
.zs-card {
|
||||
background: var(--bg-card); border-radius: var(--radius);
|
||||
box-shadow: var(--shadow); overflow: hidden;
|
||||
transition: all var(--transition); cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
.zs-card:hover { box-shadow: var(--shadow-hover); transform: translateY(-2px); }
|
||||
.zs-card .card-cover {
|
||||
height: 180px; overflow: hidden; position: relative;
|
||||
background: var(--bg-tag);
|
||||
}
|
||||
.zs-card .card-cover img {
|
||||
width: 100%; height: 100%; object-fit: cover;
|
||||
}
|
||||
.zs-card .card-cover .text-badge {
|
||||
width: 100%; height: 100%; display: flex; align-items: center; justify-content: center;
|
||||
background: linear-gradient(135deg, var(--primary), var(--accent));
|
||||
color: #fff; font-size: 2.5rem; font-weight: 700;
|
||||
}
|
||||
.zs-card .card-badge {
|
||||
position: absolute; bottom: 8px; right: 8px;
|
||||
background: rgba(0,0,0,0.6); color: #fff;
|
||||
padding: 2px 8px; border-radius: 10px; font-size: 0.72rem;
|
||||
}
|
||||
.zs-card .card-body { padding: 14px; }
|
||||
.zs-card .card-title {
|
||||
font-size: 0.95rem; font-weight: 600; color: var(--text);
|
||||
line-height: 1.5; display: -webkit-box;
|
||||
-webkit-line-clamp: 2; -webkit-box-orient: vertical;
|
||||
overflow: hidden; margin-bottom: 8px;
|
||||
}
|
||||
.zs-card .card-source {
|
||||
font-size: 0.78rem; color: var(--text-light); margin-bottom: 6px;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.zs-card .card-tags { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 8px; }
|
||||
.zs-card .card-tag {
|
||||
padding: 2px 8px; border-radius: 10px; font-size: 0.7rem;
|
||||
background: var(--bg-tag); color: var(--text-secondary);
|
||||
}
|
||||
.zs-card .card-footer {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
font-size: 0.75rem; color: var(--text-light);
|
||||
border-top: 1px solid var(--border); padding-top: 10px;
|
||||
}
|
||||
.zs-card .card-footer .fav-btn {
|
||||
background: transparent; color: var(--text-light); font-size: 1rem;
|
||||
padding: 2px 4px; transition: all var(--transition);
|
||||
}
|
||||
.zs-card .card-footer .fav-btn.favorited { color: #E74C3C; }
|
||||
.zs-card .card-footer .fav-btn:hover { color: #E74C3C; }
|
||||
|
||||
/* ── 列表视图卡片 ── */
|
||||
.list-view .zs-card {
|
||||
display: flex; flex-direction: row;
|
||||
}
|
||||
.list-view .zs-card .card-cover {
|
||||
width: 240px; min-width: 240px; height: auto; min-height: 160px;
|
||||
}
|
||||
.list-view .zs-card .card-body { flex: 1; }
|
||||
|
||||
/* ── 右侧辅助栏 ── */
|
||||
.zs-rightbar {
|
||||
width: 260px; min-width: 260px; background: var(--bg-sidebar);
|
||||
border-left: 1px solid var(--border); padding: 16px 12px;
|
||||
overflow-y: auto; position: sticky; top: 56px;
|
||||
height: calc(100vh - 56px);
|
||||
}
|
||||
.zs-rightbar .right-section { margin-bottom: 20px; }
|
||||
.zs-rightbar .right-title {
|
||||
font-size: 0.82rem; font-weight: 600; color: var(--text);
|
||||
margin-bottom: 10px; padding-bottom: 6px;
|
||||
border-bottom: 2px solid var(--accent);
|
||||
}
|
||||
.zs-rightbar .pinned-item {
|
||||
padding: 8px 0; border-bottom: 1px solid var(--border);
|
||||
cursor: pointer; transition: color var(--transition);
|
||||
}
|
||||
.zs-rightbar .pinned-item:hover { color: var(--accent); }
|
||||
.zs-rightbar .pinned-item .pinned-title {
|
||||
font-size: 0.85rem; font-weight: 500;
|
||||
display: -webkit-box; -webkit-line-clamp: 1; -webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.zs-rightbar .pinned-item .pinned-date {
|
||||
font-size: 0.72rem; color: var(--text-light); margin-top: 2px;
|
||||
}
|
||||
.zs-rightbar .latest-item {
|
||||
padding: 6px 0; font-size: 0.82rem; color: var(--text-secondary);
|
||||
cursor: pointer; transition: color var(--transition);
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.zs-rightbar .latest-item:hover { color: var(--accent); }
|
||||
.zs-rightbar .chart-bar {
|
||||
display: flex; align-items: center; gap: 8px; margin-bottom: 6px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.zs-rightbar .chart-bar .bar-label { width: 60px; text-align: right; color: var(--text-secondary); }
|
||||
.zs-rightbar .chart-bar .bar-track {
|
||||
flex: 1; height: 8px; background: var(--bg-tag); border-radius: 4px; overflow: hidden;
|
||||
}
|
||||
.zs-rightbar .chart-bar .bar-fill {
|
||||
height: 100%; background: var(--accent); border-radius: 4px;
|
||||
transition: width 0.5s ease;
|
||||
}
|
||||
.zs-rightbar .chart-bar .bar-count { width: 30px; color: var(--text-light); }
|
||||
.zs-rightbar .submit-btn {
|
||||
display: block; width: 100%; padding: 10px; border-radius: var(--radius);
|
||||
background: var(--primary); color: #fff; font-size: 0.9rem; font-weight: 600;
|
||||
text-align: center; transition: all var(--transition);
|
||||
}
|
||||
.zs-rightbar .submit-btn:hover { background: var(--primary-light); }
|
||||
|
||||
/* ── 加载状态 ── */
|
||||
.zs-loading {
|
||||
text-align: center; padding: 40px; color: var(--text-light);
|
||||
}
|
||||
.zs-loading .spinner {
|
||||
width: 32px; height: 32px; border: 3px solid var(--border);
|
||||
border-top-color: var(--accent); border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite; margin: 0 auto 12px;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.zs-empty {
|
||||
text-align: center; padding: 60px 20px; color: var(--text-light); font-size: 0.95rem;
|
||||
}
|
||||
|
||||
/* ── 提示 Toast ── */
|
||||
.zs-toast {
|
||||
position: fixed; top: 70px; left: 50%; transform: translateX(-50%);
|
||||
z-index: 9999; padding: 10px 24px; border-radius: 8px;
|
||||
font-size: 0.9rem; color: #fff; opacity: 0;
|
||||
transition: opacity 0.3s ease; pointer-events: none;
|
||||
}
|
||||
.zs-toast.show { opacity: 1; }
|
||||
.zs-toast.info { background: #3B82F6; }
|
||||
.zs-toast.success { background: #10B981; }
|
||||
.zs-toast.error { background: #EF4444; }
|
||||
|
||||
/* ── 模态弹窗(交互详情/确认) ── */
|
||||
.zs-modal-overlay {
|
||||
position: fixed; inset: 0; z-index: 5000;
|
||||
background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center;
|
||||
transition: opacity var(--transition);
|
||||
}
|
||||
.zs-modal-overlay.hidden { display: none; }
|
||||
.zs-modal {
|
||||
background: var(--bg-card); border-radius: 12px; padding: 24px;
|
||||
max-width: 500px; width: 90%; max-height: 80vh; overflow-y: auto;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.2);
|
||||
}
|
||||
.zs-modal h3 { margin-bottom: 16px; color: var(--text); }
|
||||
.zs-modal .modal-body { font-size: 0.85rem; color: var(--text-secondary); margin-bottom: 20px; }
|
||||
.zs-modal .modal-body pre {
|
||||
background: var(--bg); padding: 12px; border-radius: 6px;
|
||||
font-size: 0.78rem; overflow-x: auto; white-space: pre-wrap; word-break: break-all;
|
||||
}
|
||||
.zs-modal .modal-actions { display: flex; gap: 10px; justify-content: flex-end; }
|
||||
.zs-modal .btn {
|
||||
padding: 8px 20px; border-radius: 6px; font-size: 0.85rem; font-weight: 500;
|
||||
transition: all var(--transition);
|
||||
}
|
||||
.zs-modal .btn-primary { background: var(--primary); color: #fff; }
|
||||
.zs-modal .btn-primary:hover { background: var(--primary-light); }
|
||||
.zs-modal .btn-danger { background: #D94452; color: #fff; }
|
||||
.zs-modal .btn-danger:hover { background: #C0392B; }
|
||||
.zs-modal .btn-cancel { background: var(--bg-tag); color: var(--text-secondary); }
|
||||
.zs-modal .btn-cancel:hover { background: var(--bg-tag-hover); }
|
||||
|
||||
/* ── 响应式 ── */
|
||||
@media (max-width: 1199px) {
|
||||
.zs-rightbar { display: none; }
|
||||
.zs-sidebar { width: 200px; min-width: 200px; }
|
||||
.card-grid { grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); }
|
||||
.list-view .zs-card .card-cover { width: 180px; min-width: 180px; }
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.zs-layout { flex-direction: column; }
|
||||
.zs-sidebar {
|
||||
position: fixed; top: 56px; left: 0; bottom: 0; z-index: 900;
|
||||
width: 260px; transform: translateX(-100%);
|
||||
box-shadow: 4px 0 16px rgba(0,0,0,0.15);
|
||||
}
|
||||
.zs-sidebar.open { transform: translateX(0); }
|
||||
.zs-sidebar-overlay {
|
||||
display: none; position: fixed; inset: 0; z-index: 899;
|
||||
background: rgba(0,0,0,0.4);
|
||||
}
|
||||
.zs-sidebar-overlay.show { display: block; }
|
||||
.hamburger { display: flex; }
|
||||
.zs-header .nav-menu { display: none; }
|
||||
.zs-header .search-box input { width: 100px; }
|
||||
.zs-main { padding: 12px; }
|
||||
.card-grid { grid-template-columns: 1fr; }
|
||||
.list-view .zs-card { flex-direction: column; }
|
||||
.list-view .zs-card .card-cover { width: 100%; min-width: auto; height: 180px; }
|
||||
}
|
||||
@@ -0,0 +1,594 @@
|
||||
/**
|
||||
* 知资札记 - C端前端逻辑
|
||||
* 证券投资图文存档站
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// ── 常量 ──
|
||||
const API_BASE = '/api/zhizi_notes';
|
||||
const STORAGE_KEY = 'zhizi_notes_theme';
|
||||
const STORAGE_VIEW = 'zhizi_notes_view';
|
||||
const STORAGE_FAVORITES = 'zhizi_notes_favorites';
|
||||
|
||||
// ── 状态 ──
|
||||
let state = {
|
||||
page: 1,
|
||||
pageSize: 12,
|
||||
totalPages: 0,
|
||||
loading: false,
|
||||
done: false,
|
||||
viewMode: localStorage.getItem(STORAGE_VIEW) || 'card',
|
||||
filter: { category: '', tag: '', timeRange: '', contentType: '', search: '' },
|
||||
favorites: new Set()
|
||||
};
|
||||
|
||||
// ── DOM 缓存 ──
|
||||
const $ = (sel) => document.querySelector(sel);
|
||||
const $$ = (sel) => document.querySelectorAll(sel);
|
||||
|
||||
// ── 链接清洗函数 ──
|
||||
window.clearAllLink = function (htmlStr) {
|
||||
if (!htmlStr || typeof htmlStr !== 'string') return '';
|
||||
return htmlStr.replace(/<a\b[^>]*>(.*?)<\/a>/gi, '$1');
|
||||
};
|
||||
|
||||
// ── Toast 提示 ──
|
||||
function showToast(msg, type) {
|
||||
type = type || 'info';
|
||||
const t = $('#zs-toast');
|
||||
if (!t) return;
|
||||
t.textContent = msg;
|
||||
t.className = 'zs-toast ' + type + ' show';
|
||||
const duration = type === 'error' ? 4000 : 2000;
|
||||
clearTimeout(t._timer);
|
||||
t._timer = setTimeout(function () { t.classList.remove('show'); }, duration);
|
||||
}
|
||||
|
||||
// ── 暗黑模式 ──
|
||||
function initTheme() {
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
if (saved === 'dark') {
|
||||
document.documentElement.setAttribute('data-theme', 'dark');
|
||||
}
|
||||
const btn = $('#zs-theme-btn');
|
||||
if (btn) {
|
||||
btn.textContent = saved === 'dark' ? '☀️' : '🌙';
|
||||
btn.addEventListener('click', toggleTheme);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
|
||||
if (isDark) {
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
localStorage.setItem(STORAGE_KEY, 'light');
|
||||
$('#zs-theme-btn').textContent = '🌙';
|
||||
} else {
|
||||
document.documentElement.setAttribute('data-theme', 'dark');
|
||||
localStorage.setItem(STORAGE_KEY, 'dark');
|
||||
$('#zs-theme-btn').textContent = '☀️';
|
||||
}
|
||||
}
|
||||
|
||||
// ── API 请求 ──
|
||||
async function apiGet(path) {
|
||||
try {
|
||||
const 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 {
|
||||
const 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 loadArticles(reset) {
|
||||
if (reset) {
|
||||
state.page = 1; state.done = false;
|
||||
const grid = $('#zs-card-grid');
|
||||
if (grid) grid.innerHTML = '';
|
||||
}
|
||||
if (state.loading || state.done) return;
|
||||
state.loading = true;
|
||||
showLoading(true);
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.set('page', state.page);
|
||||
params.set('pageSize', state.pageSize);
|
||||
if (state.filter.category) params.set('category', state.filter.category);
|
||||
if (state.filter.tag) params.set('tag', state.filter.tag);
|
||||
if (state.filter.timeRange) params.set('timeRange', state.filter.timeRange);
|
||||
if (state.filter.contentType) params.set('contentType', state.filter.contentType);
|
||||
if (state.filter.search) params.set('search', state.filter.search);
|
||||
|
||||
const data = await apiGet('/articles?' + params.toString());
|
||||
showLoading(false);
|
||||
state.loading = false;
|
||||
|
||||
if (!data.ok) {
|
||||
showToast('加载失败:' + (data.error || '未知错误'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const articles = data.data || [];
|
||||
const pag = data.pagination || {};
|
||||
state.totalPages = pag.totalPages || 0;
|
||||
if (state.page >= state.totalPages) state.done = true;
|
||||
if (articles.length === 0 && state.page === 1) {
|
||||
showEmpty(true);
|
||||
} else {
|
||||
showEmpty(false);
|
||||
renderCards(articles);
|
||||
state.page++;
|
||||
}
|
||||
}
|
||||
|
||||
function showLoading(show) {
|
||||
const el = $('#zs-loading');
|
||||
if (el) el.style.display = show ? 'block' : 'none';
|
||||
}
|
||||
|
||||
function showEmpty(show) {
|
||||
const el = $('#zs-empty');
|
||||
if (el) el.style.display = show ? 'block' : 'none';
|
||||
}
|
||||
|
||||
// ── 渲染卡片 ──
|
||||
function renderCards(articles) {
|
||||
const grid = $('#zs-card-grid');
|
||||
if (!grid) return;
|
||||
articles.forEach(function (article) {
|
||||
const card = createCard(article);
|
||||
grid.appendChild(card);
|
||||
});
|
||||
lazyLoadImages();
|
||||
}
|
||||
|
||||
function createCard(article) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'zs-card';
|
||||
card.setAttribute('data-id', article.id);
|
||||
card.addEventListener('click', function (e) {
|
||||
if (e.target.closest('.fav-btn')) return;
|
||||
openDetail(article.id);
|
||||
});
|
||||
|
||||
// 封面区
|
||||
const cover = document.createElement('div');
|
||||
cover.className = 'card-cover';
|
||||
if (article.cover_url) {
|
||||
const img = document.createElement('img');
|
||||
img.setAttribute('data-src', article.cover_url);
|
||||
img.setAttribute('alt', article.title);
|
||||
img.className = 'lazy-img';
|
||||
cover.appendChild(img);
|
||||
} else {
|
||||
const badge = document.createElement('div');
|
||||
badge.className = 'text-badge';
|
||||
badge.textContent = '文';
|
||||
cover.appendChild(badge);
|
||||
}
|
||||
|
||||
// 角标
|
||||
const imgCount = article.image_count || 0;
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'card-badge';
|
||||
badge.textContent = article.content_type === 'image' ? '含' + imgCount + '张附图' : '纯文字';
|
||||
cover.appendChild(badge);
|
||||
|
||||
// 正文区
|
||||
const body = document.createElement('div');
|
||||
body.className = 'card-body';
|
||||
|
||||
const title = document.createElement('div');
|
||||
title.className = 'card-title';
|
||||
title.textContent = article.title;
|
||||
|
||||
const source = document.createElement('div');
|
||||
source.className = 'card-source';
|
||||
source.textContent = article.source || '';
|
||||
|
||||
// 标签
|
||||
const tagsRow = document.createElement('div');
|
||||
tagsRow.className = 'card-tags';
|
||||
try {
|
||||
const tags = JSON.parse(article.tags_json || '[]');
|
||||
tags.forEach(function (t) {
|
||||
const span = document.createElement('span');
|
||||
span.className = 'card-tag';
|
||||
span.textContent = t;
|
||||
tagsRow.appendChild(span);
|
||||
});
|
||||
} catch (e) {}
|
||||
|
||||
// 底部
|
||||
const footer = document.createElement('div');
|
||||
footer.className = 'card-footer';
|
||||
|
||||
const meta = document.createElement('span');
|
||||
meta.textContent = (article.created_at || '').slice(0, 10) + ' · ' + (article.views || 0) + ' 阅读';
|
||||
|
||||
const favBtn = document.createElement('button');
|
||||
favBtn.className = 'fav-btn';
|
||||
favBtn.setAttribute('data-id', article.id);
|
||||
favBtn.title = '收藏';
|
||||
if (state.favorites.has(article.id)) {
|
||||
favBtn.classList.add('favorited');
|
||||
favBtn.textContent = '♥';
|
||||
} else {
|
||||
favBtn.textContent = '♡';
|
||||
}
|
||||
favBtn.addEventListener('click', function (e) { e.stopPropagation(); toggleFavorite(article.id, favBtn); });
|
||||
|
||||
footer.appendChild(meta);
|
||||
footer.appendChild(favBtn);
|
||||
|
||||
body.appendChild(title);
|
||||
body.appendChild(source);
|
||||
body.appendChild(tagsRow);
|
||||
body.appendChild(footer);
|
||||
|
||||
card.appendChild(cover);
|
||||
card.appendChild(body);
|
||||
return card;
|
||||
}
|
||||
|
||||
// ── 图片懒加载 ──
|
||||
function lazyLoadImages() {
|
||||
const imgs = document.querySelectorAll('img.lazy-img[data-src]');
|
||||
if (!('IntersectionObserver' in window)) {
|
||||
imgs.forEach(function (img) { img.src = img.getAttribute('data-src'); img.removeAttribute('data-src'); });
|
||||
return;
|
||||
}
|
||||
const observer = new IntersectionObserver(function (entries) {
|
||||
entries.forEach(function (entry) {
|
||||
if (entry.isIntersecting) {
|
||||
const img = entry.target;
|
||||
img.src = img.getAttribute('data-src');
|
||||
img.removeAttribute('data-src');
|
||||
img.classList.remove('lazy-img');
|
||||
observer.unobserve(img);
|
||||
}
|
||||
});
|
||||
}, { rootMargin: '100px' });
|
||||
imgs.forEach(function (img) { observer.observe(img); });
|
||||
}
|
||||
|
||||
// ── 滚动触底加载 ──
|
||||
let scrollTimer = null;
|
||||
function onScroll() {
|
||||
if (scrollTimer) return;
|
||||
scrollTimer = setTimeout(function () {
|
||||
scrollTimer = null;
|
||||
if (state.loading || state.done) return;
|
||||
const scrollY = window.scrollY + window.innerHeight;
|
||||
const docH = document.documentElement.scrollHeight;
|
||||
if (scrollY >= docH - 300) loadArticles(false);
|
||||
}, 200);
|
||||
}
|
||||
|
||||
// ── 收藏切换 ──
|
||||
async function toggleFavorite(articleId, btn) {
|
||||
if (btn._pending) return;
|
||||
btn._pending = true;
|
||||
btn.style.opacity = '0.5';
|
||||
try {
|
||||
const data = await apiPost('/articles/' + articleId + '/toggle_favorite', {});
|
||||
if (data.ok) {
|
||||
if (data.data && data.data.favorited) {
|
||||
state.favorites.add(articleId);
|
||||
btn.classList.add('favorited');
|
||||
btn.textContent = '♥';
|
||||
showToast('已收藏', 'success');
|
||||
} else {
|
||||
state.favorites.delete(articleId);
|
||||
btn.classList.remove('favorited');
|
||||
btn.textContent = '♡';
|
||||
showToast('已取消收藏', 'info');
|
||||
}
|
||||
} else {
|
||||
showToast('操作失败:' + (data.error || '未知错误'), 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
showToast('网络错误', 'error');
|
||||
}
|
||||
btn._pending = false;
|
||||
btn.style.opacity = '1';
|
||||
}
|
||||
|
||||
// ── 打开详情页 ──
|
||||
function openDetail(id) {
|
||||
window.open('/tools/zhizi_notes/detail.html?id=' + id, '_blank');
|
||||
}
|
||||
|
||||
// ── 筛选逻辑 ──
|
||||
function setFilter(key, value) {
|
||||
if (key === 'category') {
|
||||
if (state.filter.category === value) value = '';
|
||||
} else if (key === 'tag') {
|
||||
if (state.filter.tag === value) value = '';
|
||||
} else if (key === 'timeRange') {
|
||||
if (state.filter.timeRange === value) value = '';
|
||||
} else if (key === 'contentType') {
|
||||
if (state.filter.contentType === value) value = '';
|
||||
}
|
||||
state.filter[key] = value;
|
||||
updateFilterUI();
|
||||
loadArticles(true);
|
||||
}
|
||||
|
||||
function setSearch(value) {
|
||||
state.filter.search = value;
|
||||
loadArticles(true);
|
||||
}
|
||||
|
||||
function updateFilterUI() {
|
||||
// 分类
|
||||
$$('.zs-sidebar .cat-item').forEach(function (el) {
|
||||
var cat = el.getAttribute('data-cat');
|
||||
el.classList.toggle('active', cat === state.filter.category);
|
||||
});
|
||||
// 标签
|
||||
$$('.zs-sidebar .tag-item').forEach(function (el) {
|
||||
var tag = el.getAttribute('data-tag');
|
||||
el.classList.toggle('active', tag === state.filter.tag);
|
||||
});
|
||||
// 时间
|
||||
$$('.zs-sidebar .time-item').forEach(function (el) {
|
||||
var tr = el.getAttribute('data-time');
|
||||
el.classList.toggle('active', tr === state.filter.timeRange);
|
||||
});
|
||||
// 图文类型
|
||||
$$('.zs-sidebar .type-btn').forEach(function (el) {
|
||||
var ct = el.getAttribute('data-type');
|
||||
el.classList.toggle('active', ct === state.filter.contentType);
|
||||
});
|
||||
// 筛选标签显示
|
||||
renderFilterTags();
|
||||
}
|
||||
|
||||
function renderFilterTags() {
|
||||
var container = $('#zs-filter-tags');
|
||||
if (!container) return;
|
||||
container.innerHTML = '';
|
||||
var items = [];
|
||||
if (state.filter.category) items.push({ label: state.filter.category, key: 'category' });
|
||||
if (state.filter.tag) items.push({ label: state.filter.tag, key: 'tag' });
|
||||
if (state.filter.timeRange) {
|
||||
var labels = { week: '本周', month: '本月', quarter: '本季度' };
|
||||
items.push({ label: labels[state.filter.timeRange] || state.filter.timeRange, key: 'timeRange' });
|
||||
}
|
||||
if (state.filter.contentType) {
|
||||
items.push({ label: state.filter.contentType === 'text' ? '纯文字' : '图文多图', key: 'contentType' });
|
||||
}
|
||||
items.forEach(function (item) {
|
||||
var span = document.createElement('span');
|
||||
span.className = 'filter-tag';
|
||||
span.textContent = item.label;
|
||||
var rm = document.createElement('button');
|
||||
rm.className = 'remove-filter';
|
||||
rm.textContent = '×';
|
||||
rm.addEventListener('click', function () { setFilter(item.key, ''); });
|
||||
span.appendChild(rm);
|
||||
container.appendChild(span);
|
||||
});
|
||||
}
|
||||
|
||||
// ── 视图切换 ──
|
||||
function setViewMode(mode) {
|
||||
state.viewMode = mode;
|
||||
localStorage.setItem(STORAGE_VIEW, mode);
|
||||
var grid = $('#zs-card-grid');
|
||||
if (grid) grid.className = mode === 'list' ? 'card-grid list-view' : 'card-grid';
|
||||
$$('.zs-main .view-toggle button').forEach(function (btn) {
|
||||
btn.classList.toggle('active', btn.getAttribute('data-view') === mode);
|
||||
});
|
||||
}
|
||||
|
||||
// ── 加载右侧栏 ──
|
||||
async function loadRightbar() {
|
||||
// 置顶
|
||||
try {
|
||||
var pinned = await apiGet('/articles/pinned');
|
||||
var pinnedContainer = $('#zs-pinned');
|
||||
if (pinnedContainer && pinned.ok && pinned.data) {
|
||||
pinnedContainer.innerHTML = '';
|
||||
pinned.data.forEach(function (a) {
|
||||
var div = document.createElement('div');
|
||||
div.className = 'pinned-item';
|
||||
div.innerHTML = '<div class="pinned-title">' + escapeHtml(a.title) + '</div><div class="pinned-date">' + (a.created_at || '').slice(0, 10) + '</div>';
|
||||
div.addEventListener('click', function () { openDetail(a.id); });
|
||||
pinnedContainer.appendChild(div);
|
||||
});
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
// 最新
|
||||
try {
|
||||
var latest = await apiGet('/articles/latest');
|
||||
var latestContainer = $('#zs-latest');
|
||||
if (latestContainer && latest.ok && latest.data) {
|
||||
latestContainer.innerHTML = '';
|
||||
latest.data.forEach(function (a) {
|
||||
var div = document.createElement('div');
|
||||
div.className = 'latest-item';
|
||||
div.textContent = a.title;
|
||||
div.addEventListener('click', function () { openDetail(a.id); });
|
||||
latestContainer.appendChild(div);
|
||||
});
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
// 统计数据
|
||||
try {
|
||||
var stats = await apiGet('/stats');
|
||||
if (stats.ok && stats.data) {
|
||||
var totalArticles = $('#zs-stat-articles');
|
||||
var totalImages = $('#zs-stat-images');
|
||||
if (totalArticles) totalArticles.textContent = stats.data.totalArticles || 0;
|
||||
if (totalImages) totalImages.textContent = stats.data.totalImages || 0;
|
||||
// 分类占比
|
||||
var chartContainer = $('#zs-chart');
|
||||
if (chartContainer && stats.data.catStats) {
|
||||
var maxCount = Math.max.apply(null, stats.data.catStats.map(function (c) { return c.c; }));
|
||||
chartContainer.innerHTML = '';
|
||||
stats.data.catStats.forEach(function (c) {
|
||||
var pct = maxCount > 0 ? Math.round((c.c / maxCount) * 100) : 0;
|
||||
var row = document.createElement('div');
|
||||
row.className = 'chart-bar';
|
||||
row.innerHTML = '<span class="bar-label">' + escapeHtml(c.category) + '</span>' +
|
||||
'<span class="bar-track"><span class="bar-fill" style="width:' + pct + '%"></span></span>' +
|
||||
'<span class="bar-count">' + c.c + '</span>';
|
||||
chartContainer.appendChild(row);
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// ── 加载分类/标签 ──
|
||||
async function loadSidebarFilters() {
|
||||
// 分类
|
||||
try {
|
||||
var cats = await apiGet('/categories');
|
||||
var catContainer = $('#zs-categories');
|
||||
if (catContainer && cats.ok && cats.data) {
|
||||
catContainer.innerHTML = '';
|
||||
cats.data.forEach(function (c) {
|
||||
var btn = document.createElement('button');
|
||||
btn.className = 'cat-item';
|
||||
btn.setAttribute('data-cat', c.name);
|
||||
btn.textContent = c.name;
|
||||
btn.addEventListener('click', function () { setFilter('category', c.name); });
|
||||
catContainer.appendChild(btn);
|
||||
});
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
// 标签
|
||||
try {
|
||||
var tags = await apiGet('/tags');
|
||||
var tagContainer = $('#zs-tags');
|
||||
if (tagContainer && tags.ok && tags.data) {
|
||||
tagContainer.innerHTML = '';
|
||||
tags.data.forEach(function (t) {
|
||||
var btn = document.createElement('button');
|
||||
btn.className = 'tag-item';
|
||||
btn.setAttribute('data-tag', t.name);
|
||||
btn.textContent = t.name + '(' + (t.article_count || 0) + ')';
|
||||
btn.addEventListener('click', function () { setFilter('tag', t.name); });
|
||||
tagContainer.appendChild(btn);
|
||||
});
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// ── 加载收藏状态 ──
|
||||
async function loadFavorites() {
|
||||
try {
|
||||
var data = await apiGet('/favorites');
|
||||
if (data.ok && data.data) {
|
||||
state.favorites = new Set();
|
||||
data.data.forEach(function (a) { state.favorites.add(a.id); });
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// ── 初始化 ──
|
||||
function init() {
|
||||
initTheme();
|
||||
|
||||
// 视图切换
|
||||
setViewMode(state.viewMode);
|
||||
$$('.zs-main .view-toggle button').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () { setViewMode(btn.getAttribute('data-view')); });
|
||||
});
|
||||
|
||||
// 分类筛选
|
||||
$$('.zs-sidebar .cat-item').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () { setFilter('category', btn.getAttribute('data-cat')); });
|
||||
});
|
||||
|
||||
// 标签筛选
|
||||
// (动态生成的,在 loadSidebarFilters 中绑定)
|
||||
|
||||
// 时间筛选
|
||||
$$('.zs-sidebar .time-item').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () { setFilter('timeRange', btn.getAttribute('data-time')); });
|
||||
});
|
||||
|
||||
// 图文类型筛选
|
||||
$$('.zs-sidebar .type-btn').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () { setFilter('contentType', btn.getAttribute('data-type')); });
|
||||
});
|
||||
|
||||
// 搜索
|
||||
var searchInput = $('#zs-search');
|
||||
var searchBtn = $('#zs-search-btn');
|
||||
if (searchBtn && searchInput) {
|
||||
searchBtn.addEventListener('click', function () { setSearch(searchInput.value.trim()); });
|
||||
searchInput.addEventListener('keydown', function (e) { if (e.key === 'Enter') setSearch(searchInput.value.trim()); });
|
||||
}
|
||||
|
||||
// 滚动加载
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
|
||||
// 汉堡菜单
|
||||
var hamburger = $('#zs-hamburger');
|
||||
var sidebar = $('#zs-sidebar');
|
||||
var overlay = $('#zs-sidebar-overlay');
|
||||
if (hamburger && sidebar) {
|
||||
hamburger.addEventListener('click', function () {
|
||||
var open = sidebar.classList.contains('open');
|
||||
if (open) {
|
||||
sidebar.classList.remove('open');
|
||||
if (overlay) overlay.classList.remove('show');
|
||||
} else {
|
||||
sidebar.classList.add('open');
|
||||
if (overlay) overlay.classList.add('show');
|
||||
}
|
||||
});
|
||||
if (overlay) {
|
||||
overlay.addEventListener('click', function () {
|
||||
sidebar.classList.remove('open');
|
||||
overlay.classList.remove('show');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 提交存档按钮
|
||||
var submitBtn = $('#zs-submit-btn');
|
||||
if (submitBtn) {
|
||||
submitBtn.addEventListener('click', function () {
|
||||
window.open('/tools/zhizi_notes/admin.html', '_blank');
|
||||
});
|
||||
}
|
||||
|
||||
// 初始加载
|
||||
loadSidebarFilters();
|
||||
loadFavorites().then(function () { loadArticles(true); });
|
||||
loadRightbar();
|
||||
}
|
||||
|
||||
// ── 启动 ──
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
})();
|
||||
Binary file not shown.
Binary file not shown.
@@ -613,6 +613,170 @@ app.use('/api/fitness_diary', (req, res, next) => {
|
||||
|
||||
ensureFitnessDiaryLoaded()
|
||||
|
||||
// ── 知资札记 鉴权 ──
|
||||
const ZHIZI_NOTES_AUTH_COOKIE = 'zhizi_notes_gate'
|
||||
const readZhiziNotesAuthConfig = () => {
|
||||
try {
|
||||
const p = path.join(process.cwd(), 'public', 'tools', 'zhizi_notes', 'auth_config.json')
|
||||
if (fs.existsSync(p)) {
|
||||
const cfg = JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||||
if (cfg && typeof cfg === 'object') return cfg
|
||||
}
|
||||
} catch {}
|
||||
return {}
|
||||
}
|
||||
const readZhiziNotesJwk = () => {
|
||||
try {
|
||||
const p = path.join(process.cwd(), 'config', 'zhizi_notes.jwk.json')
|
||||
if (fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||||
} catch {}
|
||||
return { kty: 'RSA', n: '', e: '' }
|
||||
}
|
||||
const getZhiziNotesCookieName = () => {
|
||||
try {
|
||||
const cfg = readZhiziNotesAuthConfig()
|
||||
const n = String(cfg.cookieName || '')
|
||||
if (n) return n
|
||||
} catch {}
|
||||
return ZHIZI_NOTES_AUTH_COOKIE
|
||||
}
|
||||
const getZhiziNotesMaxAge = () => {
|
||||
try {
|
||||
const cfg = readZhiziNotesAuthConfig()
|
||||
const days = Number(cfg.max_age_days || 0)
|
||||
if (Number.isFinite(days) && days > 0) return Math.min(days, 365) * 24 * 3600 * 1000
|
||||
} catch {}
|
||||
return 30 * 24 * 3600 * 1000
|
||||
}
|
||||
const getZhiziNotesGuidConfig = () => {
|
||||
try {
|
||||
const cfg = readZhiziNotesAuthConfig()
|
||||
const keyRaw = String(cfg.guidParam || cfg.guid_param || 'guid').trim()
|
||||
const valueRaw = String(cfg.guidValue || cfg.guid_value || '').trim()
|
||||
return { key: keyRaw || 'guid', value: valueRaw }
|
||||
} catch {
|
||||
return { key: 'guid', value: '' }
|
||||
}
|
||||
}
|
||||
const hasValidZhiziNotesGuid = (req) => {
|
||||
try {
|
||||
const cfg = readZhiziNotesAuthConfig()
|
||||
if (cfg.enable_auth === false) return false
|
||||
const { key, value } = getZhiziNotesGuidConfig()
|
||||
if (!value) return false
|
||||
const q = req && req.query ? req.query : {}
|
||||
const raw = q[key]
|
||||
if (raw === undefined || raw === null) return false
|
||||
const incoming = Array.isArray(raw) ? String(raw[0] || '') : String(raw)
|
||||
return incoming === value
|
||||
} catch { return false }
|
||||
}
|
||||
const isZhiziNotesForceGuid = () => {
|
||||
try {
|
||||
const cfg = readZhiziNotesAuthConfig()
|
||||
if (cfg.enable_auth === false) return false
|
||||
if (Object.prototype.hasOwnProperty.call(cfg, 'requireGuid')) return !!cfg.requireGuid
|
||||
if (Object.prototype.hasOwnProperty.call(cfg, 'require_guid')) return !!cfg.require_guid
|
||||
return false
|
||||
} catch { return false }
|
||||
}
|
||||
const hasZhiziNotesAuth = (req) => {
|
||||
try {
|
||||
const config = readZhiziNotesAuthConfig()
|
||||
if (config.enable_auth === false) return true
|
||||
const cookies = parseCookie(req.headers.cookie || '')
|
||||
if (cookies[getZhiziNotesCookieName()] === '1') return true
|
||||
return false
|
||||
} catch { return false }
|
||||
}
|
||||
|
||||
// 知资札记 入口守卫
|
||||
app.get('/tools/zhizi_notes', (req, res, next) => {
|
||||
setNoCache(res)
|
||||
try {
|
||||
const forceGuid = isZhiziNotesForceGuid()
|
||||
if (hasZhiziNotesAuth(req)) return next()
|
||||
const guidKey = getZhiziNotesGuidConfig().key
|
||||
const hasGuidInput = req && req.query && Object.prototype.hasOwnProperty.call(req.query, guidKey)
|
||||
if (hasValidZhiziNotesGuid(req)) {
|
||||
const maxAge = getZhiziNotesMaxAge()
|
||||
const cookieName = getZhiziNotesCookieName()
|
||||
res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge, path: '/' })
|
||||
return res.redirect('/tools/zhizi_notes/index.html')
|
||||
}
|
||||
if (hasGuidInput) return res.status(401).send('未授权')
|
||||
const token = String(req.query.token || '')
|
||||
if (!token) {
|
||||
if (forceGuid) return res.status(401).send('未授权')
|
||||
return next()
|
||||
}
|
||||
const payload = verifyJwtWithKeys(token, jwkKeys(readZhiziNotesJwk()))
|
||||
if (!payload) return res.status(401).send('未授权')
|
||||
{
|
||||
const iss = String(payload.iss || '')
|
||||
const navIss = getNavIssFromFlags()
|
||||
if (iss === navIss && !audMatch(payload, 'Tools-zhizi_notes')) return res.status(401).send('未授权')
|
||||
}
|
||||
const maxAge = getZhiziNotesMaxAge()
|
||||
const cookieName = getZhiziNotesCookieName()
|
||||
res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge, path: '/' })
|
||||
return res.redirect('/tools/zhizi_notes/index.html')
|
||||
} catch (e) { return res.status(401).send('未授权') }
|
||||
})
|
||||
|
||||
// 知资札记 子路径守卫
|
||||
app.use('/tools/zhizi_notes', (req, res, next) => {
|
||||
const forceGuid = isZhiziNotesForceGuid()
|
||||
if (hasZhiziNotesAuth(req)) return next()
|
||||
const isHandshake = req.method === 'GET' && (req.path === '/' || req.path === '' || req.path === '/index.html')
|
||||
const guidKey = getZhiziNotesGuidConfig().key
|
||||
const hasGuidInput = isHandshake && req && req.query && Object.prototype.hasOwnProperty.call(req.query, guidKey)
|
||||
if (isHandshake && hasValidZhiziNotesGuid(req)) {
|
||||
const maxAge = getZhiziNotesMaxAge()
|
||||
const cookieName = getZhiziNotesCookieName()
|
||||
res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge, path: '/' })
|
||||
return res.redirect('/tools/zhizi_notes/index.html')
|
||||
}
|
||||
if (hasGuidInput) return res.status(401).send('未授权')
|
||||
if (isHandshake && String(req.query.token || '')) {
|
||||
const payload = verifyJwtWithKeys(String(req.query.token), jwkKeys(readZhiziNotesJwk()))
|
||||
if (payload) {
|
||||
const iss = String(payload.iss || '')
|
||||
const navIss = getNavIssFromFlags()
|
||||
if (iss === navIss && !audMatch(payload, 'Tools-zhizi_notes')) return res.status(401).send('未授权')
|
||||
const maxAge = getZhiziNotesMaxAge()
|
||||
const cookieName = getZhiziNotesCookieName()
|
||||
res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge, path: '/' })
|
||||
return res.redirect('/tools/zhizi_notes/index.html')
|
||||
}
|
||||
}
|
||||
if (isHandshake && forceGuid) return res.status(401).send('未授权')
|
||||
return res.status(401).send('未授权')
|
||||
})
|
||||
|
||||
// 知资札记 API 鉴权
|
||||
app.use('/api/zhizi_notes', (req, res, next) => {
|
||||
if (hasZhiziNotesAuth(req)) return next()
|
||||
return res.status(401).json({ ok: false, error: 'unauthorized' })
|
||||
})
|
||||
|
||||
const ensureZhiziNotesLoaded = () => {
|
||||
let mod = null
|
||||
try { mod = require('./zhizi_notes') } catch (e) { try { logJSON('zhizi_notes.require.error', { error: String(e.message || e) }, 'zhizi_notes') } catch {}; setTimeout(ensureZhiziNotesLoaded, 2000); return }
|
||||
try {
|
||||
if (mod && mod.initDb) {
|
||||
mod.initDb(path.join(__dirname, '..', '..', 'data', 'zhizi_notes.db'))
|
||||
}
|
||||
if (mod && mod.bindRoutes) {
|
||||
mod.bindRoutes(app)
|
||||
try { logJSON('zhizi_notes.bind_routes.done', { ok: true }, 'zhizi_notes') } catch {}
|
||||
}
|
||||
} catch (e) {
|
||||
try { logJSON('zhizi_notes.bind_routes.error', { error: String(e.message || e) }, 'zhizi_notes') } catch {}
|
||||
}
|
||||
}
|
||||
ensureZhiziNotesLoaded()
|
||||
|
||||
const PLANT_HOME_AUTH_COOKIE = 'plant_home_gate'
|
||||
const readPlantHomeAuthConfig = () => {
|
||||
try {
|
||||
@@ -1406,6 +1570,7 @@ const mapSystemIdToUrl = (systemId) => {
|
||||
'Tools-calendar_voice': '/tools/calendar_voice',
|
||||
'TRAE-PSC': '/tools/psc',
|
||||
'FitnessDiary': '/tools/fitness_diary',
|
||||
'Tools-zhizi_notes': '/tools/zhizi_notes',
|
||||
'Tools-doc_cloud_keeper': '/tools/doc_cloud_keeper',
|
||||
'Tools-yuanzhupai': '/tools/yuanzhupai',
|
||||
'Tools-oss_file_cabinet': '/tools/oss_file_cabinet',
|
||||
|
||||
@@ -0,0 +1,518 @@
|
||||
/**
|
||||
* 知资札记 - 后端模块
|
||||
* 证券投资图文存档站,SQLite 存储 + 本地图片管理
|
||||
*/
|
||||
const Database = require('better-sqlite3');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const multer = require('multer');
|
||||
|
||||
let db = null;
|
||||
|
||||
// ── 链接清洗:移除所有 <a> 标签,保留文字 ──
|
||||
const clearAllLinks = (html) => {
|
||||
if (!html || typeof html !== 'string') return '';
|
||||
return html.replace(/<a\b[^>]*>(.*?)<\/a>/gi, '$1');
|
||||
};
|
||||
|
||||
// ── 初始化数据库 ──
|
||||
function initDb(dbPath) {
|
||||
const dir = path.dirname(dbPath);
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
db = new Database(dbPath);
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('foreign_keys = ON');
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS zn_articles (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
content_plain TEXT NOT NULL DEFAULT '',
|
||||
category TEXT NOT NULL DEFAULT '未分类',
|
||||
source TEXT NOT NULL DEFAULT '',
|
||||
tags_json TEXT NOT NULL DEFAULT '[]',
|
||||
pics_json TEXT NOT NULL DEFAULT '[]',
|
||||
cover_url TEXT NOT NULL DEFAULT '',
|
||||
content_type TEXT NOT NULL DEFAULT 'text',
|
||||
status TEXT NOT NULL DEFAULT 'normal',
|
||||
views INTEGER NOT NULL DEFAULT 0,
|
||||
is_pinned INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now','localtime'))
|
||||
)
|
||||
`);
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS zn_images (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
article_id INTEGER NOT NULL DEFAULT 0,
|
||||
filename TEXT NOT NULL,
|
||||
original_name TEXT NOT NULL DEFAULT '',
|
||||
file_size INTEGER NOT NULL DEFAULT 0,
|
||||
mime_type TEXT NOT NULL DEFAULT 'image/jpeg',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
|
||||
FOREIGN KEY (article_id) REFERENCES zn_articles(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS zn_tags (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
article_count INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime'))
|
||||
)
|
||||
`);
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS zn_categories (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime'))
|
||||
)
|
||||
`);
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS zn_favorites (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
article_id INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
|
||||
FOREIGN KEY (article_id) REFERENCES zn_articles(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
|
||||
// 初始化默认分类
|
||||
const defaultCats = ['宏观分析', '个股研报', '交易复盘', '行业逻辑', '估值教程', '风险避坑'];
|
||||
const insertCat = db.prepare('INSERT OR IGNORE INTO zn_categories (name, sort_order) VALUES (?, ?)');
|
||||
defaultCats.forEach((name, i) => insertCat.run(name, i));
|
||||
|
||||
// 初始化默认标签
|
||||
const defaultTags = ['价值投资', '短线技术', '新能源', '医药', '财报解读', '量化', '仓位管理'];
|
||||
const insertTag = db.prepare('INSERT OR IGNORE INTO zn_tags (name) VALUES (?)');
|
||||
defaultTags.forEach(name => insertTag.run(name));
|
||||
}
|
||||
|
||||
// ── 上传目录 ──
|
||||
const uploadsDir = path.join(process.cwd(), 'public', 'uploads', 'zhizi_notes');
|
||||
if (!fs.existsSync(uploadsDir)) fs.mkdirSync(uploadsDir, { recursive: true });
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => cb(null, uploadsDir),
|
||||
filename: (req, file, cb) => {
|
||||
const ext = path.extname(file.originalname) || '.jpg';
|
||||
const name = `${Date.now()}_${Math.random().toString(36).slice(2, 8)}${ext}`;
|
||||
cb(null, name);
|
||||
}
|
||||
});
|
||||
|
||||
const upload = multer({
|
||||
storage,
|
||||
limits: { fileSize: 20 * 1024 * 1024 },
|
||||
fileFilter: (req, file, cb) => {
|
||||
if (file.mimetype.startsWith('image/')) return cb(null, true);
|
||||
cb(new Error('仅支持图片文件上传,不支持视频'));
|
||||
}
|
||||
});
|
||||
|
||||
// ── 标签统计更新 ──
|
||||
const updateTagCounts = () => {
|
||||
try {
|
||||
const articles = db.prepare("SELECT tags_json FROM zn_articles WHERE status = 'normal'").all();
|
||||
const countMap = {};
|
||||
articles.forEach(row => {
|
||||
try {
|
||||
const tags = JSON.parse(row.tags_json || '[]');
|
||||
tags.forEach(t => { countMap[t] = (countMap[t] || 0) + 1; });
|
||||
} catch {}
|
||||
});
|
||||
const upsert = db.prepare('INSERT INTO zn_tags (name, article_count) VALUES (?, ?) ON CONFLICT(name) DO UPDATE SET article_count = excluded.article_count');
|
||||
for (const [name, count] of Object.entries(countMap)) upsert.run(name, count);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
// ── 绑定路由 ──
|
||||
function bindRoutes(app) {
|
||||
const API = '/api/zhizi_notes';
|
||||
|
||||
app.use(API, (req, res, next) => {
|
||||
res.set('Cache-Control', 'no-store');
|
||||
next();
|
||||
});
|
||||
|
||||
// ── 未授权提示(公开接口,无需鉴权) ──
|
||||
app.get(`${API}/auth/hint`, (req, res) => {
|
||||
res.json({ ok: true, message: '请通过首页九宫格进入,或使用正确的访问链接' })
|
||||
});
|
||||
|
||||
// ── 站点统计 ──
|
||||
app.get(`${API}/stats`, (req, res) => {
|
||||
try {
|
||||
const totalArticles = db.prepare("SELECT COUNT(*) AS c FROM zn_articles WHERE status = 'normal'").get().c;
|
||||
const totalImages = db.prepare('SELECT COUNT(*) AS c FROM zn_images').get().c;
|
||||
const catStats = db.prepare("SELECT category, COUNT(*) AS c FROM zn_articles WHERE status = 'normal' GROUP BY category ORDER BY c DESC").all();
|
||||
res.json({ ok: true, data: { totalArticles, totalImages, catStats } });
|
||||
} catch (e) {
|
||||
res.status(500).json({ ok: false, error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ── 分类列表 ──
|
||||
app.get(`${API}/categories`, (req, res) => {
|
||||
try {
|
||||
const rows = db.prepare('SELECT * FROM zn_categories ORDER BY sort_order ASC').all();
|
||||
res.json({ ok: true, data: rows });
|
||||
} catch (e) {
|
||||
res.status(500).json({ ok: false, error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ── 标签列表 ──
|
||||
app.get(`${API}/tags`, (req, res) => {
|
||||
try {
|
||||
updateTagCounts();
|
||||
const rows = db.prepare('SELECT * FROM zn_tags ORDER BY article_count DESC, name ASC').all();
|
||||
res.json({ ok: true, data: rows });
|
||||
} catch (e) {
|
||||
res.status(500).json({ ok: false, error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ── 文章列表(分页+筛选) ──
|
||||
app.get(`${API}/articles`, (req, res) => {
|
||||
try {
|
||||
const page = Math.max(1, parseInt(req.query.page) || 1);
|
||||
const pageSize = Math.min(100, Math.max(1, parseInt(req.query.pageSize) || 12));
|
||||
const offset = (page - 1) * pageSize;
|
||||
const { category, tag, timeRange, contentType, search, status } = req.query;
|
||||
|
||||
let where = ["1=1"];
|
||||
let params = {};
|
||||
|
||||
if (status) {
|
||||
where.push('a.status = @status');
|
||||
params.status = status;
|
||||
} else {
|
||||
where.push("a.status = 'normal'");
|
||||
}
|
||||
|
||||
if (category) {
|
||||
where.push('a.category = @category');
|
||||
params.category = category;
|
||||
}
|
||||
if (contentType) {
|
||||
where.push('a.content_type = @contentType');
|
||||
params.contentType = contentType;
|
||||
}
|
||||
if (tag) {
|
||||
where.push("a.tags_json LIKE @tagLike");
|
||||
params.tagLike = `%"${tag}"%`;
|
||||
}
|
||||
if (search) {
|
||||
where.push('(a.title LIKE @search OR a.content_plain LIKE @search OR a.tags_json LIKE @searchTag)');
|
||||
params.search = `%${search}%`;
|
||||
params.searchTag = `%${search}%`;
|
||||
}
|
||||
if (timeRange === 'week') {
|
||||
where.push("a.created_at >= datetime('now','localtime','-7 days')");
|
||||
} else if (timeRange === 'month') {
|
||||
where.push("a.created_at >= datetime('now','localtime','-1 month')");
|
||||
} else if (timeRange === 'quarter') {
|
||||
where.push("a.created_at >= datetime('now','localtime','-3 months')");
|
||||
}
|
||||
|
||||
const whereClause = where.join(' AND ');
|
||||
const countRow = db.prepare(`SELECT COUNT(*) AS total FROM zn_articles a WHERE ${whereClause}`).get(params);
|
||||
const total = countRow ? countRow.total : 0;
|
||||
|
||||
const rows = db.prepare(`
|
||||
SELECT a.*,
|
||||
(SELECT COUNT(*) FROM zn_favorites f WHERE f.article_id = a.id) AS fav_count,
|
||||
(SELECT COUNT(*) FROM zn_images im WHERE im.article_id = a.id) AS image_count
|
||||
FROM zn_articles a
|
||||
WHERE ${whereClause}
|
||||
ORDER BY a.is_pinned DESC, a.created_at DESC
|
||||
LIMIT @limit OFFSET @offset
|
||||
`).all({ ...params, limit: pageSize, offset });
|
||||
|
||||
res.json({
|
||||
ok: true,
|
||||
data: rows,
|
||||
pagination: { page, pageSize, total, totalPages: Math.ceil(total / pageSize) }
|
||||
});
|
||||
} catch (e) {
|
||||
res.status(500).json({ ok: false, error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ── 置顶文章 ──
|
||||
app.get(`${API}/articles/pinned`, (req, res) => {
|
||||
try {
|
||||
const rows = db.prepare(`
|
||||
SELECT * FROM zn_articles WHERE is_pinned = 1 AND status = 'normal' ORDER BY created_at DESC LIMIT 3
|
||||
`).all();
|
||||
res.json({ ok: true, data: rows });
|
||||
} catch (e) {
|
||||
res.status(500).json({ ok: false, error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ── 最新入库 ──
|
||||
app.get(`${API}/articles/latest`, (req, res) => {
|
||||
try {
|
||||
const rows = db.prepare(`
|
||||
SELECT * FROM zn_articles WHERE status = 'normal' ORDER BY created_at DESC LIMIT 5
|
||||
`).all();
|
||||
res.json({ ok: true, data: rows });
|
||||
} catch (e) {
|
||||
res.status(500).json({ ok: false, error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ── 文章详情 ──
|
||||
app.get(`${API}/articles/:id`, (req, res) => {
|
||||
try {
|
||||
const row = db.prepare('SELECT * FROM zn_articles WHERE id = ?').get(req.params.id);
|
||||
if (!row) return res.status(404).json({ ok: false, error: '文章不存在' });
|
||||
db.prepare('UPDATE zn_articles SET views = views + 1 WHERE id = ?').run(req.params.id);
|
||||
row.views += 1;
|
||||
const images = db.prepare('SELECT * FROM zn_images WHERE article_id = ?').all(req.params.id);
|
||||
res.json({ ok: true, data: { ...row, images } });
|
||||
} catch (e) {
|
||||
res.status(500).json({ ok: false, error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ── 新增文章 ──
|
||||
app.post(`${API}/articles`, (req, res) => {
|
||||
try {
|
||||
const { title, content, category, source, tags, pics, coverUrl, contentType } = req.body;
|
||||
if (!title) return res.status(400).json({ ok: false, error: '标题不能为空' });
|
||||
|
||||
const cleanedContent = clearAllLinks(content || '');
|
||||
const contentPlain = cleanedContent.replace(/<[^>]+>/g, '').trim();
|
||||
|
||||
const result = db.prepare(`
|
||||
INSERT INTO zn_articles (title, content, content_plain, category, source, tags_json, pics_json, cover_url, content_type)
|
||||
VALUES (@title, @content, @contentPlain, @category, @source, @tagsJson, @picsJson, @coverUrl, @contentType)
|
||||
`).run({
|
||||
title,
|
||||
content: cleanedContent,
|
||||
contentPlain,
|
||||
category: category || '未分类',
|
||||
source: source || '',
|
||||
tagsJson: JSON.stringify(tags || []),
|
||||
picsJson: JSON.stringify(pics || []),
|
||||
coverUrl: coverUrl || '',
|
||||
contentType: contentType || 'text'
|
||||
});
|
||||
|
||||
updateTagCounts();
|
||||
res.json({ ok: true, data: { id: result.lastInsertRowid } });
|
||||
} catch (e) {
|
||||
res.status(500).json({ ok: false, error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ── 更新文章 ──
|
||||
app.put(`${API}/articles/:id`, (req, res) => {
|
||||
try {
|
||||
const existing = db.prepare('SELECT * FROM zn_articles WHERE id = ?').get(req.params.id);
|
||||
if (!existing) return res.status(404).json({ ok: false, error: '文章不存在' });
|
||||
|
||||
const { title, content, category, source, tags, pics, coverUrl, contentType, isPinned, status } = req.body;
|
||||
const cleanedContent = content !== undefined ? clearAllLinks(content) : existing.content;
|
||||
const contentPlain = cleanedContent.replace(/<[^>]+>/g, '').trim();
|
||||
|
||||
db.prepare(`
|
||||
UPDATE zn_articles SET
|
||||
title = @title, content = @content, content_plain = @contentPlain,
|
||||
category = @category, source = @source,
|
||||
tags_json = @tagsJson, pics_json = @picsJson,
|
||||
cover_url = @coverUrl, content_type = @contentType,
|
||||
is_pinned = @isPinned, status = @status,
|
||||
updated_at = datetime('now','localtime')
|
||||
WHERE id = @id
|
||||
`).run({
|
||||
id: req.params.id,
|
||||
title: title || existing.title,
|
||||
content: cleanedContent,
|
||||
contentPlain,
|
||||
category: category || existing.category,
|
||||
source: source !== undefined ? source : existing.source,
|
||||
tagsJson: tags ? JSON.stringify(tags) : existing.tags_json,
|
||||
picsJson: pics ? JSON.stringify(pics) : existing.pics_json,
|
||||
coverUrl: coverUrl !== undefined ? coverUrl : existing.cover_url,
|
||||
contentType: contentType || existing.content_type,
|
||||
isPinned: isPinned !== undefined ? (isPinned ? 1 : 0) : existing.is_pinned,
|
||||
status: status || existing.status
|
||||
});
|
||||
|
||||
updateTagCounts();
|
||||
res.json({ ok: true });
|
||||
} catch (e) {
|
||||
res.status(500).json({ ok: false, error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ── 删除单张图片 ─
|
||||
app.delete(`${API}/images/:id`, (req, res) => {
|
||||
try {
|
||||
const img = db.prepare('SELECT * FROM zn_images WHERE id = ?').get(req.params.id);
|
||||
if (!img) return res.status(404).json({ ok: false, error: '图片不存在' });
|
||||
|
||||
// 删除物理文件
|
||||
const filePath = path.join(uploadsDir, img.filename);
|
||||
try { if (fs.existsSync(filePath)) fs.unlinkSync(filePath); } catch {}
|
||||
|
||||
db.prepare('DELETE FROM zn_images WHERE id = ?').run(req.params.id);
|
||||
res.json({ ok: true });
|
||||
} catch (e) {
|
||||
res.status(500).json({ ok: false, error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ── 删除文章(含关联图片) ──
|
||||
app.delete(`${API}/articles/:id`, (req, res) => {
|
||||
try {
|
||||
const existing = db.prepare('SELECT * FROM zn_articles WHERE id = ?').get(req.params.id);
|
||||
if (!existing) return res.status(404).json({ ok: false, error: '文章不存在' });
|
||||
|
||||
// 删除关联图片文件
|
||||
const images = db.prepare('SELECT * FROM zn_images WHERE article_id = ?').all(req.params.id);
|
||||
images.forEach(img => {
|
||||
const filePath = path.join(uploadsDir, img.filename);
|
||||
try { if (fs.existsSync(filePath)) fs.unlinkSync(filePath); } catch {}
|
||||
});
|
||||
|
||||
db.prepare('DELETE FROM zn_favorites WHERE article_id = ?').run(req.params.id);
|
||||
db.prepare('DELETE FROM zn_images WHERE article_id = ?').run(req.params.id);
|
||||
db.prepare('DELETE FROM zn_articles WHERE id = ?').run(req.params.id);
|
||||
|
||||
updateTagCounts();
|
||||
res.json({ ok: true });
|
||||
} catch (e) {
|
||||
res.status(500).json({ ok: false, error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ── 批量操作 ──
|
||||
app.post(`${API}/articles/batch`, (req, res) => {
|
||||
try {
|
||||
const { ids, action } = req.body;
|
||||
if (!Array.isArray(ids) || ids.length === 0) return res.status(400).json({ ok: false, error: '请选择至少一篇文章' });
|
||||
if (!['delete', 'offline', 'online'].includes(action)) return res.status(400).json({ ok: false, error: '无效操作' });
|
||||
|
||||
if (action === 'delete') {
|
||||
ids.forEach(id => {
|
||||
const images = db.prepare('SELECT * FROM zn_images WHERE article_id = ?').all(id);
|
||||
images.forEach(img => {
|
||||
const filePath = path.join(uploadsDir, img.filename);
|
||||
try { if (fs.existsSync(filePath)) fs.unlinkSync(filePath); } catch {}
|
||||
});
|
||||
db.prepare('DELETE FROM zn_favorites WHERE article_id = ?').run(id);
|
||||
db.prepare('DELETE FROM zn_images WHERE article_id = ?').run(id);
|
||||
db.prepare('DELETE FROM zn_articles WHERE id = ?').run(id);
|
||||
});
|
||||
} else {
|
||||
const newStatus = action === 'offline' ? 'offline' : 'normal';
|
||||
const stmt = db.prepare('UPDATE zn_articles SET status = ?, updated_at = datetime(\'now\',\'localtime\') WHERE id = ?');
|
||||
ids.forEach(id => stmt.run(newStatus, id));
|
||||
}
|
||||
|
||||
updateTagCounts();
|
||||
res.json({ ok: true });
|
||||
} catch (e) {
|
||||
res.status(500).json({ ok: false, error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ── 收藏切换 ──
|
||||
app.post(`${API}/articles/:id/toggle_favorite`, (req, res) => {
|
||||
try {
|
||||
const articleId = parseInt(req.params.id);
|
||||
const existing = db.prepare('SELECT * FROM zn_favorites WHERE article_id = ?').get(articleId);
|
||||
if (existing) {
|
||||
db.prepare('DELETE FROM zn_favorites WHERE article_id = ?').run(articleId);
|
||||
res.json({ ok: true, data: { favorited: false } });
|
||||
} else {
|
||||
db.prepare('INSERT INTO zn_favorites (article_id) VALUES (?)').run(articleId);
|
||||
res.json({ ok: true, data: { favorited: true } });
|
||||
}
|
||||
} catch (e) {
|
||||
res.status(500).json({ ok: false, error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ── 收藏列表 ──
|
||||
app.get(`${API}/favorites`, (req, res) => {
|
||||
try {
|
||||
const rows = db.prepare(`
|
||||
SELECT a.*, (SELECT COUNT(*) FROM zn_images im WHERE im.article_id = a.id) AS image_count
|
||||
FROM zn_articles a
|
||||
INNER JOIN zn_favorites f ON f.article_id = a.id
|
||||
WHERE a.status = 'normal'
|
||||
ORDER BY f.created_at DESC
|
||||
`).all();
|
||||
res.json({ ok: true, data: rows });
|
||||
} catch (e) {
|
||||
res.status(500).json({ ok: false, error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ── 图片上传 ──
|
||||
app.post(`${API}/upload`, upload.array('images', 20), (req, res) => {
|
||||
try {
|
||||
if (!req.files || req.files.length === 0) return res.status(400).json({ ok: false, error: '请选择图片' });
|
||||
const articleId = parseInt(req.body.articleId) || 0;
|
||||
const images = req.files.map(f => ({
|
||||
filename: f.filename,
|
||||
originalName: f.originalname,
|
||||
size: f.size,
|
||||
mimeType: f.mimetype,
|
||||
url: `/uploads/zhizi_notes/${f.filename}`
|
||||
}));
|
||||
|
||||
if (articleId > 0) {
|
||||
const stmt = db.prepare('INSERT INTO zn_images (article_id, filename, original_name, file_size, mime_type) VALUES (?, ?, ?, ?, ?)');
|
||||
images.forEach(img => stmt.run(articleId, img.filename, img.originalName, img.size, img.mimeType));
|
||||
}
|
||||
|
||||
res.json({ ok: true, data: images });
|
||||
} catch (e) {
|
||||
res.status(500).json({ ok: false, error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ── 导出纯文本 ──
|
||||
app.post(`${API}/export`, (req, res) => {
|
||||
try {
|
||||
const { ids } = req.body;
|
||||
if (!Array.isArray(ids) || ids.length === 0) return res.status(400).json({ ok: false, error: '请选择文章' });
|
||||
|
||||
const rows = db.prepare(`SELECT title, content_plain, source, category, created_at FROM zn_articles WHERE id IN (${ids.map(() => '?').join(',')})`).all(...ids);
|
||||
let text = '';
|
||||
rows.forEach((row, i) => {
|
||||
text += `【${i + 1}】${row.title}\n`;
|
||||
text += `分类:${row.category} | 来源:${row.source} | 存档时间:${row.created_at}\n`;
|
||||
text += `${'─'.repeat(50)}\n`;
|
||||
text += `${row.content_plain}\n\n`;
|
||||
});
|
||||
|
||||
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="知资札记_导出_${new Date().toISOString().slice(0, 10)}.txt"`);
|
||||
res.send(text);
|
||||
} catch (e) {
|
||||
res.status(500).json({ ok: false, error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ── 静态文件服务(上传图片) ──
|
||||
app.use('/uploads/zhizi_notes', (req, res, next) => {
|
||||
res.set('Cache-Control', 'public, max-age=86400');
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { initDb, bindRoutes };
|
||||
Reference in New Issue
Block a user