diff --git a/- 副本.gitignore.2026-06-11 b/- 副本.gitignore.2026-06-11 new file mode 100644 index 0000000..0361921 --- /dev/null +++ b/- 副本.gitignore.2026-06-11 @@ -0,0 +1,71 @@ +# ===== 开发依赖 ===== +node_modules/ + +# ===== Windows 独有 ===== +winform/ +.DS_Store +Thumbs.db +desktop.ini + +# ===== 运行时数据(服务器重新生成)===== +data/ +logs/ +temp/ +.dbg/ +exports/ +backups/ +Toolbox-Weekly-Backup/ + +# ===== 前端大文件/素材(服务器不需要)===== +public/images/korean_references/ +public/images/zhihu/ +public/images/wave_references/ +public/images/x_fashion/ +public/images/style_check_images/ +public/home-bg/ +public/tools/apple_watch/apple_health_export/ +public/download_files/ + +# ===== 上传文件 ===== +uploads/ + +# ===== 脚本 ===== +scripts/ + +# ===== 本地凭证(含密码/密钥,不上传)===== +*.env.local +config/ +public/tools/markets/auth_config.json + +# ===== SSL 证书(服务器另有自己的)===== +umersoft.com_OV_SSL/ + +# ===== 调试/临时文件 ===== +*.md +*.txt +cookies.txt +_fix_*.js +_verify*.js +_verify2*.js +check_*.js +debug-*.md +example_auto_*.js +get_cookie_*.js +OSS权限.txt +.tmp_db_path.js +tmp_*.js +reproduce_issue.js +verify_db*.js +~\$*.docx + +# ===== IIS/构建配置(服务器不需要)===== +web.config +webpack.config.js +install_service_8977.bat +uninstall_service_8977.bat + +# ===== 杂项 ===== +.vscode/ +query +service/ +/tools/ \ No newline at end of file diff --git a/.gitignore b/.gitignore index 0361921..3180a2d 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,15 @@ uploads/ scripts/ # ===== 本地凭证(含密码/密钥,不上传)===== +# +# ★ 规则:所有密码、密钥、Token、用户名等高危内容, +# 一律放在 ~/Toolbox_local_creds.env.local 中, +# 禁止硬编码在源码里。 +# env.local 已被本规则排除,config/ 目录同理。 +# +# 使用姿势:在 index.js 入口自动加载 ~/Toolbox_local_creds.env.local +# 到 process.env,各模块通过 process.env.XXX 读取。 +# *.env.local config/ public/tools/markets/auth_config.json diff --git a/public/temp_download/music-box.html b/public/temp_download/music-box.html new file mode 100644 index 0000000..3a1a4a3 --- /dev/null +++ b/public/temp_download/music-box.html @@ -0,0 +1,961 @@ + + + + + + + + + +八音盒 + + + + + + +
+ + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 八音盒 +
+
0 首
+
+ + +
+ + +
+ + +
+ +
+ +
+
+
+ + + + + + + + + + + + + + +
+
+
+ + +
+
尚未选择曲目
+
—
+
+ + +
+ +
+ 0:00 + 0:00 +
+
+ + +
+ + + + + + + + + + +
+ + +
+ + + + + + + + + + + +
+
+ +
+ + + + +
+ + +
+ + + + + + + diff --git a/public/tools/markets/wallstreet_wisdom - 17到21之前.html b/public/tools/markets/wallstreet_wisdom - 17到21之前.html new file mode 100644 index 0000000..5cdc0aa --- /dev/null +++ b/public/tools/markets/wallstreet_wisdom - 17到21之前.html @@ -0,0 +1,2480 @@ + + + + + + + + + + +
+ + + + +
+ + +
+
+ +
+
+
+
WEEK 1
+
+
+
+ +
+ + +
+
+
+
+
+
+
+
+
+
+
+
+
+
对你的意义
+
+
+
+ +
+
+
+
+ +
+ +
+ +
+ + +
+
+ + +
+
+ +
+
本金与子弹
+
Cash · Dry Powder · Expected Value · Capital Allocation
+
+ +
+
核心公式 · The Core Equation
+
+ 长期财富 = 本金安全 × 子弹效率 × 等待耐心 +
+
+ 三项任何一项为零,结果归零。
+ 三项同时最大化,才能实现非对称的长期复利。 +
+
+ + +
+
+
+
+ +
Cash is not zero. Cash is an option.
+
+
▶
+
+
+
+
01
+
+
现金不是零收益,是一张永不过期的期权
+
持有现金意味着你拥有在任意时刻、以任意价格买入任意资产的权利。这张期权没有行权成本,没有到期日,只有一个代价:你在等待期间的机会成本。
+
"Cash combined with courage in a crisis is priceless." — Buffett
+
期权价值理论
+
+
+
+
02
+
+
现金仓的三层结构
+
第一层:随时可用,应对闪崩(24小时内出手)。第二层:短期备用,应对政策/加息窗口(1~30天)。第三层:永久Dry Powder,永远不挂单,只为完全预料不到的事。三层不能混用。
+
桥水资本 流动性分层框架
+
+
+
+
03
+
+
现金仓的最低红线:10%~15%
+
无论行情多好,永远保留10%~15%的现金仓不动。这不是保守,这是保证你在市场最极端时刻永远有出手能力。一旦子弹打完,你就从猎人变成了观众。
+
仓位管理铁律
+
+
+
+
+ + +
+
+
🎯
+
+ +
Opportunity Cost · Best Use of Capital
+
+
▶
+
+
+
+
01
+
+
每笔资金都有隐形成本
+
你把¥4,220挂在¥2.11,它的机会成本不是零,而是这笔钱在更高赔率机会(¥1.80)里能创造的收益。机会成本是真实的,即使它不出现在账单上。
+
"The cost of something is what you give up to get it." — N. Gregory Mankiw
+
经济学第一原理
+
+
+
+
02
+
+
资本配置优先级三问
+
在使用任何资金前,必须问:① 这是当前最高赔率的机会吗?② 赔率是否足够倾斜(向上空间 ÷ 向下空间 ≥ 2)?③ 使用这笔资金的机会成本是什么?三问全部通过,才动用资金。
+
Capital Allocation Hierarchy
+
+
+
+
03
+
+
小机会是大机会的天敌
+
把子弹打在赔率普通的机会上(¥2.11),导致赔率极高的机会来临时(¥1.80)没有子弹——这才是现金仓管理最大的失误。不是持有太多现金,而是把子弹打在了错误的目标上。
+
Druckenmiller 重仓原则
+
+
+
+
+ + +
+
+
📐
+
+ +
Expected Value · Kelly Criterion
+
+
▶
+
+
+
+
01
+
+
期望值公式
+
期望值 = 胜率 × 盈利幅度 − 败率 × 亏损幅度。只有期望值为正,且足够高,才值得出手。赔率不够时,期望值可能为正但极小——凯利公式会告诉你最优仓位接近零。
+
"It's not whether you're right or wrong, but how much you make when right and lose when wrong." — Soros
+
Kelly Criterion · 1956 Bell Labs
+
+
+
+
02
+
+
管理的是系统,不是单笔
+
一次踏空(没买到¥2.11)不影响系统期望值。重要的是:每次决策是否符合框架?框架的长期期望值是否为正?单次结果是随机的,系统的期望值才是真实的。
+
Annie Duke · Resulting 谬误
+
+
+
+
03
+
+
频繁交易是期望值的侵蚀剂
+
在震荡市里+3%/+5%来回做,手续费+摩擦成本+机会成本加总后,实际收益趋近于零。凯利公式在低赔率情景下的答案是:仓位接近零。不做,有时候是最高期望值的选择。
+
均值回归陷阱
+
+
+
+
+ + +
+
+
🔫
+
+ +
Dry Powder Discipline · Conviction Sizing
+
+
▶
+
+
+
+
01
+
+
子弹的三种错误死法
+
太早打完:市场跌5%全仓,跌15%的真正机会来了没子弹。永远不打:等到跌40%才敢买,市场只跌30%就反弹。③打在错误目标:赔率普通的机会消耗了赔率极高时需要的子弹。你的问题主要是③。
+
子弹消耗失误分类
+
+
+
+
02
+
+
高确信度时重仓,这是职业和业余的分水岭
+
把最多的子弹压在最高确信度的区间。你的日经52,000~54,000、标普5,700~6,000是高确信区,理应是最大注。哨兵档的子弹越少越好,主仓档的子弹越多越好。
+
"The best investment you can make is one where you risk a little to make a lot." — Druckenmiller
+
Conviction Sizing · 集中押注原则
+
+
+
+
03
+
+
买入标准:必要条件 vs 充分条件
+
必要条件:价格合理(如溢价消失)。充分条件:价格合理 且 赔率足够高 且 这是当前资本最优配置。你的卖出系统(阶梯止盈)是充分条件设计。你的买入系统需要同样的严格标准。
+
买入充分条件框架
+
+
+
+
+ + +
+
+
+
+ +
Market Regime Filter · Big Picture First
+
+
▶
+
+
+
+
01
+
+
大盘是所有ETF的公因子
+
大盘不好,整个板块都不好。在做个股/ETF决策之前,先判断市场整体处于什么状态。大盘是流动性和情绪的晴雨表,是第一道过滤开关,不是预测工具。
+
Market Regime Filter · 桥水 / AQR
+
+
+
+
02
+
+
沪深300四档状态机
+
4,000点以上:正常,不追加仓位。3,500~4,000:警戒,准备子弹开始挂单。3,200~3,500:恐慌,第一批子弹触发。3,200以下:极端,主仓子弹全部出动。大盘状态决定整体仓位上限。
+
你自己悟出的 Regime Filter
+
+
+
+
03
+
+
预期值不是绝对,是开关
+
不需要知道最低点在哪里。只需要知道"现在的市场状态允不允许我动手"。大盘过滤器把不可知的(精确低点)转化为可判断的(状态档位),这是系统从感性走向理性的关键一步。
+
你的原话,华尔街叫 Regime Filter
+
+
+
+
+ + + +
+
+ + + +
+
+ +
+
实战与得失
+
Battle Scars · Hard-Won Wisdom · From Practice to Theory
+
+ + +
+
核心信条 · The Creed
+
"证券几百年就这么一点东西。找对方法,克服人性,就可以赚钱。"
+
— 清河,2026年
+
+
华尔街的同一句话
+
"Investing is simple, but not easy." — Warren Buffett. 简单,是因为原理就那几条。不容易,是因为对抗人性需要一辈子的修炼。
+
+
+ + +
+
+
+
01
+
+
+
+ +
+
+
+
+
+
+
实战经历
+
+
+
+
华尔街理论印证
+
+
+
+
+ +
+
+
+
+ +
+ + + +
+ +
+ + +
+
+ + + + + + + +
+
+ +
+
火炉边的闲聊
+
FIRESIDE CHATS · A RETROSPECTIVE
+
关于投资、教训和重新开始的故事集。每一章都来自真实的经历,写在深夜的台灯下。不是教科书,是炉火边的一席话。
+
+ +
+ + 1 / 16 +
+ + +
+ + +
+ + 1 / 16 + +
+ +
+
+ + +
+
+
+ 目录 + +
+
+
①火中取栗
+
②离场,也是一种判断
+
③条件单:把清醒的自己锁进系统
+
④生活,是最好的研究报告
+
⑤入场的逻辑,必须和你持有的标的完全匹配
+
⑥采摘节:给系统设置一个呼吸的节点
+
⑦精简,是一种勇气
+
⑧从价格到价值——条件单的进化
+
⑨克服人性的三件武器
+
⑩投资,是一门终身的手艺
+
⑪最有把握的一注
+
⑫卖出,是下一次买入的开始
+
⑬两年的沉默,和一个更好的问题
+
⑭软件工程师的投资系统
+
⑮系统的骨架
+
⑯赢得不干净的那一次
+
+
+ + +
+ + + + + 00:00 / 00:00 + + +
+ + +
+
+

🎙️ 生成语音

+
+ + +
+ +
+
+
+
+ + + + + diff --git a/public/tools/markets/wallstreet_wisdom.html b/public/tools/markets/wallstreet_wisdom.html index 5cdc0aa..3d5a929 100644 --- a/public/tools/markets/wallstreet_wisdom.html +++ b/public/tools/markets/wallstreet_wisdom.html @@ -1221,6 +1221,46 @@ const lessons=[ exp:"每一次重新进入股市,风险意识都比上一次更强——从热点个股到宽基ETF,从频繁操作到条件单等待,从无仓位上限到六层金字塔。未来随着临近退休,防御仓位会进一步提高,进攻仓位继续收缩。这个方向是正确的,但节奏需要和实际需求挂钩,不能盲目加速。", theory:"Inflation Risk(通胀风险)——不像市场下跌那样显眼,但对退休资产的长期侵蚀是真实的。中国历史通胀均值约2%~3%,实际需要名义收益率5%~6%才能保住购买力。如果风险收缩太快,全部转向防御资产,收益率可能跌到4%以下,看起来在赚钱,实际上购买力在下降。建议设动态收缩标准:每五年评估一次,根据当时的通胀率、退休时间表、现金流需求,决定防御仓位提高多少。不是线性收缩,是阶梯式的、有触发条件的收缩。Life-Cycle Investing核心原则:防御化的节奏要匹配你的负债端(退休后需要多少现金流),而不是主观上'觉得应该更稳了'。", tags:["Inflation Risk","通胀侵蚀","Life-Cycle Investing","阶梯式收缩"] + }, + { + theme:"第二十一课 · 六层仓位金字塔——风险分层是组合的骨架", + en:"仓位大小和风险特征完全反向对应。越稳的给越多钱,越进攻的给越少钱。", + zh:"仓位像盖楼打地基,越稳的层给的钱越多。大盘宽基是地基,10万打底稳稳当当;卫星博弈是楼顶的花园,1万够用,塌了也不影响整栋楼。每一层的亏损即使全亏完,也伤不到下面几层——这就是分层最大的价值,不是赚多少,是亏得起。", + exp:"2026年6月采摘节重新界定仓位分层,从大盘宽基到卫星博弈,金额从10万递减到1万,形成六级风险金字塔:大盘宽基¥10万 / 主题宽基¥8万 / 进取成长¥5万 / 优质进攻¥3.5万 / 弹性进攻¥2.5万 / 卫星博弈¥1万。通讯ETF定位在优质进攻层(¥3.5万),不是随手试错,是有真实信念支撑的标的。这个分层不是静态的,会随生命周期动态调整——临近退休,防御层比重上升,进攻层比重下降。", + theory:"Risk Pyramid(风险金字塔)——标准机构组合管理框架,底层是低风险高流动性资产,越往上风险越高、仓位越小。这个设计的价值不只是控制风险,而是让每一层的亏损即使最大化,也不会动摇下面几层的稳定性。六层架构比三层更精细,说明对持仓特征已经有了清晰的分类能力,不再是感性的配置,而是结构性的设计。Life-Cycle Investing补充:六层结构本身是动态的,防御层的比重应随退休时间临近而阶梯式上升,不是一劳永逸的固定比例。", + tags:["Risk Pyramid","仓位分层","风险金字塔","六级架构","Life-Cycle"] + }, + { + theme:"第二十二课 · 锚定偏差——卖飞了是危险的买入动机", + en:"如果从来没有持有过它,今天第一次看到,你会买吗?", + zh:"追回一个之前卖飞了的标的,动机里混杂着信念和遗憾。过滤方法很简单:假设今天是第一次看到它,你会买吗?如果答案是会,才是真信念;如果犹豫,说明冲动里有锚定的成分。补充风险:估值基准可能重置,原来的伏击价格可能永远触发不了。", + exp:"通讯ETF设了条件单但一直涨,半年没有触发买入。采摘节考虑是否重新伏击时,发现追回的动机里混杂着两种成分:一是对这个板块真实的信念,二是对'卖便宜了'的遗憾。这两种动机看起来相似,但驱动力完全不同。在做任何分析之前,必须先过一道过滤:如果从来没有持有过它,今天第一次看到,你会买吗?答案是会,才继续分析;答案是不确定,说明追回来的冲动里有锚定的成分。补充风险:光模块板块在AI算力爆发背景下可能进入新估值区间,原来的伏击价格可能永远触发不了——这时需要判断是坚持等回调,还是接受新定价基准上移伏击价格。", + theory:"Anchoring Bias(锚定偏差)——Kahneman研究:人类决策天然会被第一个接触到的数字锚定。卖出价格成为锚点后,'追回来'的冲动看起来像逻辑,实际上是情绪。市场估值基准重置风险:某些板块在结构性利好驱动下会进入全新的估值区间,原来基于历史价格设定的伏击点可能永久失效。正确的应对不是硬等,而是重新评估:这个价格对应的是旧逻辑还是新逻辑?两个答案对应两种截然不同的操作。", + tags:["Anchoring Bias","锚定偏差","卖飞追回陷阱","估值基准重置"] + }, + { + theme:"第二十三课 · 系统的终点是直觉专家——规则内化为本能", + en:"系统最高境界是成熟在胸,手到擒来。到了哪一步就走哪一步操作。", + zh:"投资系统就像开车,刚开始每一步都要想,十年后全部变肌肉记忆。达到这个境界靠的不是时间本身,而是系统化训练的积累——日志、条件单、复盘,把随机经验转化为真正的成长。没有日志,经验无法转化为进步,只是记忆在时间里的漂移。", + exp:"现阶段的系统需要刻意思考才能执行:查条件单、看仓位层级、对照多因子框架、写日志、复盘。每一个步骤都是有意识的动作。目标是10年后这些动作变成本能,不需要每次从头推演,规则已经编码进直觉——失败了怎么走,成功了怎么走,大盘跌了怎么走,大盘涨了怎么走,每种情景都有预设的路径,执行不需要临时判断。达到这个境界的条件,不是时间的积累,而是系统化训练的积累。日志、条件单、采摘节复盘,是这个训练的基础设施。", + theory:"Intuitive Expertise(直觉专家)——心理学家Klein研究:真正的专家直觉不是凭感觉,而是从系统化训练中提炼出来的模式识别能力。象棋大师看棋盘不需要逐步计算,是因为见过几十万个棋局,模式已经内化。投资的直觉专家同理。重要前提:直觉专家的直觉来自系统化训练,不是随机经验的积累。10年的随机经验只是一年经验的10次重复;10年的系统化记录和复盘,才是10年的真实成长。没有日志,经验无法转化为进步,只是记忆在时间里的漂移。", + tags:["Intuitive Expertise","直觉专家","系统化训练","模式内化"] + }, + { + theme:"第二十四课 · 现金是期权,不是负担", + en:"现金放着就是未来盈利的期权。他没有之前想象中的白白躺在账户里。有大用处。", + zh:"判断对了但子弹不够,这是最难接受的一种输——输在准备上,不是输在判断上。现金不是等待中死去的钱,是等待中增值的权利。目标是把空闲现金仓提升至总仓位的20%,宁可不触发,也不提前消耗子弹。", + exp:"纳指科技ETF那笔操作,条件单触发、方向正确、执行完整,结果盈利。但回补时现金仓不足,只能降低金额。判断对了,子弹却不够用,这是最难承受的一种输——输在准备上,不是输在判断上。此后开始重新理解现金仓的本质:不是等待中死去的钱,而是等待中增值的权利。现金仓管理被列为采摘节的核心任务,目标是把空闲现金仓提升至总仓位的20%。条件单设置开始偏向保守,宁可不触发,也不提前消耗子弹。现金仓不足的三种模式:①子弹打在了优先级更低的目标上;②仓位上限设太满没给回补留空间;③月度子弹节奏和市场机会节奏不匹配。", + theory:"Option Value of Cash(现金的期权价值)——现金持有者拥有一张在任意时刻以任意价格买入任意资产的权利,这张权利没有行权成本,没有到期日。它的价值在危机时刻达到最高,恰恰是别人最缺现金的那一天。Capacity Constraint(容量约束):判断正确但资源不足,是一种不出现在账面上的机会成本损失。需要补充的批判:偏保守不是无限制的保守,第一档哨兵单需要设在合理但可触发的位置,否则等待本身会变成另一种形式的踏空——不是追高的踏空,是等待过度的踏空。", + tags:["Option Value of Cash","Dry Powder","期权价值","Capacity Constraint","现金仓三种不足"] + }, + { + theme:"第二十五课 · 热点与非热点——聚合替代追逐", + en:"大家都知道的热点,早就不是最好的进入时机了。万变不离其宗,就是纳指科技、科创50、中证500这些聚合股票。", + zh:"追热点的本质是在消息已经定价之后入场。正确做法:热点用聚合型宽基ETF间接持有,指数自动做热点权重调整;非热点只选分红型蓝筹,焊着收分红,不依赖市场情绪。不认识的猎物不开枪,第一次遇到的猎物把打击距离拉远。", + exp:"两次追热点的教训:①航空航天ETF——举国体制非商业化,政策节奏无法预测,入场时消息已经定价,小仓位亏损。②中概互联网ETF——期待市场把腾讯阿里当AI公司定价,但市场定的是互联网平台估值,两套逻辑不匹配,仓位不小,一路下跌。形成的新原则:热点用聚合型宽基ETF(纳指科技、科创50、中证500)间接持有,指数自动做热点权重调整;非热点只选分红型蓝筹(中国石化、中国神华、中国银行),焊着收分红,不依赖市场情绪。稀土和电力设备下半年不碰。", + theory:"Catalyst Mismatch(催化剂错配):买入理由是期待A逻辑定价,市场实际采用B逻辑定价,两套逻辑不匹配,持有期间不会产生预期收益。Narrative Fallacy(叙事谬误):好故事不等于好投资。一个逻辑成立,不等于市场会在你预期的时间内为它定价。市场是前瞻性定价机器——当热点成为所有人都在讨论的话题,预期已经被定价了大半,你看到的是别人走过的路的终点,不是起点。Smart Beta via Index(指数替代选热点):通过持有聚合指数,间接持有所有热点的受益,同时不承担单一热点判断失误的风险。", + tags:["Catalyst Mismatch","Narrative Fallacy","叙事谬误","聚合替代追逐","Smart Beta"] } ]; @@ -1821,6 +1861,10 @@ renderLesson();
⑭软件工程师的投资系统
⑮系统的骨架
⑯赢得不干净的那一次
+
⑰散弹枪与狙击枪
+
⑱三个隐蔽点
+
⑲采摘节
+
⑳热点与非热点
@@ -2156,6 +2200,88 @@ const p4chapters = [ "盈利是结果,干净是过程。结果好,不代表过程没有问题。把过程里的问题找出来,是下一次赢得更干净的唯一方式。", "告诫后来者: 在每一次盈利之后,也要认真复盘。问自己:这次赢得干净吗?如果不干净,原因是什么?好结果会掩盖坏过程,而坏过程,终有一天会在没有好结果的时候,完整地呈现出来。" ] + }, + { + title: "散弹枪与狙击枪", + marker: "⑰", + paras: [ + "如果要用最简单的语言,解释我的投资方法经历了什么样的转变,我会说:我从一个散弹枪手,变成了一个狙击手。", + "这不是隐喻的优劣之争,而是两种根本不同的作战哲学。", + "从前,我每天把枪拿出来,走进森林,听说哪里有猎物,就朝那个方向走过去。感觉是猎物了,开一枪。感觉还有猎物,再开一枪。回家的时候,子弹用了不少,背篓里却没什么东西。算下来,子弹的成本,远远超过了猎获的价值。", + "这就是散弹枪打法的本质问题:不是枪法不准,而是开枪太多。", + "每一颗子弹都有成本。显性的成本是手续费和价差,隐性的成本是注意力、判断力,以及每一次错误决策之后需要重建的信心。当你把精力分散在几十个方向上,表面上看起来勤奋,实际上是在用最高的代价,换取最低的期望回报。行为金融学的研究早已证实:交易最频繁的散户,年均收益率比交易最少的散户低6.5个百分点。勤奋,在这里是一个陷阱,不是优势。", + "后来,我换了一套作战方式。", + "我在森林里找了三个隐蔽点,分别叫中国、海外和防御。每一个隐蔽点,都是猎物必经之路——不是随机选择的,而是经过仔细勘察之后确定的战略阵地。然后,我趴下来。", + "趴着,是这套方法最反人性的部分。什么都不做,在心理上等同于浪费时间。大多数人无法忍受这种沉默,会忍不住起身,走动,找点什么事情做。但对狙击手来说,在猎物没有进入射程之前起身,才是真正的危险——不只是暴露位置,而是打乱了整个作战节奏。", + "我用条件单来维持这种趴伏状态。在平静的夜晚,根据每种猎物的特性,计算好打击距离,设定好每一枪的触发价格和仓位大小,然后把枪放在那里,让它自动开火。", + "不同的猎物,有不同的计算方式。带翅膀的飞禽,要用溢价率来判断它是否在合理的高度;善于奔跑的走兽,要用PE分位数看它是否已经跑到了估值的洼地;懂得潜水的水生动物,要用股息率判断它是否已经沉到了值得捕捞的深度。统一用箱体价格来瞄准所有猎物,就像用同一把标尺测量飞禽的速度和鱼的深度——工具用错了地方。", + "四枪的节奏,是这套打法的核心设计。第一枪在-10%开火,轻仓试探,这是哨兵枪;第二枪在-20%,加大仓位,这是主力枪;第三枪在-30%,是对极端情景的备用;第四枪尽量不开,留着看它是否真的需要。子弹是宝贵的,猎物挣扎五分钟自己倒下了,你还留着两颗子弹,这是最好的结局。", + "但这里有一处必须坦诚的局限:四枪之间的间距,应该根据猎物的特性来设定,不是所有猎物都适合10%的步长。低波动的猎物可以步长小一些;高速奔跑的猎物,步长太小会让你在它下跌的途中把子弹全部打光,等不到真正的底部。", + "还有两条铁律,是我用真实的代价换来的。不认识的猎物,不开枪。也许它看起来很诱人,但如果你不了解它的习性,你的第一枪很可能惊动了它,让它跑得更远。更糟糕的是,也许那根本不是猎物。第一次遇到的猎物,把打击距离拉远。不是-10%,而是-20%。你对它的了解打了折扣,安全边际就要相应地加宽。宁可少赚,不可不慎。", + "从散弹枪到狙击枪,不只是方法的改变,是心态的根本转变。以前,我害怕错过;现在,我更害怕打错。以前,我把每天开枪当成努力;现在,我把趴着等待当成工作。以前,我认为子弹用完就叫做积极;现在,我知道子弹留在手里,才叫做准备。", + "告诫后来者: 开枪之前,先问自己三个问题。这只猎物,我认识吗?它现在进入射程了吗?我剩下的子弹,够不够应对更好的机会?三个问题都有满意的答案,再扣动扳机。", + "---" + ] + }, + { + title: "三个隐蔽点", + marker: "⑱", + paras: [ + "狙击手在出发之前,要做的第一件事不是磨枪,而是选阵地。", + "我选了三个隐蔽点。", + "一个叫中国,一个叫海外,一个叫防御。每一个都是猎物必经之路,每一个都有它独特的气候和季节规律。重要的是,我没有试图判断哪一个隐蔽点今天猎物最多——那是一个即使是最老练的猎人也常常判断错误的问题。我的选择,是在三个地方同时布点,让时间和市场来决定下一只猎物从哪里经过。", + "这是真正的分散逻辑。不是因为无知而分散,而是因为清醒而分散。", + "Buffett说,分散是对无知的对冲。这句话是对的,但有一个前提——他的分散标准,是基于深度研究之后仍然无法确定的不确定性,不是基于懒惰或者贪多。我的三个隐蔽点,每一个都有明确的逻辑支撑,不是随机撒网,是有意识的战略布局。这两种分散,有本质的区别。", + "隐蔽点没有好坏之分,只有时机的好坏之分。秋天大雁南飞,东边的隐蔽点是最佳位置;冬天狗熊出没,北边的林子更有机会。某一年龙卷风接连来袭,哪个隐蔽点都不安全。市场也是如此——中国有消费降级的周期,美国有选举年的震荡,防御资产有它自己的利率敏感性。阵地都没有问题,问题在于那一年的天气。", + "我不打算调整这三个位置。至少在现阶段,它们给我机会均等的布局——当其中一个阵地沉寂的时候,另外两个往往有猎物经过。这种低相关性,是组合里真正的稳定器。", + "有一句话值得认真对待:在不确定性最高的时候,最优仓位趋近于零。当你搞不清天气和季节,最好的做法是趴着,什么都不做。什么都不做,是一个主动的选择,不是被动的等待。等待本身,是这套系统最重要的工作内容之一。", + "偶尔,会有一只迷路的猎物,反常规地经过你的隐蔽点。2026年3月那次全球大跌,对很多人是灾难,对我是一只突然闯入的大猎物。它不在任何预测模型里,但我在那里,我有子弹,我趴着——这就是全部的条件。", + "退休之后,我可能会缩减到一个更安静的隐蔽点——少一些进攻性的布局,多一些风调雨顺的日子。防御型的阵地,猎物少一些,但天气稳定,偶尔还有日光浴。对那个年纪的我来说,这或许是最合适的节奏。", + "但有一点需要认真对待:防御不等于无风险。每一个隐蔽点都有它自己的风险,只是风险的形态不同。退休后的阵地,需要的不是没有风险,而是风险的形态符合那个阶段对现金流稳定性的需求。", + "告诫后来者: 选阵地,不是选哪里猎物最多,而是选哪里你最有把握在猎物经过时准确击发。了解自己的能力边界,比了解市场的机会,更重要。阵地选好了,趴着等,这是这套方法最难也最关键的部分。", + "---" + ] + }, + { + title: "采摘节", + marker: "⑲", + paras: [ + "在我的投资系统里,有一个节点叫做采摘节。", + "不是每天,不是每周,而是每半年一次。时间通常定在六月底和十二月底。这个设计本身,就是一种表态:市场的节奏不是我的节奏,我有自己的节奏。", + "采摘节的第一个问题,永远是:我想要多少现金仓?", + "不是先看哪只股票涨了,不是先算账面盈利,而是先确定目标。这个顺序至关重要。大多数人是反过来的——看到涨了的就卖,卖完了发现手里多了些钱,然后找下一个买入目标。这是结果在驱动决策。我选择让目标驱动决策:先定20%的现金仓比例,再推导哪些仓位需要减,减多少。", + "从前,我把现金仓看成是没有被使用的资金,是等待中的浪费。这个认知,在某一次机会来临却子弹不足之后,彻底改变了。那次,条件单触发,方向正确,判断准确,但回补的资金不够,只能缩减仓位。账面上依然是盈利,但那个盈利,比它本可以达到的数字,少了一截。这种损失不出现在任何报表里,但它是真实的。它有一个名字,叫做机会成本。", + "从那之后,我把现金仓理解成一张期权。它赋予我在任何时刻以任何价格买入任何资产的权利。这张期权没有行权成本,没有到期日。它的价值,在市场最恐慌的时候达到顶点,恰恰是别人最缺现金的那一天。", + "采摘节的第二个任务,是做减法。三十个持仓,是这套系统走到今天积累下来的数字。每一个进入的时候都有理由,但不是每一个今天还有同样充分的理由留着。采摘节的核心问题只有一个:如果今天重新建仓,你还会买这个吗?答案是否定的,就是退出候选。不是因为它亏了,而是因为它占据了仓位、占据了注意力,却没有占据你的真实信念。", + "第三个任务,是重构伏击价格。从单一的箱体价格,升级到多因子判断——PE分位数、溢价率、股息率、均线,根据不同的猎物类型,选择不同的瞄准方式。箱体告诉你价格在哪里,估值告诉你价值在哪里。用价格瞄准,你打的是位置;用价值瞄准,你打的是意义。", + "还有一项容易被忽视的工作:逐一审查所有未触发的条件单。一张等待了六个月却没有触发的伏击单,有两种可能:一是原来的逻辑依然成立,市场只是还没有给你那个价格;二是底层逻辑已经发生了变化,那个价格可能永远不会再来。这两种情况需要不同的应对,而区分它们的唯一方式,是在采摘节认真地重新审视。", + "最后一个任务,是拿出盈利的10%,奖励自己。", + "这条是议程里最重要的,没有之一。不是因为钱的多少,而是因为这个动作本身的意义。它告诉你的大脑:这套系统是真实运转的,是有收获的,是值得继续维护的。采摘节不只是工作,它是你和自己的系统之间的一次仪式。仪式感,是长期坚持的燃料。", + "采摘节结束的那个晚上,我会去吃一顿好的。不是庆祝赚了多少,而是庆祝又认真地走完了一个完整的周期。", + "告诫后来者: 给你的投资系统设置一个有节奏的呼吸节点。不是为了频繁操作,而是为了让每一个持仓,在每一个时间点,都还有它存在的理由。采摘节的价值,不在于那一天做了什么,而在于你有这样一天。", + "---" + ] + }, + { + title: "热点与非热点", + marker: "⑳", + paras: [ + "森林里每隔一段时间,就会出现一种奇特的现象。", + "某一块区域突然变得热闹起来。猎人们蜂拥而至,议论纷纷,说那里有稀有的猎物,有人已经打到了,有人正在赶去。整片森林都在谈论同一个名字。", + "我有过两次追进去的经历。", + "第一次是航空航天ETF。那段时间,相关新闻铺天盖地,政策利好接连出台,市场情绪高涨。我以为这是中国版的SpaceX时刻,追了进去。后来才明白,中国的航空航天是举国体制,不是商业化市场。它的估值逻辑不是由市场供需决定的,而是由政策节奏决定的。政策什么时候给信号,市场什么时候反应,这个节奏,局外人无从预测。进去的时机已经是消息兑现之后,最好的定价时机早已过去。幸好仓位小,代价有限。", + "第二次是中概互联网ETF。这一次,我以为自己想得很清楚——腾讯是中国互联网的基础设施,阿里是中国商业的底层平台,它们都有AI业务,是未来的受益者。逻辑严密,研究充分,仓位也不算小。但我犯了一个更隐蔽的错误。", + "我买入的理由,是期待市场把它们当作AI公司来定价。但市场在这段时间给它们的定价,是互联网平台公司——一个增长放缓、竞争加剧的行业框架。我预期的定价逻辑,和市场实际采用的定价逻辑,不是同一个。公司本身没有问题,但我等待的那个市场认可,迟迟没有到来。", + "这两次经历,让我想清楚了一件关于热点的根本性问题:大家都知道的热点,早就不是最好的进入时机了。", + "市场是一台高效的定价机器。当一个主题成为所有人都在讨论的话题,当财经媒体开始用\"风口\"这个词,当你在饭桌上听到有人推荐某只股票,这个信息的定价,往往已经发生了大半。你看到的,是别人已经走过的路的终点,不是起点。", + "真正的机会,往往安静得让人不安。", + "明白了这一点,我的应对方式变了。热点,我不再追单个板块。我用聚合型的宽基ETF来间接持有热点的受益——纳指科技、科创50、中证500。这些指数自动做了一件我做不到的事:当某个热点板块涨了,它在指数里的权重上升,我自然地获益;当热点退潮,权重下降,我的损失也被其他成分稀释。我通过持有指数,持有了所有热点,同时不需要为\"今年是半导体还是AI、是光模块还是机器人\"这个问题赌注。", + "非热点,我只选分红型蓝筹。中国石化、中国神华、中国银行——这些公司不需要热点来证明自己的价值。它们的价值来自稳定的现金流和持续的股息分配。如果焊在手上,最坏的结果是每年收分红。这是一种特殊的安全感:不依赖市场情绪,只依赖公司盈利。", + "进可攻,退可守。", + "这不是一个聪明的策略,而是一个诚实的策略。它承认了一个事实:我没有能力比市场更早知道下一个热点是什么,也没有时间逐一研究每一个新兴板块。既然如此,就用系统替代判断——指数替代选热点,分红替代判断估值。", + "有一点值得坦诚地说:这套方法,会让你错过一些真正的大机会。当某个板块从0涨到100,你持有的指数只涨了30。但它同样会保护你,当那个板块从100跌回30的时候,你的指数可能只跌了10。对于一个把投资定义为终身事业的人来说,少赚一些,是可以接受的代价。赔进去,才是真正的代价。", + "告诫后来者: 当你听说某个热点,问自己三个问题。第一,这个消息,有多少人已经知道了?第二,你比知道这件事的其他人,有什么额外的优势?第三,如果你的判断是错的,你能承受多大的损失?三个问题都有满意的答案,再考虑进场。如果答案是\"大家都知道了\"\"没有额外优势\"\"损失不确定\",那就让这只热点猎物,从你的隐蔽点旁边走过去。" + ] } ]; diff --git a/public/tools/oss_file_cabinet/auth_config.json b/public/tools/oss_file_cabinet/auth_config.json new file mode 100644 index 0000000..07c2486 --- /dev/null +++ b/public/tools/oss_file_cabinet/auth_config.json @@ -0,0 +1,15 @@ +{ + "_comment": "OSS 文件柜前端鉴权配置", + "enable_auth_comment": "true: 启用三层鉴权(cookie/GUID/token); false: 完全放开访问", + "enable_auth": true, + "max_age_days_comment": "鉴权 cookie 最大有效天数(1~365),过期后需重新获取入口授权", + "max_age_days": 30, + "cookieName_comment": "鉴权 Cookie 名称(gate 外层 cookie,不等于业务登录态)", + "cookieName": "oss_file_cabinet_gate", + "requireGuid_comment": "true:入口必须带 guid 参数,否则直接拒绝;false:允许仅凭 token 或已有会话访问", + "requireGuid": true, + "guidParam_comment": "URL 中的 GUID 参数名,建议使用小写 guid", + "guidParam": "guid", + "guidValue_comment": "GUID 参数值,建议使用高强度随机长串并定期更换,不在前端硬编码", + "guidValue": "db8a_2f1c-90e7-45a-a(d2-c3e4f50890" +} diff --git a/public/tools/oss_file_cabinet/index.html b/public/tools/oss_file_cabinet/index.html new file mode 100644 index 0000000..671db49 --- /dev/null +++ b/public/tools/oss_file_cabinet/index.html @@ -0,0 +1,152 @@ + + + + + + OSS 文件柜 + + + +
+
+ +

OSS 文件柜

