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

1. 新增知资札记完整站点:包含首页、详情页、管理后台
2. 添加站点图标、鉴权配置与后端API鉴权逻辑
3. 在市场页面新增知资札记入口跳转
4. 新增经典乡村音乐播放列表文件
5. 临时添加养生谚语批量导入脚本
This commit is contained in:
yangxiangyuan
2026-07-19 20:45:41 +08:00
parent 6a043687f5
commit 421a8aa1f7
20 changed files with 3752 additions and 14 deletions
@@ -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();
+4
View File
@@ -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

+2 -3
View File
@@ -25,6 +25,7 @@
<button id="m-tab-weekly" class="subtab">指数周报</button> <button id="m-tab-weekly" class="subtab">指数周报</button>
<button id="m-tab-news" class="subtab">全球新闻</button> <button id="m-tab-news" class="subtab">全球新闻</button>
<button id="m-tab-wisdom" 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> <button id="m-tab-tools" class="subtab">工具箱</button>
<div id="m-status" class="status status-inline" style="display:none"></div> <div id="m-status" class="status status-inline" style="display:none"></div>
</div> </div>
@@ -125,9 +126,7 @@
</div> </div>
</section> </section>
<section id="m-panel-wisdom" class="panel" style="display:none"> <!-- 理论与实践:点击tab直接新窗口打开 -->
<iframe id="wisdom-frame" src="/tools/markets/wallstreet_wisdom.html?v=10" style="width:100%;height:calc(100vh - 140px);border:none;"></iframe>
</section>
<section id="m-panel-tools" class="panel" style="display:none"> <section id="m-panel-tools" class="panel" style="display:none">
<div class="tools-container"> <div class="tools-container">
+13 -11
View File
@@ -1710,6 +1710,7 @@ const switchPanel = which => {
const wkTab = el('m-tab-weekly') const wkTab = el('m-tab-weekly')
const newsTab = el('m-tab-news') const newsTab = el('m-tab-news')
const wisdomTab = el('m-tab-wisdom') const wisdomTab = el('m-tab-wisdom')
const zhiziTab = el('m-tab-zhizi')
const toolsTab = el('m-tab-tools') const toolsTab = el('m-tab-tools')
const rt = el('m-panel-realtime') const rt = el('m-panel-realtime')
const ar = el('m-panel-archive') const ar = el('m-panel-archive')
@@ -1720,36 +1721,35 @@ const switchPanel = which => {
const tools = el('m-panel-tools') const tools = el('m-panel-tools')
if (!rtTab || !arTab || !calTab || !wkTab || !rt || !ar || !cal || !wk) return if (!rtTab || !arTab || !calTab || !wkTab || !rt || !ar || !cal || !wk) return
if (which === 'archive') { 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' 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() loadArchive()
} else if (which === 'calendar') { } 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' 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) if (!calendarState.month || !Array.isArray(calendarState.days) || calendarState.days.length === 0) loadSecurityCalendar(false)
} else if (which === 'weekly') { } 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' 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) { if (!weeklyState.weekStart || !Array.isArray(weeklyState.rows) || weeklyState.rows.length === 0) {
setWeeklyIdleState('请选择周后点击“加载”读取历史,或点“刷新并补存”主动更新。', { clearData: false }) setWeeklyIdleState('请选择周后点击“加载”读取历史,或点“刷新并补存”主动更新。', { clearData: false })
} }
} else if (which === 'news') { } 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') 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' 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) loadGlobalNews(false)
} else if (which === 'wisdom') { } else if (which === 'wisdom') {
rtTab.classList.remove('active'); arTab.classList.remove('active'); calTab.classList.remove('active'); wkTab.classList.remove('active') window.open('https://traesite.umersoft.com:8975/tools/markets/wallstreet_wisdom.html', '_blank')
if (newsTab) newsTab.classList.remove('active') return
if (wisdomTab) wisdomTab.classList.add('active') } else if (which === 'zhizi') {
rt.style.display = 'none'; ar.style.display = 'none'; cal.style.display = 'none'; wk.style.display = 'none' window.open('https://traesite.umersoft.com:8975/tools/zhizi_notes/index.html', '_blank')
if (news) news.style.display = 'none' return
if (wisdom) wisdom.style.display = ''
if (tools) tools.style.display = 'none'
} else if (which === 'tools') { } else if (which === 'tools') {
rtTab.classList.remove('active'); arTab.classList.remove('active'); calTab.classList.remove('active'); wkTab.classList.remove('active') rtTab.classList.remove('active'); arTab.classList.remove('active'); calTab.classList.remove('active'); wkTab.classList.remove('active')
if (newsTab) newsTab.classList.remove('active') if (newsTab) newsTab.classList.remove('active')
if (wisdomTab) wisdomTab.classList.remove('active') if (wisdomTab) wisdomTab.classList.remove('active')
if (zhiziTab) zhiziTab.classList.remove('active')
if (toolsTab) toolsTab.classList.add('active') if (toolsTab) toolsTab.classList.add('active')
rt.style.display = 'none'; ar.style.display = 'none'; cal.style.display = 'none'; wk.style.display = 'none' rt.style.display = 'none'; ar.style.display = 'none'; cal.style.display = 'none'; wk.style.display = 'none'
if (news) news.style.display = 'none' if (news) news.style.display = 'none'
@@ -1758,6 +1758,7 @@ const switchPanel = which => {
} else { } else {
rtTab.classList.add('active'); arTab.classList.remove('active'); calTab.classList.remove('active'); wkTab.classList.remove('active'); if (newsTab) newsTab.classList.remove('active') 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 (wisdomTab) wisdomTab.classList.remove('active')
if (zhiziTab) zhiziTab.classList.remove('active')
if (toolsTab) toolsTab.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' 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' 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 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 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 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 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 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) }) 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": ""
}
]
+245
View File
@@ -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; }
}
+104
View File
@@ -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>
+696
View File
@@ -0,0 +1,696 @@
/**
* 知资札记 - 后台CMS管理逻辑
* 图文内容管理列表页面
*/
(function () {
'use strict';
const API_BASE = '/api/zhizi_notes';
const $ = function (sel) { return document.querySelector(sel); };
const $$ = function (sel) { return document.querySelectorAll(sel); };
// ── 状态 ──
var adminState = {
page: 1,
pageSize: 20,
totalPages: 0,
total: 0,
filter: { search: '', category: '', contentType: '', status: '' },
selectedIds: new Set(),
editingId: null
};
// ── Toast ──
function showToast(msg, type) {
type = type || 'info';
var t = $('#za-toast');
if (!t) return;
t.textContent = msg;
t.className = 'za-toast ' + type + ' show';
clearTimeout(t._timer);
t._timer = setTimeout(function () { t.classList.remove('show'); }, type === 'error' ? 4000 : 2000);
}
// ── 链接清洗 ──
function clearAllLinks(html) {
if (!html || typeof html !== 'string') return '';
return html.replace(/<a\b[^>]*>(.*?)<\/a>/gi, '$1');
}
// ── API ──
async function apiGet(path) {
try {
var res = await fetch(API_BASE + path, { credentials: 'same-origin' });
return await res.json();
} catch (e) { return { ok: false, error: e.message }; }
}
async function apiPost(path, body) {
try {
var res = await fetch(API_BASE + path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify(body)
});
return await res.json();
} catch (e) { return { ok: false, error: e.message }; }
}
async function apiPut(path, body) {
try {
var res = await fetch(API_BASE + path, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify(body)
});
return await res.json();
} catch (e) { return { ok: false, error: e.message }; }
}
async function apiDelete(path) {
try {
var res = await fetch(API_BASE + path, { method: 'DELETE', credentials: 'same-origin' });
return await res.json();
} catch (e) { return { ok: false, error: e.message }; }
}
// ── 加载列表 ──
async function loadTableData() {
var params = new URLSearchParams();
params.set('page', adminState.page);
params.set('pageSize', adminState.pageSize);
if (adminState.filter.search) params.set('search', adminState.filter.search);
if (adminState.filter.category) params.set('category', adminState.filter.category);
if (adminState.filter.contentType) params.set('contentType', adminState.filter.contentType);
if (adminState.filter.status) params.set('status', adminState.filter.status);
var data = await apiGet('/articles?' + params.toString());
if (!data.ok) { showToast('加载失败:' + (data.error || '未知错误'), 'error'); return; }
adminState.totalPages = data.pagination ? data.pagination.totalPages : 0;
adminState.total = data.pagination ? data.pagination.total : 0;
adminState.selectedIds.clear();
renderTable(data.data || []);
renderPagination();
}
// ── 渲染表格 ──
function renderTable(articles) {
var tbody = $('#za-tbody');
if (!tbody) return;
tbody.innerHTML = '';
if (articles.length === 0) {
tbody.innerHTML = '<tr><td colspan="10" style="text-align:center;padding:40px;color:#999">暂无数据</td></tr>';
return;
}
articles.forEach(function (a) {
var tr = document.createElement('tr');
// 复选框
var tdCb = document.createElement('td');
var cb = document.createElement('input');
cb.type = 'checkbox';
cb.setAttribute('data-id', a.id);
cb.addEventListener('change', function () { toggleSelect(a.id, cb.checked); });
tdCb.appendChild(cb);
// 封面
var tdCover = document.createElement('td');
var coverDiv = document.createElement('div');
coverDiv.className = 'cell-cover';
if (a.cover_url) {
var img = document.createElement('img');
img.src = a.cover_url; img.alt = '';
coverDiv.appendChild(img);
} else {
coverDiv.innerHTML = '<div class="text-icon">文</div>';
}
tdCover.appendChild(coverDiv);
// 标题
var tdTitle = document.createElement('td');
var titleDiv = document.createElement('div');
titleDiv.className = 'cell-title';
titleDiv.textContent = a.title;
titleDiv.title = a.title;
tdTitle.appendChild(titleDiv);
// 分类
var tdCat = document.createElement('td');
tdCat.textContent = a.category;
// 内容类型
var tdType = document.createElement('td');
var typeSpan = document.createElement('span');
typeSpan.className = 'type-badge ' + (a.content_type === 'text' ? 'text' : 'image');
var imgCount = a.image_count || 0;
typeSpan.textContent = a.content_type === 'text' ? '纯文字' : '图文(' + imgCount + '张)';
tdType.appendChild(typeSpan);
// 来源
var tdSrc = document.createElement('td');
tdSrc.textContent = a.source || '-';
tdSrc.style.maxWidth = '100px';
tdSrc.style.overflow = 'hidden';
tdSrc.style.textOverflow = 'ellipsis';
tdSrc.style.whiteSpace = 'nowrap';
// 创建时间
var tdTime = document.createElement('td');
tdTime.textContent = (a.created_at || '').slice(0, 16);
tdTime.style.whiteSpace = 'nowrap';
// 阅读量
var tdViews = document.createElement('td');
tdViews.textContent = a.views || 0;
// 状态
var tdStatus = document.createElement('td');
var statusSpan = document.createElement('span');
statusSpan.className = 'status-badge ' + (a.status === 'normal' ? 'normal' : 'offline');
statusSpan.textContent = a.status === 'normal' ? '正常' : '已下架';
tdStatus.appendChild(statusSpan);
// 操作
var tdActions = document.createElement('td');
tdActions.className = 'actions';
var btnPreview = document.createElement('button');
btnPreview.className = 'act-preview';
btnPreview.textContent = '预览';
btnPreview.addEventListener('click', function () { window.open('/tools/zhizi_notes/detail.html?id=' + a.id, '_blank'); });
var btnEdit = document.createElement('button');
btnEdit.className = 'act-edit';
btnEdit.textContent = '编辑';
btnEdit.addEventListener('click', function () { openEditForm(a.id); });
var btnToggle = document.createElement('button');
btnToggle.className = 'act-toggle';
btnToggle.textContent = a.status === 'normal' ? '下架' : '上架';
btnToggle.addEventListener('click', function () { toggleStatus(a.id, a.status); });
var btnDel = document.createElement('button');
btnDel.className = 'act-delete';
btnDel.textContent = '删除';
btnDel.addEventListener('click', function () { confirmDelete(a.id, a.title); });
tdActions.appendChild(btnPreview);
tdActions.appendChild(btnEdit);
tdActions.appendChild(btnToggle);
tdActions.appendChild(btnDel);
tr.appendChild(tdCb);
tr.appendChild(tdCover);
tr.appendChild(tdTitle);
tr.appendChild(tdCat);
tr.appendChild(tdType);
tr.appendChild(tdSrc);
tr.appendChild(tdTime);
tr.appendChild(tdViews);
tr.appendChild(tdStatus);
tr.appendChild(tdActions);
tbody.appendChild(tr);
});
}
// ── 分页 ──
function renderPagination() {
var container = $('#za-pagination');
if (!container) return;
var info = $('#za-page-info');
if (info) info.textContent = '共 ' + adminState.total + ' 条,第 ' + adminState.page + '/' + adminState.totalPages + ' 页';
var btns = $('#za-page-btns');
if (!btns) return;
btns.innerHTML = '';
var total = adminState.totalPages;
var cur = adminState.page;
var prevBtn = document.createElement('button');
prevBtn.textContent = '上一页';
prevBtn.disabled = cur <= 1;
prevBtn.addEventListener('click', function () { if (cur > 1) { adminState.page--; loadTableData(); } });
btns.appendChild(prevBtn);
var start = Math.max(1, cur - 2);
var end = Math.min(total, cur + 2);
for (var i = start; i <= end; i++) {
var pageBtn = document.createElement('button');
pageBtn.textContent = i;
if (i === cur) pageBtn.classList.add('active');
pageBtn.addEventListener('click', function (p) {
return function () { adminState.page = p; loadTableData(); };
}(i));
btns.appendChild(pageBtn);
}
var nextBtn = document.createElement('button');
nextBtn.textContent = '下一页';
nextBtn.disabled = cur >= total;
nextBtn.addEventListener('click', function () { if (cur < total) { adminState.page++; loadTableData(); } });
btns.appendChild(nextBtn);
}
// ── 选择行 ──
function toggleSelect(id, checked) {
if (checked) adminState.selectedIds.add(id);
else adminState.selectedIds.delete(id);
updateBatchBtns();
}
function toggleSelectAll(checked) {
$$('#za-tbody input[type="checkbox"]').forEach(function (cb) {
cb.checked = checked;
var id = parseInt(cb.getAttribute('data-id'));
if (checked) adminState.selectedIds.add(id);
else adminState.selectedIds.delete(id);
});
updateBatchBtns();
}
function updateBatchBtns() {
var hasSelection = adminState.selectedIds.size > 0;
var btns = $$('.za-toolbar .btn:disabled, .za-toolbar .btn:not(:disabled)');
$$('#za-btn-batch-offline, #za-btn-batch-delete, #za-btn-batch-export').forEach(function (btn) {
btn.disabled = !hasSelection;
});
}
// ── 批量操作 ──
async function batchAction(action) {
if (adminState.selectedIds.size === 0) { showToast('请先选择文章', 'info'); return; }
var ids = Array.from(adminState.selectedIds);
var labels = { delete: '删除', offline: '下架', online: '上架' };
var label = labels[action] || action;
if (action === 'delete') {
showConfirm('确认批量' + label, '确定要批量' + label + ' ' + ids.length + ' 篇文章吗?此操作不可恢复。', async function () {
var data = await apiPost('/articles/batch', { ids: ids, action: action });
if (data.ok) { showToast('批量' + label + '成功', 'success'); loadTableData(); }
else showToast('操作失败:' + (data.error || '未知错误'), 'error');
});
} else {
var data = await apiPost('/articles/batch', { ids: ids, action: action });
if (data.ok) { showToast('批量' + label + '成功', 'success'); loadTableData(); }
else showToast('操作失败:' + (data.error || '未知错误'), 'error');
}
}
// ── 状态切换 ──
async function toggleStatus(id, currentStatus) {
var newStatus = currentStatus === 'normal' ? 'offline' : 'normal';
var label = newStatus === 'normal' ? '上架' : '下架';
var data = await apiPut('/articles/' + id, { status: newStatus });
if (data.ok) { showToast(label + '成功', 'success'); loadTableData(); }
else showToast('操作失败:' + (data.error || '未知错误'), 'error');
}
// ── 删除确认 ──
function confirmDelete(id, title) {
showConfirm('确认删除', '确定要永久删除文章「' + title + '」吗?关联图片也会一并删除,此操作不可恢复。', async function () {
var data = await apiDelete('/articles/' + id);
if (data.ok) { showToast('删除成功', 'success'); loadTableData(); }
else showToast('删除失败:' + (data.error || '未知错误'), 'error');
});
}
// ── 确认弹窗 ──
function showConfirm(title, message, onConfirm) {
var overlay = document.createElement('div');
overlay.className = 'za-modal-overlay';
overlay.innerHTML = '<div class="za-modal">' +
'<h3>' + escapeHtml(title) + '</h3>' +
'<div class="modal-body">' + escapeHtml(message) + '</div>' +
'<div class="modal-actions">' +
'<button class="btn btn-cancel" id="za-confirm-cancel">取消</button>' +
'<button class="btn btn-danger" id="za-confirm-ok">确认删除</button>' +
'</div></div>';
document.body.appendChild(overlay);
overlay.querySelector('#za-confirm-cancel').addEventListener('click', function () { overlay.remove(); });
overlay.querySelector('#za-confirm-ok').addEventListener('click', function () {
overlay.remove();
if (onConfirm) onConfirm();
});
overlay.addEventListener('click', function (e) { if (e.target === overlay) overlay.remove(); });
}
// ── 上传文件 ──
async function uploadFiles(files, articleId) {
var formData = new FormData();
formData.append('articleId', articleId || 0);
for (var i = 0; i < files.length; i++) {
formData.append('images', files[i]);
}
try {
var res = await fetch(API_BASE + '/upload', { method: 'POST', credentials: 'same-origin', body: formData });
return await res.json();
} catch (e) { return { ok: false, error: e.message }; }
}
// ── 新增/编辑表单 ──
async function openEditForm(id) {
adminState.editingId = id;
var article = null;
if (id) {
var data = await apiGet('/articles/' + id);
if (data.ok) article = data.data;
else { showToast('加载文章失败', 'error'); return; }
}
var overlay = document.createElement('div');
overlay.className = 'za-modal-overlay';
overlay.innerHTML = '<div class="za-modal form-modal">' +
'<h3>' + (id ? '编辑存档' : '新增存档') + '</h3>' +
'<div class="form-group"><label>标题</label><input id="za-form-title" value="' + escapeHtml(article ? article.title : '') + '" placeholder="输入文章标题" /></div>' +
'<div class="form-group"><label>分类</label><select id="za-form-category"><option value="">选择分类</option></select></div>' +
'<div class="form-group"><label>来源备注</label><input id="za-form-source" value="' + escapeHtml(article ? article.source : '') + '" placeholder="来源信息(纯文字)" /></div>' +
'<div class="form-group"><label>标签</label><div class="tag-input" id="za-form-tags"><input id="za-tag-input" placeholder="输入标签后回车" /></div></div>' +
'<div class="form-group"><label>正文</label><textarea id="za-form-content" placeholder="粘贴正文内容(自动清除外链)">' + escapeHtml(article ? article.content : '') + '</textarea></div>' +
'<div class="form-group"><label>内容类型</label><select id="za-form-contentType"><option value="text">纯文字</option><option value="image">图文</option></select></div>' +
'<div class="form-group"><label>置顶</label><select id="za-form-pinned"><option value="0">否</option><option value="1">是</option></select></div>' +
'<div class="form-group"><label>图片上传</label>' +
'<div class="upload-area" id="za-upload-area">' +
'<input type="file" id="za-file-input" accept="image/*" multiple style="display:none" />' +
'<button type="button" class="btn btn-outline" id="za-upload-btn">选择图片(可多选)</button>' +
'<div id="za-upload-preview" class="upload-preview"></div>' +
'</div>' +
'</div>' +
'<div class="modal-actions">' +
'<button class="btn btn-cancel" id="za-form-cancel">取消</button>' +
'<button class="btn btn-primary" id="za-form-save">保存</button>' +
'</div></div>';
document.body.appendChild(overlay);
// 填充分类
var cats = await apiGet('/categories');
var catSelect = overlay.querySelector('#za-form-category');
if (cats.ok && cats.data) {
cats.data.forEach(function (c) {
var opt = document.createElement('option');
opt.value = c.name; opt.textContent = c.name;
if (article && article.category === c.name) opt.selected = true;
catSelect.appendChild(opt);
});
}
if (article) {
overlay.querySelector('#za-form-contentType').value = article.content_type || 'text';
overlay.querySelector('#za-form-pinned').value = article.is_pinned ? '1' : '0';
}
// 标签管理
var tagContainer = overlay.querySelector('#za-form-tags');
var tagInput = overlay.querySelector('#za-tag-input');
var currentTags = [];
try { if (article && article.tags_json) currentTags = JSON.parse(article.tags_json); } catch (e) {}
function renderTagChips() {
var existing = tagContainer.querySelectorAll('.tag-chip');
existing.forEach(function (el) { el.remove(); });
currentTags.forEach(function (t, i) {
var chip = document.createElement('span');
chip.className = 'tag-chip';
chip.innerHTML = t + '<button class="remove-tag" data-idx="' + i + '">×</button>';
chip.querySelector('.remove-tag').addEventListener('click', function () {
currentTags.splice(i, 1);
renderTagChips();
});
tagContainer.insertBefore(chip, tagInput);
});
}
renderTagChips();
tagInput.addEventListener('keydown', function (e) {
if (e.key === 'Enter') {
e.preventDefault();
var val = tagInput.value.trim();
if (val && currentTags.indexOf(val) === -1) {
currentTags.push(val);
renderTagChips();
}
tagInput.value = '';
}
});
// ── 图片上传逻辑 ──
var selectedFiles = [];
var removedImageIds = []; // 编辑时删除的已有图片ID
var uploadPreview = overlay.querySelector('#za-upload-preview');
var fileInput = overlay.querySelector('#za-file-input');
var uploadBtn = overlay.querySelector('#za-upload-btn');
// 编辑时加载已有图片
if (article && article.images) {
article.images.forEach(function (img) {
var item = document.createElement('div');
item.className = 'preview-item';
item.innerHTML = '<img src="/uploads/zhizi_notes/' + img.filename + '" alt="" />' +
'<button class="preview-remove" data-filename="' + img.filename + '" data-id="' + (img.id || '') + '">×</button>';
uploadPreview.appendChild(item);
});
}
// 为所有已有图片的删除按钮绑定事件
uploadPreview.querySelectorAll('.preview-remove').forEach(function (btn) {
btn.addEventListener('click', function () {
var id = btn.getAttribute('data-id');
if (id) removedImageIds.push(parseInt(id));
btn.closest('.preview-item').remove();
});
});
uploadBtn.addEventListener('click', function () { fileInput.click(); });
fileInput.addEventListener('change', function () {
if (!fileInput.files) return;
Array.from(fileInput.files).forEach(function (file, idx) {
if (!file.type.startsWith('image/')) return;
selectedFiles.push(file);
var reader = new FileReader();
reader.onload = (function (i) {
return function (e) {
var item = document.createElement('div');
item.className = 'preview-item';
item.innerHTML = '<img src="' + e.target.result + '" alt="" />' +
'<button class="preview-remove">×</button>';
item.querySelector('.preview-remove').addEventListener('click', function () {
selectedFiles[i] = null;
item.remove();
});
uploadPreview.appendChild(item);
};
})(idx);
reader.readAsDataURL(file);
});
fileInput.value = '';
});
overlay.querySelector('#za-form-cancel').addEventListener('click', function () { overlay.remove(); });
overlay.querySelector('#za-form-save').addEventListener('click', async function () {
var saveBtn = overlay.querySelector('#za-form-save');
saveBtn.disabled = true;
saveBtn.textContent = '保存中…';
var title = overlay.querySelector('#za-form-title').value.trim();
if (!title) { showToast('标题不能为空', 'error'); saveBtn.disabled = false; saveBtn.textContent = '保存'; return; }
// 先保存文章获取 ID
var rawContent = overlay.querySelector('#za-form-content').value;
var content = clearAllLinks(rawContent);
var body = {
title: title,
content: content,
category: overlay.querySelector('#za-form-category').value || '未分类',
source: overlay.querySelector('#za-form-source').value.trim(),
tags: currentTags,
contentType: overlay.querySelector('#za-form-contentType').value,
isPinned: overlay.querySelector('#za-form-pinned').value === '1'
};
var result;
if (id) {
result = await apiPut('/articles/' + id, body);
result.data = { id: id };
} else {
result = await apiPost('/articles', body);
}
if (!result.ok) {
showToast('保存失败:' + (result.error || '未知错误'), 'error');
saveBtn.disabled = false;
saveBtn.textContent = '保存';
return;
}
var savedId = result.data ? result.data.id : id;
if (!savedId) savedId = id;
// 编辑时删除已被移除的图片
if (id && removedImageIds.length > 0) {
for (var k = 0; k < removedImageIds.length; k++) {
await apiDelete('/images/' + removedImageIds[k]);
}
}
// 过滤已删除的文件(标记为 null)
var filesToUpload = selectedFiles.filter(function (f) { return f !== null; });
// 上传图片
if (filesToUpload.length > 0 && savedId) {
saveBtn.textContent = '上传图片中…';
var uploadResult = await uploadFiles(filesToUpload, savedId);
if (!uploadResult.ok) {
showToast('图片上传失败:' + (uploadResult.error || '未知错误'), 'error');
saveBtn.disabled = false;
saveBtn.textContent = '保存';
return;
}
// 更新封面为新上传的第一张图
if (uploadResult.data && uploadResult.data.length > 0) {
body.coverUrl = uploadResult.data[0].url;
await apiPut('/articles/' + savedId, body);
}
}
showToast(id ? '更新成功' : '创建成功', 'success');
overlay.remove();
loadTableData();
});
overlay.addEventListener('click', function (e) { if (e.target === overlay) overlay.remove(); });
}
function escapeHtml(str) {
if (!str) return '';
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// ── 导出 ──
async function exportSelected() {
if (adminState.selectedIds.size === 0) { showToast('请先选择文章', 'info'); return; }
var ids = Array.from(adminState.selectedIds);
try {
var res = await fetch(API_BASE + '/export', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ ids: ids })
});
if (res.ok) {
var blob = await res.blob();
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url; a.download = '知资札记_导出.txt'; a.click();
URL.revokeObjectURL(url);
showToast('导出成功', 'success');
} else {
showToast('导出失败', 'error');
}
} catch (e) {
showToast('导出失败:' + e.message, 'error');
}
}
// ── 初始化 ──
function init() {
// 全选
var selectAllCb = $('#za-select-all');
if (selectAllCb) {
selectAllCb.addEventListener('change', function () { toggleSelectAll(selectAllCb.checked); });
}
// 搜索
var searchInput = $('#za-search');
var searchBtn = $('#za-search-btn');
if (searchBtn && searchInput) {
searchBtn.addEventListener('click', function () {
adminState.filter.search = searchInput.value.trim();
adminState.page = 1;
loadTableData();
});
searchInput.addEventListener('keydown', function (e) {
if (e.key === 'Enter') {
adminState.filter.search = searchInput.value.trim();
adminState.page = 1;
loadTableData();
}
});
}
// 分类筛选
var catFilter = $('#za-filter-category');
if (catFilter) {
catFilter.addEventListener('change', function () {
adminState.filter.category = catFilter.value;
adminState.page = 1;
loadTableData();
});
}
// 内容类型筛选
var typeFilter = $('#za-filter-type');
if (typeFilter) {
typeFilter.addEventListener('change', function () {
adminState.filter.contentType = typeFilter.value;
adminState.page = 1;
loadTableData();
});
}
// 状态筛选
var statusFilter = $('#za-filter-status');
if (statusFilter) {
statusFilter.addEventListener('change', function () {
adminState.filter.status = statusFilter.value;
adminState.page = 1;
loadTableData();
});
}
// 每页条数
var pageSizeSel = $('#za-page-size');
if (pageSizeSel) {
pageSizeSel.addEventListener('change', function () {
adminState.pageSize = parseInt(pageSizeSel.value) || 20;
adminState.page = 1;
loadTableData();
});
}
// 批量操作按钮
var btnBatchOffline = $('#za-btn-batch-offline');
var btnBatchDelete = $('#za-btn-batch-delete');
var btnBatchExport = $('#za-btn-batch-export');
if (btnBatchOffline) btnBatchOffline.addEventListener('click', function () { batchAction('offline'); });
if (btnBatchDelete) btnBatchDelete.addEventListener('click', function () { batchAction('delete'); });
if (btnBatchExport) btnBatchExport.addEventListener('click', function () { exportSelected(); });
// 新增按钮
var btnNew = $('#za-btn-new');
if (btnNew) btnNew.addEventListener('click', function () { openEditForm(null); });
// 加载分类选项
loadFilterOptions();
loadTableData();
}
async function loadFilterOptions() {
var cats = await apiGet('/categories');
var catFilter = $('#za-filter-category');
if (catFilter && cats.ok && cats.data) {
cats.data.forEach(function (c) {
var opt = document.createElement('option');
opt.value = c.name; opt.textContent = c.name;
catFilter.appendChild(opt);
});
}
}
document.addEventListener('DOMContentLoaded', init);
})();
+15
View File
@@ -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
}
+96
View File
@@ -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)); }
}
+79
View File
@@ -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>
+224
View File
@@ -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);
})();
+147
View File
@@ -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>
+416
View File
@@ -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; }
}
+594
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// ── 加载分类/标签 ──
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);
})();
+165
View File
@@ -613,6 +613,170 @@ app.use('/api/fitness_diary', (req, res, next) => {
ensureFitnessDiaryLoaded() 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 PLANT_HOME_AUTH_COOKIE = 'plant_home_gate'
const readPlantHomeAuthConfig = () => { const readPlantHomeAuthConfig = () => {
try { try {
@@ -1406,6 +1570,7 @@ const mapSystemIdToUrl = (systemId) => {
'Tools-calendar_voice': '/tools/calendar_voice', 'Tools-calendar_voice': '/tools/calendar_voice',
'TRAE-PSC': '/tools/psc', 'TRAE-PSC': '/tools/psc',
'FitnessDiary': '/tools/fitness_diary', 'FitnessDiary': '/tools/fitness_diary',
'Tools-zhizi_notes': '/tools/zhizi_notes',
'Tools-doc_cloud_keeper': '/tools/doc_cloud_keeper', 'Tools-doc_cloud_keeper': '/tools/doc_cloud_keeper',
'Tools-yuanzhupai': '/tools/yuanzhupai', 'Tools-yuanzhupai': '/tools/yuanzhupai',
'Tools-oss_file_cabinet': '/tools/oss_file_cabinet', 'Tools-oss_file_cabinet': '/tools/oss_file_cabinet',
+518
View File
@@ -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 };