+ + +
+
+ + + + + + + + + + + + + + + + + diff --git a/public/tools/oss_file_cabinet/oss_file_cabinet.css b/public/tools/oss_file_cabinet/oss_file_cabinet.css new file mode 100644 index 0000000..bf66d7f --- /dev/null +++ b/public/tools/oss_file_cabinet/oss_file_cabinet.css @@ -0,0 +1,688 @@ +* { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --bg: #f5f5f7; + --window-bg: #ffffff; + --sidebar-bg: #ececec; + --header-bg: #f0f0f2; + --text: #1d1d1f; + --text-secondary: #6e6e73; + --border: rgba(0,0,0,0.08); + --selection: #0a84ff; + --selection-bg: rgba(10,132,255,0.14); + --selection-border: rgba(10,132,255,0.35); + --hover-bg: rgba(0,0,0,0.04); + --danger: #ff3b30; + --shadow: 0 10px 40px rgba(0,0,0,0.12), 0 2px 10px rgba(0,0,0,0.08); + --radius: 10px; + --radius-sm: 6px; +} + +:root.theme-dark { + --bg: #1e1e1e; + --window-bg: #2c2c2e; + --sidebar-bg: #232325; + --header-bg: #2a2a2c; + --text: #f5f5f7; + --text-secondary: #a1a1a6; + --border: rgba(255,255,255,0.08); + --selection: #0a84ff; + --selection-bg: rgba(10,132,255,0.25); + --selection-border: rgba(10,132,255,0.5); + --hover-bg: rgba(255,255,255,0.05); + --shadow: 0 10px 40px rgba(0,0,0,0.5), 0 2px 10px rgba(0,0,0,0.3); +} + +html, body { + height: 100%; + width: 100%; + font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Helvetica Neue', 'PingFang SC', 'Microsoft YaHei', sans-serif; + font-size: 13px; + color: var(--text); + background: var(--bg); + -webkit-font-smoothing: antialiased; + overflow: hidden; +} + +button { font-family: inherit; cursor: pointer; border: none; outline: none; background: none; color: inherit; } +input, select, textarea { font-family: inherit; outline: none; } + +/* ===== 登录页 ===== */ +.login-screen { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100vh; + background: linear-gradient(180deg, #6b7dff 0%, #b066ff 50%, #ff6ec7 100%); +} + +:root.theme-dark .login-screen { background: linear-gradient(180deg, #1a1d2e 0%, #2a1d3a 50%, #3a1d2e 100%); } + +.login-card { + width: 320px; + padding: 32px 28px; + background: var(--window-bg); + border-radius: 14px; + box-shadow: var(--shadow); + text-align: center; + backdrop-filter: blur(20px) saturate(180%); +} + +.login-icon { font-size: 48px; margin-bottom: 8px; } +.login-card h1 { font-size: 20px; font-weight: 600; margin-bottom: 4px; } +.login-subtitle { font-size: 12px; color: var(--text-secondary); margin-bottom: 20px; } + +.login-form { text-align: left; } +.form-group { margin-bottom: 12px; } +.form-group label { display: block; font-size: 12px; color: var(--text-secondary); margin-bottom: 6px; } +.form-group input { + width: 100%; + padding: 8px 12px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg); + color: var(--text); + font-size: 13px; +} +.form-group input:focus { border-color: var(--selection); } + +.btn-primary { + width: 100%; + padding: 9px 16px; + background: var(--selection); + color: #fff; + border-radius: var(--radius-sm); + font-size: 13px; + font-weight: 500; + margin-top: 6px; + transition: opacity 0.15s; +} +.btn-primary:hover { opacity: 0.88; } +.btn-ghost { + padding: 8px 16px; + background: transparent; + color: var(--text); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + font-size: 13px; +} +.btn-ghost:hover { background: var(--hover-bg); } + +.login-error { margin-top: 12px; color: var(--danger); font-size: 12px; text-align: center; min-height: 16px; } + +/* ===== Finder 窗口 ===== */ +.finder-app { width: 100%; height: 100vh; padding: 12px; } + +.finder-window { + width: 100%; + height: 100%; + background: var(--window-bg); + border-radius: var(--radius); + box-shadow: var(--shadow); + display: flex; + flex-direction: column; + overflow: hidden; +} + +.finder-titlebar { + height: 38px; + display: flex; + align-items: center; + padding: 0 14px; + background: var(--header-bg); + border-bottom: 1px solid var(--border); + position: relative; +} + +.window-buttons { display: flex; gap: 8px; } +.window-buttons span { + width: 12px; height: 12px; + border-radius: 50%; + display: block; +} +.btn-close { background: #ff5f57; } +.btn-mini { background: #febc2e; } +.btn-max { background: #28c840; } + +.finder-title { + flex: 1; + text-align: center; + font-size: 13px; + font-weight: 600; + color: var(--text); +} + +.finder-actions { display: flex; gap: 6px; } +.icon-btn { + padding: 4px 8px; + font-size: 14px; + border-radius: var(--radius-sm); + color: var(--text-secondary); +} +.icon-btn:hover { background: var(--hover-bg); color: var(--text); } + +/* ===== Toolbar ===== */ +.finder-toolbar { + height: 44px; + display: flex; + align-items: center; + padding: 0 14px; + gap: 12px; + background: var(--header-bg); + border-bottom: 1px solid var(--border); +} + +.nav-buttons { display: flex; gap: 4px; } +.nav-btn { + width: 28px; height: 28px; + border-radius: var(--radius-sm); + font-size: 14px; + color: var(--text-secondary); + display: flex; align-items: center; justify-content: center; +} +.nav-btn:hover { background: var(--hover-bg); color: var(--text); } + +.path-bar { + flex: 1; + display: flex; + align-items: center; + gap: 4px; + padding: 4px 12px; + background: var(--bg); + border-radius: var(--radius-sm); + font-size: 13px; + min-height: 28px; + overflow-x: auto; + white-space: nowrap; +} +.crumb { + padding: 2px 8px; + border-radius: 4px; + color: var(--text-secondary); + cursor: pointer; + white-space: nowrap; +} +.crumb.active { color: var(--text); font-weight: 500; } +.crumb:hover:not(.active) { background: var(--hover-bg); color: var(--text); } +.crumb-sep { color: var(--text-secondary); opacity: 0.5; } + +.view-switcher { + display: flex; + background: var(--bg); + border-radius: var(--radius-sm); + padding: 2px; +} +.view-btn { + width: 28px; height: 24px; + border-radius: 4px; + font-size: 12px; + color: var(--text-secondary); + display: flex; align-items: center; justify-content: center; +} +.view-btn:hover { color: var(--text); } +.view-btn.active { background: var(--window-bg); color: var(--text); box-shadow: 0 1px 2px rgba(0,0,0,0.06); } + +.search-box input { + width: 180px; + padding: 6px 10px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text); + font-size: 12px; +} +.search-box input:focus { border-color: var(--selection); } + +/* ===== Body ===== */ +.finder-body { + flex: 1; + display: flex; + overflow: hidden; +} + +.finder-sidebar { + width: 180px; + background: var(--sidebar-bg); + padding: 10px 6px; + overflow-y: auto; + border-right: 1px solid var(--border); +} +.sidebar-section-title { + font-size: 11px; + color: var(--text-secondary); + padding: 8px 10px 4px; + text-transform: uppercase; + letter-spacing: 0.5px; + font-weight: 600; +} +.sidebar-item { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + margin: 1px 0; + border-radius: 5px; + cursor: pointer; + font-size: 13px; + color: var(--text); +} +.sidebar-item:hover { background: var(--hover-bg); } +.sidebar-item.active { background: var(--selection); color: #fff; } +.sidebar-icon { font-size: 14px; } + +.finder-content { + flex: 1; + overflow: hidden; + background: var(--window-bg); +} + +/* ===== 文件区 ===== */ +.file-area { + width: 100%; + height: 100%; + padding: 18px; + overflow-y: auto; + position: relative; +} + +/* 图标视图 */ +.icon-view { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(92px, 1fr)); + gap: 16px; + align-content: start; +} +.icon-item { + display: flex; + flex-direction: column; + align-items: center; + padding: 8px 6px; + border-radius: 8px; + cursor: pointer; + text-align: center; + transition: background 0.12s; + user-select: none; +} +.icon-item:hover { background: var(--hover-bg); } +.icon-item.selected { background: var(--selection-bg); } +.icon-wrap { + width: 64px; height: 64px; + display: flex; align-items: center; justify-content: center; + font-size: 34px; + margin-bottom: 4px; + border-radius: 12px; + background: var(--file-icon-bg, #f0f0f0); + overflow: hidden; +} +.icon-wrap.file-red { background: #ffebee; } +.icon-wrap.file-blue { background: #e3f2fd; } +.icon-wrap.file-green { background: #e8f5e9; } +.icon-wrap.file-orange { background: #fff3e0; } +.icon-wrap.file-purple { background: #f3e5f5; } +.icon-wrap.file-pink { background: #fce4ec; } +.icon-wrap.file-yellow { background: #fffde7; } +.icon-wrap.file-image { background: #e8eaf6; } +.icon-wrap.file-text { background: #f5f5f5; } +.icon-wrap.file-gray { background: #f0f0f0; } +.icon-thumb { + width: 100%; height: 100%; + object-fit: cover; +} +.icon-name { + font-size: 12px; + line-height: 1.3; + max-width: 80px; + word-break: break-all; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + text-align: center; +} + +/* 列表视图 */ +.list-table { + width: 100%; + display: flex; + flex-direction: column; +} +.list-row { + display: grid; + grid-template-columns: minmax(280px, 1fr) 100px 180px 100px; + padding: 6px 12px; + font-size: 12px; + cursor: pointer; + border-bottom: 1px solid var(--border); + align-items: center; + user-select: none; +} +.list-row:hover { background: var(--hover-bg); } +.list-row.selected { background: var(--selection-bg); } +.list-header { + background: var(--header-bg); + font-weight: 600; + color: var(--text-secondary); + position: sticky; + top: 0; + cursor: default; + border-bottom: 1px solid var(--border); + z-index: 1; +} +.list-header:hover { background: var(--header-bg); } +.col-name { display: flex; align-items: center; gap: 8px; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.col-size, .col-modified, .col-kind { color: var(--text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.row-icon { + width: 22px; height: 22px; + display: inline-flex; align-items: center; justify-content: center; + font-size: 13px; + border-radius: 5px; + flex-shrink: 0; + background: var(--file-icon-bg, #f0f0f0); +} +.row-icon.file-red { background: #ffebee; } +.row-icon.file-blue { background: #e3f2fd; } +.row-icon.file-green { background: #e8f5e9; } +.row-icon.file-orange { background: #fff3e0; } +.row-icon.file-purple { background: #f3e5f5; } +.row-icon.file-pink { background: #fce4ec; } +.row-icon.file-yellow { background: #fffde7; } +.row-icon.file-image { background: #e8eaf6; } +.row-icon.file-text { background: #f5f5f5; } +.row-icon.file-gray { background: #f0f0f0; } + +/* 分栏视图 */ +.file-area.column-view { + display: flex; + padding: 0; +} +.column { + flex: 1; + min-width: 200px; + border-right: 1px solid var(--border); + overflow-y: auto; +} +.column:last-child { border-right: none; } +.column-row { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + font-size: 12px; + cursor: pointer; + border-bottom: 1px solid var(--border); + user-select: none; +} +.column-row:hover { background: var(--hover-bg); } +.column-row.selected { background: var(--selection); color: #fff; } +.column-hint { flex: 2; display: flex; align-items: center; justify-content: center; } +.column-empty { color: var(--text-secondary); font-size: 12px; } +.col-name-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.column-info-panel { padding: 20px; text-align: center; } +.column-info-name { font-size: 14px; font-weight: 600; margin-bottom: 12px; word-break: break-all; } +.column-info-row { font-size: 12px; color: var(--text-secondary); margin-bottom: 6px; } +.file-area.column-view { overflow-x: auto; } +.column { min-width: 180px; max-width: 280px; } + +/* 状态栏 */ +.finder-statusbar { + height: 24px; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 14px; + background: var(--header-bg); + border-top: 1px solid var(--border); + font-size: 11px; + color: var(--text-secondary); +} + +/* 空状态 */ +.empty-state { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + text-align: center; + color: var(--text-secondary); +} +.empty-icon { font-size: 48px; margin-bottom: 12px; opacity: 0.5; } +.empty-title { font-size: 15px; margin-bottom: 4px; font-weight: 500; color: var(--text); opacity: 0.7; } +.empty-hint { font-size: 12px; } + +/* 拖拽蒙层 */ +.drop-overlay { + position: fixed; + inset: 0; + background: rgba(10,132,255,0.15); + backdrop-filter: blur(8px); + display: none; + align-items: center; + justify-content: center; + z-index: 999; + pointer-events: none; + border: 3px dashed var(--selection); +} +.drop-box { + background: var(--window-bg); + padding: 32px 48px; + border-radius: 12px; + text-align: center; + box-shadow: var(--shadow); + pointer-events: auto; +} +.drop-icon { font-size: 48px; margin-bottom: 8px; } +.drop-text { font-size: 14px; color: var(--text); } + +/* 选框 */ +.selection-rect { + position: fixed; + border: 1px solid var(--selection); + background: var(--selection-bg); + border-radius: 2px; + pointer-events: none; + z-index: 998; +} + +/* ===== 右键菜单 ===== */ +.context-menu { + position: fixed; + min-width: 200px; + background: var(--window-bg); + border-radius: 8px; + box-shadow: 0 8px 32px rgba(0,0,0,0.25), 0 2px 8px rgba(0,0,0,0.1); + padding: 4px; + z-index: 1000; + backdrop-filter: blur(30px) saturate(180%); + border: 1px solid var(--border); +} +.cm-item { + padding: 7px 12px; + font-size: 13px; + border-radius: 4px; + cursor: pointer; + color: var(--text); +} +.cm-item:hover { background: var(--selection); color: #fff; } +.cm-item.danger { color: var(--danger); } +.cm-item.danger:hover { background: var(--danger); color: #fff; } +.cm-sep { + height: 1px; + background: var(--border); + margin: 4px 8px; +} + +/* ===== 弹窗 ===== */ +.modal { + position: fixed; + inset: 0; + display: none; + align-items: center; + justify-content: center; + z-index: 2000; +} +.modal-backdrop { + position: absolute; + inset: 0; + background: rgba(0,0,0,0.3); + backdrop-filter: blur(8px); +} +.modal-content { + position: relative; + background: var(--window-bg); + border-radius: 12px; + box-shadow: 0 20px 60px rgba(0,0,0,0.3); + padding: 20px 24px; + min-width: 360px; + max-width: 520px; + max-height: 80vh; + overflow-y: auto; +} +.modal-title { + font-size: 15px; + font-weight: 600; + margin-bottom: 14px; + color: var(--text); +} +.modal-body { + font-size: 13px; + color: var(--text); + line-height: 1.6; + margin-bottom: 18px; +} +.modal-body label { + display: block; + font-size: 12px; + color: var(--text-secondary); + margin-bottom: 6px; + margin-top: 8px; +} +.modal-body label:first-child { margin-top: 0; } +.modal-body input, .modal-body select { + width: 100%; + padding: 7px 10px; + border: 1px solid var(--border); + border-radius: 5px; + background: var(--bg); + color: var(--text); + font-size: 13px; +} +.modal-body input:focus, .modal-body select:focus { border-color: var(--selection); } + +.modal-footer { + display: flex; + justify-content: flex-end; + gap: 8px; +} +.modal-footer .btn-primary, .modal-footer .btn-ghost { + width: auto; + min-width: 72px; + margin-top: 0; +} + +/* 简介 */ +.info-row { + display: flex; + padding: 4px 0; + border-bottom: 1px solid var(--border); + font-size: 12px; +} +.info-row:last-child { border-bottom: none; } +.info-label { + width: 80px; + color: var(--text-secondary); + flex-shrink: 0; +} +.info-value { + flex: 1; + color: var(--text); + overflow-wrap: break-word; + word-break: break-all; +} + +/* 分享输出 */ +.share-output { margin-top: 8px; } +.share-title { font-size: 12px; color: var(--text-secondary); margin-bottom: 6px; } +.share-url-box { + padding: 8px 10px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 5px; + font-size: 11px; + font-family: 'SF Mono', Menlo, monospace; + color: var(--text); + word-break: break-all; + line-height: 1.5; +} + +/* 图片预览 */ +.modal-content.image-preview { + background: transparent; + box-shadow: none; + padding: 0; + min-width: auto; + max-width: 90vw; + max-height: 90vh; + overflow: visible; +} +.modal-content.image-preview img { + max-width: 90vw; + max-height: 85vh; + border-radius: 12px; + box-shadow: 0 20px 60px rgba(0,0,0,0.5); + transition: transform 0.08s ease-out; + cursor: zoom-in; +} +.modal-close { + position: absolute; + top: -10px; + right: -10px; + width: 32px; + height: 32px; + border-radius: 50%; + background: var(--window-bg); + color: var(--text); + font-size: 14px; + box-shadow: 0 4px 12px rgba(0,0,0,0.2); + display: flex; + align-items: center; + justify-content: center; + z-index: 10; +} +.modal-close:hover { background: var(--selection); color: #fff; } + +/* ===== Toast ===== */ +.toast { + position: fixed; + bottom: 24px; + left: 50%; + transform: translateX(-50%); + background: var(--window-bg); + color: var(--text); + padding: 10px 20px; + border-radius: 20px; + box-shadow: 0 8px 32px rgba(0,0,0,0.15); + font-size: 13px; + z-index: 9999; + display: none; + align-items: center; + border: 1px solid var(--border); + backdrop-filter: blur(20px); +} +.toast-success { border-left: 3px solid #28c840; } +.toast-error { border-left: 3px solid var(--danger); } +.toast-info { border-left: 3px solid var(--selection); } + +/* 滚动条 */ +::-webkit-scrollbar { width: 8px; height: 8px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.15); border-radius: 4px; } +::-webkit-scrollbar-thumb:hover { background: rgba(0,0,0,0.3); } +:root.theme-dark ::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.15); } +:root.theme-dark ::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.3); } + +@media (max-width: 900px) { + .finder-sidebar { width: 140px; } + .list-row { grid-template-columns: minmax(180px, 1fr) 80px 140px 80px; font-size: 11px; } + .search-box input { width: 120px; } +} diff --git a/public/tools/oss_file_cabinet/oss_file_cabinet.js b/public/tools/oss_file_cabinet/oss_file_cabinet.js new file mode 100644 index 0000000..84e6a00 --- /dev/null +++ b/public/tools/oss_file_cabinet/oss_file_cabinet.js @@ -0,0 +1,1122 @@ +(function () { + 'use strict' + + const API = '/api/oss_file_cabinet' + const state = { + view: 'icon', + prefix: '', + history: [], + historyIndex: -1, + items: [], + selected: new Set(), + lastSelectedIdx: -1, + clipboard: null, + search: '', + mode: 'files', + shareToken: '', + toastTimer: null, + columnStack: [], + columnCache: {} + } + + const $ = (sel) => document.querySelector(sel) + const $$ = (sel) => Array.from(document.querySelectorAll(sel)) + + const showToast = (msg, type) => { + let t = document.getElementById('globalToast') + if (!t) { + t = document.createElement('div') + t.id = 'globalToast' + t.className = 'toast' + document.body.appendChild(t) + } + t.textContent = msg + t.className = 'toast toast-' + (type || 'info') + t.style.display = 'flex' + clearTimeout(state.toastTimer) + state.toastTimer = setTimeout(() => { t.style.display = 'none' }, 2200) + } + + const formatSize = (bytes) => { + const b = Number(bytes) || 0 + if (b < 1024) return b + ' B' + if (b < 1024 * 1024) return (b / 1024).toFixed(1) + ' KB' + if (b < 1024 * 1024 * 1024) return (b / 1024 / 1024).toFixed(1) + ' MB' + return (b / 1024 / 1024 / 1024).toFixed(2) + ' GB' + } + + const formatDate = (ts) => { + if (!ts) return '' + const d = new Date(ts) + const y = d.getFullYear() + const m = String(d.getMonth() + 1).padStart(2, '0') + const day = String(d.getDate()).padStart(2, '0') + const hh = String(d.getHours()).padStart(2, '0') + const mm = String(d.getMinutes()).padStart(2, '0') + return y + '-' + m + '-' + day + ' ' + hh + ':' + mm + } + + const getExt = (name) => { + const n = String(name || '') + const idx = n.lastIndexOf('.') + if (idx < 0 || idx === n.length - 1) return '' + return n.slice(idx + 1).toLowerCase() + } + + const getFileIcon = (name, isFolder) => { + if (isFolder) return '📁' + const ext = getExt(name) + const iconMap = { + jpg: '🖼️', jpeg: '🖼️', png: '🖼️', gif: '🖼️', webp: '🖼️', svg: '🖼️', bmp: '🖼️', ico: '🖼️', + pdf: '📕', + doc: '📝', docx: '📝', odt: '📝', + xls: '📗', xlsx: '📗', csv: '📗', ods: '📗', + ppt: '📙', pptx: '📙', odp: '📙', + txt: '📄', md: '📄', log: '📄', + zip: '📦', rar: '📦', '7z': '📦', tar: '📦', gz: '📦', + mp3: '🎵', wav: '🎵', flac: '🎵', aac: '🎵', ogg: '🎵', + mp4: '🎬', mov: '🎬', avi: '🎬', mkv: '🎬', webm: '🎬', flv: '🎬', m4v: '🎬', + json: '📋', js: '📋', ts: '📋', jsx: '📋', tsx: '📋', + html: '🌐', css: '🌐', xml: '🌐', + py: '🐍', java: '☕', c: '⚙️', cpp: '⚙️', h: '⚙️', go: '🐹', rs: '⚙️', sh: '⚙️' + } + return iconMap[ext] || '📄' + } + + const getFileIconClass = (name) => { + const ext = getExt(name) + if (['pdf'].includes(ext)) return 'file-red' + if (['doc', 'docx', 'odt'].includes(ext)) return 'file-blue' + if (['xls', 'xlsx', 'csv', 'ods'].includes(ext)) return 'file-green' + if (['ppt', 'pptx', 'odp'].includes(ext)) return 'file-orange' + if (['mp4', 'mov', 'avi', 'mkv', 'webm', 'flv', 'm4v'].includes(ext)) return 'file-purple' + if (['mp3', 'wav', 'flac', 'aac', 'ogg'].includes(ext)) return 'file-pink' + if (['zip', 'rar', '7z', 'tar', 'gz'].includes(ext)) return 'file-yellow' + if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'ico'].includes(ext)) return 'file-image' + if (['txt', 'md', 'log'].includes(ext)) return 'file-text' + return 'file-gray' + } + + const apiGet = (url) => fetch(url, { credentials: 'same-origin' }).then(r => r.json()) + const apiPost = (url, body) => fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: body ? JSON.stringify(body) : '{}' + }).then(r => r.json()) + + const apiPostFormData = (url, formData) => fetch(url, { + method: 'POST', + credentials: 'same-origin', + body: formData + }).then(r => r.json()) + + const doLogin = async (user, pass) => { + const r = await apiPost(API + '/auth/login', { username: user, password: pass }) + return r && r.ok + } + + const checkAuth = async () => { + try { + const r = await apiGet(API + '/auth/me') + return r && r.ok && r.authorized + } catch { return false } + } + + const tryAutoFill = async () => { + try { + const token = localStorage.getItem('toolbox_creds_token') || '' + if (!token) return + const r = await apiGet(API + '/auth/local_creds?token=' + encodeURIComponent(token)) + if (r && r.ok && r.creds) { + if (r.creds.username) $('#loginUsername').value = r.creds.username + if (r.creds.password) $('#loginPassword').value = r.creds.password + } + } catch {} + } + + const clearUrlQuery = () => { + try { + if (!window.location.search) return + const clean = location.pathname + location.hash + history.replaceState(null, document.title, clean) + } catch {} + } + + const main = async () => { + initTheme() + + // ===== set_token 处理:仅存入 localStorage,不自动登录 ===== + try { + const params = new URLSearchParams(location.search) + const setToken = params.get('set_token') + if (setToken) { + localStorage.setItem('toolbox_creds_token', setToken) + } + } catch {} + + clearUrlQuery() + + // ===== 先检查是否已登录 ===== + const already = await checkAuth() + if (already) { + showApp() + initEvents() + initDragDrop() + navigateTo('') + return + } + + // ===== 未登录,仅自动填充,不自动提交 ===== + showLogin() + await tryAutoFill() + const $loginBtn = $('#loginBtn') + if ($loginBtn && !$loginBtn.dataset.bound) { + $loginBtn.dataset.bound = '1' + $loginBtn.addEventListener('click', async () => { + const u = $('#loginUsername').value.trim() + const p = $('#loginPassword').value + $('#loginError').textContent = '' + if (!u || !p) { $('#loginError').textContent = '请输入用户名和密码'; return } + const ok = await doLogin(u, p) + if (ok) { + showApp() + initEvents() + initDragDrop() + navigateTo('') + } else $('#loginError').textContent = '用户名或密码错误' + }) + } + $('#loginPassword').addEventListener('keydown', (e) => { if (e.key === 'Enter') $('#loginBtn').click() }) + $('#loginUsername').addEventListener('keydown', (e) => { if (e.key === 'Enter') $('#loginBtn').click() }) + } + + const showLogin = () => { + $('#loginScreen').style.display = 'flex' + $('#app').style.display = 'none' + } + + const showApp = () => { + $('#loginScreen').style.display = 'none' + $('#app').style.display = 'block' + } + + const initTheme = () => { + const saved = localStorage.getItem('oss_file_cabinet_theme') + if (saved === 'dark' || (!saved && window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches)) { + document.documentElement.classList.add('theme-dark') + } + } + + const toggleTheme = () => { + const isDark = document.documentElement.classList.toggle('theme-dark') + localStorage.setItem('oss_file_cabinet_theme', isDark ? 'dark' : 'light') + } + + const setBreadcrumb = () => { + const pb = $('#pathBar') + pb.innerHTML = '' + const parts = state.prefix ? state.prefix.split('/').filter(Boolean) : [] + const homeItem = document.createElement('span') + homeItem.className = 'crumb active' + homeItem.textContent = ' ' + pb.appendChild(homeItem) + let accPath = '' + parts.forEach((p, idx) => { + const sep = document.createElement('span') + sep.className = 'crumb-sep' + sep.textContent = ' | ' + pb.appendChild(sep) + accPath = accPath ? accPath + '/' + p : p + const isLast = idx === parts.length - 1 + const target = isLast && state.prefix && state.prefix.endsWith('/') ? accPath + '/' : accPath + const crumb = document.createElement('span') + crumb.className = 'crumb' + crumb.textContent = p + crumb.addEventListener('click', () => navigateTo(target)) + pb.appendChild(crumb) + }) + } + + const setStatusBar = () => { + const total = state.items ? state.items.length : 0 + const sel = state.selected.size + $('#statusInfo').textContent = total + ' 个项目' + $('#statusSelect').textContent = sel > 0 ? '已选择 ' + sel + ' 个' : '' + } + + const buildColumnStack = (prefix) => { + const parts = String(prefix || '').split('/').filter(Boolean) + const stack = [] + let currentPrefix = '' + for (let i = 0; i < parts.length; i++) { + stack.push({ prefix: currentPrefix, selectedKey: parts[i] }) + currentPrefix = parts.slice(0, i + 1).join('/') + '/' + } + stack.push({ prefix: currentPrefix, selectedKey: null }) + return stack + } + + const navigateTo = (prefix, addHistory) => { + let clean = String(prefix || '').replace(/[\\]+/g, '/').replace(/\/+/g, '/') + if (clean.startsWith('/')) clean = clean.slice(1) + if (clean === '/') clean = '' + if (clean === state.prefix && addHistory === false) return + if (addHistory !== false) { + state.history = state.history.slice(0, state.historyIndex + 1) + state.history.push(clean) + state.historyIndex = state.history.length - 1 + } + state.prefix = clean + state.selected.clear() + state.lastSelectedIdx = -1 + state.columnStack = buildColumnStack(clean) + loadItems() + setBreadcrumb() + } + + const goBack = () => { + if (state.historyIndex > 0) { + state.historyIndex-- + state.prefix = state.history[state.historyIndex] + state.selected.clear() + loadItems() + setBreadcrumb() + } + } + + const goForward = () => { + if (state.historyIndex < state.history.length - 1) { + state.historyIndex++ + state.prefix = state.history[state.historyIndex] + state.selected.clear() + loadItems() + setBreadcrumb() + } + } + + const goUp = () => { + if (!state.prefix) return + const parts = state.prefix.split('/').filter(Boolean) + parts.pop() + navigateTo(parts.length ? parts.join('/') + '/' : '') + } + + const loadItems = async () => { + if (state.mode === 'trash') { await loadTrash(); return } + if (state.mode === 'images') { await loadImages(); return } + const r = await apiGet(API + '/files/list?prefix=' + encodeURIComponent(state.prefix) + (state.search ? '&search=' + encodeURIComponent(state.search) : '')) + console.log('[oss_file_cabinet] list response', r) + if (!r || !r.ok) { state.items = []; render(); return } + state.items = r.items || [] + state.items.sort((a, b) => { + if (a.isFolder !== b.isFolder) return a.isFolder ? -1 : 1 + return (a.name || '').localeCompare(b.name || '') + }) + render() + } + + const loadTrash = async () => { + const r = await apiGet(API + '/trash/list') + if (!r || !r.ok) { state.items = []; render(); return } + state.items = r.items || [] + render() + } + + const loadImages = async () => { + const r = await apiGet(API + '/files/list?prefix=') + if (!r || !r.ok) { state.items = []; render(); return } + const collect = async (prefix) => { + const res = await apiGet(API + '/files/list?prefix=' + encodeURIComponent(prefix)) + const out = [] + if (res && res.ok) { + for (const it of res.items) { + if (it.isFolder) { + const nested = await collect(it.key) + out.push(...nested) + } else if (it.isImage) out.push(it) + } + } + return out + } + state.items = await collect('') + render() + } + + const render = () => { + const area = $('#fileArea') + area.className = 'file-area ' + state.view + '-view' + area.innerHTML = '' + setStatusBar() + if (!state.items || state.items.length === 0) { + const empty = document.createElement('div') + empty.className = 'empty-state' + empty.innerHTML = '
🗑
' + + '
空文件夹
' + + '
'+(state.mode === 'trash' ? '废纸篓为空' : '拖拽文件到此处或点击「上传」')+'
' + area.appendChild(empty) + return + } + + if (state.view === 'icon') renderIconView(area) + else if (state.view === 'list') renderListView(area) + else renderColumnView(area).catch(() => {}) + } + + const renderIconView = (area) => { + state.items.forEach((it, idx) => { + const div = document.createElement('div') + div.className = 'icon-item' + (state.selected.has(it.key) ? ' selected' : '') + div.dataset.key = it.key + div.dataset.idx = String(idx) + + const iconWrap = document.createElement('div') + iconWrap.className = 'icon-wrap ' + getFileIconClass(it.name) + if (it.isImage && state.mode !== 'trash') { + const img = document.createElement('img') + img.src = API + '/files/preview?key=' + encodeURIComponent(it.key) + img.className = 'icon-thumb' + img.addEventListener('error', () => { iconWrap.textContent = getFileIcon(it.name, it.isFolder) }) + iconWrap.appendChild(img) + } else { + iconWrap.textContent = getFileIcon(it.name, it.isFolder) + } + div.appendChild(iconWrap) + + const nameEl = document.createElement('div') + nameEl.className = 'icon-name' + nameEl.textContent = it.name || '未命名' + div.appendChild(nameEl) + + bindItemEvents(div, it, idx) + area.appendChild(div) + }) + } + + const renderListView = (area) => { + const table = document.createElement('div') + table.className = 'list-table' + const header = document.createElement('div') + header.className = 'list-row list-header' + header.innerHTML = '
名称
大小
修改日期
类型
' + table.appendChild(header) + state.items.forEach((it, idx) => { + const row = document.createElement('div') + row.className = 'list-row' + (state.selected.has(it.key) ? ' selected' : '') + row.dataset.key = it.key + row.dataset.idx = String(idx) + const nameCell = document.createElement('div') + nameCell.className = 'col-name' + nameCell.innerHTML = '' + getFileIcon(it.name, it.isFolder) + '' + (it.name || '未命名') + '' + row.appendChild(nameCell) + const szCell = document.createElement('div') + szCell.className = 'col-size' + szCell.textContent = it.isFolder ? '-' : formatSize(it.size) + row.appendChild(szCell) + const modCell = document.createElement('div') + modCell.className = 'col-modified' + modCell.textContent = it.deleted_at ? '删除于 ' + formatDate(it.deleted_at) : formatDate(it.lastModified) + row.appendChild(modCell) + const kindCell = document.createElement('div') + kindCell.className = 'col-kind' + kindCell.textContent = it.isFolder ? '文件夹' : (it.ext ? it.ext.toUpperCase() : '文件') + row.appendChild(kindCell) + bindItemEvents(row, it, idx) + table.appendChild(row) + }) + area.appendChild(table) + } + + const loadColumnItems = async (prefix) => { + const cacheKey = prefix || '__root__' + if (state.columnCache[cacheKey]) return state.columnCache[cacheKey] + try { + const r = await apiGet(API + '/files/list?prefix=' + encodeURIComponent(prefix)) + const items = (r && r.ok ? r.items : []) || [] + items.sort((a, b) => { + if (a.isFolder !== b.isFolder) return a.isFolder ? -1 : 1 + return (a.name || '').localeCompare(b.name || '') + }) + state.columnCache[cacheKey] = items + return items + } catch { return [] } + } + + const renderColumnView = async (area) => { + area.innerHTML = '' + if (!state.columnStack || state.columnStack.length === 0) { + state.columnStack = buildColumnStack(state.prefix) + } + + let lastSelectedItem = null + for (let i = 0; i < state.columnStack.length; i++) { + const colDef = state.columnStack[i] + const colEl = document.createElement('div') + colEl.className = 'column' + const items = await loadColumnItems(colDef.prefix) + items.forEach((it) => { + const row = document.createElement('div') + row.className = 'column-row' + (colDef.selectedKey === it.key ? ' selected' : '') + row.innerHTML = '' + getFileIcon(it.name, it.isFolder) + '' + + '' + (it.name || '未命名') + '' + row.addEventListener('click', (ev) => { + ev.stopPropagation() + onColumnItemClick(i, it) + }) + row.addEventListener('dblclick', (ev) => { + ev.stopPropagation() + if (it.isFolder) navigateTo(it.key) + else if (isPreviewable(it.name)) openFilePreview(it.key, it.name) + else downloadFile(it.key, it.name) + }) + colEl.appendChild(row) + if (colDef.selectedKey === it.key) lastSelectedItem = it + }) + area.appendChild(colEl) + } + + const lastCol = state.columnStack[state.columnStack.length - 1] + if (lastSelectedItem && !lastSelectedItem.isFolder) { + const infoCol = document.createElement('div') + infoCol.className = 'column column-hint' + infoCol.innerHTML = '
' + + '
' + (lastSelectedItem.name || '') + '
' + + '
类型:' + (lastSelectedItem.ext ? lastSelectedItem.ext.toUpperCase() + ' 文件' : '文件') + '
' + + '
大小:' + formatSize(lastSelectedItem.size) + '
' + + '
修改日期:' + formatDate(lastSelectedItem.lastModified) + '
' + + '
' + area.appendChild(infoCol) + } else if (!lastCol.selectedKey) { + const hintCol = document.createElement('div') + hintCol.className = 'column column-hint' + hintCol.innerHTML = '
选择一项以查看详情
' + area.appendChild(hintCol) + } + } + + const onColumnItemClick = async (colIndex, item) => { + // Truncate stack after this column + state.columnStack = state.columnStack.slice(0, colIndex + 1) + const colDef = state.columnStack[colIndex] + colDef.selectedKey = item.name + if (item.isFolder) { + const nextPrefix = colDef.prefix + item.name + '/' + state.columnStack.push({ prefix: nextPrefix, selectedKey: null }) + // Pre-load next column into cache for smoother feel + await loadColumnItems(nextPrefix) + } + if (state.view === 'column') renderColumnView($('#fileArea')) + } + + const updateSelectionUI = () => { + const elems = $$('#fileArea [data-key]') + elems.forEach(el => { + const k = el.dataset.key + if (state.selected.has(k)) el.classList.add('selected') + else el.classList.remove('selected') + }) + } + + const bindItemEvents = (el, item, idx) => { + el.addEventListener('click', (ev) => { + ev.stopPropagation() + const multi = ev.metaKey || ev.ctrlKey + const range = ev.shiftKey + if (range && state.lastSelectedIdx >= 0) { + const lo = Math.min(idx, state.lastSelectedIdx) + const hi = Math.max(idx, state.lastSelectedIdx) + for (let i = lo; i <= hi; i++) { + const k = state.items[i] && state.items[i].key + if (k) state.selected.add(k) + } + } else if (multi) { + if (state.selected.has(item.key)) state.selected.delete(item.key) + else state.selected.add(item.key) + } else { + state.selected.clear() + state.selected.add(item.key) + } + state.lastSelectedIdx = idx + updateSelectionUI() + }) + + el.addEventListener('dblclick', (ev) => { + ev.stopPropagation() + if (item.isFolder) { + if (state.mode === 'files') navigateTo(item.key) + } else if (isPreviewable(item.name) && state.mode !== 'trash') { + openFilePreview(item.key, item.name) + } else if (state.mode !== 'trash') { + downloadFile(item.key, item.name) + } + }) + + el.addEventListener('contextmenu', (ev) => { + ev.preventDefault() + ev.stopPropagation() + openContextMenu(ev.clientX, ev.clientY, item) + }) + } + + const openContextMenu = (x, y, firstItem) => { + hideAllContextMenus() + const menuId = state.mode === 'trash' ? 'trashContextMenu' : (firstItem ? 'contextMenu' : 'folderContextMenu') + const menu = document.getElementById(menuId) + if (!menu) return + + // 文件夹显示"打开",文件隐藏"打开" + const openItem = menu.querySelector('.cm-item[data-action="open"]') + if (openItem) { + openItem.style.display = (firstItem && firstItem.isFolder) ? 'block' : 'none' + } + + menu.style.left = x + 'px' + menu.style.top = y + 'px' + menu.style.display = 'block' + const rect = menu.getBoundingClientRect() + if (rect.right > window.innerWidth - 8) menu.style.left = Math.max(8, window.innerWidth - rect.width - 8) + 'px' + if (rect.bottom > window.innerHeight - 8) menu.style.top = Math.max(8, window.innerHeight - rect.height - 8) + 'px' + menu._firstItem = firstItem + } + + const hideAllContextMenus = () => { + ['contextMenu', 'folderContextMenu', 'trashContextMenu'].forEach(id => { + const el = document.getElementById(id) + if (el) el.style.display = 'none' + }) + } + + let previewScale = 1 + const isVideo = name => ['mp4', 'mov', 'avi', 'webm', 'mkv', 'flv', 'm4v'].includes(getExt(name)) + const isPdf = name => getExt(name) === 'pdf' + const isText = name => ['txt', 'md', 'json', 'js', 'ts', 'html', 'css', 'xml', 'log', 'csv', 'yml', 'yaml', 'sh', 'py', 'java', 'c', 'cpp', 'h', 'go', 'rs'].includes(getExt(name)) + const isImage = name => ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'ico'].includes(getExt(name)) + const isPreviewable = name => isImage(name) || isVideo(name) || isPdf(name) || isText(name) + const escapeHtml = s => String(s || '').replace(/&/g, '&').replace(//g, '>') + + const openFilePreview = async (key, name) => { + previewScale = 1 + const modal = $('#filePreview') + const container = $('#previewContainer') + container.innerHTML = '' + modal.style.display = 'flex' + const url = API + '/files/preview?key=' + encodeURIComponent(key) + if (isVideo(name)) { + container.innerHTML = '' + } else if (isPdf(name)) { + container.innerHTML = '' + } else if (isText(name)) { + container.innerHTML = '
正在加载…
' + try { + const r = await fetch(url, { credentials: 'include' }) + if (!r.ok) throw new Error('加载失败') + const text = await r.text() + container.innerHTML = '
' + escapeHtml(text) + '
' + } catch (e) { + container.innerHTML = '
预览加载失败
' + } + } else { + container.innerHTML = '' + (name || '预览') + '' + } + } + + const closeFilePreview = () => { + $('#filePreview').style.display = 'none' + previewScale = 1 + $('#previewContainer').innerHTML = '' + } + + const downloadFile = (key, name) => { + const url = API + '/files/download?key=' + encodeURIComponent(key) + const a = document.createElement('a') + a.href = url + a.download = name || '文件' + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + } + + const showModal = (title, bodyHtml, onOk, okText) => { + $('#modalTitle').textContent = title + $('#modalBody').innerHTML = bodyHtml || '' + $('#modalOk').textContent = okText || '确定' + $('#modalDialog').style.display = 'flex' + $('#modalCancel').onclick = () => { $('#modalDialog').style.display = 'none' } + $('#modalOk').onclick = () => { + if (onOk) { + const result = onOk() + if (result === false) return + } + $('#modalDialog').style.display = 'none' + } + const firstInput = $('#modalBody').querySelector('input, select, textarea') + if (firstInput) setTimeout(() => firstInput.focus(), 50) + } + + const handleMenuAction = async (action, firstItem) => { + if (action === 'open') { + if (!firstItem) return + if (firstItem.isFolder) navigateTo(firstItem.key) + else if (isPreviewable(firstItem.name)) openFilePreview(firstItem.key, firstItem.name) + else downloadFile(firstItem.key, firstItem.name) + return + } + if (action === 'preview') { + if (!firstItem || !isPreviewable(firstItem.name)) return + openFilePreview(firstItem.key, firstItem.name).catch(() => {}) + return + } + if (action === 'rename') { + if (state.selected.size !== 1) return + const key = Array.from(state.selected)[0] + const it = state.items.find(x => x.key === key) + if (!it) return + showModal('重命名', '', async () => { + const newName = $('#renameInput').value.trim() + if (!newName) { showToast('名称不能为空', 'error'); return false } + const r = await apiPost(API + '/files/rename', { key: key, name: newName }) + if (r && r.ok) { showToast('重命名成功', 'success'); loadItems() } + else showToast(r && r.error ? r.error : '重命名失败', 'error') + }) + return + } + if (action === 'info') { + const keys = Array.from(state.selected) + if (keys.length === 0) return + const key = keys[0] + const r = await apiGet(API + '/files/info?key=' + encodeURIComponent(key)) + if (!r || !r.ok) { showToast('获取信息失败', 'error'); return } + const info = r.info || {} + const lines = [ + ['名称', info.name], + ['类型', info.isFolder ? '文件夹' : info.ext ? info.ext.toUpperCase() + ' 文件' : '文件'], + ['大小', info.isFolder ? '-' : formatSize(info.size)], + ['修改日期', info.lastModified ? formatDate(info.lastModified) : '-'], + ['路径', '' + (info.fullPath || '') + ''] + ] + const html = lines.map(l => '
' + l[0] + '' + l[1] + '
').join('') + showModal('简介', html, null, '关闭') + return + } + if (action === 'copy') { + if (state.selected.size === 0) return + state.clipboard = { op: 'copy', keys: Array.from(state.selected) } + showToast('已复制 ' + state.clipboard.keys.length + ' 个项目到剪贴板', 'success') + return + } + if (action === 'move') { + if (state.selected.size === 0) return + state.clipboard = { op: 'move', keys: Array.from(state.selected) } + showToast('已选择 ' + state.clipboard.keys.length + ' 个项目,点击目标文件夹粘贴', 'info') + return + } + if (action === 'paste') { + if (!state.clipboard || state.clipboard.keys.length === 0) return + const op = state.clipboard.op + let okCount = 0 + for (const k of state.clipboard.keys) { + const r = op === 'copy' + ? await apiPost(API + '/files/copy', { source: k, dest: state.prefix }) + : await apiPost(API + '/files/move', { source: k, dest: state.prefix }) + if (r && r.ok) okCount++ + } + if (okCount === state.clipboard.keys.length) { + showToast(op === 'copy' ? '复制成功' : '移动成功', 'success') + if (op === 'move') state.clipboard = null + } else showToast('部分操作失败', 'error') + loadItems() + return + } + if (action === 'download') { + const keys = Array.from(state.selected) + if (keys.length === 0) return + // 多选或单个文件夹:打包 zip 下载 + if (keys.length > 1 || (keys.length === 1 && state.items.find(x => x.key === keys[0])?.isFolder)) { + const a = document.createElement('a') + a.href = API + '/files/batch_download' + a.download = '归档.zip' + a.style.display = 'none' + document.body.appendChild(a) + // 使用 POST 触发下载 + fetch(API + '/files/batch_download', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify({ keys, mode: 'download' }) + }).then(r => { + if (!r.ok) throw new Error('下载失败') + return r.blob() + }).then(blob => { + const url = URL.createObjectURL(blob) + const a2 = document.createElement('a') + a2.href = url + a2.download = '归档.zip' + document.body.appendChild(a2) + a2.click() + document.body.removeChild(a2) + URL.revokeObjectURL(url) + }).catch(() => showToast('下载失败', 'error')) + return + } + // 单选文件:直接下载 + const k = keys[0] + const it = state.items.find(x => x.key === k) + const name = it ? it.name : '文件' + downloadFile(k, name) + return + } + if (action === 'share') { + if (state.selected.size !== 1) { showToast('请选择一个项目分享', 'info'); return } + const key = Array.from(state.selected)[0] + showModal('分享临时链接', '
', + () => { + const out = $('#shareOutput') + if (out.dataset.done === '1') return false + const ttl = Number($('#shareTtl').value) || 24 + out.dataset.done = '1' + out.innerHTML = '
正在生成链接…
' + apiPost(API + '/share/create', { key: key, ttl_hours: ttl }).then(async (r) => { + if (!r || !r.ok) { showToast('分享失败', 'error'); return } + const shareUrl = location.origin + API + '/share/access/' + r.token + let copied = false + try { await navigator.clipboard.writeText(shareUrl); copied = true } catch {} + out.innerHTML = '
临时链接(' + ttl + ' 小时后过期)
' + + '
' + shareUrl + '
' + + (copied ? '
已复制到剪贴板
' : '') + }) + return false + }, '生成并复制') + return + } + if (action === 'raw_url') { + if (state.selected.size !== 1) { showToast('请选择一个文件', 'info'); return } + const key = Array.from(state.selected)[0] + const r = await apiGet(API + '/files/raw_url?key=' + encodeURIComponent(key)) + if (!r || !r.ok || !r.url) { showToast('获取原始链接失败', 'error'); return } + try { + await navigator.clipboard.writeText(r.url) + showToast('原始 OSS 链接已复制', 'success') + } catch { showToast('复制失败', 'error') } + return + } + if (action === 'delete') { + const keys = Array.from(state.selected) + if (keys.length === 0) return + showModal('确认删除', '
将 ' + keys.length + ' 个项目移到废纸篓?
', async () => { + let okCount = 0 + for (const k of keys) { + const r = await apiPost(API + '/files/delete', { key: k }) + if (r && r.ok) okCount++ + } + if (okCount === keys.length) showToast('已移到废纸篓', 'success') + else showToast('部分删除失败', 'error') + loadItems() + }, '删除') + return + } + if (action === 'new_folder') { + showModal('新建文件夹', '', async () => { + const name = $('#newFolderName').value.trim() + if (!name) { showToast('名称不能为空', 'error'); return false } + const r = await apiPost(API + '/files/mkdir', { parent: state.prefix, name: name }) + if (r && r.ok) { showToast('创建成功', 'success'); loadItems() } + else showToast(r && r.error ? r.error : '创建失败', 'error') + }) + return + } + if (action === 'upload') { + triggerFileUpload() + return + } + if (action === 'compress') { + const keys = Array.from(state.selected) + if (keys.length === 0) return + showToast('正在压缩...', 'info') + const r = await apiPost(API + '/files/batch_download', { keys, mode: 'compress', prefix: state.prefix }) + if (r && r.ok) { showToast('已生成 ' + (r.name || '归档.zip'), 'success'); loadItems() } + else showToast(r && r.error ? r.error : '压缩失败', 'error') + return + } + if (action === 'refresh') { loadItems(); return } + if (action === 'restore') { + const keys = Array.from(state.selected) + if (keys.length === 0) return + let okCount = 0 + for (const k of keys) { + const it = state.items.find(x => x.key === k) + const id = it && it.id + if (!id) continue + const r = await apiPost(API + '/trash/restore', { id: id }) + if (r && r.ok) okCount++ + } + if (okCount === keys.length) showToast('已还原', 'success') + else showToast('部分还原失败', 'error') + loadItems() + return + } + if (action === 'empty_perm') { + const keys = Array.from(state.selected) + showModal('彻底删除', '
此操作不可撤销。确认从 OSS 彻底删除 ' + keys.length + ' 个项目?
', async () => { + const ids = state.items.filter(it => keys.indexOf(it.key) >= 0).map(it => it.id).filter(Boolean) + const r = await apiPost(API + '/trash/empty', { ids: ids }) + if (r && r.ok) showToast('已彻底删除 ' + r.deleted_count + ' 个项目', 'success') + else showToast(r && r.error ? r.error : '删除失败', 'error') + loadItems() + }, '彻底删除') + return + } + } + + const triggerFileUpload = () => { + const input = $('#fileInput') + input.value = '' + input.click() + } + + const doUpload = async (files) => { + const fd = new FormData() + fd.append('prefix', state.prefix) + for (const f of files) fd.append('files', f) + const r = await apiPostFormData(API + '/files/upload', fd) + if (r && r.ok) { showToast('已上传 ' + (r.results ? r.results.length : 0) + ' 个文件', 'success'); loadItems() } + else showToast(r && r.error ? r.error : '上传失败', 'error') + } + + const uploadFiles = async (files) => { + if (!files || files.length === 0) return + const oversized = files.some(f => f.size > 50 * 1024 * 1024) + if (oversized) { showToast('文件超过 50MB 限制', 'error'); return } + + await doUpload(files) + } + + const initDragDrop = () => { + const overlay = $('#dropOverlay') + let dragOver = false + window.addEventListener('dragenter', (e) => { + if (state.mode !== 'files') return + if (e.dataTransfer && Array.from(e.dataTransfer.types || []).indexOf('Files') >= 0) { + dragOver = true + overlay.style.display = 'flex' + } + }) + window.addEventListener('dragover', (e) => { + if (dragOver) { e.preventDefault(); e.stopPropagation() } + }) + window.addEventListener('dragleave', (e) => { + if (!e.relatedTarget || e.relatedTarget === document.documentElement) { + dragOver = false + overlay.style.display = 'none' + } + }) + window.addEventListener('drop', (e) => { + if (dragOver) { + e.preventDefault() + overlay.style.display = 'none' + dragOver = false + const files = Array.from(e.dataTransfer && e.dataTransfer.files || []) + uploadFiles(files) + } + }) + } + + const initEvents = () => { + $('#btnThemeToggle').addEventListener('click', toggleTheme) + $('#btnLogout').addEventListener('click', async () => { + try { await apiPost(API + '/auth/logout', {}) } catch {} + showLogin() + }) + + $('#btnBack').addEventListener('click', goBack) + $('#btnForward').addEventListener('click', goForward) + + $$('.view-btn').forEach(btn => btn.addEventListener('click', () => { + const view = btn.dataset.view + if (!view || state.view === view) return + state.view = view + $$('.view-btn').forEach(b => b.classList.remove('active')) + btn.classList.add('active') + render() + })) + + $('#searchInput').addEventListener('input', (e) => { + state.search = e.target.value.trim() + loadItems() + }) + + $$('.sidebar-item').forEach(item => item.addEventListener('click', () => { + $$('.sidebar-item').forEach(i => i.classList.remove('active')) + item.classList.add('active') + const target = item.dataset.target + if (target === 'home') { state.mode = 'files'; navigateTo('') } + else if (target === 'trash') { state.mode = 'trash'; state.prefix = ''; loadItems() } + else if (target === 'images') { state.mode = 'images'; state.prefix = ''; loadItems() } + })) + + $('#pathBar').addEventListener('click', (ev) => { + const crumb = ev.target.closest('.crumb') + if (!crumb) return + }) + + $('#fileArea').addEventListener('click', (ev) => { + if (ev.target === $('#fileArea') || ev.target.classList.contains('empty-state') || ev.target.closest('.empty-state')) { + state.selected.clear() + state.lastSelectedIdx = -1 + updateSelectionUI() + } + }) + + $('#fileArea').addEventListener('dblclick', (ev) => { + if (ev.target === $('#fileArea') || ev.target.classList.contains('empty-state') || ev.target.closest('.empty-state')) { + if (state.prefix) goUp() + } + }) + + $('#fileArea').addEventListener('contextmenu', (ev) => { + if (ev.target === $('#fileArea') || ev.target.classList.contains('empty-state') || ev.target.closest('.empty-state')) { + ev.preventDefault() + state.selected.clear() + updateSelectionUI() + if (state.mode === 'files') openContextMenu(ev.clientX, ev.clientY, null) + } + }) + + let dragStart = null + let dragRect = null + $('#fileArea').addEventListener('mousedown', (ev) => { + if (ev.button !== 0) return + const target = ev.target + if (target.closest('.icon-item') || target.closest('.list-row') || target.closest('.column-row')) return + const area = $('#fileArea') + const rect = area.getBoundingClientRect() + dragStart = { x: ev.clientX, y: ev.clientY, rect: rect } + dragRect = document.createElement('div') + dragRect.className = 'selection-rect' + dragRect.style.left = ev.clientX + 'px' + dragRect.style.top = ev.clientY + 'px' + dragRect.style.width = '0px' + dragRect.style.height = '0px' + document.body.appendChild(dragRect) + ev.preventDefault() + }) + + window.addEventListener('mousemove', (ev) => { + if (!dragStart || !dragRect) return + const x1 = dragStart.x + const y1 = dragStart.y + const x2 = ev.clientX + const y2 = ev.clientY + const left = Math.min(x1, x2) + const top = Math.min(y1, y2) + const width = Math.abs(x2 - x1) + const height = Math.abs(y2 - y1) + dragRect.style.left = left + 'px' + dragRect.style.top = top + 'px' + dragRect.style.width = width + 'px' + dragRect.style.height = height + 'px' + const sel = { left, top, right: left + width, bottom: top + height } + const els = $$('#fileArea .icon-item, #fileArea .list-row, #fileArea .column-row') + for (const el of els) { + const r = el.getBoundingClientRect() + const overlap = !(r.right < sel.left || r.left > sel.right || r.bottom < sel.top || r.top > sel.bottom) + const k = el.dataset.key + if (!k) continue + if (overlap) state.selected.add(k) + else state.selected.delete(k) + } + updateSelectionUI() + }) + + window.addEventListener('mouseup', () => { + if (dragRect) { try { document.body.removeChild(dragRect) } catch {}; dragRect = null } + dragStart = null + }) + + document.addEventListener('click', (ev) => { + if (!ev.target.closest('.context-menu') && !ev.target.closest('.icon-item') && !ev.target.closest('.list-row') && !ev.target.closest('.column-row')) { + hideAllContextMenus() + } + }) + + $$('.context-menu .cm-item').forEach(item => { + item.addEventListener('click', (ev) => { + ev.stopPropagation() + const parentMenu = item.closest('.context-menu') + const first = parentMenu && parentMenu._firstItem + const action = item.dataset.action + hideAllContextMenus() + if (action) handleMenuAction(action, first) + }) + }) + + $('#fileInput').addEventListener('change', (e) => { + const files = Array.from(e.target.files || []) + uploadFiles(files) + }) + + $('#closePreview').addEventListener('click', closeFilePreview) + $('#filePreview').addEventListener('click', (e) => { if (e.target.id === 'filePreview' || e.target.classList.contains('modal-backdrop')) closeFilePreview() }) + $('#filePreview').addEventListener('wheel', (e) => { + const img = $('#previewImg') + if (!img) return + e.preventDefault() + const delta = e.deltaY > 0 ? -0.15 : 0.15 + previewScale = Math.max(0.3, Math.min(5, previewScale + delta)) + img.style.transform = 'scale(' + previewScale + ')' + }, { passive: false }) + + document.addEventListener('keydown', (e) => { + if ($('#filePreview').style.display === 'flex') { + if (e.key === 'Escape') { e.preventDefault(); closeFilePreview() } + return + } + if ($('#modalDialog').style.display === 'flex') { + if (e.key === 'Escape') { e.preventDefault(); $('#modalDialog').style.display = 'none' } + if (e.key === 'Enter') { e.preventDefault(); $('#modalOk').click() } + return + } + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'a') { + if (state.items.length > 0 && document.activeElement && document.activeElement.tagName !== 'INPUT') { + e.preventDefault() + state.selected.clear() + state.items.forEach(it => state.selected.add(it.key)) + render() + } + } + if (e.key === 'Backspace' && document.activeElement && document.activeElement.tagName !== 'INPUT') { + if (state.mode === 'files') { e.preventDefault(); goUp() } + } + if (e.key === 'Delete' && document.activeElement && document.activeElement.tagName !== 'INPUT') { + if (state.selected.size > 0) { e.preventDefault(); handleMenuAction(state.mode === 'trash' ? 'empty_perm' : 'delete') } + } + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'c' && state.selected.size > 0) { + e.preventDefault() + state.clipboard = { op: 'copy', keys: Array.from(state.selected) } + showToast('已复制 ' + state.clipboard.keys.length + ' 个项目到剪贴板', 'success') + } + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'v' && state.clipboard && state.mode === 'files') { + e.preventDefault() + handleMenuAction('paste') + } + }) + + // 系统剪贴板粘贴:Ctrl+V 时如果剪贴板有文件,直接上传 + document.addEventListener('paste', (e) => { + if (state.mode !== 'files') return + const items = e.clipboardData && e.clipboardData.items + if (!items) return + const files = [] + for (let i = 0; i < items.length; i++) { + if (items[i].kind === 'file') files.push(items[i].getAsFile()) + } + if (files.length > 0) { + e.preventDefault() + uploadFiles(files) + } + }) + } + + document.addEventListener('DOMContentLoaded', main) +})() \ No newline at end of file diff --git a/public/tools/plant_home/plant_home.css b/public/tools/plant_home/plant_home.css index 83e6c1a..e1d40a1 100644 --- a/public/tools/plant_home/plant_home.css +++ b/public/tools/plant_home/plant_home.css @@ -206,6 +206,14 @@ textarea { margin-bottom: 8px; } +.weather-data-timestamp { + font-size: 11px; + color: rgba(63, 95, 45, 0.5); + margin-bottom: 4px; + font-family: "Consolas", "Monaco", monospace; + letter-spacing: 0.5px; +} + .expand-btn { align-self: flex-end; background: transparent; diff --git a/public/tools/plant_home/plant_home.js b/public/tools/plant_home/plant_home.js index 5c92b13..11b7322 100644 --- a/public/tools/plant_home/plant_home.js +++ b/public/tools/plant_home/plant_home.js @@ -567,6 +567,17 @@ const renderWeather = weather => { const adviceText = escapeHtml(weather.advice_text || '暂无建议') const weatherIcon = getWeatherIcon(weather.icon) + // 转换 data_gateway 更新时间为北京时间 + const formatBeijingTime = (utcStr) => { + if (!utcStr) return '' + const d = new Date(utcStr.replace(' ', 'T') + '+00:00') + if (isNaN(d.getTime())) return '' + const pad = n => String(n).padStart(2, '0') + return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds())}` + } + const dataTimestamp = formatBeijingTime(weather._data_gateway_updated_at) + const dataSourceLabel = dataTimestamp ? `数据更新:${dataTimestamp}` : '' + // 检查温度是否已经包含°C,如果没有则添加 if (temp && !temp.includes('°C')) { temp = temp + '°C' @@ -602,6 +613,7 @@ const renderWeather = weather => {
${adviceText}
+ ${dataSourceLabel ? `
${escapeHtml(dataSourceLabel)}
` : ''}
` diff --git a/public/tools/private_clipboard/auth_config.json b/public/tools/private_clipboard/auth_config.json index 42a9361..bde8b02 100644 --- a/public/tools/private_clipboard/auth_config.json +++ b/public/tools/private_clipboard/auth_config.json @@ -2,8 +2,8 @@ "_comment": "私人剪贴板工具的前端鉴权配置", "enable_auth_comment": "true:启用鉴权;false:关闭鉴权并放开访问", "enable_auth": true, - "max_age_days_comment": "鉴权 Cookie 有效期(天),范围建议 1~365", - "max_age_days": 30, + "max_age_days_comment": "鉴权 Cookie 有效期(天),1天后过期需重新登录", + "max_age_days": 1, "cookieName_comment": "鉴权 Cookie 名称,建议保持默认", "cookieName": "private_clipboard_gate", "requireGuid_comment": "true:入口必须带 guid 参数;false:允许仅凭 token 或会话访问", @@ -11,5 +11,5 @@ "guidParam_comment": "URL 中的 GUID 参数名,建议使用小写 guid", "guidParam": "guid", "guidValue_comment": "GUID 参数值,建议使用高强度随机长串并定期更换", - "guidValue": "SD9KK-LW32S-9KJJH-JJH43-LJ432J" + "guidValue": "db8a2f1c-90e7-4b5a-a1d2-c3e4f5067890" } diff --git a/public/tools/private_clipboard/clipboard.css b/public/tools/private_clipboard/clipboard.css index b86c6e8..672b76e 100644 --- a/public/tools/private_clipboard/clipboard.css +++ b/public/tools/private_clipboard/clipboard.css @@ -4,6 +4,68 @@ body { font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; } +/* ===== 登录界面 ===== */ +.pc-auth-card { + max-width: 360px; + margin: 120px auto 0; + padding: 32px 28px; + background: #1a1d24; + border: 1px solid #2a2d35; + border-radius: 12px; + text-align: center; +} +.pc-auth-card h2 { + margin: 0 0 24px; + font-size: 20px; + font-weight: 600; +} +.pc-auth-form { + display: flex; + flex-direction: column; + gap: 12px; +} +.pc-auth-form input { + padding: 10px 12px; + background: #0f1115; + border: 1px solid #2a2d35; + border-radius: 6px; + color: #e6e8ee; + font-size: 14px; + outline: none; +} +.pc-auth-form input:focus { + border-color: #4a9eff; +} +.pc-auth-status { + margin-top: 12px; + font-size: 13px; + color: #ff6b6b; +} +.pc-auth-status.ok { + color: #4ade80; +} + +/* ===== 顶部栏 ===== */ +.pc-topbar { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; +} +.pc-topbar-title { + font-size: 20px; + font-weight: 600; +} +.pc-btn-logout { + padding: 6px 16px; + font-size: 13px; + background: #2a2d35; + border: 1px solid #3a3d45; +} +.pc-btn-logout:hover { + background: #3a3d45; +} + .pc-main { max-width: 1100px; margin: 0 auto; diff --git a/public/tools/private_clipboard/clipboard.js b/public/tools/private_clipboard/clipboard.js index 8ed3d99..240019e 100644 --- a/public/tools/private_clipboard/clipboard.js +++ b/public/tools/private_clipboard/clipboard.js @@ -736,6 +736,99 @@ const createFolder = async () => { } const init = async () => { + // ===== set_token 处理:必须在 clearUrlQuery 之前 ===== + try { + const params = new URLSearchParams(window.location.search) + const setToken = params.get('set_token') + if (setToken) { + localStorage.setItem('toolbox_creds_token', setToken) + } + } catch {} + + clearUrlQuery() + + // ===== 登录逻辑 ===== + const $auth = $('pc-auth') + const $app = $('pc-app') + const $status = $('pc-auth-status') + const $user = $('pc-login-user') + const $pass = $('pc-login-pass') + + async function doLogin() { + try { + const username = $user.value.trim() + const password = $pass.value + const res = await fetch(apiBase + '/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }), + credentials: 'same-origin', + cache: 'no-store' + }) + const data = await res.json() + if (!data.ok) throw new Error(data.error || '登录失败') + $auth.style.display = 'none' + $app.style.display = '' + initApp() + } catch (e) { + $status.textContent = String(e.message || e) + $status.style.display = '' + $status.className = 'pc-auth-status' + } + } + + async function doLogout() { + try { + await fetch(apiBase + '/auth/logout', { method: 'POST', credentials: 'same-origin' }) + } catch {} + $app.style.display = 'none' + $auth.style.display = '' + $pass.value = '' + $status.textContent = '已注销' + $status.className = 'pc-auth-status ok' + $status.style.display = '' + } + + // 自动填充 + async function tryAutoFill() { + try { + const token = localStorage.getItem('toolbox_creds_token') || '' + if (!token) return + const res = await fetch(apiBase + '/auth/local_creds?token=' + encodeURIComponent(token), { + credentials: 'same-origin', + cache: 'no-store' + }) + const data = await res.json().catch(() => null) + if (data && data.ok && data.creds) { + if (data.creds.username) $user.value = data.creds.username + if (data.creds.password) $pass.value = data.creds.password + } + } catch {} + } + + $('pc-btn-login').onclick = doLogin + $('pc-btn-logout').onclick = doLogout + $user.removeAttribute('readonly') + $pass.removeAttribute('readonly') + $pass.addEventListener('keydown', e => { if (e.key === 'Enter') doLogin() }) + + // 先检查是否已登录 + try { + const res = await fetch(apiBase + '/auth/me', { credentials: 'same-origin', cache: 'no-store' }) + const data = await res.json() + if (data && data.ok) { + $auth.style.display = 'none' + $app.style.display = '' + initApp() + return + } + } catch {} + + // 未登录,尝试自动填充 + await tryAutoFill() +} + +const initApp = async () => { clearUrlQuery() const isMobile = detectMobile() const refreshBtn = $('pc-refresh') diff --git a/public/tools/private_clipboard/index.html b/public/tools/private_clipboard/index.html index 8a32efd..cb903cc 100644 --- a/public/tools/private_clipboard/index.html +++ b/public/tools/private_clipboard/index.html @@ -9,7 +9,23 @@ -
+ +
+

私人剪贴板 - 登录

+
+ + + +
+ +
+ + +
+
+ 私人剪贴板 + +
在这里粘贴文字 / 图片 / 文件
diff --git a/public/tools/threshing_ground/hell.html b/public/tools/threshing_ground/hell.html new file mode 100644 index 0000000..74d7d1e --- /dev/null +++ b/public/tools/threshing_ground/hell.html @@ -0,0 +1,2110 @@ + + + + + +打谷场 - 地狱版 + + + + + +
+ + +
波次 0
+
+
金币 500
+
+
生命 20
+
+
击杀 0
+
+
速度 x1
+
+
编辑模式
+
+ + + + + +
+ + +
+ 空格:暂停1-3:速度左键:操作右键:平移滚轮:缩放G:网格N:新路线 +
+ + +
+ + + + + + + + \ No newline at end of file diff --git a/public/tools/threshing_ground/index.html b/public/tools/threshing_ground/index.html new file mode 100644 index 0000000..bc23567 --- /dev/null +++ b/public/tools/threshing_ground/index.html @@ -0,0 +1,131 @@ + + + + + +打谷场 - 塔防合集 + + + + +
+

打谷场

+

军事风自定义路线塔防游戏合集 - 多个难度版本,自由绘制敌军路径

+
+ +
+ + +
🎯
+

简单版

+ 入门 +

标准难度,500金币起步,20生命值。BOSS每10波出现。适合新手熟悉游戏机制。

+
+ 金币500生命20BOSS=10波 +
+
+ + + +
💀
+

地狱版

+ 地狱 +

极难难度!300金币紧巴巴,仅10生命值。BOSS每5波就来,敌人1.5倍数量+1.3倍速度,塔造价涨30%。

+
+ 金币300生命10BOSS=5波1.5x敌军1.3x塔价 +
+
+ + +
+
⚡
+

闪电战

+ 即将推出 +

敌人速度翻倍,但塔攻击力也翻倍。快节奏极限操作。

+
+ 2x速度2x伤害 +
+
+ +
+
♾️
+

无尽模式

+ 即将推出 +

无波次限制,敌人无限刷新,难度持续递增,挑战最高击杀数。

+
+ 无限波排行榜 +
+
+ +
+
🌙
+

噩梦版

+ 即将推出 +

比地狱更恐怖。0金币起步,杀敌才得金币。每波随机出特殊兵种。

+
+ 0金币特殊兵种 +
+
+ +
+
🛡️
+

坚守阵地

+ 即将推出 +

固定路线+预设塔布局,纯策略防御。能用有限的塔撑多久?

+
+ 固定路线预设塔 +
+
+
+ +
+

所有版本离线可玩 · 存档永久保留在浏览器中 · 无贴图无音效极简风格

+
+ + + \ No newline at end of file diff --git a/public/tools/threshing_ground/simple.html b/public/tools/threshing_ground/simple.html new file mode 100644 index 0000000..de3419f --- /dev/null +++ b/public/tools/threshing_ground/simple.html @@ -0,0 +1,2055 @@ + + + + + +打谷场 - 简单版 + + + + + +
+ + +
波次 0
+
+
金币 500
+
+
生命 20
+
+
击杀 0
+
+
速度 x1
+
+
编辑模式
+
+ + + + + +
+ + +
+ 空格:暂停1-3:速度左键:操作右键:平移滚轮:缩放G:网格N:新路线 +
+ + +
+ + + + + + + + \ No newline at end of file diff --git a/src/server/data_gateway/auth.js b/src/server/data_gateway/auth.js new file mode 100644 index 0000000..9c208b1 --- /dev/null +++ b/src/server/data_gateway/auth.js @@ -0,0 +1,85 @@ +// ============================================================ +// data_gateway/auth.js - 对外 API 鉴权模块 +// 职责:校验 X-API-Id + X-API-Key,匹配 skill config.json 中的哈希 +// ============================================================ +const crypto = require('crypto') +const fs = require('fs') +const path = require('path') + +const SKILLS_DIR = path.join(__dirname, 'skills') + +// 缓存已加载的 skill 配置 +const skillConfigCache = new Map() + +const loadSkillConfig = (skillId) => { + if (skillConfigCache.has(skillId)) return skillConfigCache.get(skillId) + const cfgPath = path.join(SKILLS_DIR, skillId, 'config.json') + if (!fs.existsSync(cfgPath)) return null + try { + const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf-8')) + skillConfigCache.set(skillId, cfg) + return cfg + } catch { + return null + } +} + +// 刷新缓存(用于配置热更新) +const refreshCache = (skillId) => { + skillConfigCache.delete(skillId) + return loadSkillConfig(skillId) +} + +// 枚举所有已启用的 skill +const listEnabledSkills = () => { + const result = [] + if (!fs.existsSync(SKILLS_DIR)) return result + const dirs = fs.readdirSync(SKILLS_DIR, { withFileTypes: true }) + for (const d of dirs) { + if (!d.isDirectory()) continue + const cfg = loadSkillConfig(d.name) + if (cfg && cfg.enabled !== false) { + result.push({ id: cfg.id, name: cfg.name }) + } + } + return result +} + +// 校验外部 API 请求 +const verifyApiKey = (req) => { + const apiId = String(req.headers['x-api-id'] || '').trim() + const apiKey = String(req.headers['x-api-key'] || '').trim() + if (!apiId || !apiKey) return { ok: false, error: 'missing api id or key' } + + const cfg = loadSkillConfig(apiId) + if (!cfg) return { ok: false, error: 'skill not found' } + if (cfg.enabled === false) return { ok: false, error: 'skill disabled' } + + const expectedHash = String(cfg.api_key_hash || '') + if (!expectedHash) return { ok: false, error: 'skill not configured' } + + const actualHash = 'sha256:' + crypto.createHash('sha256').update(apiKey).digest('hex') + if (actualHash !== expectedHash) return { ok: false, error: 'invalid api key' } + + return { ok: true, skill: cfg } +} + +// 校验内部 Tool 读取请求 +const verifyInternalToken = (req) => { + const token = String(req.headers['x-internal-token'] || '').trim() + const skillId = String(req.headers['x-skill-id'] || req.params?.skillId || '').trim() + if (!token || !skillId) return { ok: false, error: 'missing token or skill id' } + + const cfg = loadSkillConfig(skillId) + if (!cfg) return { ok: false, error: 'skill not found' } + + const expectedHash = String(cfg.read_token_hash || '') + if (!expectedHash) return { ok: false, error: 'skill not configured for internal read' } + + const actualHash = 'sha256:' + crypto.createHash('sha256').update(token).digest('hex') + if (actualHash !== expectedHash) return { ok: false, error: 'invalid internal token' } + + return { ok: true, skill: cfg } +} + +module.exports = { loadSkillConfig, refreshCache, listEnabledSkills, verifyApiKey, verifyInternalToken } \ No newline at end of file diff --git a/src/server/data_gateway/index.js b/src/server/data_gateway/index.js new file mode 100644 index 0000000..9835610 --- /dev/null +++ b/src/server/data_gateway/index.js @@ -0,0 +1,125 @@ +// ============================================================ +// data_gateway/index.js - 对外 API 数据网关主入口 +// 暴露两个路由: +// POST /api/v1/ingest/:skillId 外部数据写入(公网,需 API Key) +// GET /api/v1/data/:skillId/query 内部数据读取(仅 localhost,需 Internal Token) +// ============================================================ +const { logJSON } = require('../logger') +const { verifyApiKey, verifyInternalToken, listEnabledSkills } = require('./auth') +const { insert, upsertLatest, queryLatest, queryList, queryByTimeRange, count } = require('./store') +const { loadSkillConfig } = require('./auth') +const { bindRoutes: bindExternalStorageRoutes } = require('./skills/external_storage') + +const setNoCache = (res) => { + try { + res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate') + res.set('Pragma', 'no-cache') + res.set('Expires', '0') + } catch {} +} + +const bindRoutes = (app) => { + // ============================================================ + // 外部存储 skill(第三方 OSS 读写) + // 路由挂载在 /api/v1/skills/external_storage/* + // ============================================================ + try { + bindExternalStorageRoutes(app) + } catch (e) { + console.error(`[data_gateway] Failed to bind external_storage routes: ${e.message}`) + } + + // ============================================================ + // 外部写入接口(公网暴露) + // POST /api/v1/ingest/:skillId + // Headers: X-API-Id, X-API-Key + // Body: { "source": "...", "data": { ... } } + // ============================================================ + app.post('/api/v1/ingest/:skillId', (req, res) => { + setNoCache(res) + const skillId = String(req.params.skillId || '').trim() + if (!skillId) return res.status(400).json({ ok: false, error: 'missing skill id' }) + + const auth = verifyApiKey(req) + if (!auth.ok) { + try { logJSON('data_gateway.auth.fail', { skillId, error: auth.error, ip: req.ip }, 'data_gateway') } catch {} + return res.status(401).json({ ok: false, error: auth.error }) + } + + const { source, data } = req.body || {} + if (data === undefined && req.body && typeof req.body === 'object') { + // 如果没传 data 字段,将整个 body 视为 data + const bodyData = { ...req.body } + delete bodyData.source + const record = insert(skillId, { source: String(source || ''), data: Object.keys(bodyData).length > 0 ? bodyData : req.body }) + try { logJSON('data_gateway.ingest.ok', { skillId, source, recordId: record.id }, 'data_gateway') } catch {} + return res.json({ ok: true, id: record.id, created_at: record.created_at }) + } + + const record = insert(skillId, { source: String(source || ''), data: data || {} }) + try { logJSON('data_gateway.ingest.ok', { skillId, source, recordId: record.id }, 'data_gateway') } catch {} + return res.json({ ok: true, id: record.id, created_at: record.created_at }) + }) + + // ============================================================ + // 内部读取接口(仅 localhost) + // GET /api/v1/data/:skillId/query?mode=latest|list&limit=100&offset=0 + // Headers: X-Internal-Token, X-Skill-Id + // ============================================================ + app.get('/api/v1/data/:skillId/query', (req, res) => { + setNoCache(res) + const skillId = String(req.params.skillId || '').trim() + if (!skillId) return res.status(400).json({ ok: false, error: 'missing skill id' }) + + const auth = verifyInternalToken(req) + if (!auth.ok) return res.status(401).json({ ok: false, error: auth.error }) + + const mode = String(req.query.mode || 'latest').trim() + const limit = parseInt(req.query.limit, 10) || 100 + const offset = parseInt(req.query.offset, 10) || 0 + const source = req.query.source || null + const start = req.query.start || null + const end = req.query.end || null + + try { + if (mode === 'list') { + if (start || end) { + const result = queryByTimeRange(skillId, { start, end, limit }) + return res.json({ ok: true, ...result }) + } + const result = queryList(skillId, { limit, offset, source }) + return res.json({ ok: true, ...result }) + } + + // mode === 'latest'(默认) + const row = queryLatest(skillId) + if (!row) return res.json({ ok: true, rows: [], total: 0 }) + return res.json({ ok: true, rows: [row], total: 1 }) + } catch (e) { + try { logJSON('data_gateway.query.error', { skillId, error: String(e.message || e) }, 'data_gateway') } catch {} + return res.status(500).json({ ok: false, error: String(e.message || e) }) + } + }) + + // ============================================================ + // 管理接口:列出所有已启用的 skill(仅 localhost) + // GET /api/v1/data/skills + // ============================================================ + app.get('/api/v1/data/skills', (req, res) => { + setNoCache(res) + const skills = listEnabledSkills() + const result = skills.map(s => { + const cfg = loadSkillConfig(s.id) + return { + id: s.id, + name: s.name, + mode: String(cfg && cfg.mode || 'append'), + record_count: count(s.id), + db_path: require('./store').getDbPath(s.id) + } + }) + res.json({ ok: true, skills: result }) + }) +} + +module.exports = { bindRoutes, listEnabledSkills, loadSkillConfig } \ No newline at end of file diff --git a/src/server/data_gateway/skills/external_storage/config.json b/src/server/data_gateway/skills/external_storage/config.json new file mode 100644 index 0000000..1c5af5b --- /dev/null +++ b/src/server/data_gateway/skills/external_storage/config.json @@ -0,0 +1,14 @@ +{ + "_comment": "外部存储 skill - 供第三方程序读写 OSS external_storage 区域", + "id": "external_storage", + "name": "外部存储", + "enabled": true, + "api_key_comment": "这个发给对方,API Key,用于上传文件到外部存储区域", + "api_key": "4a211084d059Pk0C6t8dlk3ew61d46", + "api_key_hash": "sha256:17eb4c895064b955d80d007b15af992d7bb7bd1525ce5726f2f63f06229857c9", + "oss_bucket": "android-o1-images", + "oss_endpoint_backup1": "oss-accelerate.aliyuncs.com 传输加速域名(全地域上传下载加速)", + "oss_endpoint_backup2": "oss-cn-shanghai.aliyuncs.com 上海区域", + "oss_endpoint": "oss-accelerate.aliyuncs.com", + "oss_prefix": "550e8400-e29b-41d4-a716-446655442082/external_storage" +} diff --git a/src/server/data_gateway/skills/external_storage/index.js b/src/server/data_gateway/skills/external_storage/index.js new file mode 100644 index 0000000..1d9e8d5 --- /dev/null +++ b/src/server/data_gateway/skills/external_storage/index.js @@ -0,0 +1,322 @@ +// ============================================================ +// external_storage/index.js - 外部存储 skill 路由入口 +// 提供第三方程序读写 OSS external_storage 区域的 API +// ============================================================ +const express = require('express') +const multer = require('multer') +const path = require('path') +const archiver = require('archiver') +const { createOssClient } = require('./oss_client') +const { sanitizePath, createSizeLimitMiddleware } = require('./middleware') +const { loadSkillConfig } = require('../../auth') +const { verifyApiKey } = require('../../auth') +const { logJSON } = require('../../../logger') + +const MAX_UPLOAD_BYTES = 50 * 1024 * 1024 // 50MB + +// 内存存储上传文件 +const upload = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: MAX_UPLOAD_BYTES, files: 20 } +}) + +// MIME 类型推断(支持中文文件名 UTF-8) +const contentTypeByExt = (filename) => { + const ext = path.extname(filename).toLowerCase().slice(1) + const map = { + jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml', ico: 'image/x-icon', bmp: 'image/bmp', avif: 'image/avif', + txt: 'text/plain; charset=utf-8', md: 'text/plain; charset=utf-8', html: 'text/html; charset=utf-8', + js: 'application/javascript', ts: 'application/javascript', css: 'text/css', json: 'application/json', + xml: 'text/xml', log: 'text/plain; charset=utf-8', csv: 'text/csv', + pdf: 'application/pdf', + doc: 'application/msword', docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + xls: 'application/vnd.ms-excel', xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ppt: 'application/vnd.ms-powerpoint', pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + mp4: 'video/mp4', mov: 'video/quicktime', avi: 'video/x-msvideo', webm: 'video/webm', mkv: 'video/x-matroska', + mp3: 'audio/mpeg', wav: 'audio/wav', ogg: 'audio/ogg', flac: 'audio/flac', + zip: 'application/zip', rar: 'application/x-rar-compressed', '7z': 'application/x-7z-compressed', + tar: 'application/x-tar', gz: 'application/gzip', + exe: 'application/x-msdownload', dmg: 'application/x-apple-diskimage', + font: 'font/woff', woff: 'font/woff', woff2: 'font/woff2', ttf: 'font/ttf', otf: 'font/otf' + } + return map[ext] || 'application/octet-stream' +} + +// 安全文件名清理(保留中文,移除危险字符) +const safeName = (name) => { + let s = String(name || 'untitled') + // 尝试修复编码(UTF-8 被误读为 latin1 的情况) + try { + const recovered = Buffer.from(s, 'latin1').toString('utf8') + if (recovered !== s && !/\ufffd/.test(recovered)) s = recovered + } catch {} + // 移除危险字符,但保留中文、英文、数字、常见符号 + return s.replace(/[<>\":/\\|?*\x00-\x1F]/g, '_').trim() || 'untitled' +} + +// 设置无缓存响应头 +const setNoCache = (res) => { + try { + res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate') + res.set('Pragma', 'no-cache') + res.set('Expires', '0') + } catch {} +} + +/** + * 绑定路由到 Express 应用 + * @param {express.Application} app + */ +const bindRoutes = (app) => { + const skillId = 'external_storage' + const skillConfig = loadSkillConfig(skillId) + if (!skillConfig) { + console.warn(`[external_storage] config.json not found, skill disabled`) + return + } + + let ossClient + try { + ossClient = createOssClient(skillConfig) + } catch (e) { + console.error(`[external_storage] Failed to init OSS client: ${e.message}`) + return + } + + const router = express.Router() + + // API Key 鉴权中间件 + router.use((req, res, next) => { + const auth = verifyApiKey(req) + if (!auth.ok) { + try { logJSON('external_storage.auth.fail', { error: auth.error, ip: req.ip }, 'external_storage') } catch {} + return res.status(401).json({ ok: false, error: auth.error }) + } + next() + }) + + // 全局无缓存 + router.use((req, res, next) => { + setNoCache(res) + next() + }) + + // ============================================================ + // 上传文件 + // POST /api/v1/skills/external_storage/upload?path=subdir/ + // Headers: X-API-Id, X-API-Key + // Body: multipart/form-data, field: "files" + // ============================================================ + router.post('/upload', createSizeLimitMiddleware(MAX_UPLOAD_BYTES), upload.array('files', 20), async (req, res) => { + try { + const basePath = sanitizePath(req.query.path || '') + if (!req.files || !Array.isArray(req.files) || req.files.length === 0) { + return res.status(400).json({ ok: false, error: '未收到文件' }) + } + + const results = [] + for (const f of req.files) { + const originalName = safeName(f.originalname || 'file') + const keyPath = basePath ? `${basePath}/${originalName}` : originalName + const contentType = f.mimetype || contentTypeByExt(originalName) + + const { key, raw_url } = await ossClient.putObject(keyPath, f.buffer, contentType) + results.push({ + path: keyPath, + oss_key: key, + raw_url, + name: originalName, + size: f.size, + content_type: contentType + }) + } + + res.json({ ok: true, count: results.length, files: results }) + } catch (e) { + if (String(e.message || '').includes('File too large')) { + return res.status(413).json({ ok: false, error: '文件超过 50MB 上限' }) + } + res.status(500).json({ ok: false, error: String(e.message || e) }) + } + }) + + // ============================================================ + // 下载文件(代理流) + // GET /api/v1/skills/external_storage/download?path=xxx + // Headers: X-API-Id, X-API-Key + // ============================================================ + router.get('/download', async (req, res) => { + try { + const filePath = sanitizePath(req.query.path) + if (!filePath) return res.status(400).json({ ok: false, error: '缺少 path 参数' }) + + const displayName = path.basename(filePath) + const { stream, headers } = await ossClient.getObjectStream(filePath) + + // 使用 RFC 5987 格式支持 UTF-8 文件名 + res.set('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(displayName)}`) + if (headers['content-type']) res.set('Content-Type', headers['content-type']) + if (headers['content-length']) res.set('Content-Length', headers['content-length']) + + stream.pipe(res) + } catch (e) { + res.status(500).json({ ok: false, error: String(e.message || e) }) + } + }) + + // ============================================================ + // 获取原始 URL + // GET /api/v1/skills/external_storage/raw_url?path=xxx + // Headers: X-API-Id, X-API-Key + // ============================================================ + router.get('/raw_url', async (req, res) => { + try { + const filePath = sanitizePath(req.query.path) + if (!filePath) return res.status(400).json({ ok: false, error: '缺少 path 参数' }) + + const raw_url = ossClient.getRawUrl(filePath) + res.json({ ok: true, path: filePath, raw_url }) + } catch (e) { + res.status(500).json({ ok: false, error: String(e.message || e) }) + } + }) + + // ============================================================ + // 列出文件 + // GET /api/v1/skills/external_storage/list?prefix=subdir/&recursive=true|false + // Headers: X-API-Id, X-API-Key + // ============================================================ + router.get('/list', async (req, res) => { + try { + const prefix = sanitizePath(req.query.prefix || '') + const recursive = req.query.recursive === 'true' + + let result + if (recursive) { + const files = await ossClient.listRecursive(prefix) + result = { files, folders: [] } + } else { + result = await ossClient.listObjects(prefix) + } + + res.json({ ok: true, prefix, recursive, ...result }) + } catch (e) { + res.status(500).json({ ok: false, error: String(e.message || e) }) + } + }) + + // ============================================================ + // 删除文件 + // DELETE /api/v1/skills/external_storage/delete?path=xxx + // Headers: X-API-Id, X-API-Key + // ============================================================ + router.delete('/delete', async (req, res) => { + try { + const filePath = sanitizePath(req.query.path) + if (!filePath) return res.status(400).json({ ok: false, error: '缺少 path 参数' }) + + await ossClient.deleteObject(filePath) + res.json({ ok: true, path: filePath }) + } catch (e) { + res.status(500).json({ ok: false, error: String(e.message || e) }) + } + }) + + // ============================================================ + // 文件夹打包下载 + // GET /api/v1/skills/external_storage/folder/download?path=subdir/ + // Headers: X-API-Id, X-API-Key + // ============================================================ + router.get('/folder/download', async (req, res) => { + try { + const folderPath = sanitizePath(req.query.path) + if (!folderPath) return res.status(400).json({ ok: false, error: '缺少 path 参数' }) + + const folderKey = folderPath.endsWith('/') ? folderPath : folderPath + '/' + const folderName = path.basename(folderKey.replace(/\/+$/, '')) || 'folder' + + const files = await ossClient.listRecursive(folderKey) + + res.set('Content-Type', 'application/zip') + res.set('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(folderName + '.zip')}`) + + const archive = archiver('zip', { zlib: { level: 6 } }) + archive.on('error', err => { + try { res.status(500).end() } catch {} + }) + archive.pipe(res) + + const prefixClean = folderKey.replace(/\/+$/, '') + '/' + for (const f of files) { + try { + // 提取相对于文件夹前缀的路径 + const fullRel = f.key + const relPart = fullRel.slice(prefixClean.length) + const { stream } = await ossClient.getObjectStream(relPart) + archive.append(stream, { name: relPart }) + } catch {} + } + + archive.finalize() + } catch (e) { + res.status(500).json({ ok: false, error: String(e.message || e) }) + } + }) + + // ============================================================ + // 获取文件信息 + // GET /api/v1/skills/external_storage/info?path=xxx + // Headers: X-API-Id, X-API-Key + // ============================================================ + router.get('/info', async (req, res) => { + try { + const filePath = sanitizePath(req.query.path) + if (!filePath) return res.status(400).json({ ok: false, error: '缺少 path 参数' }) + + const isFolder = filePath.endsWith('/') + const fullKey = ossClient.fullKey(filePath) + const raw_url = ossClient.getRawUrl(filePath) + + let size = 0 + let lastModified = 0 + + if (!isFolder) { + try { + const parent = filePath.includes('/') ? filePath.split('/').slice(0, -1).join('/') : '' + const result = await ossClient.listObjects(parent) + const targetKey = fullKey + const hit = result.files.find(f => f.key === targetKey) + if (hit) { + size = hit.size + lastModified = hit.lastModified + } + } catch {} + } else { + try { + const files = await ossClient.listRecursive(filePath) + size = files.reduce((s, f) => s + (Number(f.size) || 0), 0) + } catch {} + } + + res.json({ + ok: true, + info: { + path: filePath, + oss_key: fullKey, + raw_url, + isFolder, + size, + lastModified: lastModified ? new Date(lastModified).toISOString() : null + } + }) + } catch (e) { + res.status(500).json({ ok: false, error: String(e.message || e) }) + } + }) + + // 挂载路由 + app.use('/api/v1/skills/external_storage', router) + console.log('[external_storage] Routes mounted at /api/v1/skills/external_storage') +} + +module.exports = { bindRoutes } diff --git a/src/server/data_gateway/skills/external_storage/middleware.js b/src/server/data_gateway/skills/external_storage/middleware.js new file mode 100644 index 0000000..4f2c49c --- /dev/null +++ b/src/server/data_gateway/skills/external_storage/middleware.js @@ -0,0 +1,83 @@ +// ============================================================ +// external_storage/middleware.js - 路径安全校验中间件 +// 职责:防止路径遍历攻击,确保所有操作限定在 external_storage 范围内 +// ============================================================ + +/** + * 校验并清理路径 + * - 拒绝包含 ../ 的路径遍历 + * - 拒绝绝对路径 + * - 拒绝控制字符 + * - 统一使用 UTF-8 编码,正确处理中文 + * @param {string} rawPath - 原始路径参数 + * @returns {string} 清理后的安全路径 + * @throws {Error} 如果路径不合法 + */ +const sanitizePath = (rawPath) => { + if (rawPath === undefined || rawPath === null) return '' + + // 确保使用 UTF-8 字符串 + let p = String(rawPath) + + // 拒绝控制字符(除了正常的换行和制表符) + if (/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/.test(p)) { + throw new Error('路径包含非法控制字符') + } + + // 拒绝路径遍历 + if (p.includes('..')) { + throw new Error('路径不允许包含 ".."') + } + + // 拒绝绝对路径 + if (p.startsWith('/') || p.startsWith('\\')) { + throw new Error('路径不允许以 "/" 或 "\\" 开头') + } + + // 统一斜杠 + p = p.replace(/[\\]+/g, '/') + + // 去除连续斜杠 + p = p.replace(/\/+/g, '/') + + // 去除首尾斜杠 + p = p.replace(/^\/+|\/+$/g, '') + + return p +} + +/** + * 校验路径是否在允许的前缀范围内 + * @param {string} ossKey - 完整 OSS key + * @param {string} allowedPrefix - 允许的前缀 + * @returns {boolean} + */ +const isPathWithinPrefix = (ossKey, allowedPrefix) => { + const key = String(ossKey || '') + const prefix = String(allowedPrefix || '').replace(/\/+$/, '') + return key === prefix || key.startsWith(prefix + '/') +} + +/** + * 文件大小校验中间件 + * @param {number} maxBytes - 最大字节数 + * @returns {Function} express 中间件 + */ +const createSizeLimitMiddleware = (maxBytes = 50 * 1024 * 1024) => { + return (req, res, next) => { + const contentLength = parseInt(req.headers['content-length'] || '0', 10) + if (contentLength > maxBytes) { + return res.status(413).json({ + ok: false, + error: `文件超过大小限制 (${Math.round(maxBytes / 1024 / 1024)}MB)` + }) + } + next() + } +} + +module.exports = { + sanitizePath, + isPathWithinPrefix, + createSizeLimitMiddleware +} diff --git a/src/server/data_gateway/skills/external_storage/oss_client.js b/src/server/data_gateway/skills/external_storage/oss_client.js new file mode 100644 index 0000000..6553569 --- /dev/null +++ b/src/server/data_gateway/skills/external_storage/oss_client.js @@ -0,0 +1,321 @@ +// ============================================================ +// external_storage/oss_client.js - 阿里云 OSS 客户端封装 +// 职责:提供上传、下载、删除、列表、获取URL等基础操作 +// 所有操作限定在 config.json 配置的 oss_prefix 范围内 +// ============================================================ +const crypto = require('crypto') +const https = require('https') +const http = require('http') +const fs = require('fs') +const path = require('path') +const { URLSearchParams } = require('url') + +/** + * 初始化 OSS 客户端配置 + * @param {Object} skillConfig - skill 的 config.json 内容 + * @returns {Object} 客户端实例 + */ +const createOssClient = (skillConfig) => { + const bucket = skillConfig.oss_bucket || 'android-o1-images' + const endpoint = skillConfig.oss_endpoint || 'oss-cn-shanghai.aliyuncs.com' + const prefix = (skillConfig.oss_prefix || '550e8400-e29b-41d4-a716-446655442082/external_storage').replace(/\/+$/, '') + + // 从 process.env 读取(index.js 启动时已从 Toolbox_local_creds.env.local 加载) + let accessKeyId = process.env.EXTERNAL_STORAGE_OSS_ACCESS_KEY_ID || '' + let accessKeySecret = process.env.EXTERNAL_STORAGE_OSS_ACCESS_KEY_SECRET || '' + + if (!accessKeyId || !accessKeySecret) { + throw new Error('OSS credentials not found. Please set oss_access_key_id and oss_access_key_secret in config.json') + } + + /** + * 规范化 OSS key:处理斜杠、去除危险字符 + */ + const normalizeKey = (relativePath) => { + let k = String(relativePath || '').replace(/[\\]+/g, '/').replace(/\/+/g, '/') + if (k.startsWith('/')) k = k.slice(1) + return k + } + + /** + * 拼接完整 OSS key(自动加前缀) + */ + const fullKey = (relativePath) => { + const rel = normalizeKey(relativePath) + return rel ? `${prefix}/${rel}` : prefix + } + + /** + * URL 编码 OSS key(用于 HTTP 路径) + */ + const encodeKey = (key) => String(key || '').split('/').map(s => encodeURIComponent(s)).join('/') + + /** + * 构建 OSS 资源标识 + */ + const ossResource = (key) => `/${bucket}/${String(key || '').replace(/^\/+/, '')}` + + /** + * OSS 请求签名(HMAC-SHA1) + */ + const signRequest = ({ method, contentType = '', date, key, extraHeaders = {} }) => { + const canonicalHeaders = Object.keys(extraHeaders) + .filter(k => k && k.toLowerCase().startsWith('x-oss-')) + .sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())) + .map(k => `${k.toLowerCase()}:${String(extraHeaders[k] || '').trim()}`) + .join('\n') + const resource = ossResource(key) + const stringToSign = `${method.toUpperCase()}\n\n${contentType}\n${date}\n${canonicalHeaders ? canonicalHeaders + '\n' : ''}${resource}` + return crypto.createHmac('sha1', accessKeySecret).update(stringToSign).digest('base64') + } + + /** + * 发起 HTTP 请求 + */ + const httpRequest = (options, body = null, timeoutMs = 60000) => new Promise((resolve, reject) => { + const lib = options.protocol === 'https:' ? https : http + const req = lib.request(options, (res) => { + const chunks = [] + res.on('data', chunk => chunks.push(chunk)) + res.on('end', () => { + const buf = Buffer.concat(chunks) + resolve({ statusCode: res.statusCode, headers: res.headers, body: buf }) + }) + }) + req.on('error', reject) + req.setTimeout(timeoutMs, () => req.destroy(new Error('request timeout'))) + if (body !== null && body !== undefined) { + if (Buffer.isBuffer(body) || typeof body === 'string') req.write(body) + } + req.end() + }) + + /** + * 获取文件流 + */ + const getObjectStream = (relativePath) => new Promise((resolve, reject) => { + const key = fullKey(relativePath) + const date = new Date().toUTCString() + const authorization = `OSS ${accessKeyId}:${signRequest({ method: 'GET', date, key })}` + const options = { + hostname: `${bucket}.${endpoint}`, + path: `/${encodeKey(key)}`, + method: 'GET', + headers: { + Host: `${bucket}.${endpoint}`, + Date: date, + Authorization: authorization + } + } + const req = https.request(options, (res) => { + if (res.statusCode < 200 || res.statusCode >= 300) { + const chunks = [] + res.on('data', c => chunks.push(c)) + res.on('end', () => reject(new Error(`OSS get failed: ${res.statusCode} ${Buffer.concat(chunks).toString('utf8').slice(0, 500)}`))) + return + } + resolve({ stream: res, headers: res.headers }) + }) + req.on('error', reject) + req.setTimeout(60000, () => req.destroy(new Error('request timeout'))) + req.end() + }) + + /** + * 上传文件 + * @param {string} relativePath - 相对路径(自动加前缀) + * @param {Buffer} body - 文件内容 + * @param {string} contentType - MIME 类型 + * @returns {Object} { key, raw_url } + */ + const putObject = async (relativePath, body, contentType = 'application/octet-stream') => { + const key = fullKey(relativePath) + const date = new Date().toUTCString() + const extraHeaders = { 'x-oss-object-acl': 'public-read' } + const authorization = `OSS ${accessKeyId}:${signRequest({ method: 'PUT', contentType, date, key, extraHeaders })}` + const options = { + hostname: `${bucket}.${endpoint}`, + path: `/${encodeKey(key)}`, + method: 'PUT', + headers: { + Host: `${bucket}.${endpoint}`, + Date: date, + 'Content-Type': contentType, + 'Content-Length': Buffer.isBuffer(body) ? body.length : Buffer.byteLength(body || ''), + ...extraHeaders, + Authorization: authorization + } + } + const resp = await httpRequest(options, body, 120000) + if (resp.statusCode < 200 || resp.statusCode >= 300) { + throw new Error(`OSS put failed: ${resp.statusCode} ${resp.body.toString('utf8').slice(0, 500)}`) + } + const raw_url = `https://${bucket}.${endpoint}/${encodeKey(key)}` + return { key, raw_url } + } + + /** + * 删除文件 + */ + const deleteObject = async (relativePath) => { + const key = fullKey(relativePath) + const date = new Date().toUTCString() + const authorization = `OSS ${accessKeyId}:${signRequest({ method: 'DELETE', date, key })}` + const options = { + hostname: `${bucket}.${endpoint}`, + path: `/${encodeKey(key)}`, + method: 'DELETE', + headers: { + Host: `${bucket}.${endpoint}`, + Date: date, + Authorization: authorization + } + } + const resp = await httpRequest(options) + if (resp.statusCode < 200 || resp.statusCode >= 300) { + throw new Error(`OSS delete failed: ${resp.statusCode} ${resp.body.toString('utf8').slice(0, 500)}`) + } + return true + } + + /** + * 解析 OSS ListObjects XML 响应 + */ + const parseListXml = (xml) => { + const extractTag = (tag, src) => { + const open = `<${tag}>` + const close = `` + const result = [] + let idx = 0 + while (true) { + const s = src.indexOf(open, idx) + if (s < 0) break + const e = src.indexOf(close, s + open.length) + if (e < 0) break + result.push(src.slice(s + open.length, e)) + idx = e + close.length + } + return result + } + const extractInSection = (tag, sectionOpen, sectionClose, src) => { + const result = [] + let searchFrom = 0 + while (true) { + const sOpen = src.indexOf(sectionOpen, searchFrom) + if (sOpen < 0) break + const sClose = src.indexOf(sectionClose, sOpen) + if (sClose < 0) break + const section = src.slice(sOpen, sClose) + result.push(...extractTag(tag, section)) + searchFrom = sClose + sectionClose.length + } + return result + } + const folders = extractInSection('Prefix', '', '', xml).map(p => ({ + key: p, + name: p.split('/').filter(Boolean).pop() + '/', + isFolder: true, + size: 0 + })) + const fileKeys = extractTag('Key', xml) + const fileSizes = extractTag('Size', xml) + const fileLms = extractTag('LastModified', xml) + const files = fileKeys.map((k, i) => ({ + key: k, + name: k.split('/').filter(Boolean).pop(), + isFolder: false, + size: parseInt(fileSizes[i] || '0', 10) || 0, + lastModified: fileLms[i] ? Date.parse(fileLms[i]) : 0 + })).filter(f => f.key && !f.key.endsWith('/')) + return { folders, files } + } + + /** + * 列出文件(递归) + * @param {string} relativePrefix - 相对前缀 + * @returns {Array} 文件列表 + */ + const listRecursive = async (relativePrefix = '') => { + const all = [] + const stack = [relativePrefix] + while (stack.length > 0) { + const current = stack.pop() + const keyPrefix = fullKey(current) + const date = new Date().toUTCString() + const authorization = `OSS ${accessKeyId}:${signRequest({ method: 'GET', date, key: '' })}` + const query = new URLSearchParams() + query.set('prefix', keyPrefix) + query.set('delimiter', '/') + query.set('max-keys', '1000') + const options = { + hostname: `${bucket}.${endpoint}`, + path: `/?${query.toString()}`, + method: 'GET', + headers: { + Host: `${bucket}.${endpoint}`, + Date: date, + Authorization: authorization + } + } + const resp = await httpRequest(options) + if (resp.statusCode < 200 || resp.statusCode >= 300) { + throw new Error(`OSS list failed: ${resp.statusCode} ${resp.body.toString('utf8').slice(0, 500)}`) + } + const parsed = parseListXml(resp.body.toString('utf8')) + all.push(...parsed.files) + for (const f of parsed.folders) { + stack.push(f.key.slice(keyPrefix.length > 0 ? (fullKey('').length) : 0)) + } + } + return all + } + + /** + * 列出对象(单层) + */ + const listObjects = async (relativePrefix = '') => { + const keyPrefix = fullKey(relativePrefix) + const date = new Date().toUTCString() + const authorization = `OSS ${accessKeyId}:${signRequest({ method: 'GET', date, key: '' })}` + const query = new URLSearchParams() + query.set('prefix', keyPrefix) + query.set('delimiter', '/') + query.set('max-keys', '1000') + const options = { + hostname: `${bucket}.${endpoint}`, + path: `/?${query.toString()}`, + method: 'GET', + headers: { + Host: `${bucket}.${endpoint}`, + Date: date, + Authorization: authorization + } + } + const resp = await httpRequest(options) + if (resp.statusCode < 200 || resp.statusCode >= 300) { + throw new Error(`OSS list failed: ${resp.statusCode} ${resp.body.toString('utf8').slice(0, 500)}`) + } + return parseListXml(resp.body.toString('utf8')) + } + + /** + * 获取文件原始 URL(公共读可直接访问) + */ + const getRawUrl = (relativePath) => { + const key = fullKey(relativePath) + return `https://${bucket}.${endpoint}/${encodeKey(key)}` + } + + return { + fullKey, + normalizeKey, + putObject, + deleteObject, + getObjectStream, + listObjects, + listRecursive, + getRawUrl + } +} + +module.exports = { createOssClient } diff --git a/src/server/data_gateway/skills/weather/config.json b/src/server/data_gateway/skills/weather/config.json new file mode 100644 index 0000000..b710548 --- /dev/null +++ b/src/server/data_gateway/skills/weather/config.json @@ -0,0 +1,10 @@ +{ + "_comment": "天气数据 skill - 由外部 Hermes Agent 每30分钟写入一次", + "id": "weather", + "name": "天气数据", + "enabled": true, + "mode": "append", + "mode_comment": "append: 增量追加保留所有历史;latest: 只保留最新一条", + "api_key_hash": "sha256:7b4035f766bf66e39cff1a58a5e8c73f2fcbf75f3f2a368bf0003e1110a56255", + "read_token_hash": "sha256:aa40833cd71a76b2fc558edb1384f4d993c9e8d966a730005f7c99d56ab19037" +} \ No newline at end of file diff --git a/src/server/data_gateway/store.js b/src/server/data_gateway/store.js new file mode 100644 index 0000000..9976d5f --- /dev/null +++ b/src/server/data_gateway/store.js @@ -0,0 +1,123 @@ +// ============================================================ +// data_gateway/store.js - 数据存储模块 +// 职责:动态创建/管理每个 skill 的独立 SQLite 数据库 +// 表结构:id | source | data(JSON) | created_at +// 支持 mode: "append"(增量追加)和 "latest"(覆盖最新) +// ============================================================ +const Database = require('better-sqlite3') +const fs = require('fs') +const path = require('path') + +const DATA_DIR = path.join(process.cwd(), 'data', 'ingest') +if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true }) + +// 已打开的数据库连接缓存 +const dbCache = new Map() + +const getDbPath = (skillId) => path.join(DATA_DIR, `${skillId}.db`) + +const getOrOpenDb = (skillId) => { + if (dbCache.has(skillId)) return dbCache.get(skillId) + const dbPath = getDbPath(skillId) + const db = new Database(dbPath) + // 启用 WAL 模式提高并发写入性能 + db.pragma('journal_mode = WAL') + db.pragma('synchronous = NORMAL') + db.exec( + `CREATE TABLE IF NOT EXISTS records ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source TEXT NOT NULL DEFAULT '', + data TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + )` + ) + db.exec(`CREATE INDEX IF NOT EXISTS idx_records_created ON records(created_at DESC)`) + dbCache.set(skillId, db) + return db +} + +// 写入一条记录 +const insert = (skillId, { source = '', data = {} }) => { + const db = getOrOpenDb(skillId) + const dataStr = JSON.stringify(data) + const info = db.prepare(`INSERT INTO records (source, data) VALUES (?, ?)`).run( + String(source || ''), dataStr + ) + return { id: info.lastInsertRowid, source, data, created_at: new Date().toISOString() } +} + +// 覆盖写入(mode: "latest")- 先删后插,保证只有一条 +const upsertLatest = (skillId, { source = '', data = {} }) => { + const db = getOrOpenDb(skillId) + const dataStr = JSON.stringify(data) + const tx = db.transaction(() => { + db.prepare(`DELETE FROM records`).run() + return db.prepare(`INSERT INTO records (source, data) VALUES (?, ?)`).run( + String(source || ''), dataStr + ) + }) + const info = tx() + return { id: info.lastInsertRowid, source, data, created_at: new Date().toISOString() } +} + +// 查询最新一条记录 +const queryLatest = (skillId) => { + const db = getOrOpenDb(skillId) + const row = db.prepare(`SELECT * FROM records ORDER BY id DESC LIMIT 1`).get() + if (!row) return null + try { row.data = JSON.parse(row.data) } catch { /* keep as string */ } + return row +} + +// 查询记录列表(分页) +const queryList = (skillId, { limit = 100, offset = 0, source = null } = {}) => { + const db = getOrOpenDb(skillId) + let sql = `SELECT * FROM records` + const params = [] + if (source) { + sql += ` WHERE source = ?` + params.push(String(source)) + } + sql += ` ORDER BY id DESC LIMIT ? OFFSET ?` + params.push(Math.min(Number(limit) || 100, 1000), Math.max(0, Number(offset) || 0)) + const rows = db.prepare(sql).all(...params) + for (const row of rows) { + try { row.data = JSON.parse(row.data) } catch { /* keep as string */ } + } + const total = db.prepare(`SELECT COUNT(*) as cnt FROM records`).get().cnt + return { rows, total } +} + +// 按时间范围查询 +const queryByTimeRange = (skillId, { start, end, limit = 100 } = {}) => { + const db = getOrOpenDb(skillId) + let sql = `SELECT * FROM records WHERE 1=1` + const params = [] + if (start) { sql += ` AND created_at >= ?`; params.push(String(start)) } + if (end) { sql += ` AND created_at <= ?`; params.push(String(end)) } + sql += ` ORDER BY id DESC LIMIT ?` + params.push(Math.min(Number(limit) || 100, 1000)) + const rows = db.prepare(sql).all(...params) + for (const row of rows) { + try { row.data = JSON.parse(row.data) } catch { /* keep as string */ } + } + return { rows, total: rows.length } +} + +// 获取记录总数 +const count = (skillId) => { + const db = getOrOpenDb(skillId) + return db.prepare(`SELECT COUNT(*) as cnt FROM records`).get().cnt +} + +// 关闭所有数据库连接 +const closeAll = () => { + for (const [skillId, db] of dbCache) { + try { db.close() } catch {} + dbCache.delete(skillId) + } +} + +module.exports = { + insert, upsertLatest, queryLatest, queryList, queryByTimeRange, count, closeAll, getDbPath, getOrOpenDb +} \ No newline at end of file diff --git a/src/server/index.js b/src/server/index.js index 0ab9e50..656b5be 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -7,7 +7,9 @@ const Database = require('better-sqlite3') const crypto = require('crypto') // ===== 从 ~/Toolbox_local_creds.env.local 加载变量到 process.env ===== -// 所有模块的 process.env.XXX fallback 依赖此加载器 +// ★ 规则:所有密码、密钥、Token、用户名等高危内容,禁止硬编码在源码里。 +// 一律放在 ~/Toolbox_local_creds.env.local 中,在此处自动加载到 process.env +// 各模块通过 process.env.XXX 读取,不要直接 import 或 require 凭证。 // 优先级:已存在的 process.env > env 文件 > 模块内 '' fallback try { const os = require('os') @@ -76,6 +78,8 @@ const plantHome = require('./plant_home') const globalNews = require('./global_news') const boxBackup = require('./box_backup') const yuanzhupai = require('./yuanzhupai') +const dataGateway = require('./data_gateway') +const ossFileCabinet = require('./oss_file_cabinet') yuanzhupai.initDb() const app = express() @@ -1335,6 +1339,7 @@ const mapSystemIdToUrl = (systemId) => { 'FitnessDiary': '/tools/fitness_diary', 'Tools-doc_cloud_keeper': '/tools/doc_cloud_keeper', 'Tools-yuanzhupai': '/tools/yuanzhupai', + 'Tools-oss_file_cabinet': '/tools/oss_file_cabinet', 'Tools-plant_home': '/tools/plant_home', 'Tools-apple_watch': '/tools/apple_watch', 'Tools-private_clipboard': '/tools/private_clipboard', @@ -2078,6 +2083,145 @@ app.use('/api/private_clipboard', (req, res, next) => { return res.status(503).json({ ok: false, error: 'service_not_ready' }) }) +const OSS_FILE_CABINET_AUTH_COOKIE = 'oss_file_cabinet_gate' +const readOssFileCabinetAuthConfig = () => { + try { + const p = path.join(process.cwd(), 'public', 'tools', 'oss_file_cabinet', '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 readOssFileCabinetJwk = () => { + try { + const p = path.join(process.cwd(), 'config', 'oss_file_cabinet.jwk.json') + if (fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, 'utf-8')) + } catch {} + return { kty: 'RSA', n: '', e: '' } +} +const getOssFileCabinetCookieName = () => { + try { + const cfg = readOssFileCabinetAuthConfig() + const n = String(cfg.cookieName || '') + if (n) return n + } catch {} + return OSS_FILE_CABINET_AUTH_COOKIE +} +const getOssFileCabinetMaxAge = () => { + try { + const cfg = readOssFileCabinetAuthConfig() + 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 getOssFileCabinetGuidConfig = () => { + try { + const cfg = readOssFileCabinetAuthConfig() + 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 hasValidOssFileCabinetGuid = (req) => { + try { + const cfg = readOssFileCabinetAuthConfig() + if (cfg.enable_auth === false) return false + const { key, value } = getOssFileCabinetGuidConfig() + 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 hasOssFileCabinetAuth = (req) => { + try { + const cfg = readOssFileCabinetAuthConfig() + if (cfg.enable_auth === false) return true + const cookies = parseCookie(req.headers.cookie || '') + const name = getOssFileCabinetCookieName() + return cookies[name] === '1' + } catch { return false } +} +const isOssFileCabinetForceGuid = () => { + try { + const cfg = readOssFileCabinetAuthConfig() + 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 true + } catch { return true } +} +app.get('/tools/oss_file_cabinet', (req, res, next) => { + setNoCache(res) + try { + const forceGuid = isOssFileCabinetForceGuid() + if (hasOssFileCabinetAuth(req)) return next() + const guidKey = getOssFileCabinetGuidConfig().key + const hasGuidInput = req && req.query && Object.prototype.hasOwnProperty.call(req.query, guidKey) + if (hasValidOssFileCabinetGuid(req)) { + const maxAgeOfc = getOssFileCabinetMaxAge() + const cookieName = getOssFileCabinetCookieName() + res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge: maxAgeOfc, path: '/' }) + return res.redirect('/tools/oss_file_cabinet/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(readOssFileCabinetJwk())) + if (!payload) return res.status(401).send('未授权') + { + const iss = String(payload.iss || '') + const navIss = getNavIssFromFlags() + if (iss === navIss && !audMatch(payload, 'Tools-oss_file_cabinet')) return res.status(401).send('未授权') + } + const maxAgeOfc = getOssFileCabinetMaxAge() + const cookieName = getOssFileCabinetCookieName() + res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge: maxAgeOfc, path: '/' }) + return res.redirect('/tools/oss_file_cabinet/index.html') + } catch { return res.status(401).send('未授权') } +}) +app.use('/tools/oss_file_cabinet', (req, res, next) => { + const forceGuid = isOssFileCabinetForceGuid() + if (hasOssFileCabinetAuth(req)) return next() + const isHandshake = req.method === 'GET' && (req.path === '/' || req.path === '' || req.path === '/index.html') + const guidKey = getOssFileCabinetGuidConfig().key + const hasGuidInput = isHandshake && req && req.query && Object.prototype.hasOwnProperty.call(req.query, guidKey) + if (isHandshake && hasValidOssFileCabinetGuid(req)) { + const maxAgeOfc = getOssFileCabinetMaxAge() + const cookieName = getOssFileCabinetCookieName() + res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge: maxAgeOfc, path: '/' }) + return res.redirect('/tools/oss_file_cabinet/index.html') + } + if (hasGuidInput) return res.status(401).send('未授权') + if (isHandshake && String(req.query.token || '')) { + const payload = verifyJwtWithKeys(String(req.query.token), jwkKeys(readOssFileCabinetJwk())) + if (payload) { + const iss = String(payload.iss || '') + const navIss = getNavIssFromFlags() + if (iss === navIss && !audMatch(payload, 'Tools-oss_file_cabinet')) return res.status(401).send('未授权') + const maxAgeOfc = getOssFileCabinetMaxAge() + const cookieName = getOssFileCabinetCookieName() + res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge: maxAgeOfc, path: '/' }) + return res.redirect('/tools/oss_file_cabinet/index.html') + } + } + if (isHandshake && forceGuid) return res.status(401).send('未授权') + return res.status(401).send('未授权') +}) +app.use('/api/oss_file_cabinet', (req, res, next) => { + if (req.path.startsWith('/share/access/')) return next() + if (hasOssFileCabinetAuth(req)) return next() + return res.status(401).json({ ok: false, error: '未授权' }) +}) + const WEEKLY_AUTH_COOKIE = 'weekly_gate' const readWeeklyJwk = () => { try { @@ -5842,6 +5986,8 @@ expense.bindRoutes(app) investmentLedger.bindRoutes(app) styleCheck.bindRoutes(app) globalNews.bindRoutes(app) +dataGateway.bindRoutes(app) +ossFileCabinet.bindRoutes(app) const marketsCfgAtBoot = getMarketsCfg() const currentPort = Number(process.env.PORT || '8976') || 8976 diff --git a/src/server/market_skills/brazil_ibovespa/config.json b/src/server/market_skills/brazil_ibovespa/config.json index 55696f0..b150a00 100644 --- a/src/server/market_skills/brazil_ibovespa/config.json +++ b/src/server/market_skills/brazil_ibovespa/config.json @@ -1,42 +1,42 @@ { - "name_comment": "显示的名ç§?, + "name_comment": "显示的名", "name": "巴西IBOVESPA", "card_order": 18, "card_type": "overseas", - "enabled_comment": "是否启用该监æŽ?(true/false)", + "enabled_comment": "是否启用该监", "enabled": true, - "display_comment": "是否显示该卡ç‰?(false 则不拉取数据也不封存)", + "display_comment": "是否显示该卡", "display": true, - "schedule_comment": "刷新频率 (å¦?10m 表示10分钟)", + "schedule_comment": "刷新频率 (", "schedule": "10m", - "symbol_comment": "监控的代ç ?(iShares MSCI Brazil ETF)", + "symbol_comment": "监控的代", "symbol": "usEWZ", "url_comment": "点击卡片右上角房子图标跳转的地址", "url": "https://gu.qq.com/usEWZ", - "source_comment": "数据来源 (å¦?tencent)", + "source_comment": "数据来源 (", "source": "yahoo_probe", - "alerts_comment": "预警阈值配ç½?, + "alerts_comment": "预警阈值配", "alerts": { - "change_percent_down_comment": "跌幅预警阈å€?(负数,如 -2 表示è·?2%)", + "change_percent_down_comment": "跌幅预警阈", "change_percent_down": -2, - "urgent_abs_change_percent_comment": "严重告警阈å€?(绝对波动百分æ¯?", + "urgent_abs_change_percent_comment": "严重告警阈", "urgent_abs_change_percent": 5, - "urgent_cooldown_minutes_comment": "严重告警冷却分钟æ•?, + "urgent_cooldown_minutes_comment": "严重告警冷却分钟", "urgent_cooldown_minutes": 120, - "change_percent_up_comment": "涨幅预警阈å€?(正数,如 2 表示æ¶?2%)", + "change_percent_up_comment": "涨幅预警阈", "change_percent_up": 2 }, "email_comment": "邮件通知配置", "email": { "enabled_comment": "是否启用邮件通知", "enabled": true, - "from_comment": "发件人邮ç®?, + "from_comment": "发件人邮", "from": "yangdafe@gmail.com", - "to_comment": "收件人邮箱列è¡?, + "to_comment": "收件人邮箱列", "to": [ "yangxiangyuan@umer.com.cn" ], - "smtp_comment": "SMTP 服务器配ç½?, + "smtp_comment": "SMTP 服务器配", "smtp": { "host": "smtp.gmail.com", "port": 465, @@ -47,19 +47,19 @@ } } }, - "email_reminder_enabled_comment": "是否发送邮件提é†?, + "email_reminder_enabled_comment": "是否发送邮件提", "email_reminder_enabled": true, "calendar_reminder_enabled_comment": "是否写入日历提醒", "calendar_reminder_enabled": true, - "push_targets_comment": "推送目标列è¡?(支持多路推é€?", + "push_targets_comment": "推送目标列", "push_targets": [ { "url_comment": "推送接口地址", "url": "http://localhost:{{PORT}}/api/weekly/ext/event", "enabled_comment": "是否启用", "enabled": true, - "key_comment": "Weekly 工具çš?Key", - \"key\": \"\" + "key_comment": "Weekly 工具", + "key": "" }, { "url_comment": "推送接口地址", diff --git a/src/server/market_skills/bse50/config.json b/src/server/market_skills/bse50/config.json index 2902ae1..efac55f 100644 --- a/src/server/market_skills/bse50/config.json +++ b/src/server/market_skills/bse50/config.json @@ -1,42 +1,42 @@ { - "name_comment": "显示的名ç§?, + "name_comment": "显示的名", "name": "北证50", "card_order": 6, "card_type": "domestic", - "enabled_comment": "是否启用该监æŽ?(true/false)", + "enabled_comment": "是否启用该监", "enabled": true, - "display_comment": "是否显示该卡ç‰?(false 则不拉取数据也不封存)", + "display_comment": "是否显示该卡", "display": false, - "schedule_comment": "刷新频率 (å¦?10m 表示10分钟)", + "schedule_comment": "刷新频率 (", "schedule": "10m", - "symbol_comment": "监控的代ç ?(腾讯财经或其他源)", + "symbol_comment": "监控的代", "symbol": "bj899050", "url_comment": "点击卡片右上角房子图标跳转的地址", "url": "https://gu.qq.com/bj899050", - "source_comment": "数据来源 (å¦?tencent)", + "source_comment": "数据来源 (", "source": "tencent", - "alerts_comment": "预警阈值配ç½?, + "alerts_comment": "预警阈值配", "alerts": { - "change_percent_down_comment": "跌幅预警阈å€?(负数,如 -2 表示è·?2%)", + "change_percent_down_comment": "跌幅预警阈", "change_percent_down": -2, - "urgent_abs_change_percent_comment": "严重告警阈å€?(绝对波动百分æ¯?", + "urgent_abs_change_percent_comment": "严重告警阈", "urgent_abs_change_percent": 5, - "urgent_cooldown_minutes_comment": "严重告警冷却分钟æ•?, + "urgent_cooldown_minutes_comment": "严重告警冷却分钟", "urgent_cooldown_minutes": 120, - "change_percent_up_comment": "涨幅预警阈å€?(正数,如 2 表示æ¶?2%)", + "change_percent_up_comment": "涨幅预警阈", "change_percent_up": 2 }, "email_comment": "邮件通知配置", "email": { "enabled_comment": "是否启用邮件通知", "enabled": true, - "from_comment": "发件人邮ç®?, + "from_comment": "发件人邮", "from": "yangdafe@gmail.com", - "to_comment": "收件人邮箱列è¡?, + "to_comment": "收件人邮箱列", "to": [ "yangxiangyuan@umer.com.cn" ], - "smtp_comment": "SMTP 服务器配ç½?, + "smtp_comment": "SMTP 服务器配", "smtp": { "host": "smtp.gmail.com", "port": 465, @@ -47,19 +47,19 @@ } } }, - "email_reminder_enabled_comment": "是否发送邮件提é†?, + "email_reminder_enabled_comment": "是否发送邮件提", "email_reminder_enabled": true, "calendar_reminder_enabled_comment": "是否写入日历提醒", "calendar_reminder_enabled": true, - "push_targets_comment": "推送目标列è¡?(支持多路推é€?", + "push_targets_comment": "推送目标列", "push_targets": [ { "url_comment": "推送接口地址", "url": "http://localhost:{{PORT}}/api/weekly/ext/event", "enabled_comment": "是否启用", "enabled": true, - "key_comment": "Weekly 工具çš?Key", - \"key\": \"\" + "key_comment": "Weekly 工具", + "key": "" }, { "url_comment": "推送接口地址", diff --git a/src/server/market_skills/buffett_index/config.json b/src/server/market_skills/buffett_index/config.json index dfc8daa..97cb7ec 100644 --- a/src/server/market_skills/buffett_index/config.json +++ b/src/server/market_skills/buffett_index/config.json @@ -1,17 +1,17 @@ { - "name_comment": "显示的名ç§?, - "name": "巴菲特指æ•?, - "card_order_comment": "卡片排序序号,越大越靠后(当前最大å€?87,设ä¸?88 排最后)", + "name_comment": "显示的名", + "name": "巴菲特指", + "card_order_comment": "卡片排序序号,越大越靠后(当前最大", "card_order": 88, "card_type_comment": "卡片类型:domestic(国内)/ overseas(海外)/ enhanced(增强)/ defensive(防御)", "card_type": "overseas", - "card_span_comment": "卡片横向占位列数ï¼?为默认,2为跨两列", + "card_span_comment": "卡片横向占位列数", "card_span": 2, - "enabled_comment": "是否启用该监æŽ?(true/false)", + "enabled_comment": "是否启用该监", "enabled": true, - "display_comment": "是否显示该卡ç‰?(false 则不拉取数据也不封存)", + "display_comment": "是否显示该卡", "display": true, - "schedule_comment": "刷新频率 (å¦?1h 表示1小时)", + "schedule_comment": "刷新频率 (", "schedule": "1h", "symbol_comment": "监控的代码(技能内部定义)", "symbol": "us_buffett_index", @@ -21,9 +21,9 @@ "source": "fred", "alerts_comment": "预警阈值配置(基于S&P500/GDP比率,非Wilshire5000原值)", "alerts": { - "ratio_high_warning_comment": "S&P500/GDP 比率高于此值触发估值偏高预è­?, + "ratio_high_warning_comment": "S&P500/GDP 比率高于此值触发估值偏高预", "ratio_high_warning": 0.19, - "ratio_critical_comment": "S&P500/GDP 比率高于此值触发极端高估严重告è­?, + "ratio_critical_comment": "S&P500/GDP 比率高于此值触发极端高估严重告", "ratio_critical": 0.21, "change_percent_down_comment": "单日跌幅告警阈值(比率变化百分比)", "change_percent_down": -5, @@ -31,20 +31,20 @@ "change_percent_up": 5, "urgent_abs_change_percent_comment": "严重告警阈值(绝对波动百分比,日变动超过此值触发紧急推送)", "urgent_abs_change_percent": 8, - "urgent_cooldown_minutes_comment": "严重告警冷却分钟æ•?, + "urgent_cooldown_minutes_comment": "严重告警冷却分钟", "urgent_cooldown_minutes": 240 }, "email_comment": "邮件通知配置", "email": { "enabled_comment": "是否启用邮件通知", "enabled": false, - "from_comment": "发件人邮ç®?, + "from_comment": "发件人邮", "from": "yangdafe@gmail.com", - "to_comment": "收件人邮箱列è¡?, + "to_comment": "收件人邮箱列", "to": [ "yangxiangyuan@umer.com.cn" ], - "smtp_comment": "SMTP 服务器配ç½?, + "smtp_comment": "SMTP 服务器配", "smtp": { "host": "smtp.gmail.com", "port": 465, @@ -55,19 +55,19 @@ } } }, - "email_reminder_enabled_comment": "是否发送邮件提é†?, + "email_reminder_enabled_comment": "是否发送邮件提", "email_reminder_enabled": false, "calendar_reminder_enabled_comment": "是否写入日历提醒", "calendar_reminder_enabled": false, - "push_targets_comment": "推送目标列è¡?(支持多路推é€?", + "push_targets_comment": "推送目标列", "push_targets": [ { "url_comment": "推送接口地址", "url": "http://localhost:{{PORT}}/api/weekly/ext/event", "enabled_comment": "是否启用", "enabled": false, - "key_comment": "Weekly 工具çš?Key", - \"key\": \"\" + "key_comment": "Weekly 工具", + "key": "" }, { "url_comment": "推送接口地址", @@ -76,7 +76,7 @@ "enabled": false } ], - "market_status_comment": "市场时区与交易时段定义,用于前端显示开休市状æ€?, + "market_status_comment": "市场时区与交易时段定义,用于前端显示开休市状", "market_status": { "timezone": "America/New_York", "sessions": [ diff --git a/src/server/market_skills/china_korea_semiconductor/config.json b/src/server/market_skills/china_korea_semiconductor/config.json index 9fd36bc..5c1cea1 100644 --- a/src/server/market_skills/china_korea_semiconductor/config.json +++ b/src/server/market_skills/china_korea_semiconductor/config.json @@ -1,42 +1,42 @@ { - "name_comment": "显示的名ç§?, - "name": "中韩半导体指æ•?, + "name_comment": "显示的名", + "name": "中韩半导体指", "card_order": 19, "card_type": "overseas", - "enabled_comment": "是否启用该监æŽ?(true/false)", + "enabled_comment": "是否启用该监", "enabled": true, - "display_comment": "是否显示该卡ç‰?(false 则不拉取数据也不封存)", + "display_comment": "是否显示该卡", "display": true, - "schedule_comment": "刷新频率 (å¦?10m 表示10分钟)", + "schedule_comment": "刷新频率 (", "schedule": "10m", - "symbol_comment": "监控的代ç ?(科创50,原中韩半导体指数无ETF替代)", + "symbol_comment": "监控的代", "symbol": "sh000688", "url_comment": "点击卡片右上角房子图标跳转的地址", "url": "https://gu.qq.com/sh000688", "source_comment": "数据来源", "source": "tencent", - "alerts_comment": "预警阈值配ç½?, + "alerts_comment": "预警阈值配", "alerts": { - "change_percent_down_comment": "跌幅预警阈å€?(负数,如 -2 表示è·?2%)", + "change_percent_down_comment": "跌幅预警阈", "change_percent_down": -2, - "urgent_abs_change_percent_comment": "严重告警阈å€?(绝对波动百分æ¯?", + "urgent_abs_change_percent_comment": "严重告警阈", "urgent_abs_change_percent": 5, - "urgent_cooldown_minutes_comment": "严重告警冷却分钟æ•?, + "urgent_cooldown_minutes_comment": "严重告警冷却分钟", "urgent_cooldown_minutes": 120, - "change_percent_up_comment": "涨幅预警阈å€?(正数,如 2 表示æ¶?2%)", + "change_percent_up_comment": "涨幅预警阈", "change_percent_up": 2 }, "email_comment": "邮件通知配置", "email": { "enabled_comment": "是否启用邮件通知", "enabled": true, - "from_comment": "发件人邮ç®?, + "from_comment": "发件人邮", "from": "yangdafe@gmail.com", - "to_comment": "收件人邮箱列è¡?, + "to_comment": "收件人邮箱列", "to": [ "yangxiangyuan@umer.com.cn" ], - "smtp_comment": "SMTP 服务器配ç½?, + "smtp_comment": "SMTP 服务器配", "smtp": { "host": "smtp.gmail.com", "port": 465, @@ -47,19 +47,19 @@ } } }, - "email_reminder_enabled_comment": "是否发送邮件提é†?, + "email_reminder_enabled_comment": "是否发送邮件提", "email_reminder_enabled": true, "calendar_reminder_enabled_comment": "是否写入日历提醒", "calendar_reminder_enabled": true, - "push_targets_comment": "推送目标列è¡?(支持多路推é€?", + "push_targets_comment": "推送目标列", "push_targets": [ { "url_comment": "推送接口地址", "url": "http://localhost:{{PORT}}/api/weekly/ext/event", "enabled_comment": "是否启用", "enabled": true, - "key_comment": "Weekly 工具çš?Key", - \"key\": \"\" + "key_comment": "Weekly 工具", + "key": "" }, { "url_comment": "推送接口地址", @@ -76,7 +76,7 @@ "end": "15:30" } ], - "display_label": "中韩半导ä½? - }, + "display_label": "中韩半导" +}, "market_status_comment": "Defines the source market timezone and trading sessions for status display." } diff --git a/src/server/market_skills/chinext/config.json b/src/server/market_skills/chinext/config.json index 93dd44b..31250b2 100644 --- a/src/server/market_skills/chinext/config.json +++ b/src/server/market_skills/chinext/config.json @@ -1,42 +1,42 @@ { - "name_comment": "显示的名ç§?, + "name_comment": "显示的名", "name": "创业板指", "card_order": 5, "card_type": "domestic", - "enabled_comment": "是否启用该监æŽ?(true/false)", + "enabled_comment": "是否启用该监", "enabled": true, - "display_comment": "是否显示该卡ç‰?(false 则不拉取数据也不封存)", + "display_comment": "是否显示该卡", "display": true, - "schedule_comment": "刷新频率 (å¦?10m 表示10分钟)", + "schedule_comment": "刷新频率 (", "schedule": "10m", - "symbol_comment": "监控的代ç ?(腾讯财经或其他源)", + "symbol_comment": "监控的代", "symbol": "sz399006", "url_comment": "点击卡片右上角房子图标跳转的地址", "url": "https://gu.qq.com/sz399006", - "source_comment": "数据来源 (å¦?tencent)", + "source_comment": "数据来源 (", "source": "tencent", - "alerts_comment": "预警阈值配ç½?, + "alerts_comment": "预警阈值配", "alerts": { - "change_percent_down_comment": "跌幅预警阈å€?(负数,如 -2 表示è·?2%)", + "change_percent_down_comment": "跌幅预警阈", "change_percent_down": -2, - "urgent_abs_change_percent_comment": "严重告警阈å€?(绝对波动百分æ¯?", + "urgent_abs_change_percent_comment": "严重告警阈", "urgent_abs_change_percent": 5, - "urgent_cooldown_minutes_comment": "严重告警冷却分钟æ•?, + "urgent_cooldown_minutes_comment": "严重告警冷却分钟", "urgent_cooldown_minutes": 120, - "change_percent_up_comment": "涨幅预警阈å€?(正数,如 2 表示æ¶?2%)", + "change_percent_up_comment": "涨幅预警阈", "change_percent_up": 2 }, "email_comment": "邮件通知配置", "email": { "enabled_comment": "是否启用邮件通知", "enabled": true, - "from_comment": "发件人邮ç®?, + "from_comment": "发件人邮", "from": "yangdafe@gmail.com", - "to_comment": "收件人邮箱列è¡?, + "to_comment": "收件人邮箱列", "to": [ "yangxiangyuan@umer.com.cn" ], - "smtp_comment": "SMTP 服务器配ç½?, + "smtp_comment": "SMTP 服务器配", "smtp": { "host": "smtp.gmail.com", "port": 465, @@ -47,19 +47,19 @@ } } }, - "email_reminder_enabled_comment": "是否发送邮件提é†?, + "email_reminder_enabled_comment": "是否发送邮件提", "email_reminder_enabled": true, "calendar_reminder_enabled_comment": "是否写入日历提醒", "calendar_reminder_enabled": true, - "push_targets_comment": "推送目标列è¡?(支持多路推é€?", + "push_targets_comment": "推送目标列", "push_targets": [ { "url_comment": "推送接口地址", "url": "http://localhost:{{PORT}}/api/weekly/ext/event", "enabled_comment": "是否启用", "enabled": true, - "key_comment": "Weekly 工具çš?Key", - \"key\": \"\" + "key_comment": "Weekly 工具", + "key": "" }, { "url_comment": "推送接口地址", @@ -80,7 +80,7 @@ "end": "15:00" } ], - "display_label": "创业æ�? - }, + "display_label": "创业" +}, "market_status_comment": "Defines the source market timezone and trading sessions for status display." } diff --git a/src/server/market_skills/comex_gold/config.json b/src/server/market_skills/comex_gold/config.json index 0589dce..0c99837 100644 --- a/src/server/market_skills/comex_gold/config.json +++ b/src/server/market_skills/comex_gold/config.json @@ -1,52 +1,52 @@ { - "name_comment": "显示的名ç§?, + "name_comment": "显示的名", "name": "COMEX黄金", "card_order": 15, "card_type": "defensive", - "enabled_comment": "是否启用该监æŽ?(true/false)", + "enabled_comment": "是否启用该监", "enabled": true, - "display_comment": "是否显示该卡ç‰?(false 则不拉取数据也不封存)", + "display_comment": "是否显示该卡", "display": true, - "schedule_comment": "刷新频率 (å¦?10m 表示10分钟)", + "schedule_comment": "刷新频率 (", "schedule": "10m", - "symbol_comment": "监控的代ç ?(腾讯财经或其他源)", + "symbol_comment": "监控的代", "symbol": "hf_GC", "url_comment": "点击卡片右上角房子图标跳转的地址", "url": "https://quote.eastmoney.com/globalfuture/GC00Y.html", - "silver_symbol_comment": "白银代码 (用于计算金银æ¯?", + "silver_symbol_comment": "白银代码 (用于计算金银", "silver_symbol": "hf_SI", - "source_comment": "数据来源 (å¦?tencent)", + "source_comment": "数据来源 (", "source": "tencent", - "alerts_comment": "预警阈值配ç½?, + "alerts_comment": "预警阈值配", "alerts": { - "price_min_comment": "最低价格预è­?, + "price_min_comment": "最低价格预", "price_min": 4800, - "price_max_comment": "最高价格预è­?, + "price_max_comment": "最高价格预", "price_max": 5500, - "change_percent_down_comment": "跌幅预警阈å€?(负数,如 -2 表示è·?2%)", + "change_percent_down_comment": "跌幅预警阈", "change_percent_down": -2, - "urgent_abs_change_percent_comment": "严重告警阈å€?(绝对波动百分æ¯?", + "urgent_abs_change_percent_comment": "严重告警阈", "urgent_abs_change_percent": 5, - "urgent_cooldown_minutes_comment": "严重告警冷却分钟æ•?, + "urgent_cooldown_minutes_comment": "严重告警冷却分钟", "urgent_cooldown_minutes": 120, - "change_percent_up_comment": "涨幅预警阈å€?(正数,如 2 表示æ¶?2%)", + "change_percent_up_comment": "涨幅预警阈", "change_percent_up": 3, - "gold_silver_ratio_min_comment": "金银比最低预è­?, + "gold_silver_ratio_min_comment": "金银比最低预", "gold_silver_ratio_min": 60, - "gold_silver_ratio_max_comment": "金银比最高预è­?, + "gold_silver_ratio_max_comment": "金银比最高预", "gold_silver_ratio_max": 65 }, "email_comment": "邮件通知配置", "email": { "enabled_comment": "是否启用邮件通知", "enabled": true, - "from_comment": "发件人邮ç®?, + "from_comment": "发件人邮", "from": "yangdafe@gmail.com", - "to_comment": "收件人邮箱列è¡?, + "to_comment": "收件人邮箱列", "to": [ "yangxiangyuan@umer.com.cn" ], - "smtp_comment": "SMTP 服务器配ç½?, + "smtp_comment": "SMTP 服务器配", "smtp": { "host": "smtp.gmail.com", "port": 465, @@ -57,19 +57,19 @@ } } }, - "email_reminder_enabled_comment": "是否发送邮件提é†?, + "email_reminder_enabled_comment": "是否发送邮件提", "email_reminder_enabled": true, "calendar_reminder_enabled_comment": "是否写入日历提醒", "calendar_reminder_enabled": true, - "push_targets_comment": "推送目标列è¡?(支持多路推é€?", + "push_targets_comment": "推送目标列", "push_targets": [ { "url_comment": "推送接口地址", "url": "http://localhost:{{PORT}}/api/weekly/ext/event", "enabled_comment": "是否启用", "enabled": true, - "key_comment": "Weekly 工具çš?Key", - \"key\": \"\" + "key_comment": "Weekly 工具", + "key": "" }, { "url_comment": "推送接口地址", @@ -86,7 +86,7 @@ "end": "17:00" } ], - "display_label": "纽约é‡? - }, + "display_label": "纽约" +}, "market_status_comment": "Defines the source market timezone and trading sessions for status display." } diff --git a/src/server/market_skills/comex_silver/config.json b/src/server/market_skills/comex_silver/config.json index 8465fff..eda7383 100644 --- a/src/server/market_skills/comex_silver/config.json +++ b/src/server/market_skills/comex_silver/config.json @@ -1,46 +1,46 @@ { - "name_comment": "显示的名ç§?, + "name_comment": "显示的名", "name": "COMEX白银", "card_order": 15, "card_type": "defensive", - "enabled_comment": "是否启用该监æŽ?(true/false)", + "enabled_comment": "是否启用该监", "enabled": true, - "display_comment": "是否显示该卡ç‰?(false 则不拉取数据也不封存)", + "display_comment": "是否显示该卡", "display": true, - "schedule_comment": "刷新频率 (å¦?10m 表示10分钟)", + "schedule_comment": "刷新频率 (", "schedule": "10m", - "symbol_comment": "监控的代ç ?(腾讯财经或其他源)", + "symbol_comment": "监控的代", "symbol": "hf_SI", "url_comment": "点击卡片右上角房子图标跳转的地址", "url": "https://quote.eastmoney.com/globalfuture/SI00Y.html", - "source_comment": "数据来源 (å¦?tencent)", + "source_comment": "数据来源 (", "source": "tencent", - "alerts_comment": "预警阈值配ç½?, + "alerts_comment": "预警阈值配", "alerts": { - "price_min_comment": "最低价格预è­?, + "price_min_comment": "最低价格预", "price_min": 75, - "price_max_comment": "最高价格预è­?, + "price_max_comment": "最高价格预", "price_max": 95, - "change_percent_down_comment": "跌幅预警阈å€?(负数,如 -2 表示è·?2%)", + "change_percent_down_comment": "跌幅预警阈", "change_percent_down": -2, - "urgent_abs_change_percent_comment": "严重告警阈å€?(绝对波动百分æ¯?", + "urgent_abs_change_percent_comment": "严重告警阈", "urgent_abs_change_percent": 5, - "urgent_cooldown_minutes_comment": "严重告警冷却分钟æ•?, + "urgent_cooldown_minutes_comment": "严重告警冷却分钟", "urgent_cooldown_minutes": 120, - "change_percent_up_comment": "涨幅预警阈å€?(正数,如 2 表示æ¶?2%)", + "change_percent_up_comment": "涨幅预警阈", "change_percent_up": 3 }, "email_comment": "邮件通知配置", "email": { "enabled_comment": "是否启用邮件通知", "enabled": true, - "from_comment": "发件人邮ç®?, + "from_comment": "发件人邮", "from": "yangdafe@gmail.com", - "to_comment": "收件人邮箱列è¡?, + "to_comment": "收件人邮箱列", "to": [ "yangxiangyuan@umer.com.cn" ], - "smtp_comment": "SMTP 服务器配ç½?, + "smtp_comment": "SMTP 服务器配", "smtp": { "host": "smtp.gmail.com", "port": 465, @@ -51,19 +51,19 @@ } } }, - "email_reminder_enabled_comment": "是否发送邮件提é†?, + "email_reminder_enabled_comment": "是否发送邮件提", "email_reminder_enabled": true, "calendar_reminder_enabled_comment": "是否写入日历提醒", "calendar_reminder_enabled": true, - "push_targets_comment": "推送目标列è¡?(支持多路推é€?", + "push_targets_comment": "推送目标列", "push_targets": [ { "url_comment": "推送接口地址", "url": "http://localhost:{{PORT}}/api/weekly/ext/event", "enabled_comment": "是否启用", "enabled": true, - "key_comment": "Weekly 工具çš?Key", - \"key\": \"\" + "key_comment": "Weekly 工具", + "key": "" }, { "url_comment": "推送接口地址", @@ -80,7 +80,7 @@ "end": "17:00" } ], - "display_label": "纽约é“? - }, + "display_label": "纽约" +}, "market_status_comment": "Defines the source market timezone and trading sessions for status display." } diff --git a/src/server/market_skills/csi300/config.json b/src/server/market_skills/csi300/config.json index 6ca5cf6..f0d6ae3 100644 --- a/src/server/market_skills/csi300/config.json +++ b/src/server/market_skills/csi300/config.json @@ -1,42 +1,42 @@ { - "name_comment": "显示的名ç§?, + "name_comment": "显示的名", "name": "沪深300", "card_order": 3, "card_type": "domestic", - "enabled_comment": "是否启用该监æŽ?(true/false)", + "enabled_comment": "是否启用该监", "enabled": true, - "display_comment": "是否显示该卡ç‰?(false 则不拉取数据也不封存)", + "display_comment": "是否显示该卡", "display": true, - "schedule_comment": "刷新频率 (å¦?10m 表示10分钟)", + "schedule_comment": "刷新频率 (", "schedule": "10m", - "symbol_comment": "监控的代ç ?(腾讯财经或其他源)", + "symbol_comment": "监控的代", "symbol": "sh000300", "url_comment": "点击卡片右上角房子图标跳转的地址", "url": "https://gu.qq.com/sh000300", - "source_comment": "数据来源 (å¦?tencent)", + "source_comment": "数据来源 (", "source": "tencent", - "alerts_comment": "预警阈值配ç½?, + "alerts_comment": "预警阈值配", "alerts": { - "change_percent_down_comment": "跌幅预警阈å€?(负数,如 -2 表示è·?2%)", + "change_percent_down_comment": "跌幅预警阈", "change_percent_down": -2, - "urgent_abs_change_percent_comment": "严重告警阈å€?(绝对波动百分æ¯?", + "urgent_abs_change_percent_comment": "严重告警阈", "urgent_abs_change_percent": 5, - "urgent_cooldown_minutes_comment": "严重告警冷却分钟æ•?, + "urgent_cooldown_minutes_comment": "严重告警冷却分钟", "urgent_cooldown_minutes": 120, - "change_percent_up_comment": "涨幅预警阈å€?(正数,如 2 表示æ¶?2%)", + "change_percent_up_comment": "涨幅预警阈", "change_percent_up": 2 }, "email_comment": "邮件通知配置", "email": { "enabled_comment": "是否启用邮件通知", "enabled": true, - "from_comment": "发件人邮ç®?, + "from_comment": "发件人邮", "from": "yangdafe@gmail.com", - "to_comment": "收件人邮箱列è¡?, + "to_comment": "收件人邮箱列", "to": [ "yangxiangyuan@umer.com.cn" ], - "smtp_comment": "SMTP 服务器配ç½?, + "smtp_comment": "SMTP 服务器配", "smtp": { "host": "smtp.gmail.com", "port": 465, @@ -47,19 +47,19 @@ } } }, - "email_reminder_enabled_comment": "是否发送邮件提é†?, + "email_reminder_enabled_comment": "是否发送邮件提", "email_reminder_enabled": true, "calendar_reminder_enabled_comment": "是否写入日历提醒", "calendar_reminder_enabled": true, - "push_targets_comment": "推送目标列è¡?(支持多路推é€?", + "push_targets_comment": "推送目标列", "push_targets": [ { "url_comment": "推送接口地址", "url": "http://localhost:{{PORT}}/api/weekly/ext/event", "enabled_comment": "是否启用", "enabled": true, - "key_comment": "Weekly 工具çš?Key", - \"key\": \"\" + "key_comment": "Weekly 工具", + "key": "" }, { "url_comment": "推送接口地址", diff --git a/src/server/market_skills/dow_jones/config.json b/src/server/market_skills/dow_jones/config.json index e4c4cf6..1286c55 100644 --- a/src/server/market_skills/dow_jones/config.json +++ b/src/server/market_skills/dow_jones/config.json @@ -1,42 +1,42 @@ { - "name_comment": "显示的名ç§?, - "name": "道琼斯工业指æ•?, + "name_comment": "显示的名", + "name": "道琼斯工业指", "card_order": 13.5, "card_type": "overseas", - "enabled_comment": "是否启用该监æŽ?(true/false)", + "enabled_comment": "是否启用该监", "enabled": true, - "display_comment": "是否显示该卡ç‰?(false 则不拉取数据也不封存)", + "display_comment": "是否显示该卡", "display": true, - "schedule_comment": "刷新频率 (å¦?10m 表示10分钟)", + "schedule_comment": "刷新频率 (", "schedule": "10m", - "symbol_comment": "监控的代ç ?(腾讯财经或其他源)", + "symbol_comment": "监控的代", "symbol": "usDJI", "url_comment": "点击卡片右上角房子图标跳转的地址", "url": "https://gu.qq.com/usDJI", - "source_comment": "数据来源 (å¦?tencent)", + "source_comment": "数据来源 (", "source": "yahoo_probe", - "alerts_comment": "预警阈值配ç½?, + "alerts_comment": "预警阈值配", "alerts": { - "change_percent_down_comment": "跌幅预警阈å€?(负数,如 -2 表示è·?2%)", + "change_percent_down_comment": "跌幅预警阈", "change_percent_down": -2, - "urgent_abs_change_percent_comment": "严重告警阈å€?(绝对波动百分æ¯?", + "urgent_abs_change_percent_comment": "严重告警阈", "urgent_abs_change_percent": 5, - "urgent_cooldown_minutes_comment": "严重告警冷却分钟æ•?, + "urgent_cooldown_minutes_comment": "严重告警冷却分钟", "urgent_cooldown_minutes": 120, - "change_percent_up_comment": "涨幅预警阈å€?(正数,如 2 表示æ¶?2%)", + "change_percent_up_comment": "涨幅预警阈", "change_percent_up": 2 }, "email_comment": "邮件通知配置", "email": { "enabled_comment": "是否启用邮件通知", "enabled": true, - "from_comment": "发件人邮ç®?, + "from_comment": "发件人邮", "from": "yangdafe@gmail.com", - "to_comment": "收件人邮箱列è¡?, + "to_comment": "收件人邮箱列", "to": [ "yangxiangyuan@umer.com.cn" ], - "smtp_comment": "SMTP 服务器配ç½?, + "smtp_comment": "SMTP 服务器配", "smtp": { "host": "smtp.gmail.com", "port": 465, @@ -47,19 +47,19 @@ } } }, - "email_reminder_enabled_comment": "是否发送邮件提é†?, + "email_reminder_enabled_comment": "是否发送邮件提", "email_reminder_enabled": true, "calendar_reminder_enabled_comment": "是否写入日历提醒", "calendar_reminder_enabled": true, - "push_targets_comment": "推送目标列è¡?(支持多路推é€?", + "push_targets_comment": "推送目标列", "push_targets": [ { "url_comment": "推送接口地址", "url": "http://localhost:{{PORT}}/api/weekly/ext/event", "enabled_comment": "是否启用", "enabled": true, - "key_comment": "Weekly 工具çš?Key", - \"key\": \"\" + "key_comment": "Weekly 工具", + "key": "" }, { "url_comment": "推送接口地址", @@ -76,7 +76,7 @@ "end": "16:00" } ], - "display_label": "道琼æ–? - }, + "display_label": "道琼" +}, "market_status_comment": "Defines the source market timezone and trading sessions for status display." } diff --git a/src/server/market_skills/germany_etf/config.json b/src/server/market_skills/germany_etf/config.json index 706767d..40ecf6b 100644 --- a/src/server/market_skills/germany_etf/config.json +++ b/src/server/market_skills/germany_etf/config.json @@ -1,42 +1,42 @@ { - "name_comment": "显示的名ç§?, + "name_comment": "显示的名", "name": "德国DAX30", "card_order": 10, "card_type": "overseas", - "enabled_comment": "是否启用该监æŽ?(true/false)", + "enabled_comment": "是否启用该监", "enabled": true, - "display_comment": "是否显示该卡ç‰?(false 则不拉取数据也不封存)", + "display_comment": "是否显示该卡", "display": true, - "schedule_comment": "刷新频率 (å¦?10m 表示10分钟)", + "schedule_comment": "刷新频率 (", "schedule": "10m", - "symbol_comment": "监控的代ç ?(iShares MSCI Germany ETF)", + "symbol_comment": "监控的代", "symbol": "usEWG", "url_comment": "点击卡片右上角房子图标跳转的地址", "url": "https://gu.qq.com/usEWG", - "source_comment": "数据来源 (å¦?tencent)", + "source_comment": "数据来源 (", "source": "yahoo_probe", - "alerts_comment": "预警阈值配ç½?, + "alerts_comment": "预警阈值配", "alerts": { - "change_percent_down_comment": "跌幅预警阈å€?(负数,如 -2 表示è·?2%)", + "change_percent_down_comment": "跌幅预警阈", "change_percent_down": -2, - "urgent_abs_change_percent_comment": "严重告警阈å€?(绝对波动百分æ¯?", + "urgent_abs_change_percent_comment": "严重告警阈", "urgent_abs_change_percent": 5, - "urgent_cooldown_minutes_comment": "严重告警冷却分钟æ•?, + "urgent_cooldown_minutes_comment": "严重告警冷却分钟", "urgent_cooldown_minutes": 120, - "change_percent_up_comment": "涨幅预警阈å€?(正数,如 2 表示æ¶?2%)", + "change_percent_up_comment": "涨幅预警阈", "change_percent_up": 2 }, "email_comment": "邮件通知配置", "email": { "enabled_comment": "是否启用邮件通知", "enabled": true, - "from_comment": "发件人邮ç®?, + "from_comment": "发件人邮", "from": "yangdafe@gmail.com", - "to_comment": "收件人邮箱列è¡?, + "to_comment": "收件人邮箱列", "to": [ "yangxiangyuan@umer.com.cn" ], - "smtp_comment": "SMTP 服务器配ç½?, + "smtp_comment": "SMTP 服务器配", "smtp": { "host": "smtp.gmail.com", "port": 465, @@ -47,19 +47,19 @@ } } }, - "email_reminder_enabled_comment": "是否发送邮件提é†?, + "email_reminder_enabled_comment": "是否发送邮件提", "email_reminder_enabled": true, "calendar_reminder_enabled_comment": "是否写入日历提醒", "calendar_reminder_enabled": true, - "push_targets_comment": "推送目标列è¡?(支持多路推é€?", + "push_targets_comment": "推送目标列", "push_targets": [ { "url_comment": "推送接口地址", "url": "http://localhost:{{PORT}}/api/weekly/ext/event", "enabled_comment": "是否启用", "enabled": true, - "key_comment": "Weekly 工具çš?Key", - \"key\": \"\" + "key_comment": "Weekly 工具", + "key": "" }, { "url_comment": "推送接口地址", diff --git a/src/server/market_skills/gold_silver_nav/config.json b/src/server/market_skills/gold_silver_nav/config.json index 7bf3e2d..f795bed 100644 --- a/src/server/market_skills/gold_silver_nav/config.json +++ b/src/server/market_skills/gold_silver_nav/config.json @@ -1,9 +1,9 @@ { "enabled": true, - "display_comment": "是否显示该卡ç‰?(false 则不拉取数据也不封存)", + "display_comment": "是否显示该卡", "display": true, "schedule": "5m", - "name": "黄金白银净值跟è¸?, + "name": "黄金白银净值跟", "card_order": 16, "card_type": "defensive", "gold_fund_code": "518880", @@ -33,7 +33,7 @@ { "url": "http://localhost:{{PORT}}/api/weekly/ext/event", "enabled": true, - \"key\": \"\" + "key": "" }, { "url": "http://localhost:{{PORT}}/api/calendar/events", diff --git a/src/server/market_skills/hsi/config.json b/src/server/market_skills/hsi/config.json index d2a2b8d..867d7f4 100644 --- a/src/server/market_skills/hsi/config.json +++ b/src/server/market_skills/hsi/config.json @@ -1,42 +1,42 @@ { - "name_comment": "显示的名ç§?, + "name_comment": "显示的名", "name": "恒生指数", "card_order": 8, "card_type": "overseas", - "enabled_comment": "是否启用该监æŽ?(true/false)", + "enabled_comment": "是否启用该监", "enabled": true, - "display_comment": "是否显示该卡ç‰?(false 则不拉取数据也不封存)", + "display_comment": "是否显示该卡", "display": true, - "schedule_comment": "刷新频率 (å¦?10m 表示10分钟)", + "schedule_comment": "刷新频率 (", "schedule": "10m", - "symbol_comment": "监控的代ç ?(腾讯财经或其他源)", + "symbol_comment": "监控的代", "symbol": "hkHSI", "url_comment": "点击卡片右上角房子图标跳转的地址", "url": "https://gu.qq.com/hkHSI", - "source_comment": "数据来源 (å¦?tencent)", + "source_comment": "数据来源 (", "source": "tencent", - "alerts_comment": "预警阈值配ç½?, + "alerts_comment": "预警阈值配", "alerts": { - "change_percent_down_comment": "跌幅预警阈å€?(负数,如 -2 表示è·?2%)", + "change_percent_down_comment": "跌幅预警阈", "change_percent_down": -2, - "urgent_abs_change_percent_comment": "严重告警阈å€?(绝对波动百分æ¯?", + "urgent_abs_change_percent_comment": "严重告警阈", "urgent_abs_change_percent": 5, - "urgent_cooldown_minutes_comment": "严重告警冷却分钟æ•?, + "urgent_cooldown_minutes_comment": "严重告警冷却分钟", "urgent_cooldown_minutes": 120, - "change_percent_up_comment": "涨幅预警阈å€?(正数,如 2 表示æ¶?2%)", + "change_percent_up_comment": "涨幅预警阈", "change_percent_up": 2 }, "email_comment": "邮件通知配置", "email": { "enabled_comment": "是否启用邮件通知", "enabled": true, - "from_comment": "发件人邮ç®?, + "from_comment": "发件人邮", "from": "yangdafe@gmail.com", - "to_comment": "收件人邮箱列è¡?, + "to_comment": "收件人邮箱列", "to": [ "yangxiangyuan@umer.com.cn" ], - "smtp_comment": "SMTP 服务器配ç½?, + "smtp_comment": "SMTP 服务器配", "smtp": { "host": "smtp.gmail.com", "port": 465, @@ -47,19 +47,19 @@ } } }, - "email_reminder_enabled_comment": "是否发送邮件提é†?, + "email_reminder_enabled_comment": "是否发送邮件提", "email_reminder_enabled": true, "calendar_reminder_enabled_comment": "是否写入日历提醒", "calendar_reminder_enabled": true, - "push_targets_comment": "推送目标列è¡?(支持多路推é€?", + "push_targets_comment": "推送目标列", "push_targets": [ { "url_comment": "推送接口地址", "url": "http://localhost:{{PORT}}/api/weekly/ext/event", "enabled_comment": "是否启用", "enabled": true, - "key_comment": "Weekly 工具çš?Key", - \"key\": \"\" + "key_comment": "Weekly 工具", + "key": "" }, { "url_comment": "推送接口地址", diff --git a/src/server/market_skills/india_etf/config.json b/src/server/market_skills/india_etf/config.json index 343e445..fa940c9 100644 --- a/src/server/market_skills/india_etf/config.json +++ b/src/server/market_skills/india_etf/config.json @@ -1,42 +1,42 @@ { - "name_comment": "显示的名ç§?, - "name": "印度NFTY(ç¾?ETF", + "name_comment": "显示的名", + "name": "印度NFTY(", "card_order": 11, "card_type": "overseas", - "enabled_comment": "是否启用该监æŽ?(true/false)", + "enabled_comment": "是否启用该监", "enabled": true, - "display_comment": "是否显示该卡ç‰?(false 则不拉取数据也不封存)", + "display_comment": "是否显示该卡", "display": true, - "schedule_comment": "刷新频率 (å¦?10m 表示10分钟)", + "schedule_comment": "刷新频率 (", "schedule": "10m", - "symbol_comment": "监控的代ç ?(India Nifty 50 ETF)", + "symbol_comment": "监控的代", "symbol": "usNFTY", "url_comment": "点击卡片右上角房子图标跳转的地址", "url": "https://gu.qq.com/usNFTY", - "source_comment": "数据来源 (å¦?tencent)", + "source_comment": "数据来源 (", "source": "yahoo_probe", - "alerts_comment": "预警阈值配ç½?, + "alerts_comment": "预警阈值配", "alerts": { - "change_percent_down_comment": "跌幅预警阈å€?(负数,如 -2 表示è·?2%)", + "change_percent_down_comment": "跌幅预警阈", "change_percent_down": -2, - "urgent_abs_change_percent_comment": "严重告警阈å€?(绝对波动百分æ¯?", + "urgent_abs_change_percent_comment": "严重告警阈", "urgent_abs_change_percent": 5, - "urgent_cooldown_minutes_comment": "严重告警冷却分钟æ•?, + "urgent_cooldown_minutes_comment": "严重告警冷却分钟", "urgent_cooldown_minutes": 120, - "change_percent_up_comment": "涨幅预警阈å€?(正数,如 2 表示æ¶?2%)", + "change_percent_up_comment": "涨幅预警阈", "change_percent_up": 2 }, "email_comment": "邮件通知配置", "email": { "enabled_comment": "是否启用邮件通知", "enabled": true, - "from_comment": "发件人邮ç®?, + "from_comment": "发件人邮", "from": "yangdafe@gmail.com", - "to_comment": "收件人邮箱列è¡?, + "to_comment": "收件人邮箱列", "to": [ "yangxiangyuan@umer.com.cn" ], - "smtp_comment": "SMTP 服务器配ç½?, + "smtp_comment": "SMTP 服务器配", "smtp": { "host": "smtp.gmail.com", "port": 465, @@ -47,19 +47,19 @@ } } }, - "email_reminder_enabled_comment": "是否发送邮件提é†?, + "email_reminder_enabled_comment": "是否发送邮件提", "email_reminder_enabled": true, "calendar_reminder_enabled_comment": "是否写入日历提醒", "calendar_reminder_enabled": true, - "push_targets_comment": "推送目标列è¡?(支持多路推é€?", + "push_targets_comment": "推送目标列", "push_targets": [ { "url_comment": "推送接口地址", "url": "http://localhost:{{PORT}}/api/weekly/ext/event", "enabled_comment": "是否启用", "enabled": true, - "key_comment": "Weekly 工具çš?Key", - \"key\": \"\" + "key_comment": "Weekly 工具", + "key": "" }, { "url_comment": "推送接口地址", diff --git a/src/server/market_skills/korea_kospi/config.json b/src/server/market_skills/korea_kospi/config.json index 5933e37..f0ca62e 100644 --- a/src/server/market_skills/korea_kospi/config.json +++ b/src/server/market_skills/korea_kospi/config.json @@ -1,42 +1,42 @@ { - "name_comment": "显示的名ç§?, + "name_comment": "显示的名", "name": "韩国综合指数", "card_order": 86, "card_type": "overseas", - "enabled_comment": "是否启用该监æŽ?(true/false)", + "enabled_comment": "是否启用该监", "enabled": true, - "display_comment": "是否显示该卡ç‰?(false 则不拉取数据也不封存)", + "display_comment": "是否显示该卡", "display": true, - "schedule_comment": "刷新频率 (å¦?10m 表示10分钟)", + "schedule_comment": "刷新频率 (", "schedule": "10m", - "symbol_comment": "监控的代ç ?(iShares MSCI South Korea ETF)", + "symbol_comment": "监控的代", "symbol": "usEWY", "url_comment": "点击卡片右上角房子图标跳转的地址", "url": "https://gu.qq.com/usEWY", - "source_comment": "数据来源 (å¦?tencent)", + "source_comment": "数据来源 (", "source": "yahoo_probe", - "alerts_comment": "预警阈值配ç½?, + "alerts_comment": "预警阈值配", "alerts": { - "change_percent_down_comment": "跌幅预警阈å€?(负数,如 -2 表示è·?2%)", + "change_percent_down_comment": "跌幅预警阈", "change_percent_down": -2, - "urgent_abs_change_percent_comment": "严重告警阈å€?(绝对波动百分æ¯?", + "urgent_abs_change_percent_comment": "严重告警阈", "urgent_abs_change_percent": 5, - "urgent_cooldown_minutes_comment": "严重告警冷却分钟æ•?, + "urgent_cooldown_minutes_comment": "严重告警冷却分钟", "urgent_cooldown_minutes": 120, - "change_percent_up_comment": "涨幅预警阈å€?(正数,如 2 表示æ¶?2%)", + "change_percent_up_comment": "涨幅预警阈", "change_percent_up": 2 }, "email_comment": "邮件通知配置", "email": { "enabled_comment": "是否启用邮件通知", "enabled": true, - "from_comment": "发件人邮ç®?, + "from_comment": "发件人邮", "from": "yangdafe@gmail.com", - "to_comment": "收件人邮箱列è¡?, + "to_comment": "收件人邮箱列", "to": [ "yangxiangyuan@umer.com.cn" ], - "smtp_comment": "SMTP 服务器配ç½?, + "smtp_comment": "SMTP 服务器配", "smtp": { "host": "smtp.gmail.com", "port": 465, @@ -47,19 +47,19 @@ } } }, - "email_reminder_enabled_comment": "是否发送邮件提é†?, + "email_reminder_enabled_comment": "是否发送邮件提", "email_reminder_enabled": true, "calendar_reminder_enabled_comment": "是否写入日历提醒", "calendar_reminder_enabled": true, - "push_targets_comment": "推送目标列è¡?(支持多路推é€?", + "push_targets_comment": "推送目标列", "push_targets": [ { "url_comment": "推送接口地址", "url": "http://localhost:{{PORT}}/api/weekly/ext/event", "enabled_comment": "是否启用", "enabled": true, - "key_comment": "Weekly 工具çš?Key", - \"key\": \"\" + "key_comment": "Weekly 工具", + "key": "" }, { "url_comment": "推送接口地址", diff --git a/src/server/market_skills/liquidity_state_machine/config.json b/src/server/market_skills/liquidity_state_machine/config.json index 7fb7250..5123849 100644 --- a/src/server/market_skills/liquidity_state_machine/config.json +++ b/src/server/market_skills/liquidity_state_machine/config.json @@ -1,44 +1,44 @@ { - "name_comment": "显示的名ç§?, + "name_comment": "显示的名", "name": "流动性状态机", "card_order": 6, "card_type": "overseas", - "card_span_comment": "卡片横向占位列数ï¼?为默认,2为跨两列", + "card_span_comment": "卡片横向占位列数", "card_span": 2, - "enabled_comment": "是否启用该监æŽ?(true/false)", + "enabled_comment": "是否启用该监", "enabled": true, - "display_comment": "是否显示该卡ç‰?(false 则不拉取数据也不封存)", + "display_comment": "是否显示该卡", "display": true, - "schedule_comment": "刷新频率 (å¦?10m 表示10分钟)", + "schedule_comment": "刷新频率 (", "schedule": "6h", - "symbol_comment": "监控的代ç ?(腾讯财经或其他源)", + "symbol_comment": "监控的代", "symbol": "us_liquidity_state", "url_comment": "点击卡片右上角房子图标跳转的地址", "url": "https://fred.stlouisfed.org/series/FEDFUNDS", - "source_comment": "数据来源 (å¦?tencent)", + "source_comment": "数据来源 (", "source": "fred", - "alerts_comment": "预警阈值配ç½?, + "alerts_comment": "预警阈值配", "alerts": { - "change_percent_down_comment": "跌幅预警阈å€?(负数,如 -2 表示è·?2%)", + "change_percent_down_comment": "跌幅预警阈", "change_percent_down": -20, - "urgent_abs_change_percent_comment": "严重告警阈å€?(绝对波动百分æ¯?", + "urgent_abs_change_percent_comment": "严重告警阈", "urgent_abs_change_percent": 40, - "urgent_cooldown_minutes_comment": "严重告警冷却分钟æ•?, + "urgent_cooldown_minutes_comment": "严重告警冷却分钟", "urgent_cooldown_minutes": 240, - "change_percent_up_comment": "涨幅预警阈å€?(正数,如 2 表示æ¶?2%)", + "change_percent_up_comment": "涨幅预警阈", "change_percent_up": 20 }, "email_comment": "邮件通知配置", "email": { "enabled_comment": "是否启用邮件通知", "enabled": true, - "from_comment": "发件人邮ç®?, + "from_comment": "发件人邮", "from": "yangdafe@gmail.com", - "to_comment": "收件人邮箱列è¡?, + "to_comment": "收件人邮箱列", "to": [ "yangxiangyuan@umer.com.cn" ], - "smtp_comment": "SMTP 服务器配ç½?, + "smtp_comment": "SMTP 服务器配", "smtp": { "host": "smtp.gmail.com", "port": 465, @@ -49,19 +49,19 @@ } } }, - "email_reminder_enabled_comment": "是否发送邮件提é†?, + "email_reminder_enabled_comment": "是否发送邮件提", "email_reminder_enabled": true, "calendar_reminder_enabled_comment": "是否写入日历提醒", "calendar_reminder_enabled": true, - "push_targets_comment": "推送目标列è¡?(支持多路推é€?", + "push_targets_comment": "推送目标列", "push_targets": [ { "url_comment": "推送接口地址", "url": "http://localhost:{{PORT}}/api/weekly/ext/event", "enabled_comment": "是否启用", "enabled": true, - "key_comment": "Weekly 工具çš?Key", - \"key\": \"\" + "key_comment": "Weekly 工具", + "key": "" }, { "url_comment": "推送接口地址", diff --git a/src/server/market_skills/nasdaq/config.json b/src/server/market_skills/nasdaq/config.json index ad2a83b..429d7bc 100644 --- a/src/server/market_skills/nasdaq/config.json +++ b/src/server/market_skills/nasdaq/config.json @@ -1,42 +1,42 @@ { - "name_comment": "显示的名ç§?, + "name_comment": "显示的名", "name": "纳斯达克", "card_order": 13, "card_type": "overseas", - "enabled_comment": "是否启用该监æŽ?(true/false)", + "enabled_comment": "是否启用该监", "enabled": true, - "display_comment": "是否显示该卡ç‰?(false 则不拉取数据也不封存)", + "display_comment": "是否显示该卡", "display": true, - "schedule_comment": "刷新频率 (å¦?10m 表示10分钟)", + "schedule_comment": "刷新频率 (", "schedule": "10m", - "symbol_comment": "监控的代ç ?(腾讯财经或其他源)", + "symbol_comment": "监控的代", "symbol": "usIXIC", "url_comment": "点击卡片右上角房子图标跳转的地址", "url": "https://gu.qq.com/usIXIC", - "source_comment": "数据来源 (å¦?tencent)", + "source_comment": "数据来源 (", "source": "yahoo_probe", - "alerts_comment": "预警阈值配ç½?, + "alerts_comment": "预警阈值配", "alerts": { - "change_percent_down_comment": "跌幅预警阈å€?(负数,如 -2 表示è·?2%)", + "change_percent_down_comment": "跌幅预警阈", "change_percent_down": -2, - "urgent_abs_change_percent_comment": "严重告警阈å€?(绝对波动百分æ¯?", + "urgent_abs_change_percent_comment": "严重告警阈", "urgent_abs_change_percent": 5, - "urgent_cooldown_minutes_comment": "严重告警冷却分钟æ•?, + "urgent_cooldown_minutes_comment": "严重告警冷却分钟", "urgent_cooldown_minutes": 120, - "change_percent_up_comment": "涨幅预警阈å€?(正数,如 2 表示æ¶?2%)", + "change_percent_up_comment": "涨幅预警阈", "change_percent_up": 2 }, "email_comment": "邮件通知配置", "email": { "enabled_comment": "是否启用邮件通知", "enabled": true, - "from_comment": "发件人邮ç®?, + "from_comment": "发件人邮", "from": "yangdafe@gmail.com", - "to_comment": "收件人邮箱列è¡?, + "to_comment": "收件人邮箱列", "to": [ "yangxiangyuan@umer.com.cn" ], - "smtp_comment": "SMTP 服务器配ç½?, + "smtp_comment": "SMTP 服务器配", "smtp": { "host": "smtp.gmail.com", "port": 465, @@ -47,19 +47,19 @@ } } }, - "email_reminder_enabled_comment": "是否发送邮件提é†?, + "email_reminder_enabled_comment": "是否发送邮件提", "email_reminder_enabled": true, "calendar_reminder_enabled_comment": "是否写入日历提醒", "calendar_reminder_enabled": true, - "push_targets_comment": "推送目标列è¡?(支持多路推é€?", + "push_targets_comment": "推送目标列", "push_targets": [ { "url_comment": "推送接口地址", "url": "http://localhost:{{PORT}}/api/weekly/ext/event", "enabled_comment": "是否启用", "enabled": true, - "key_comment": "Weekly 工具çš?Key", - \"key\": \"\" + "key_comment": "Weekly 工具", + "key": "" }, { "url_comment": "推送接口地址", diff --git a/src/server/market_skills/nikkei_etf/config.json b/src/server/market_skills/nikkei_etf/config.json index 6c49809..434d00b 100644 --- a/src/server/market_skills/nikkei_etf/config.json +++ b/src/server/market_skills/nikkei_etf/config.json @@ -1,42 +1,42 @@ { - "name_comment": "显示的名ç§?, + "name_comment": "显示的名", "name": "日经225指数", "card_order": 9, "card_type": "overseas", - "enabled_comment": "是否启用该监æŽ?(true/false)", + "enabled_comment": "是否启用该监", "enabled": true, - "display_comment": "是否显示该卡ç‰?(false 则不拉取数据也不封存)", + "display_comment": "是否显示该卡", "display": true, - "schedule_comment": "刷新频率 (å¦?10m 表示10分钟)", + "schedule_comment": "刷新频率 (", "schedule": "10m", - "symbol_comment": "监控的代ç ?(iShares MSCI Japan ETF)", + "symbol_comment": "监控的代", "symbol": "usEWJ", "url_comment": "点击卡片右上角房子图标跳转的地址", "url": "https://gu.qq.com/usEWJ", - "source_comment": "数据来源 (å¦?tencent)", + "source_comment": "数据来源 (", "source": "yahoo_probe", - "alerts_comment": "预警阈值配ç½?, + "alerts_comment": "预警阈值配", "alerts": { - "change_percent_down_comment": "跌幅预警阈å€?(负数,如 -2 表示è·?2%)", + "change_percent_down_comment": "跌幅预警阈", "change_percent_down": -2, - "urgent_abs_change_percent_comment": "严重告警阈å€?(绝对波动百分æ¯?", + "urgent_abs_change_percent_comment": "严重告警阈", "urgent_abs_change_percent": 5, - "urgent_cooldown_minutes_comment": "严重告警冷却分钟æ•?, + "urgent_cooldown_minutes_comment": "严重告警冷却分钟", "urgent_cooldown_minutes": 120, - "change_percent_up_comment": "涨幅预警阈å€?(正数,如 2 表示æ¶?2%)", + "change_percent_up_comment": "涨幅预警阈", "change_percent_up": 2 }, "email_comment": "邮件通知配置", "email": { "enabled_comment": "是否启用邮件通知", "enabled": true, - "from_comment": "发件人邮ç®?, + "from_comment": "发件人邮", "from": "yangdafe@gmail.com", - "to_comment": "收件人邮箱列è¡?, + "to_comment": "收件人邮箱列", "to": [ "yangxiangyuan@umer.com.cn" ], - "smtp_comment": "SMTP 服务器配ç½?, + "smtp_comment": "SMTP 服务器配", "smtp": { "host": "smtp.gmail.com", "port": 465, @@ -47,19 +47,19 @@ } } }, - "email_reminder_enabled_comment": "是否发送邮件提é†?, + "email_reminder_enabled_comment": "是否发送邮件提", "email_reminder_enabled": true, "calendar_reminder_enabled_comment": "是否写入日历提醒", "calendar_reminder_enabled": true, - "push_targets_comment": "推送目标列è¡?(支持多路推é€?", + "push_targets_comment": "推送目标列", "push_targets": [ { "url_comment": "推送接口地址", "url": "http://localhost:{{PORT}}/api/weekly/ext/event", "enabled_comment": "是否启用", "enabled": true, - "key_comment": "Weekly 工具çš?Key", - \"key\": \"\" + "key_comment": "Weekly 工具", + "key": "" }, { "url_comment": "推送接口地址", diff --git a/src/server/market_skills/saudi_ftse/config.json b/src/server/market_skills/saudi_ftse/config.json index 87a9521..3d7f897 100644 --- a/src/server/market_skills/saudi_ftse/config.json +++ b/src/server/market_skills/saudi_ftse/config.json @@ -1,42 +1,42 @@ { - "name_comment": "显示的名ç§?, + "name_comment": "显示的名", "name": "沙特ETF(富时沙特)", "card_order": 17, "card_type": "overseas", - "enabled_comment": "是否启用该监æŽ?(true/false)", + "enabled_comment": "是否启用该监", "enabled": true, - "display_comment": "是否显示该卡ç‰?(false 则不拉取数据也不封存)", + "display_comment": "是否显示该卡", "display": true, - "schedule_comment": "刷新频率 (å¦?10m 表示10分钟)", + "schedule_comment": "刷新频率 (", "schedule": "10m", - "symbol_comment": "监控的代ç ?(Franklin FTSE Saudi Arabia ETF)", + "symbol_comment": "监控的代", "symbol": "usFLSA", "url_comment": "点击卡片右上角房子图标跳转的地址", "url": "https://gu.qq.com/usFLSA", - "source_comment": "数据来源 (å¦?tencent)", + "source_comment": "数据来源 (", "source": "yahoo_probe", - "alerts_comment": "预警阈值配ç½?, + "alerts_comment": "预警阈值配", "alerts": { - "change_percent_down_comment": "跌幅预警阈å€?(负数,如 -2 表示è·?2%)", + "change_percent_down_comment": "跌幅预警阈", "change_percent_down": -2, - "urgent_abs_change_percent_comment": "严重告警阈å€?(绝对波动百分æ¯?", + "urgent_abs_change_percent_comment": "严重告警阈", "urgent_abs_change_percent": 5, - "urgent_cooldown_minutes_comment": "严重告警冷却分钟æ•?, + "urgent_cooldown_minutes_comment": "严重告警冷却分钟", "urgent_cooldown_minutes": 120, - "change_percent_up_comment": "涨幅预警阈å€?(正数,如 2 表示æ¶?2%)", + "change_percent_up_comment": "涨幅预警阈", "change_percent_up": 2 }, "email_comment": "邮件通知配置", "email": { "enabled_comment": "是否启用邮件通知", "enabled": true, - "from_comment": "发件人邮ç®?, + "from_comment": "发件人邮", "from": "yangdafe@gmail.com", - "to_comment": "收件人邮箱列è¡?, + "to_comment": "收件人邮箱列", "to": [ "yangxiangyuan@umer.com.cn" ], - "smtp_comment": "SMTP 服务器配ç½?, + "smtp_comment": "SMTP 服务器配", "smtp": { "host": "smtp.gmail.com", "port": 465, @@ -47,19 +47,19 @@ } } }, - "email_reminder_enabled_comment": "是否发送邮件提é†?, + "email_reminder_enabled_comment": "是否发送邮件提", "email_reminder_enabled": true, "calendar_reminder_enabled_comment": "是否写入日历提醒", "calendar_reminder_enabled": true, - "push_targets_comment": "推送目标列è¡?(支持多路推é€?", + "push_targets_comment": "推送目标列", "push_targets": [ { "url_comment": "推送接口地址", "url": "http://localhost:{{PORT}}/api/weekly/ext/event", "enabled_comment": "是否启用", "enabled": true, - "key_comment": "Weekly 工具çš?Key", - \"key\": \"\" + "key_comment": "Weekly 工具", + "key": "" }, { "url_comment": "推送接口地址", diff --git a/src/server/market_skills/sh_index/config.json b/src/server/market_skills/sh_index/config.json index d1e7ff7..f8777ea 100644 --- a/src/server/market_skills/sh_index/config.json +++ b/src/server/market_skills/sh_index/config.json @@ -1,42 +1,42 @@ { - "name_comment": "显示的名ç§?, + "name_comment": "显示的名", "name": "上证指数", "card_order": 1, "card_type": "domestic", - "enabled_comment": "是否启用该监æŽ?(true/false)", + "enabled_comment": "是否启用该监", "enabled": true, - "display_comment": "是否显示该卡ç‰?(false 则不拉取数据也不封存)", + "display_comment": "是否显示该卡", "display": true, - "schedule_comment": "刷新频率 (å¦?10m 表示10分钟)", + "schedule_comment": "刷新频率 (", "schedule": "10m", - "symbol_comment": "监控的代ç ?(腾讯财经或其他源)", + "symbol_comment": "监控的代", "symbol": "sh000001", "url_comment": "点击卡片右上角房子图标跳转的地址", "url": "https://gu.qq.com/sh000001", - "source_comment": "数据来源 (å¦?tencent)", + "source_comment": "数据来源 (", "source": "tencent", - "alerts_comment": "预警阈值配ç½?, + "alerts_comment": "预警阈值配", "alerts": { - "change_percent_down_comment": "跌幅预警阈å€?(负数,如 -2 表示è·?2%)", + "change_percent_down_comment": "跌幅预警阈", "change_percent_down": -2, - "urgent_abs_change_percent_comment": "严重告警阈å€?(绝对波动百分æ¯?", + "urgent_abs_change_percent_comment": "严重告警阈", "urgent_abs_change_percent": 5, - "urgent_cooldown_minutes_comment": "严重告警冷却分钟æ•?, + "urgent_cooldown_minutes_comment": "严重告警冷却分钟", "urgent_cooldown_minutes": 120, - "change_percent_up_comment": "涨幅预警阈å€?(正数,如 2 表示æ¶?2%)", + "change_percent_up_comment": "涨幅预警阈", "change_percent_up": 2 }, "email_comment": "邮件通知配置", "email": { "enabled_comment": "是否启用邮件通知", "enabled": true, - "from_comment": "发件人邮ç®?, + "from_comment": "发件人邮", "from": "yangdafe@gmail.com", - "to_comment": "收件人邮箱列è¡?, + "to_comment": "收件人邮箱列", "to": [ "yangxiangyuan@umer.com.cn" ], - "smtp_comment": "SMTP 服务器配ç½?, + "smtp_comment": "SMTP 服务器配", "smtp": { "host": "smtp.gmail.com", "port": 465, @@ -47,19 +47,19 @@ } } }, - "email_reminder_enabled_comment": "是否发送邮件提é†?, + "email_reminder_enabled_comment": "是否发送邮件提", "email_reminder_enabled": true, "calendar_reminder_enabled_comment": "是否写入日历提醒", "calendar_reminder_enabled": true, - "push_targets_comment": "推送目标列è¡?(支持多路推é€?", + "push_targets_comment": "推送目标列", "push_targets": [ { "url_comment": "推送接口地址", "url": "http://localhost:{{PORT}}/api/weekly/ext/event", "enabled_comment": "是否启用", "enabled": true, - "key_comment": "Weekly 工具çš?Key", - \"key\": \"\" + "key_comment": "Weekly 工具", + "key": "" }, { "url_comment": "推送接口地址", diff --git a/src/server/market_skills/shenhua_299/config.json b/src/server/market_skills/shenhua_299/config.json index 15b057f..8b8db6e 100644 --- a/src/server/market_skills/shenhua_299/config.json +++ b/src/server/market_skills/shenhua_299/config.json @@ -1,33 +1,33 @@ { - "name_comment": "显示的名ç§?, - "name": "中国神华-299æ³?, + "name_comment": "显示的名", + "name": "中国神华-299", "card_order": 1, "card_type": "299", - "enabled_comment": "是否启用该监æŽ?(true/false)", + "enabled_comment": "是否启用该监", "enabled": true, - "display_comment": "是否显示该卡ç‰?(false 则不拉取数据也不封存)", + "display_comment": "是否显示该卡", "display": false, - "schedule_comment": "刷新频率 (å¦?10m 表示10分钟)", + "schedule_comment": "刷新频率 (", "schedule": "10m", - "symbol_comment": "监控的代ç ?(腾讯财经或其他源)", + "symbol_comment": "监控的代", "symbol": "sh601088_299", "source_symbol_comment": "用于拉取K线的真实代码", "source_symbol": "sh601088", "url_comment": "点击卡片右上角房子图标跳转的地址", "url": "https://quote.eastmoney.com/sh601088.html", - "source_comment": "数据来源 (å¦?tencent)", + "source_comment": "数据来源 (", "source": "tencent", "email_comment": "邮件通知配置", "email": { "enabled_comment": "是否启用邮件通知", "enabled": true, - "from_comment": "发件人邮ç®?, + "from_comment": "发件人邮", "from": "yangdafe@gmail.com", - "to_comment": "收件人邮箱列è¡?, + "to_comment": "收件人邮箱列", "to": [ "yangxiangyuan@umer.com.cn" ], - "smtp_comment": "SMTP 服务器配ç½?, + "smtp_comment": "SMTP 服务器配", "smtp": { "host": "smtp.gmail.com", "port": 465, @@ -38,19 +38,19 @@ } } }, - "email_reminder_enabled_comment": "是否发送邮件提é†?, + "email_reminder_enabled_comment": "是否发送邮件提", "email_reminder_enabled": true, "calendar_reminder_enabled_comment": "是否写入日历提醒", "calendar_reminder_enabled": true, - "push_targets_comment": "推送目标列è¡?(支持多路推é€?", + "push_targets_comment": "推送目标列", "push_targets": [ { "url_comment": "推送接口地址", "url": "http://localhost:{{PORT}}/api/weekly/ext/event", "enabled_comment": "是否启用", "enabled": true, - "key_comment": "Weekly 工具çš?Key", - \"key\": \"\" + "key_comment": "Weekly 工具", + "key": "" }, { "url_comment": "推送接口地址", @@ -71,7 +71,7 @@ "end": "15:00" } ], - "display_label": "中国Aè‚? - }, + "display_label": "中国A" +}, "market_status_comment": "Defines the source market timezone and trading sessions for status display." } diff --git a/src/server/market_skills/southeast_asia_tech/config.json b/src/server/market_skills/southeast_asia_tech/config.json index 42f7738..d487a2c 100644 --- a/src/server/market_skills/southeast_asia_tech/config.json +++ b/src/server/market_skills/southeast_asia_tech/config.json @@ -1,42 +1,42 @@ { - "name_comment": "显示的名ç§?, + "name_comment": "显示的名", "name": "东南亚科技(海峡时报)", "card_order": 20, "card_type": "overseas", - "enabled_comment": "是否启用该监æŽ?(true/false)", + "enabled_comment": "是否启用该监", "enabled": true, - "display_comment": "是否显示该卡ç‰?(false 则不拉取数据也不封存)", + "display_comment": "是否显示该卡", "display": true, - "schedule_comment": "刷新频率 (å¦?10m 表示10分钟)", + "schedule_comment": "刷新频率 (", "schedule": "10m", - "symbol_comment": "监控的代ç ?(Global X 东南äº?ETF)", + "symbol_comment": "监控的代", "symbol": "usASEA", "url_comment": "点击卡片右上角房子图标跳转的地址", "url": "https://gu.qq.com/usASEA", - "source_comment": "数据来源 (å¦?tencent)", + "source_comment": "数据来源 (", "source": "yahoo_probe", - "alerts_comment": "预警阈值配ç½?, + "alerts_comment": "预警阈值配", "alerts": { - "change_percent_down_comment": "跌幅预警阈å€?(负数,如 -2 表示è·?2%)", + "change_percent_down_comment": "跌幅预警阈", "change_percent_down": -2, - "urgent_abs_change_percent_comment": "严重告警阈å€?(绝对波动百分æ¯?", + "urgent_abs_change_percent_comment": "严重告警阈", "urgent_abs_change_percent": 5, - "urgent_cooldown_minutes_comment": "严重告警冷却分钟æ•?, + "urgent_cooldown_minutes_comment": "严重告警冷却分钟", "urgent_cooldown_minutes": 120, - "change_percent_up_comment": "涨幅预警阈å€?(正数,如 2 表示æ¶?2%)", + "change_percent_up_comment": "涨幅预警阈", "change_percent_up": 2 }, "email_comment": "邮件通知配置", "email": { "enabled_comment": "是否启用邮件通知", "enabled": true, - "from_comment": "发件人邮ç®?, + "from_comment": "发件人邮", "from": "yangdafe@gmail.com", - "to_comment": "收件人邮箱列è¡?, + "to_comment": "收件人邮箱列", "to": [ "yangxiangyuan@umer.com.cn" ], - "smtp_comment": "SMTP 服务器配ç½?, + "smtp_comment": "SMTP 服务器配", "smtp": { "host": "smtp.gmail.com", "port": 465, @@ -47,19 +47,19 @@ } } }, - "email_reminder_enabled_comment": "是否发送邮件提é†?, + "email_reminder_enabled_comment": "是否发送邮件提", "email_reminder_enabled": true, "calendar_reminder_enabled_comment": "是否写入日历提醒", "calendar_reminder_enabled": true, - "push_targets_comment": "推送目标列è¡?(支持多路推é€?", + "push_targets_comment": "推送目标列", "push_targets": [ { "url_comment": "推送接口地址", "url": "http://localhost:{{PORT}}/api/weekly/ext/event", "enabled_comment": "是否启用", "enabled": true, - "key_comment": "Weekly 工具çš?Key", - \"key\": \"\" + "key_comment": "Weekly 工具", + "key": "" }, { "url_comment": "推送接口地址", diff --git a/src/server/market_skills/sp500/config.json b/src/server/market_skills/sp500/config.json index a61c1c7..3014cb7 100644 --- a/src/server/market_skills/sp500/config.json +++ b/src/server/market_skills/sp500/config.json @@ -1,42 +1,42 @@ { - "name_comment": "显示的名ç§?, + "name_comment": "显示的名", "name": "标普500", "card_order": 12, "card_type": "overseas", - "enabled_comment": "是否启用该监æŽ?(true/false)", + "enabled_comment": "是否启用该监", "enabled": true, - "display_comment": "是否显示该卡ç‰?(false 则不拉取数据也不封存)", + "display_comment": "是否显示该卡", "display": true, - "schedule_comment": "刷新频率 (å¦?10m 表示10分钟)", + "schedule_comment": "刷新频率 (", "schedule": "10m", - "symbol_comment": "监控的代ç ?(腾讯财经或其他源)", + "symbol_comment": "监控的代", "symbol": "usINX", "url_comment": "点击卡片右上角房子图标跳转的地址", "url": "https://gu.qq.com/usINX", - "source_comment": "数据来源 (å¦?tencent)", + "source_comment": "数据来源 (", "source": "yahoo_probe", - "alerts_comment": "预警阈值配ç½?, + "alerts_comment": "预警阈值配", "alerts": { - "change_percent_down_comment": "跌幅预警阈å€?(负数,如 -2 表示è·?2%)", + "change_percent_down_comment": "跌幅预警阈", "change_percent_down": -2, - "urgent_abs_change_percent_comment": "严重告警阈å€?(绝对波动百分æ¯?", + "urgent_abs_change_percent_comment": "严重告警阈", "urgent_abs_change_percent": 5, - "urgent_cooldown_minutes_comment": "严重告警冷却分钟æ•?, + "urgent_cooldown_minutes_comment": "严重告警冷却分钟", "urgent_cooldown_minutes": 120, - "change_percent_up_comment": "涨幅预警阈å€?(正数,如 2 表示æ¶?2%)", + "change_percent_up_comment": "涨幅预警阈", "change_percent_up": 2 }, "email_comment": "邮件通知配置", "email": { "enabled_comment": "是否启用邮件通知", "enabled": true, - "from_comment": "发件人邮ç®?, + "from_comment": "发件人邮", "from": "yangdafe@gmail.com", - "to_comment": "收件人邮箱列è¡?, + "to_comment": "收件人邮箱列", "to": [ "yangxiangyuan@umer.com.cn" ], - "smtp_comment": "SMTP 服务器配ç½?, + "smtp_comment": "SMTP 服务器配", "smtp": { "host": "smtp.gmail.com", "port": 465, @@ -47,19 +47,19 @@ } } }, - "email_reminder_enabled_comment": "是否发送邮件提é†?, + "email_reminder_enabled_comment": "是否发送邮件提", "email_reminder_enabled": true, "calendar_reminder_enabled_comment": "是否写入日历提醒", "calendar_reminder_enabled": true, - "push_targets_comment": "推送目标列è¡?(支持多路推é€?", + "push_targets_comment": "推送目标列", "push_targets": [ { "url_comment": "推送接口地址", "url": "http://localhost:{{PORT}}/api/weekly/ext/event", "enabled_comment": "是否启用", "enabled": true, - "key_comment": "Weekly 工具çš?Key", - \"key\": \"\" + "key_comment": "Weekly 工具", + "key": "" }, { "url_comment": "推送接口地址", diff --git a/src/server/market_skills/star50/config.json b/src/server/market_skills/star50/config.json index 3c73e37..13a153c 100644 --- a/src/server/market_skills/star50/config.json +++ b/src/server/market_skills/star50/config.json @@ -1,42 +1,42 @@ { - "name_comment": "显示的名ç§?, + "name_comment": "显示的名", "name": "科创50", "card_order": 4, "card_type": "domestic", - "enabled_comment": "是否启用该监æŽ?(true/false)", + "enabled_comment": "是否启用该监", "enabled": true, - "display_comment": "是否显示该卡ç‰?(false 则不拉取数据也不封存)", + "display_comment": "是否显示该卡", "display": true, - "schedule_comment": "刷新频率 (å¦?10m 表示10分钟)", + "schedule_comment": "刷新频率 (", "schedule": "10m", - "symbol_comment": "监控的代ç ?(腾讯财经或其他源)", + "symbol_comment": "监控的代", "symbol": "sh000688", "url_comment": "点击卡片右上角房子图标跳转的地址", "url": "https://gu.qq.com/sh000688", - "source_comment": "数据来源 (å¦?tencent)", + "source_comment": "数据来源 (", "source": "tencent", - "alerts_comment": "预警阈值配ç½?, + "alerts_comment": "预警阈值配", "alerts": { - "change_percent_down_comment": "跌幅预警阈å€?(负数,如 -2 表示è·?2%)", + "change_percent_down_comment": "跌幅预警阈", "change_percent_down": -2, - "urgent_abs_change_percent_comment": "严重告警阈å€?(绝对波动百分æ¯?", + "urgent_abs_change_percent_comment": "严重告警阈", "urgent_abs_change_percent": 5, - "urgent_cooldown_minutes_comment": "严重告警冷却分钟æ•?, + "urgent_cooldown_minutes_comment": "严重告警冷却分钟", "urgent_cooldown_minutes": 120, - "change_percent_up_comment": "涨幅预警阈å€?(正数,如 2 表示æ¶?2%)", + "change_percent_up_comment": "涨幅预警阈", "change_percent_up": 2 }, "email_comment": "邮件通知配置", "email": { "enabled_comment": "是否启用邮件通知", "enabled": true, - "from_comment": "发件人邮ç®?, + "from_comment": "发件人邮", "from": "yangdafe@gmail.com", - "to_comment": "收件人邮箱列è¡?, + "to_comment": "收件人邮箱列", "to": [ "yangxiangyuan@umer.com.cn" ], - "smtp_comment": "SMTP 服务器配ç½?, + "smtp_comment": "SMTP 服务器配", "smtp": { "host": "smtp.gmail.com", "port": 465, @@ -47,19 +47,19 @@ } } }, - "email_reminder_enabled_comment": "是否发送邮件提é†?, + "email_reminder_enabled_comment": "是否发送邮件提", "email_reminder_enabled": true, "calendar_reminder_enabled_comment": "是否写入日历提醒", "calendar_reminder_enabled": true, - "push_targets_comment": "推送目标列è¡?(支持多路推é€?", + "push_targets_comment": "推送目标列", "push_targets": [ { "url_comment": "推送接口地址", "url": "http://localhost:{{PORT}}/api/weekly/ext/event", "enabled_comment": "是否启用", "enabled": true, - "key_comment": "Weekly 工具çš?Key", - \"key\": \"\" + "key_comment": "Weekly 工具", + "key": "" }, { "url_comment": "推送接口地址", diff --git a/src/server/market_skills/sz_index/config.json b/src/server/market_skills/sz_index/config.json index bde0bea..9f99c27 100644 --- a/src/server/market_skills/sz_index/config.json +++ b/src/server/market_skills/sz_index/config.json @@ -1,42 +1,42 @@ { - "name_comment": "显示的名ç§?, + "name_comment": "显示的名", "name": "深证成指", "card_order": 2, "card_type": "domestic", - "enabled_comment": "是否启用该监æŽ?(true/false)", + "enabled_comment": "是否启用该监", "enabled": true, - "display_comment": "是否显示该卡ç‰?(false 则不拉取数据也不封存)", + "display_comment": "是否显示该卡", "display": true, - "schedule_comment": "刷新频率 (å¦?10m 表示10分钟)", + "schedule_comment": "刷新频率 (", "schedule": "10m", - "symbol_comment": "监控的代ç ?(腾讯财经或其他源)", + "symbol_comment": "监控的代", "symbol": "sz399001", "url_comment": "点击卡片右上角房子图标跳转的地址", "url": "https://gu.qq.com/sz399001", - "source_comment": "数据来源 (å¦?tencent)", + "source_comment": "数据来源 (", "source": "tencent", - "alerts_comment": "预警阈值配ç½?, + "alerts_comment": "预警阈值配", "alerts": { - "change_percent_down_comment": "跌幅预警阈å€?(负数,如 -2 表示è·?2%)", + "change_percent_down_comment": "跌幅预警阈", "change_percent_down": -2, - "urgent_abs_change_percent_comment": "严重告警阈å€?(绝对波动百分æ¯?", + "urgent_abs_change_percent_comment": "严重告警阈", "urgent_abs_change_percent": 5, - "urgent_cooldown_minutes_comment": "严重告警冷却分钟æ•?, + "urgent_cooldown_minutes_comment": "严重告警冷却分钟", "urgent_cooldown_minutes": 120, - "change_percent_up_comment": "涨幅预警阈å€?(正数,如 2 表示æ¶?2%)", + "change_percent_up_comment": "涨幅预警阈", "change_percent_up": 2 }, "email_comment": "邮件通知配置", "email": { "enabled_comment": "是否启用邮件通知", "enabled": true, - "from_comment": "发件人邮ç®?, + "from_comment": "发件人邮", "from": "yangdafe@gmail.com", - "to_comment": "收件人邮箱列è¡?, + "to_comment": "收件人邮箱列", "to": [ "yangxiangyuan@umer.com.cn" ], - "smtp_comment": "SMTP 服务器配ç½?, + "smtp_comment": "SMTP 服务器配", "smtp": { "host": "smtp.gmail.com", "port": 465, @@ -47,19 +47,19 @@ } } }, - "email_reminder_enabled_comment": "是否发送邮件提é†?, + "email_reminder_enabled_comment": "是否发送邮件提", "email_reminder_enabled": true, "calendar_reminder_enabled_comment": "是否写入日历提醒", "calendar_reminder_enabled": true, - "push_targets_comment": "推送目标列è¡?(支持多路推é€?", + "push_targets_comment": "推送目标列", "push_targets": [ { "url_comment": "推送接口地址", "url": "http://localhost:{{PORT}}/api/weekly/ext/event", "enabled_comment": "是否启用", "enabled": true, - "key_comment": "Weekly 工具çš?Key", - \"key\": \"\" + "key_comment": "Weekly 工具", + "key": "" }, { "url_comment": "推送接口地址", diff --git a/src/server/market_skills/taiwan_twii/config.json b/src/server/market_skills/taiwan_twii/config.json index b966aa9..742ca49 100644 --- a/src/server/market_skills/taiwan_twii/config.json +++ b/src/server/market_skills/taiwan_twii/config.json @@ -1,42 +1,42 @@ { - "name_comment": "显示的名ç§?, + "name_comment": "显示的名", "name": "宝岛加权指数", "card_order": 87, "card_type": "overseas", - "enabled_comment": "是否启用该监æŽ?(true/false)", + "enabled_comment": "是否启用该监", "enabled": true, - "display_comment": "是否显示该卡ç‰?(false 则不拉取数据也不封存)", + "display_comment": "是否显示该卡", "display": true, - "schedule_comment": "刷新频率 (å¦?10m 表示10分钟)", + "schedule_comment": "刷新频率 (", "schedule": "10m", - "symbol_comment": "监控的代ç ?(iShares MSCI Taiwan ETF)", + "symbol_comment": "监控的代", "symbol": "usEWT", "url_comment": "点击卡片右上角房子图标跳转的地址", "url": "https://gu.qq.com/usEWT", - "source_comment": "数据来源 (å¦?tencent)", + "source_comment": "数据来源 (", "source": "yahoo_probe", - "alerts_comment": "预警阈值配ç½?, + "alerts_comment": "预警阈值配", "alerts": { - "change_percent_down_comment": "跌幅预警阈å€?(负数,如 -2 表示è·?2%)", + "change_percent_down_comment": "跌幅预警阈", "change_percent_down": -2, - "urgent_abs_change_percent_comment": "严重告警阈å€?(绝对波动百分æ¯?", + "urgent_abs_change_percent_comment": "严重告警阈", "urgent_abs_change_percent": 5, - "urgent_cooldown_minutes_comment": "严重告警冷却分钟æ•?, + "urgent_cooldown_minutes_comment": "严重告警冷却分钟", "urgent_cooldown_minutes": 120, - "change_percent_up_comment": "涨幅预警阈å€?(正数,如 2 表示æ¶?2%)", + "change_percent_up_comment": "涨幅预警阈", "change_percent_up": 2 }, "email_comment": "邮件通知配置", "email": { "enabled_comment": "是否启用邮件通知", "enabled": true, - "from_comment": "发件人邮ç®?, + "from_comment": "发件人邮", "from": "yangdafe@gmail.com", - "to_comment": "收件人邮箱列è¡?, + "to_comment": "收件人邮箱列", "to": [ "yangxiangyuan@umer.com.cn" ], - "smtp_comment": "SMTP 服务器配ç½?, + "smtp_comment": "SMTP 服务器配", "smtp": { "host": "smtp.gmail.com", "port": 465, @@ -47,19 +47,19 @@ } } }, - "email_reminder_enabled_comment": "是否发送邮件提é†?, + "email_reminder_enabled_comment": "是否发送邮件提", "email_reminder_enabled": true, "calendar_reminder_enabled_comment": "是否写入日历提醒", "calendar_reminder_enabled": true, - "push_targets_comment": "推送目标列è¡?(支持多路推é€?", + "push_targets_comment": "推送目标列", "push_targets": [ { "url_comment": "推送接口地址", "url": "http://localhost:{{PORT}}/api/weekly/ext/event", "enabled_comment": "是否启用", "enabled": true, - "key_comment": "Weekly 工具çš?Key", - \"key\": \"\" + "key_comment": "Weekly 工具", + "key": "" }, { "url_comment": "推送接口地址", diff --git a/src/server/market_skills/us_war_index/config.json b/src/server/market_skills/us_war_index/config.json index b705220..1c26d9d 100644 --- a/src/server/market_skills/us_war_index/config.json +++ b/src/server/market_skills/us_war_index/config.json @@ -1,15 +1,15 @@ { - "name_comment": "显示的名ç§?, + "name_comment": "显示的名", "name": "美国战争指数", "card_order": 14, "card_type": "overseas", - "enabled_comment": "是否启用该监æŽ?(true/false)", + "enabled_comment": "是否启用该监", "enabled": true, - "display_comment": "是否显示该卡ç‰?(false 则不拉取数据也不封存)", + "display_comment": "是否显示该卡", "display": true, - "schedule_comment": "刷新频率 (å¦?10m 表示10分钟)", + "schedule_comment": "刷新频率 (", "schedule": "10m", - "symbol_comment": "监控的代ç ?(技能内部定ä¹?", + "symbol_comment": "监控的代", "symbol": "us_war_pizzint", "url_comment": "点击卡片右上角房子图标跳转的地址", "url": "https://fred.stlouisfed.org/series/VIXCLS", @@ -21,34 +21,34 @@ "fail_alert_consecutive": 3, "source_comment": "数据来源", "source": "fred_vix", - "alerts_comment": "预警阈值配ç½?, + "alerts_comment": "预警阈值配", "alerts": { "doughcon_critical_max_comment": "DOUGHCON 小于等于该值触发高风险预警", "doughcon_critical_max": 2, - "doughcon_warning_max_comment": "DOUGHCON 小于等于该值触发风险抬升预è­?, + "doughcon_warning_max_comment": "DOUGHCON 小于等于该值触发风险抬升预", "doughcon_warning_max": 3, - "vix_warning_min_comment": "VIX 大于等于该值触发风险抬升预è­?, + "vix_warning_min_comment": "VIX 大于等于该值触发风险抬升预", "vix_warning_min": 22, "vix_critical_min_comment": "VIX 大于等于该值触发高风险预警", "vix_critical_min": 30, - "index_jump_threshold_comment": "战争指数单次上跳达到该值触发快速升温预è­?, + "index_jump_threshold_comment": "战争指数单次上跳达到该值触发快速升温预", "index_jump_threshold": 20, - "urgent_abs_change_percent_comment": "严重告警阈å€?(绝对波动百分æ¯?", + "urgent_abs_change_percent_comment": "严重告警阈", "urgent_abs_change_percent": 5, - "urgent_cooldown_minutes_comment": "严重告警冷却分钟æ•?, + "urgent_cooldown_minutes_comment": "严重告警冷却分钟", "urgent_cooldown_minutes": 120 }, "email_comment": "邮件通知配置", "email": { "enabled_comment": "是否启用邮件通知", "enabled": false, - "from_comment": "发件人邮ç®?, + "from_comment": "发件人邮", "from": "yangdafe@gmail.com", - "to_comment": "收件人邮箱列è¡?, + "to_comment": "收件人邮箱列", "to": [ "yangxiangyuan@umer.com.cn" ], - "smtp_comment": "SMTP 服务器配ç½?, + "smtp_comment": "SMTP 服务器配", "smtp": { "host": "smtp.gmail.com", "port": 465, @@ -59,19 +59,19 @@ } } }, - "email_reminder_enabled_comment": "是否发送邮件提é†?, + "email_reminder_enabled_comment": "是否发送邮件提", "email_reminder_enabled": false, "calendar_reminder_enabled_comment": "是否写入日历提醒", "calendar_reminder_enabled": false, - "push_targets_comment": "推送目标列è¡?(支持多路推é€?", + "push_targets_comment": "推送目标列", "push_targets": [ { "url_comment": "推送接口地址", "url": "http://localhost:{{PORT}}/api/weekly/ext/event", "enabled_comment": "是否启用", "enabled": false, - "key_comment": "Weekly 工具çš?Key", - \"key\": \"\" + "key_comment": "Weekly 工具", + "key": "" }, { "url_comment": "推送接口地址", diff --git a/src/server/market_skills/zhonghang_299/config.json b/src/server/market_skills/zhonghang_299/config.json index 8bc1118..57e2201 100644 --- a/src/server/market_skills/zhonghang_299/config.json +++ b/src/server/market_skills/zhonghang_299/config.json @@ -1,45 +1,45 @@ { - "name_comment": "显示的名ç§?, - "name": "中国银行-299æ³?, + "name_comment": "显示的名", + "name": "中国银行-299", "card_order": 2, "card_type": "299", - "card_span_comment": "卡片横向占位列数ï¼?为默认,2为跨两列", + "card_span_comment": "卡片横向占位列数", "card_span": 2, "card_custom_class_comment": "中国银行卡片专属样式类名,仅用于前端独立展示", "card_custom_class": "card-zhonghang-299", - "enabled_comment": "是否启用该监æŽ?(true/false)", + "enabled_comment": "是否启用该监", "enabled": true, - "display_comment": "是否显示该卡ç‰?(false 则不拉取数据也不封存)", + "display_comment": "是否显示该卡", "display": true, - "schedule_comment": "刷新频率 (å¦?10m 表示10分钟)", + "schedule_comment": "刷新频率 (", "schedule": "10m", - "symbol_comment": "监控的代ç ?(腾讯财经或其他源)", + "symbol_comment": "监控的代", "symbol": "sh601988_299", "source_symbol_comment": "用于拉取K线的真实代码", "source_symbol": "sh601988", "url_comment": "点击卡片右上角房子图标跳转的地址", "url": "https://quote.eastmoney.com/sh601988.html", - "source_comment": "数据来源 (å¦?tencent)", + "source_comment": "数据来源 (", "source": "tencent", - "box_window_comment": "收盘价箱体窗口,仅支æŒ?0/30/40", + "box_window_comment": "收盘价箱体窗口,仅支", "box_window": 30, "box_confirm_days_comment": "箱体突破确认天数", "box_confirm_days": 2, "box_volume_ma_window_comment": "突破日成交量均量窗口", "box_volume_ma_window": 5, - "box_volume_expand_ratio_comment": "突破日放量倍数阈å€?, + "box_volume_expand_ratio_comment": "突破日放量倍数阈", "box_volume_expand_ratio": 1.2, "email_comment": "邮件通知配置", "email": { "enabled_comment": "是否启用邮件通知", "enabled": false, - "from_comment": "发件人邮ç®?, + "from_comment": "发件人邮", "from": "yangdafe@gmail.com", - "to_comment": "收件人邮箱列è¡?, + "to_comment": "收件人邮箱列", "to": [ "yangxiangyuan@umer.com.cn" ], - "smtp_comment": "SMTP 服务器配ç½?, + "smtp_comment": "SMTP 服务器配", "smtp": { "host": "smtp.gmail.com", "port": 465, @@ -50,19 +50,19 @@ } } }, - "email_reminder_enabled_comment": "是否发送邮件提é†?, + "email_reminder_enabled_comment": "是否发送邮件提", "email_reminder_enabled": false, "calendar_reminder_enabled_comment": "是否写入日历提醒", "calendar_reminder_enabled": false, - "push_targets_comment": "推送目标列è¡?(支持多路推é€?", + "push_targets_comment": "推送目标列", "push_targets": [ { "url_comment": "推送接口地址", "url": "http://localhost:{{PORT}}/api/weekly/ext/event", "enabled_comment": "是否启用", "enabled": false, - "key_comment": "Weekly 工具çš?Key", - \"key\": \"\" + "key_comment": "Weekly 工具", + "key": "" }, { "url_comment": "推送接口地址", @@ -83,7 +83,7 @@ "end": "15:00" } ], - "display_label": "中国Aè‚? - }, + "display_label": "中国A" +}, "market_status_comment": "Defines the source market timezone and trading sessions for status display." } diff --git a/src/server/markets.js b/src/server/markets.js index b3bcd59..f8b968d 100644 --- a/src/server/markets.js +++ b/src/server/markets.js @@ -27,7 +27,7 @@ if (!fs.existsSync(cfgPath)) { enabled_comment: '是否启用 Yahoo 探针', enabled: true, base_url_comment: 'Yahoo 探针基础地址(不带结尾斜杠)', - base_url: 'http://47.252.30.127:19071', + base_url: 'https://yang-probe.umersoft.com', token_comment: 'Yahoo 探针 token,仅后端使用,需在 config/markets.json 中配置', token: '', endpoint_comment: 'Yahoo 探针统一路由', diff --git a/src/server/marketsClient.js b/src/server/marketsClient.js index 89d0f11..aef8133 100644 --- a/src/server/marketsClient.js +++ b/src/server/marketsClient.js @@ -175,10 +175,10 @@ const readMarketsConfig = () => { const getYahooProbeConfig = () => { const cfg = readMarketsConfig() const raw = cfg && typeof cfg.yahoo_probe === 'object' ? cfg.yahoo_probe : {} - const baseUrl = String(raw.base_url || 'http://47.252.30.127:19071').trim().replace(/\/$/, '') + const baseUrl = String(raw.base_url || 'https://yang-probe.umersoft.com').trim().replace(/\/$/, '') return { enabled: raw.enabled !== false, - base_url: baseUrl || 'http://47.252.30.127:19071', + base_url: baseUrl || 'https://yang-probe.umersoft.com', token: String(raw.token || '').trim(), quote_endpoint: String(raw.quote_endpoint || raw.endpoint || '/probe/yahoo').trim() || '/probe/yahoo', chart_endpoint: String(raw.chart_endpoint || raw.endpoint || '/probe/yahoo').trim() || '/probe/yahoo', diff --git a/src/server/markets_weekly_report.js b/src/server/markets_weekly_report.js index a3f9ec7..0103e6d 100644 --- a/src/server/markets_weekly_report.js +++ b/src/server/markets_weekly_report.js @@ -228,10 +228,10 @@ function getConfig() { function getAlphaVantageConfig() { const cfg = getConfig() const raw = cfg && typeof cfg.alphavantage_probe === 'object' ? cfg.alphavantage_probe : {} - const baseUrl = String(raw.base_url || 'http://47.252.30.127:19071').trim().replace(/\/$/, '') + const baseUrl = String(raw.base_url || 'https://yang-probe.umersoft.com').trim().replace(/\/$/, '') return { enabled: raw.enabled !== false, - base_url: baseUrl || 'http://47.252.30.127:19071', + base_url: baseUrl || 'https://yang-probe.umersoft.com', token: String(raw.token || '').trim(), candle_endpoint: String(raw.candle_endpoint || '/probe/alphavantage/candle').trim() || '/probe/alphavantage/candle', request_timeout_ms: Math.max(5000, Number(raw.request_timeout_ms || 20000)), diff --git a/src/server/oss_file_cabinet.js b/src/server/oss_file_cabinet.js new file mode 100644 index 0000000..a8f85da --- /dev/null +++ b/src/server/oss_file_cabinet.js @@ -0,0 +1,1096 @@ +const express = require('express') +const fs = require('fs') +const path = require('path') +const crypto = require('crypto') +const Database = require('better-sqlite3') +const multer = require('multer') +const archiver = require('archiver') + +const DATA_DIR = path.join(process.cwd(), 'data') +const DB_PATH = process.env.OSS_FILE_CABINET_DB_PATH || path.join(DATA_DIR, 'oss_file_cabinet.db') +const MAX_UPLOAD_BYTES = 50 * 1024 * 1024 + +const OSS_CONFIG = { + bucket: process.env.OSS_FILE_CABINET_OSS_BUCKET || process.env.DOC_CLOUD_KEEPER_OSS_BUCKET || 'android-o1-images', + endpoint: process.env.OSS_FILE_CABINET_OSS_ENDPOINT || process.env.DOC_CLOUD_KEEPER_OSS_ENDPOINT || 'oss-cn-shanghai.aliyuncs.com', + root_prefix: process.env.OSS_FILE_CABINET_OSS_ROOT_PREFIX || '550e8400-e29b-41d4-a716-446655442082/file_cabinet', + access_key_id: process.env.OSS_FILE_CABINET_OSS_ACCESS_KEY_ID || process.env.DOC_CLOUD_KEEPER_OSS_ACCESS_KEY_ID || '', + access_key_secret: process.env.OSS_FILE_CABINET_OSS_ACCESS_KEY_SECRET || process.env.DOC_CLOUD_KEEPER_OSS_ACCESS_KEY_SECRET || '' +} + +const AUTH_COOKIE = 'oss_file_cabinet_auth' +const AUTH_MAX_AGE_MS = 24 * 3600 * 1000 +const AUTH_HMAC_SECRET = process.env.OSS_FILE_CABINET_AUTH_SECRET || 'ofc-auth-secret-change-me' + +if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true }) + +let db = null + +const getDb = () => { + if (db) return db + db = new Database(DB_PATH) + db.pragma('journal_mode = WAL') + db.pragma('synchronous = NORMAL') + db.pragma('busy_timeout = 4000') + db.exec(` + CREATE TABLE IF NOT EXISTS trash_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + oss_key TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL, + is_folder INTEGER NOT NULL DEFAULT 0, + original_path TEXT NOT NULL, + size INTEGER DEFAULT 0, + deleted_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_trash_items_deleted_at ON trash_items(deleted_at); + CREATE TABLE IF NOT EXISTS share_links ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + token TEXT NOT NULL UNIQUE, + oss_key TEXT NOT NULL, + display_name TEXT NOT NULL, + is_folder INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_share_links_token ON share_links(token); + CREATE INDEX IF NOT EXISTS idx_share_links_expires_at ON share_links(expires_at); + `) + return db +} + +const safeName = name => { + let s = String(name || 'untitled') + try { + const recovered = Buffer.from(s, 'latin1').toString('utf8') + if (recovered !== s && !/\ufffd/.test(recovered)) s = recovered + } catch {} + return s.replace(/[<>\":/\\|?*\u0000-\u001F]/g, '_').trim() || 'untitled' +} +const normalizeOssKey = key => { + let k = String(key || '').replace(/[\\]+/g, '/').replace(/\/+/g, '/') + const endsSlash = k.endsWith('/') + if (k.startsWith('/')) k = k.slice(1) + if (endsSlash && !k.endsWith('/') && k) k += '/' + return k +} +const fullKey = relKey => { + const r = normalizeOssKey(relKey) + const p = OSS_CONFIG.root_prefix ? `${OSS_CONFIG.root_prefix.replace(/\/+$/, '')}/` : '' + return p + r +} +const displayNameFromKey = key => { + const k = normalizeOssKey(key) + if (!k) return '' + const parts = k.split('/').filter(Boolean) + const last = parts[parts.length - 1] || '' + if (k.endsWith('/') && last) return last + return last +} +const encodeOssKey = key => String(key || '').split('/').map(s => encodeURIComponent(s)).join('/') +const ossObjectResource = key => `/${OSS_CONFIG.bucket}/${String(key || '').replace(/^\/+/, '')}` + +const readLocalCreds = () => { + try { + const os = require('os') + const userProfile = process.env.USERPROFILE || process.env.HOME || os.homedir() + const candidates = [path.join(userProfile, 'Toolbox_local_creds.env.local')] + const usersRoot = path.join(path.parse(userProfile).root, 'Users') + try { + if (fs.existsSync(usersRoot)) { + fs.readdirSync(usersRoot).forEach(name => { + const dir = path.join(usersRoot, name) + if (dir !== userProfile && fs.existsSync(dir)) { + candidates.push(path.join(dir, 'Toolbox_local_creds.env.local')) + } + }) + } + } catch {} + let content = null + for (const p of candidates) { + if (fs.existsSync(p)) { content = fs.readFileSync(p, 'utf-8'); break } + } + if (!content) return {} + const creds = {} + content.split(/\r?\n/).forEach(line => { + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith('#')) return + const idx = trimmed.indexOf('=') + if (idx > 0) creds[trimmed.slice(0, idx).trim()] = trimmed.slice(idx + 1).trim() + }) + return creds + } catch { return {} } +} + +const getExpectedUsername = () => { + const fromEnv = String(process.env.OSS_FILE_CABINET_USERNAME || '').trim() + if (fromEnv) return fromEnv + const creds = readLocalCreds() + return String(creds.OSS_FILE_CABINET_USERNAME || '').trim() +} +const getExpectedPassword = () => { + const fromEnv = String(process.env.OSS_FILE_CABINET_PASSWORD || '').trim() + if (fromEnv) return fromEnv + const creds = readLocalCreds() + return String(creds.OSS_FILE_CABINET_PASSWORD || '').trim() +} +const getExpectedToken = () => { + const fromEnv = String(process.env.TOOLBOX_LOCAL_CREDS_TOKEN || '').trim() + if (fromEnv) return fromEnv + const creds = readLocalCreds() + return String(creds.TOOLBOX_LOCAL_CREDS_TOKEN || '').trim() +} + +const signAuthPayload = (username) => { + const payload = { sub: username, iat: Date.now() } + const payloadStr = Buffer.from(JSON.stringify(payload)).toString('base64url') + const sig = crypto.createHmac('sha256', AUTH_HMAC_SECRET).update(payloadStr).digest('base64url') + return `${payloadStr}.${sig}` +} +const verifyAuthPayload = (token) => { + if (!token || typeof token !== 'string') return null + const [payloadStr, sig] = token.split('.') + if (!payloadStr || !sig) return null + const expected = crypto.createHmac('sha256', AUTH_HMAC_SECRET).update(payloadStr).digest('base64url') + if (expected.length !== sig.length) return null + if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig))) return null + try { + const payload = JSON.parse(Buffer.from(payloadStr, 'base64url').toString()) + if (!payload || typeof payload !== 'object') return null + return payload + } catch { return null } +} +const parseCookies = (cookieHeader) => { + const cookies = {} + if (!cookieHeader) return cookies + cookieHeader.split(';').forEach(part => { + const idx = part.indexOf('=') + if (idx < 0) return + const key = part.slice(0, idx).trim() + const val = decodeURIComponent(part.slice(idx + 1).trim()) + if (key) cookies[key] = val + }) + return cookies +} +const hasAuth = (req) => { + const cookies = parseCookies(req.headers.cookie || '') + const token = cookies[AUTH_COOKIE] + if (!token) return false + return !!verifyAuthPayload(token) +} +const mustAuth = (req, res, next) => { + if (hasAuth(req)) return next() + return res.status(401).json({ ok: false, error: '未登录或会话已过期' }) +} + +const signOssRequest = ({ method, contentType = '', date, key, headers = {} }) => { + const canonicalHeaders = Object.keys(headers) + .filter(k => k && k.toLowerCase().startsWith('x-oss-')) + .sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())) + .map(k => `${k.toLowerCase()}:${String(headers[k] || '').trim()}`) + .join('\n') + const resource = ossObjectResource(key) + const stringToSign = `${method.toUpperCase()}\n\n${contentType}\n${date}\n${canonicalHeaders ? canonicalHeaders + '\n' : ''}${resource}` + return crypto.createHmac('sha1', OSS_CONFIG.access_key_secret).update(stringToSign).digest('base64') +} + +const httpRequest = (options, body = null, timeoutMs = 30000) => new Promise((resolve, reject) => { + const lib = options.protocol === 'https:' ? require('https') : require('http') + const req = lib.request(options, (res) => { + const chunks = [] + res.on('data', chunk => chunks.push(chunk)) + res.on('end', () => { + const buf = Buffer.concat(chunks) + resolve({ statusCode: res.statusCode, headers: res.headers, body: buf }) + }) + }) + req.on('error', reject) + req.setTimeout(timeoutMs, () => { + req.destroy(new Error('request timeout')) + }) + if (body !== null && body !== undefined) { + if (Buffer.isBuffer(body) || typeof body === 'string') req.write(body) + } + req.end() +}) + +const ossListObjects = async (prefix = '', delimiter = '/') => { + const fullPrefix = fullKey(prefix) + const date = new Date().toUTCString() + const auth = `OSS ${OSS_CONFIG.access_key_id}:${signOssRequest({ method: 'GET', date, key: '' })}` + const query = new URLSearchParams() + query.set('prefix', fullPrefix) + if (delimiter) query.set('delimiter', delimiter) + query.set('max-keys', '1000') + const options = { + hostname: `${OSS_CONFIG.bucket}.${OSS_CONFIG.endpoint}`, + path: `/?${query.toString()}`, + method: 'GET', + headers: { + Host: `${OSS_CONFIG.bucket}.${OSS_CONFIG.endpoint}`, + Date: date, + Authorization: auth + } + } + const resp = await httpRequest(options) + if (resp.statusCode < 200 || resp.statusCode >= 300) { + throw new Error(`OSS list failed: ${resp.statusCode} ${resp.body.toString('utf8').slice(0, 200)}`) + } + return parseOssListXml(resp.body.toString('utf8')) +} + +const parseOssListXml = (xml) => { + const extractTag = (tag, src) => { + const open = `<${tag}>` + const close = `` + const result = [] + let idx = 0 + while (true) { + const s = src.indexOf(open, idx) + if (s < 0) break + const e = src.indexOf(close, s + open.length) + if (e < 0) break + result.push(src.slice(s + open.length, e)) + idx = e + close.length + } + return result + } + const extractTagInSection = (tag, sectionOpen, sectionClose, src) => { + const result = [] + let searchFrom = 0 + while (true) { + const sOpen = src.indexOf(sectionOpen, searchFrom) + if (sOpen < 0) break + const sClose = src.indexOf(sectionClose, sOpen) + if (sClose < 0) break + const section = src.slice(sOpen, sClose) + result.push(...extractTag(tag, section)) + searchFrom = sClose + sectionClose.length + } + return result + } + const folders = extractTagInSection('Prefix', '', '', xml).map(p => ({ key: p, name: p.split('/').filter(Boolean).pop() + '/', isFolder: true, size: 0 })) + const fileKeys = extractTag('Key', xml) + const fileSizes = extractTag('Size', xml) + const fileLms = extractTag('LastModified', xml) + const files = fileKeys.map((k, i) => { + const size = parseInt(fileSizes[i] || '0', 10) || 0 + const lastModified = fileLms[i] ? Date.parse(fileLms[i]) : 0 + return { key: k, name: k.split('/').filter(Boolean).pop(), isFolder: false, size, lastModified } + }).filter(f => f.key && !f.key.endsWith('/')) + return { folders, files } +} + +const ossPutObject = async (key, body, contentType = 'application/octet-stream') => { + const full = fullKey(key) + const date = new Date().toUTCString() + const auth = `OSS ${OSS_CONFIG.access_key_id}:${signOssRequest({ method: 'PUT', contentType, date, key: full })}` + const options = { + hostname: `${OSS_CONFIG.bucket}.${OSS_CONFIG.endpoint}`, + path: `/${encodeOssKey(full)}`, + method: 'PUT', + headers: { + Host: `${OSS_CONFIG.bucket}.${OSS_CONFIG.endpoint}`, + Date: date, + 'Content-Type': contentType, + 'Content-Length': Buffer.isBuffer(body) ? body.length : Buffer.byteLength(body || ''), + Authorization: auth + } + } + const resp = await httpRequest(options, body) + if (resp.statusCode < 200 || resp.statusCode >= 300) { + throw new Error(`OSS put failed: ${resp.statusCode} ${resp.body.toString('utf8').slice(0, 200)}`) + } + return { key: full } +} + +const ossDeleteObject = async (key) => { + const full = fullKey(key) + const date = new Date().toUTCString() + const auth = `OSS ${OSS_CONFIG.access_key_id}:${signOssRequest({ method: 'DELETE', date, key: full })}` + const options = { + hostname: `${OSS_CONFIG.bucket}.${OSS_CONFIG.endpoint}`, + path: `/${encodeOssKey(full)}`, + method: 'DELETE', + headers: { + Host: `${OSS_CONFIG.bucket}.${OSS_CONFIG.endpoint}`, + Date: date, + Authorization: auth + } + } + const resp = await httpRequest(options) + if (resp.statusCode < 200 || resp.statusCode >= 300) { + throw new Error(`OSS delete failed: ${resp.statusCode} ${resp.body.toString('utf8').slice(0, 200)}`) + } + return true +} + +const ossCopyObject = async (srcKey, destKey) => { + const srcFull = fullKey(srcKey) + const destFull = fullKey(destKey) + const date = new Date().toUTCString() + const xOssCopySource = `/${OSS_CONFIG.bucket}/${encodeOssKey(srcFull)}` + const auth = `OSS ${OSS_CONFIG.access_key_id}:${signOssRequest({ method: 'PUT', date, key: destFull, headers: { 'x-oss-copy-source': xOssCopySource } })}` + const options = { + hostname: `${OSS_CONFIG.bucket}.${OSS_CONFIG.endpoint}`, + path: `/${encodeOssKey(destFull)}`, + method: 'PUT', + headers: { + Host: `${OSS_CONFIG.bucket}.${OSS_CONFIG.endpoint}`, + Date: date, + 'x-oss-copy-source': xOssCopySource, + Authorization: auth + } + } + const resp = await httpRequest(options) + if (resp.statusCode < 200 || resp.statusCode >= 300) { + throw new Error(`OSS copy failed: ${resp.statusCode} ${resp.body.toString('utf8').slice(0, 200)}`) + } + return true +} + +const ossGetObjectBuffer = async (key) => { + const full = fullKey(key) + const date = new Date().toUTCString() + const auth = `OSS ${OSS_CONFIG.access_key_id}:${signOssRequest({ method: 'GET', date, key: full })}` + const options = { + hostname: `${OSS_CONFIG.bucket}.${OSS_CONFIG.endpoint}`, + path: `/${encodeOssKey(full)}`, + method: 'GET', + headers: { + Host: `${OSS_CONFIG.bucket}.${OSS_CONFIG.endpoint}`, + Date: date, + Authorization: auth + } + } + return await httpRequest(options) +} + +const ossObjectStream = (key) => new Promise((resolve, reject) => { + const https = require('https') + const full = fullKey(key) + const date = new Date().toUTCString() + const auth = `OSS ${OSS_CONFIG.access_key_id}:${signOssRequest({ method: 'GET', date, key: full })}` + const options = { + hostname: `${OSS_CONFIG.bucket}.${OSS_CONFIG.endpoint}`, + path: `/${encodeOssKey(full)}`, + method: 'GET', + headers: { + Host: `${OSS_CONFIG.bucket}.${OSS_CONFIG.endpoint}`, + Date: date, + Authorization: auth + } + } + const req = https.request(options, (res) => { + if (res.statusCode < 200 || res.statusCode >= 300) { + const chunks = [] + res.on('data', c => chunks.push(c)) + res.on('end', () => reject(new Error(`OSS get failed: ${res.statusCode} ${Buffer.concat(chunks).toString('utf8').slice(0, 200)}`))) + return + } + resolve({ stream: res, headers: res.headers }) + }) + req.on('error', reject) + req.setTimeout(30000, () => req.destroy(new Error('request timeout'))) + req.end() +}) + +const listRecursive = async (prefix = '') => { + const all = [] + const stack = [prefix] + while (stack.length > 0) { + const current = stack.pop() + const result = await ossListObjects(current, '/') + for (const f of result.folders) stack.push(normalizeOssKey(f.key)) + for (const f of result.files) all.push(f) + } + return all +} + +const ossDeleteFolderRecursive = async (prefix = '') => { + const files = await listRecursive(prefix) + for (const f of files) { + const relKey = normalizeOssKey(f.key).slice(OSS_CONFIG.root_prefix ? OSS_CONFIG.root_prefix.length + 1 : 0) + await ossDeleteObject(relKey) + } + const emptyPrefixKey = normalizeOssKey(prefix).replace(/\/+$/, '') + '/' + try { await ossDeleteObject(emptyPrefixKey) } catch {} +} + +const humanSize = bytes => { + const b = Number(bytes) || 0 + if (b < 1024) return `${b} B` + if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} KB` + if (b < 1024 * 1024 * 1024) return `${(b / (1024 * 1024)).toFixed(1)} MB` + return `${(b / (1024 * 1024 * 1024)).toFixed(2)} GB` +} +const getExt = name => { + const n = String(name || '') + const idx = n.lastIndexOf('.') + if (idx < 0 || idx === n.length - 1) return '' + return n.slice(idx + 1).toLowerCase() +} +const isImage = name => { + const ext = getExt(name) + return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(ext) +} +const isVideo = name => { + const ext = getExt(name) + return ['mp4', 'mov', 'avi', 'webm', 'mkv', 'flv', 'm4v'].includes(ext) +} +const isPdf = name => getExt(name) === 'pdf' +const isText = name => { + const ext = getExt(name) + return ['txt', 'md', 'json', 'js', 'ts', 'html', 'css', 'xml', 'log', 'csv', 'yml', 'yaml', 'sh', 'py', 'java', 'c', 'cpp', 'h', 'go', 'rs'].includes(ext) +} +const contentTypeByExt = name => { + const ext = getExt(name) + const map = { + jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', + webp: 'image/webp', svg: 'image/svg+xml', pdf: 'application/pdf', + txt: 'text/plain; charset=utf-8', md: 'text/plain; charset=utf-8', html: 'text/html; charset=utf-8', + js: 'application/javascript', ts: 'application/javascript', css: 'text/css', json: 'application/json', + xml: 'text/xml', log: 'text/plain; charset=utf-8', csv: 'text/csv', + mp4: 'video/mp4', mov: 'video/quicktime', avi: 'video/x-msvideo', webm: 'video/webm', mkv: 'video/x-matroska', flv: 'video/x-flv', m4v: 'video/mp4', + mp3: 'audio/mpeg', zip: 'application/zip' + } + return map[ext] || 'application/octet-stream' +} + +const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: MAX_UPLOAD_BYTES, files: 20 } }) + +const createRouter = () => { + const router = express.Router() + + router.use((req, res, next) => { + try { + res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate') + res.set('Pragma', 'no-cache') + res.set('Expires', '0') + } catch {} + next() + }) + + router.post('/auth/login', (req, res) => { + try { + const username = String((req.body && req.body.username) || '').trim() + const password = String((req.body && req.body.password) || '') + const expectedUser = getExpectedUsername() + const expectedPass = getExpectedPassword() + if (!expectedUser || !expectedPass) return res.status(500).json({ ok: false, error: '服务器未配置登录凭据' }) + if (username !== expectedUser || password !== expectedPass) return res.status(401).json({ ok: false, error: '用户名或密码错误' }) + const token = signAuthPayload(username) + res.cookie(AUTH_COOKIE, token, { httpOnly: true, sameSite: 'lax', maxAge: AUTH_MAX_AGE_MS, path: '/' }) + res.json({ ok: true }) + } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) } + }) + + router.post('/auth/logout', (req, res) => { + res.clearCookie(AUTH_COOKIE, { path: '/' }) + res.json({ ok: true }) + }) + + router.get('/auth/me', (req, res) => { + res.json({ ok: true, authorized: hasAuth(req) }) + }) + + router.get('/auth/local_creds', (req, res) => { + try { + const token = String((req.query && req.query.token) || '').trim() + const expectedToken = getExpectedToken() + if (!expectedToken || token !== expectedToken) return res.status(401).json({ ok: false, error: '令牌无效' }) + const username = getExpectedUsername() + const password = getExpectedPassword() + if (!username || !password) return res.status(500).json({ ok: false, error: '未配置本地凭据' }) + res.json({ ok: true, creds: { username, password } }) + } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) } + }) + + router.get('/share/access/:token', async (req, res) => { + try { + const token = String(req.params.token || '').trim() + const now = Date.now() + const d = getDb() + const row = d.prepare('SELECT * FROM share_links WHERE token = ?').get(token) + if (!row) return res.status(404).json({ ok: false, error: '链接不存在' }) + if (row.expires_at < now) return res.status(410).json({ ok: false, error: '链接已过期' }) + + const key = normalizeOssKey(row.oss_key) + const isFolder = row.is_folder === 1 + const displayName = row.display_name + + if (!isFolder) { + const { stream, headers } = await ossObjectStream(key) + res.set('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(displayName)}`) + if (headers['content-type']) res.set('Content-Type', headers['content-type']) + if (headers['content-length']) res.set('Content-Length', headers['content-length']) + stream.pipe(res) + } else { + const files = await listRecursive(key) + res.set('Content-Type', 'application/zip') + res.set('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(displayName + '.zip')}`) + const archive = archiver('zip', { zlib: { level: 6 } }) + archive.on('error', err => { try { res.status(500).end() } catch {} }) + archive.pipe(res) + const prefixClean = normalizeOssKey(key).replace(/\/+$/, '') + '/' + for (const f of files) { + try { + const fullRel = OSS_CONFIG.root_prefix ? normalizeOssKey(f.key).slice(OSS_CONFIG.root_prefix.length + 1) : normalizeOssKey(f.key) + const relPart = fullRel.slice(prefixClean.length) + const { stream } = await ossObjectStream(fullRel) + archive.append(stream, { name: relPart }) + } catch {} + } + archive.finalize() + } + } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) } + }) + + router.use(mustAuth) + + router.get('/files/list', async (req, res) => { + try { + const prefix = normalizeOssKey(String(req.query.prefix || '')) + const search = String(req.query.search || '').trim().toLowerCase() + const result = await ossListObjects(prefix || '', '/') + + const trashDb = getDb() + const trashKeys = new Set(trashDb.prepare('SELECT oss_key FROM trash_items').all().map(r => r.oss_key)) + + const isInTrash = (ossKey) => { + const full = fullKey(normalizeOssKey(ossKey)) + if (trashKeys.has(full)) return true + for (const tk of trashKeys) { + if (tk.endsWith('/') && full.startsWith(tk)) return true + } + return false + } + + const folders = result.folders + .map(f => { + const relKey = OSS_CONFIG.root_prefix ? normalizeOssKey(f.key).slice(OSS_CONFIG.root_prefix.length + 1) : normalizeOssKey(f.key) + const display = displayNameFromKey(relKey) + return { key: relKey, name: display, isFolder: true, size: 0, lastModified: 0, isImage: false, ext: '', inTrash: isInTrash(relKey) } + }) + .filter(f => !f.inTrash && f.key && f.name) + const files = result.files + .map(f => { + const relKey = OSS_CONFIG.root_prefix ? normalizeOssKey(f.key).slice(OSS_CONFIG.root_prefix.length + 1) : normalizeOssKey(f.key) + const display = displayNameFromKey(relKey) + return { key: relKey, name: display, isFolder: false, size: f.size, lastModified: f.lastModified, isImage: isImage(display), ext: getExt(display), inTrash: isInTrash(relKey) } + }) + .filter(f => !f.inTrash && f.key && f.name) + + const seen = new Set() + let allItems = [...folders, ...files].filter(item => { + if (seen.has(item.key)) return false + seen.add(item.key) + return true + }) + if (search) { + allItems = allItems.filter(item => item.name && item.name.toLowerCase().includes(search)) + } + + res.json({ ok: true, items: allItems, prefix, total: allItems.length }) + } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) } + }) + + router.post('/files/mkdir', async (req, res) => { + try { + const parent = normalizeOssKey(String((req.body && req.body.parent) || '')) + const folderName = safeName(String((req.body && req.body.name) || '新文件夹')) + const folderKey = (parent ? parent + '/' : '') + folderName + '/' + await ossPutObject(folderKey, Buffer.alloc(0), 'application/x-directory') + res.json({ ok: true, key: normalizeOssKey(folderKey) }) + } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) } + }) + + router.post('/files/rename', async (req, res) => { + try { + const oldKey = normalizeOssKey(String((req.body && req.body.key) || '')) + const newName = safeName(String((req.body && req.body.name) || '')) + if (!oldKey || !newName) return res.status(400).json({ ok: false, error: '参数错误' }) + + const isFolder = oldKey.endsWith('/') + const parts = oldKey.split('/').filter(Boolean) + const parent = parts.slice(0, -1).join('/') + const newKey = parent ? parent + '/' + newName : newName + const finalNewKey = isFolder ? newKey + '/' : newKey + + if (!isFolder) { + await ossCopyObject(oldKey, finalNewKey) + await ossDeleteObject(oldKey) + } else { + const files = await listRecursive(oldKey) + const oldPrefix = normalizeOssKey(oldKey) + const newPrefix = normalizeOssKey(finalNewKey) + for (const f of files) { + const fullRel = OSS_CONFIG.root_prefix ? normalizeOssKey(f.key).slice(OSS_CONFIG.root_prefix.length + 1) : normalizeOssKey(f.key) + const relPart = fullRel.slice(oldPrefix.length + (oldPrefix.endsWith('/') ? 0 : 1)) + const destKey = newPrefix + (newPrefix.endsWith('/') ? '' : '/') + relPart + await ossCopyObject(fullRel, destKey) + await ossDeleteObject(fullRel) + } + await ossPutObject(finalNewKey, Buffer.alloc(0), 'application/x-directory') + await ossDeleteObject(oldKey) + } + res.json({ ok: true, newKey: finalNewKey }) + } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) } + }) + + router.post('/files/copy', async (req, res) => { + try { + const sourceKey = normalizeOssKey(String((req.body && req.body.source) || '')) + const destDir = normalizeOssKey(String((req.body && req.body.dest) || '')) + if (!sourceKey) return res.status(400).json({ ok: false, error: '源文件缺失' }) + + const isFolder = sourceKey.endsWith('/') + const displayName = displayNameFromKey(sourceKey) + + const buildUniqueKey = (target) => { + let candidate = target + let counter = 1 + const maxTries = 100 + while (counter < maxTries) { + try { + const r = ossListObjects('', '/') + const prefixToCheck = candidate.split('/').slice(0, -1).join('/') + '/' + const existing = r.folders.concat(r.files.map(x => ({ key: x.key }))) + const exists = existing.some(e => normalizeOssKey(e.key) === normalizeOssKey(candidate) || normalizeOssKey(e.key) === normalizeOssKey(candidate + '/')) + if (!exists) return candidate + const ext = getExt(candidate) + const base = candidate.slice(0, candidate.length - (ext ? ext.length + 1 : 0)).replace(/\/+$/, '') + candidate = `${base} 副本${counter}${ext ? '.' + ext : ''}${candidate.endsWith('/') ? '/' : ''}` + counter++ + } catch { return candidate } + } + return candidate + } + + if (!isFolder) { + let destKey = destDir ? destDir + '/' + displayName : displayName + const fullCheck = await ossListObjects(destDir || '', '/') + const exists = fullCheck.files.some(f => { + const rel = OSS_CONFIG.root_prefix ? normalizeOssKey(f.key).slice(OSS_CONFIG.root_prefix.length + 1) : normalizeOssKey(f.key) + return rel === normalizeOssKey(destKey) + }) + if (exists) { + const ext = getExt(displayName) + const base = displayName.slice(0, displayName.length - (ext ? ext.length + 1 : 0)) + destKey = destDir ? destDir + '/' + `${base} 副本${ext ? '.' + ext : ''}` : `${base} 副本${ext ? '.' + ext : ''}` + } + await ossCopyObject(sourceKey, destKey) + res.json({ ok: true, newKey: destKey }) + } else { + const files = await listRecursive(sourceKey) + const newFolderBase = displayName + ' 副本' + let counter = 1 + let newFolder = destDir ? destDir + '/' + newFolderBase + '/' : newFolderBase + '/' + while (true) { + try { + const r = await ossListObjects(destDir || '', '/') + const exists = r.folders.some(f => { + const rel = OSS_CONFIG.root_prefix ? normalizeOssKey(f.key).slice(OSS_CONFIG.root_prefix.length + 1) : normalizeOssKey(f.key) + return rel === normalizeOssKey(newFolder) + }) + if (!exists) break + newFolder = destDir ? destDir + '/' + newFolderBase + counter + '/' : newFolderBase + counter + '/' + counter++ + if (counter > 100) break + } catch { break } + } + await ossPutObject(newFolder, Buffer.alloc(0), 'application/x-directory') + const oldPrefix = normalizeOssKey(sourceKey) + const newPrefix = normalizeOssKey(newFolder) + for (const f of files) { + const fullRel = OSS_CONFIG.root_prefix ? normalizeOssKey(f.key).slice(OSS_CONFIG.root_prefix.length + 1) : normalizeOssKey(f.key) + const relPart = fullRel.slice(oldPrefix.length + (oldPrefix.endsWith('/') ? 0 : 1)) + const destKey = newPrefix + '/' + relPart + await ossCopyObject(fullRel, destKey) + } + res.json({ ok: true, newKey: newFolder }) + } + } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) } + }) + + router.post('/files/move', async (req, res) => { + try { + const sourceKey = normalizeOssKey(String((req.body && req.body.source) || '')) + const destDir = normalizeOssKey(String((req.body && req.body.dest) || '')) + if (!sourceKey) return res.status(400).json({ ok: false, error: '源文件缺失' }) + const isFolder = sourceKey.endsWith('/') + const displayName = displayNameFromKey(sourceKey) + + if (!isFolder) { + const destKey = destDir ? destDir + '/' + displayName : displayName + await ossCopyObject(sourceKey, destKey) + await ossDeleteObject(sourceKey) + res.json({ ok: true, newKey: destKey }) + } else { + const files = await listRecursive(sourceKey) + const newFolder = destDir ? destDir + '/' + displayName + '/' : displayName + '/' + await ossPutObject(newFolder, Buffer.alloc(0), 'application/x-directory') + const oldPrefix = normalizeOssKey(sourceKey) + const newPrefix = normalizeOssKey(newFolder) + for (const f of files) { + const fullRel = OSS_CONFIG.root_prefix ? normalizeOssKey(f.key).slice(OSS_CONFIG.root_prefix.length + 1) : normalizeOssKey(f.key) + const relPart = fullRel.slice(oldPrefix.length + (oldPrefix.endsWith('/') ? 0 : 1)) + const destKey = newPrefix + '/' + relPart + await ossCopyObject(fullRel, destKey) + await ossDeleteObject(fullRel) + } + try { await ossDeleteObject(oldPrefix + '/') } catch {} + res.json({ ok: true, newKey: newFolder }) + } + } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) } + }) + + router.post('/files/delete', async (req, res) => { + try { + const key = normalizeOssKey(String((req.body && req.body.key) || '')) + if (!key) return res.status(400).json({ ok: false, error: '参数错误' }) + const isFolder = key.endsWith('/') + const full = fullKey(key) + const displayName = displayNameFromKey(key) + + let totalSize = 0 + if (isFolder) { + try { + const files = await listRecursive(key) + totalSize = files.reduce((s, f) => s + (Number(f.size) || 0), 0) + } catch {} + } else { + try { + const r = await ossListObjects(key.split('/').slice(0, -1).join('/') || '', '/') + const hit = r.files.find(f => { + const rel = OSS_CONFIG.root_prefix ? normalizeOssKey(f.key).slice(OSS_CONFIG.root_prefix.length + 1) : normalizeOssKey(f.key) + return rel === key + }) + totalSize = hit ? hit.size : 0 + } catch {} + } + + const trashDb = getDb() + const existing = trashDb.prepare('SELECT id FROM trash_items WHERE oss_key = ?').get(full) + if (!existing) { + trashDb.prepare(`INSERT INTO trash_items(oss_key, display_name, is_folder, original_path, size, deleted_at) VALUES(?,?,?,?,?,?)`).run( + full, displayName, isFolder ? 1 : 0, key, totalSize, Date.now() + ) + } + res.json({ ok: true, moved: true }) + } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) } + }) + + router.get('/files/info', async (req, res) => { + try { + const key = normalizeOssKey(String(req.query.key || '')) + if (!key) return res.status(400).json({ ok: false, error: '参数错误' }) + const isFolder = key.endsWith('/') + const displayName = displayNameFromKey(key) + const ext = getExt(displayName) + let size = 0 + let lastModified = 0 + if (!isFolder) { + try { + const parent = key.includes('/') ? key.split('/').slice(0, -1).join('/') : '' + const r = await ossListObjects(parent || '', '/') + const hit = r.files.find(f => { + const rel = OSS_CONFIG.root_prefix ? normalizeOssKey(f.key).slice(OSS_CONFIG.root_prefix.length + 1) : normalizeOssKey(f.key) + return rel === key + }) + if (hit) { size = hit.size; lastModified = hit.lastModified } + } catch {} + } else { + try { + const files = await listRecursive(key) + size = files.reduce((s, f) => s + (Number(f.size) || 0), 0) + } catch {} + } + res.json({ ok: true, info: { key, name: displayName, isFolder, ext, size, lastModified, fullPath: fullKey(key), isImage: isImage(displayName) } }) + } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) } + }) + + router.get('/files/raw_url', async (req, res) => { + try { + const key = normalizeOssKey(String(req.query.key || '')) + if (!key || key.endsWith('/')) return res.status(400).json({ ok: false, error: '仅支持文件' }) + const full = fullKey(key) + const url = `https://${OSS_CONFIG.bucket}.${OSS_CONFIG.endpoint}/${encodeOssKey(full)}` + res.json({ ok: true, url }) + } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) } + }) + + router.post('/files/upload', upload.array('files', 20), async (req, res) => { + try { + const parent = normalizeOssKey(String((req.body && req.body.prefix) || '')) + if (!req.files || !Array.isArray(req.files) || req.files.length === 0) return res.status(400).json({ ok: false, error: '未收到文件' }) + const results = [] + for (const f of req.files) { + const name = safeName(f.originalname || 'file') + const key = parent ? parent + '/' + name : name + await ossPutObject(key, f.buffer, f.mimetype || contentTypeByExt(name)) + results.push({ key, name, size: f.size }) + } + res.json({ ok: true, results }) + } catch (e) { + if (String(e.message || '').includes('File too large')) return res.status(413).json({ ok: false, error: '文件超过 50MB 上限' }) + res.status(500).json({ ok: false, error: String(e.message || e) }) + } + }) + + router.get('/files/download', async (req, res) => { + try { + const key = normalizeOssKey(String(req.query.key || '')) + if (!key) return res.status(400).json({ ok: false, error: '参数错误' }) + const displayName = displayNameFromKey(key) || 'download' + const { stream, headers } = await ossObjectStream(key) + res.set('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(displayName)}`) + if (headers['content-type']) res.set('Content-Type', headers['content-type']) + if (headers['content-length']) res.set('Content-Length', headers['content-length']) + stream.pipe(res) + } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) } + }) + + router.get('/files/preview', async (req, res) => { + try { + const key = normalizeOssKey(String(req.query.key || '')) + if (!key || key.endsWith('/')) return res.status(400).json({ ok: false, error: '参数错误' }) + const displayName = displayNameFromKey(key) + const previewable = isImage(displayName) || isVideo(displayName) || isPdf(displayName) || isText(displayName) + if (!previewable) return res.status(400).json({ ok: false, error: '不支持的预览格式' }) + const { stream, headers } = await ossObjectStream(key) + res.set('Content-Disposition', `inline; filename*=UTF-8''${encodeURIComponent(displayName)}`) + if (headers['content-type']) res.set('Content-Type', contentTypeByExt(displayName)) + if (headers['content-length']) res.set('Content-Length', headers['content-length']) + stream.pipe(res) + } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) } + }) + + router.get('/folder/download', async (req, res) => { + try { + const prefix = normalizeOssKey(String(req.query.prefix || '')) + if (!prefix) return res.status(400).json({ ok: false, error: '参数错误' }) + const displayName = displayNameFromKey(prefix) || 'folder' + const files = await listRecursive(prefix) + + res.set('Content-Type', 'application/zip') + res.set('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(displayName + '.zip')}`) + + const archive = archiver('zip', { zlib: { level: 6 } }) + archive.on('error', err => { try { res.status(500).end() } catch {} }) + archive.pipe(res) + + const prefixClean = normalizeOssKey(prefix).replace(/\/+$/, '') + '/' + for (const f of files) { + try { + const fullRel = OSS_CONFIG.root_prefix ? normalizeOssKey(f.key).slice(OSS_CONFIG.root_prefix.length + 1) : normalizeOssKey(f.key) + const relPart = fullRel.slice(prefixClean.length) + const { stream } = await ossObjectStream(fullRel) + archive.append(stream, { name: relPart }) + } catch {} + } + + archive.finalize() + } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) } + }) + + router.post('/files/batch_download', async (req, res) => { + try { + const keys = Array.isArray(req.body && req.body.keys) ? req.body.keys : [] + const mode = String(req.body && req.body.mode === 'compress' ? 'compress' : 'download') + if (keys.length === 0) return res.status(400).json({ ok: false, error: '请选择至少一个项目' }) + + const archiveName = '归档.zip' + + if (mode === 'compress') { + const parent = normalizeOssKey(String((req.body && req.body.prefix) || '')) + let destKey = parent ? parent + '/' + archiveName : archiveName + let counter = 1 + while (counter < 100) { + try { + const r = await ossListObjects(parent || '', '/') + const existing = r.files.some(f => { + const rel = OSS_CONFIG.root_prefix ? normalizeOssKey(f.key).slice(OSS_CONFIG.root_prefix.length + 1) : normalizeOssKey(f.key) + return rel === normalizeOssKey(destKey) + }) + if (!existing) break + const base = '归档' + destKey = parent ? parent + '/' + base + ' ' + counter + '.zip' : base + ' ' + counter + '.zip' + counter++ + } catch { break } + } + + const archive = archiver('zip', { zlib: { level: 6 } }) + const chunks = [] + archive.on('data', chunk => chunks.push(chunk)) + const archivePromise = new Promise((resolve, reject) => { + archive.on('end', resolve) + archive.on('error', reject) + }) + + for (const k of keys) { + const nk = normalizeOssKey(k) + if (nk.endsWith('/')) { + const files = await listRecursive(nk) + const prefixClean = nk.replace(/\/+$/, '') + '/' + for (const f of files) { + try { + const fullRel = OSS_CONFIG.root_prefix ? normalizeOssKey(f.key).slice(OSS_CONFIG.root_prefix.length + 1) : normalizeOssKey(f.key) + const relPart = fullRel.slice(prefixClean.length) + const { stream } = await ossObjectStream(fullRel) + archive.append(stream, { name: relPart }) + } catch {} + } + } else { + const name = displayNameFromKey(nk) + try { + const { stream } = await ossObjectStream(nk) + archive.append(stream, { name }) + } catch {} + } + } + + archive.finalize() + await archivePromise + const zipBuf = Buffer.concat(chunks) + await ossPutObject(destKey, zipBuf, 'application/zip') + res.json({ ok: true, key: destKey, name: archiveName }) + } else { + res.set('Content-Type', 'application/zip') + res.set('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(archiveName)}`) + + const archive = archiver('zip', { zlib: { level: 6 } }) + archive.on('error', err => { try { res.status(500).end() } catch {} }) + archive.pipe(res) + + for (const k of keys) { + const nk = normalizeOssKey(k) + if (nk.endsWith('/')) { + const files = await listRecursive(nk) + const prefixClean = nk.replace(/\/+$/, '') + '/' + for (const f of files) { + try { + const fullRel = OSS_CONFIG.root_prefix ? normalizeOssKey(f.key).slice(OSS_CONFIG.root_prefix.length + 1) : normalizeOssKey(f.key) + const relPart = fullRel.slice(prefixClean.length) + const { stream } = await ossObjectStream(fullRel) + archive.append(stream, { name: relPart }) + } catch {} + } + } else { + const name = displayNameFromKey(nk) + try { + const { stream } = await ossObjectStream(nk) + archive.append(stream, { name }) + } catch {} + } + } + + archive.finalize() + } + } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) } + }) + + router.post('/share/create', async (req, res) => { + try { + const key = normalizeOssKey(String((req.body && req.body.key) || '')) + const ttlHours = Math.max(1, Math.min(7 * 24, Number((req.body && req.body.ttl_hours) || 24))) + if (!key) return res.status(400).json({ ok: false, error: '参数错误' }) + const isFolder = key.endsWith('/') + const displayName = displayNameFromKey(key) + const token = crypto.randomBytes(16).toString('hex') + const now = Date.now() + const expiresAt = now + ttlHours * 3600 * 1000 + const d = getDb() + d.prepare(`INSERT INTO share_links(token, oss_key, display_name, is_folder, created_at, expires_at) VALUES(?,?,?,?,?,?)`).run( + token, key, displayName, isFolder ? 1 : 0, now, expiresAt + ) + res.json({ ok: true, token, expires_at: expiresAt, display_name: displayName }) + } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) } + }) + + router.get('/trash/list', (req, res) => { + try { + const d = getDb() + const rows = d.prepare('SELECT * FROM trash_items ORDER BY deleted_at DESC').all() + const now = Date.now() + const items = rows.map(r => ({ + id: r.id, + key: OSS_CONFIG.root_prefix ? String(r.oss_key).slice(OSS_CONFIG.root_prefix.length + 1) : r.oss_key, + name: r.display_name, + isFolder: r.is_folder === 1, + size: r.size, + deleted_at: r.deleted_at, + original_path: r.original_path + })) + res.json({ ok: true, items }) + } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) } + }) + + router.post('/trash/restore', async (req, res) => { + try { + const id = Number((req.body && req.body.id) || 0) + if (!id) return res.status(400).json({ ok: false, error: '参数错误' }) + const d = getDb() + const row = d.prepare('SELECT * FROM trash_items WHERE id = ?').get(id) + if (!row) return res.status(404).json({ ok: false, error: '记录不存在' }) + d.prepare('DELETE FROM trash_items WHERE id = ?').run(id) + res.json({ ok: true, restored: true }) + } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) } + }) + + router.post('/trash/empty', async (req, res) => { + try { + const ids = Array.isArray(req.body && req.body.ids) ? req.body.ids : null + const d = getDb() + const rows = ids && ids.length > 0 + ? d.prepare(`SELECT * FROM trash_items WHERE id IN (${ids.map(() => '?').join(',')})`).all(...ids.map(i => Number(i))) + : d.prepare('SELECT * FROM trash_items').all() + + let deleted = 0 + for (const row of rows) { + const relKey = OSS_CONFIG.root_prefix ? String(row.oss_key).slice(OSS_CONFIG.root_prefix.length + 1) : row.oss_key + try { + if (row.is_folder === 1) { + await ossDeleteFolderRecursive(relKey) + } else { + await ossDeleteObject(relKey) + } + d.prepare('DELETE FROM trash_items WHERE id = ?').run(row.id) + deleted++ + } catch {} + } + res.json({ ok: true, deleted_count: deleted }) + } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) } + }) + + startTrashAutoCleanup() + return router +} + +const startTrashAutoCleanup = () => { + const MAX_AGE_MS = 2 * 30 * 24 * 3600 * 1000 + const run = async () => { + try { + const d = getDb() + const cutoff = Date.now() - MAX_AGE_MS + const rows = d.prepare('SELECT * FROM trash_items WHERE deleted_at < ?').all(cutoff) + for (const row of rows) { + try { + const relKey = OSS_CONFIG.root_prefix ? String(row.oss_key).slice(OSS_CONFIG.root_prefix.length + 1) : row.oss_key + const normalized = normalizeOssKey(relKey) + if (row.is_folder === 1) await ossDeleteFolderRecursive(normalized) + else await ossDeleteObject(normalized) + d.prepare('DELETE FROM trash_items WHERE id = ?').run(row.id) + } catch {} + } + } catch {} + } + run() + setInterval(run, 60 * 60 * 1000) +} + +const bindRoutes = (app) => { + const router = createRouter() + app.use('/api/oss_file_cabinet', router) +} + +module.exports = { bindRoutes, getDb, humanSize, getExt, isImage } \ No newline at end of file diff --git a/src/server/plant_home.js b/src/server/plant_home.js index 832d777..d4d2856 100644 --- a/src/server/plant_home.js +++ b/src/server/plant_home.js @@ -890,7 +890,6 @@ const fetchWeather = async ({ force = false } = {}) => { return weatherCache.payload } - // 使用AI来获取天气信息,而不是依赖付费API const locInfo = cfg.location || {} const city = safeString(locInfo.city) || '上海' const district = safeString(locInfo.district) || '宝山区' @@ -916,14 +915,47 @@ const fetchWeather = async ({ force = false } = {}) => { ] } - // 使用AI来生成更准确的天气信息 + // 优先从 data_gateway 读取外部 Agent 预写入的天气数据 const ai = getAiConfig() let adviceText = '' let reviewedByAi = false + let dataGatewayUsed = false - if (ai.enabled && ai.apiKey) { + try { + const dataGateway = require('./data_gateway/store') + const row = dataGateway.queryLatest('weather') + if (row && row.data && typeof row.data === 'object') { + const d = row.data + // 合并 data_gateway 数据到 payload(外部 Agent 写入的字段覆盖默认值) + if (d.text !== undefined) payload.text = safeString(d.text) + if (d.temp !== undefined) payload.temp = safeString(d.temp) + if (d.feelsLike !== undefined) payload.feelsLike = safeString(d.feelsLike) + if (d.humidity !== undefined) payload.humidity = safeString(d.humidity) + if (d.windDir !== undefined) payload.windDir = safeString(d.windDir) + if (d.windScale !== undefined) payload.windScale = safeString(d.windScale) + if (d.icon !== undefined) payload.icon = safeString(d.icon) + if (d.updateTime !== undefined) payload.updateTime = safeString(d.updateTime) + if (d.location !== undefined) payload.location = { ...payload.location, ...d.location } + if (Array.isArray(d.daily)) { + payload.daily = d.daily.slice(0, 3).map(day => ({ + fxDate: safeString(day.fxDate || day.fxdate || ''), + textDay: safeString(day.textDay || day.textday || ''), + textNight: safeString(day.textNight || day.textnight || ''), + tempMin: safeString(day.tempMin || day.tempmin || ''), + tempMax: safeString(day.tempMax || day.tempmax || '') + })) + } + // 记录 data_gateway 更新时间(UTC),前端转为北京时间 + if (row.created_at) payload._data_gateway_updated_at = safeString(row.created_at) + dataGatewayUsed = true + } + } catch (e) { + // data_gateway 不可用时静默回退,不影响 plant_home 正常运行 + } + + // 如果 data_gateway 没有数据,回退到 AI 获取天气(保留兼容) + if (!dataGatewayUsed && ai.enabled && ai.apiKey) { try { - // 构建一个请求天气信息的消息 const weatherPrompt = `请基于以下位置信息提供当前天气情况和未来3天天气预报:位置:${city}${district}。请提供以下信息:当前天气状况、温度、体感温度、湿度、风向风力,以及未来3天的天气预报(包括每日最高最低温度和天气状况)。请以JSON格式返回,格式如下:{"current":{"text":"天气状况","temp":"温度","feelsLike":"体感温度","humidity":"湿度","windDir":"风向","windScale":"风力"},"daily":[{"fxDate":"日期","textDay":"白天天气","textNight":"夜间天气","tempMin":"最低温度","tempMax":"最高温度"}]}` const aiResponse = await callDashscope({ @@ -935,12 +967,10 @@ const fetchWeather = async ({ force = false } = {}) => { ] }) - // 尝试解析AI返回的天气信息 const jsonMatch = aiResponse.match(/```json\s*([\s\S]*?)```|```([\s\S]*?)```/) if (jsonMatch) { try { const jsonData = JSON.parse(jsonMatch[1] || jsonMatch[2]) - if (jsonData && jsonData.current) { payload.text = safeString(jsonData.current.text) payload.temp = safeString(jsonData.current.temp) @@ -949,7 +979,6 @@ const fetchWeather = async ({ force = false } = {}) => { payload.windDir = safeString(jsonData.current.windDir) payload.windScale = safeString(jsonData.current.windScale) } - if (jsonData && Array.isArray(jsonData.daily)) { payload.daily = jsonData.daily.slice(0, 3).map(day => ({ fxDate: safeString(day.fxDate), @@ -963,8 +992,14 @@ const fetchWeather = async ({ force = false } = {}) => { console.warn('AI天气JSON解析失败:', parseError.message) } } - - // 生成植物养护建议 + } catch (error) { + console.warn('AI天气获取失败,使用默认值:', error.message) + } + } + + // 生成植物养护建议(plant_home 保留本地 AI 生成) + if (ai.enabled && ai.apiKey) { + try { adviceText = await callDashscope({ model: ai.model, temperature: 0.2, @@ -975,22 +1010,7 @@ const fetchWeather = async ({ force = false } = {}) => { }) reviewedByAi = !!adviceText } catch (error) { - console.warn('AI天气获取失败,使用默认值:', error.message) - // 如果AI调用失败,使用默认的天气信息 - if (ai.autoWeatherReview) { - try { - // 使用AI基于默认天气生成建议 - adviceText = await callDashscope({ - model: ai.model, - temperature: 0.2, - messages: [ - { role: 'system', content: '输出简短、克制、实用的植物养护提醒。不要编造天气。' }, - { role: 'user', content: buildWeatherAdvicePrompt(payload) } - ] - }) - reviewedByAi = !!adviceText - } catch {} - } + console.warn('养护建议生成失败:', error.message) } } diff --git a/src/server/private_clipboard.js b/src/server/private_clipboard.js index a595e38..4bafb4e 100644 --- a/src/server/private_clipboard.js +++ b/src/server/private_clipboard.js @@ -111,6 +111,109 @@ const formatRow = (row) => { } } +// ===== 登录鉴权 ===== +const AUTH_COOKIE = 'private_clipboard_auth' +const AUTH_MAX_AGE_MS = 24 * 3600 * 1000 // 1天 +const AUTH_HMAC_SECRET = process.env.PRIVATE_CLIPBOARD_AUTH_SECRET || 'pc-auth-secret-change-me' + +const signAuthPayload = (username) => { + const payload = { sub: username, iat: Date.now() } + const payloadStr = Buffer.from(JSON.stringify(payload)).toString('base64url') + const sig = crypto.createHmac('sha256', AUTH_HMAC_SECRET).update(payloadStr).digest('base64url') + return `${payloadStr}.${sig}` +} + +const verifyAuthPayload = (token) => { + if (!token || typeof token !== 'string') return null + const [payloadStr, sig] = token.split('.') + if (!payloadStr || !sig) return null + const expected = crypto.createHmac('sha256', AUTH_HMAC_SECRET).update(payloadStr).digest('base64url') + if (expected.length !== sig.length) return null + if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig))) return null + try { + const payload = JSON.parse(Buffer.from(payloadStr, 'base64url').toString()) + if (!payload || typeof payload !== 'object') return null + return payload + } catch { return null } +} + +const parseCookies = (cookieHeader) => { + const cookies = {} + if (!cookieHeader) return cookies + cookieHeader.split(';').forEach(part => { + const idx = part.indexOf('=') + if (idx < 0) return + const key = part.slice(0, idx).trim() + const val = decodeURIComponent(part.slice(idx + 1).trim()) + if (key) cookies[key] = val + }) + return cookies +} + +const hasAuth = (req) => { + const cookies = parseCookies(req.headers.cookie || '') + const token = cookies[AUTH_COOKIE] + if (!token) return false + const payload = verifyAuthPayload(token) + return !!payload +} + +const mustAuth = (req, res, next) => { + if (hasAuth(req)) return next() + return res.status(401).json({ ok: false, error: 'unauthorized' }) +} + +const readLocalCreds = () => { + try { + const os = require('os') + const userProfile = process.env.USERPROFILE || process.env.HOME || os.homedir() + const candidates = [path.join(userProfile, 'Toolbox_local_creds.env.local')] + const usersRoot = path.join(path.parse(userProfile).root, 'Users') + try { + if (fs.existsSync(usersRoot)) { + fs.readdirSync(usersRoot).forEach(name => { + const dir = path.join(usersRoot, name) + if (dir !== userProfile && fs.existsSync(dir)) { + candidates.push(path.join(dir, 'Toolbox_local_creds.env.local')) + } + }) + } + } catch {} + let content = null + for (const p of candidates) { + if (fs.existsSync(p)) { content = fs.readFileSync(p, 'utf-8'); break } + } + if (!content) return {} + const creds = {} + content.split(/\r?\n/).forEach(line => { + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith('#')) return + const idx = trimmed.indexOf('=') + if (idx > 0) creds[trimmed.slice(0, idx).trim()] = trimmed.slice(idx + 1).trim() + }) + return creds + } catch { return {} } +} + +const getExpectedUsername = () => { + const fromEnv = String(process.env.PRIVATE_CLIPBOARD_USERNAME || '').trim() + if (fromEnv) return fromEnv + const creds = readLocalCreds() + return String(creds.PRIVATE_CLIPBOARD_USERNAME || '').trim() +} +const getExpectedPassword = () => { + const fromEnv = String(process.env.PRIVATE_CLIPBOARD_PASSWORD || '').trim() + if (fromEnv) return fromEnv + const creds = readLocalCreds() + return String(creds.PRIVATE_CLIPBOARD_PASSWORD || '').trim() +} +const getExpectedToken = () => { + const fromEnv = String(process.env.TOOLBOX_LOCAL_CREDS_TOKEN || '').trim() + if (fromEnv) return fromEnv + const creds = readLocalCreds() + return String(creds.TOOLBOX_LOCAL_CREDS_TOKEN || '').trim() +} + const createRouter = () => { const router = express.Router() @@ -124,6 +227,66 @@ const createRouter = () => { next() }) + // ===== Auth 路由(白名单,不需要登录)===== + router.post('/auth/login', (req, res) => { + try { + const username = String((req.body && req.body.username) || '').trim() + const password = String((req.body && req.body.password) || '') + const expectedUser = getExpectedUsername() + const expectedPass = getExpectedPassword() + if (!expectedUser || !expectedPass) { + return res.status(500).json({ ok: false, error: 'server_not_configured' }) + } + if (username !== expectedUser || password !== expectedPass) { + return res.status(401).json({ ok: false, error: 'invalid_credentials' }) + } + const token = signAuthPayload(username) + res.cookie(AUTH_COOKIE, token, { + httpOnly: true, + sameSite: 'lax', + maxAge: AUTH_MAX_AGE_MS, + path: '/' + }) + res.json({ ok: true }) + } catch (e) { + res.status(500).json({ ok: false, error: String(e.message || e) }) + } + }) + + router.post('/auth/logout', (req, res) => { + res.clearCookie(AUTH_COOKIE, { path: '/' }) + res.json({ ok: true }) + }) + + router.get('/auth/me', (req, res) => { + if (hasAuth(req)) { + return res.json({ ok: true, authorized: true }) + } + res.json({ ok: false, authorized: false }) + }) + + router.get('/auth/local_creds', (req, res) => { + try { + const token = String((req.query && req.query.token) || '').trim() + const expectedToken = getExpectedToken() + if (!expectedToken || token !== expectedToken) { + return res.status(401).json({ ok: false, error: 'invalid_token' }) + } + const creds = readLocalCreds() + const username = String(creds.PRIVATE_CLIPBOARD_USERNAME || '').trim() + const password = String(creds.PRIVATE_CLIPBOARD_PASSWORD || '').trim() + if (!username || !password) { + return res.status(500).json({ ok: false, error: 'no_local_creds' }) + } + res.json({ ok: true, creds: { username, password } }) + } catch (e) { + res.status(500).json({ ok: false, error: String(e.message || e) }) + } + }) + + // ===== 以下业务路由需要登录 ===== + router.use(mustAuth) + router.get('/list', (req, res) => { try { const rows = getDb().prepare(`